Repository: mkdocs/mkdocs

Issue:
TypeError: Object of type 'date' is not JSON serializable when using mkdocs serve
```brian@brian-2920x:~/github/valvers/valvers-mkdocs$ /opt/wsl/bin/mkdocs --version
mkdocs, version 1.0.4 from /opt/wsl/lib/python3.6/site-packages/mkdocs (Python 3.6)
```
When generating the docs for my website (`mkdocs serve`) `mkdocs` barfs with the following stacktrace:

```
brian@brian-2920x:~/github/valvers/valvers-mkdocs$ /opt/wsl/bin/mkdocs serve
INFO    -  Building documentation... 
INFO    -  Cleaning site directory 
INFO    -  The following pages exist in the docs directory, but are not included in the "nav" configuration:
  - v6manta.md
  - open_software/index.md 
Traceback (most recent call last):
  File "/opt/wsl/bin/mkdocs", line 8, in <module>
    sys.exit(cli())
  File "/opt/wsl/lib/python3.6/site-packages/click/core.py", line 764, in __call__
    return self.main(*args, **kwargs)
  File "/opt/wsl/lib/python3.6/site-packages/click/core.py", line 717, in main
    rv = self.invoke(ctx)
  File "/opt/wsl/lib/python3.6/site-packages/click/core.py", line 1137, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/opt/wsl/lib/python3.6/site-packages/click/core.py", line 956, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/opt/wsl/lib/python3.6/site-packages/click/core.py", line 555, in invoke
    return callback(*args, **kwargs)
  File "/opt/wsl/lib/python3.6/site-packages/mkdocs/__main__.py", line 134, in serve_command
    livereload=livereload
  File "/opt/wsl/lib/python3.6/site-packages/mkdocs/commands/serve.py", line 119, in serve
    config = builder()
  File "/opt/wsl/lib/python3.6/site-packages/mkdocs/commands/serve.py", line 114, in builder
    build(config, live_server=live_server, dirty=dirty)
  File "/opt/wsl/lib/python3.6/site-packages/mkdocs/commands/build.py", line 298, in build
    config['plugins'].run_event('post_build', config)
  File "/opt/wsl/lib/python3.6/site-packages/mkdocs/plugins.py", line 94, in run_event
    result = method(item, **kwargs)
  File "/opt/wsl/lib/python3.6/site-packages/mkdocs/contrib/search/__init__.py", line 68, in on_post_build
    search_index = self.search_index.generate_search_index()
  File "/opt/wsl/lib/python3.6/site-packages/mkdocs/contrib/search/search_index.py", line 106, in generate_search_index
    data = json.dumps(page_dicts, sort_keys=True, separators=(',', ':'))
  File "/opt/wsl/lib/python3.6/json/__init__.py", line 238, in dumps
    **kw).encode(obj)
  File "/opt/wsl/lib/python3.6/json/encoder.py", line 199, in encode
    chunks = self.iterencode(o, _one_shot=True)
  File "/opt/wsl/lib/python3.6/json/encoder.py", line 257, in iterencode
    return _iterencode(o, 0)
  File "/opt/wsl/lib/python3.6/json/encoder.py", line 180, in default
    o.__class__.__name__)
TypeError: Object of type 'date' is not JSON serializable
```

The `mkdocs.yml` configuration file looks a bit like (I've abbreviated it a bit):

```
brian@brian-2920x:~/github/valvers/valvers-mkdocs$ cat mkdocs.yml 
site_name: valvers.com
site_author: 'Brian Sidebotham'
site_url: 'https://www.valvers.com'
copyright: 'Copyright &copy; 2002-2020 Brian Sidebotham'

nav:
    - Home: index.md
    - 'V6 Manta':
        - Index: v6manta/index.md
        - 2003-07-28: v6manta/2003-07-28-.md
        ...

    - 'Open Software':
        ...
theme:
    name: material
    palette:
        primary: 'blue grey'
        accent: 'deep orange'
    feature:
        tabs: true
    markdown_extensions:
      - admonition
      - codehilite:
          guess_lang: false
      - toc:
```
Recursively validate `Nav` config option types
Closes #1964 and closes #1961


Discussion:
Quoting the date to ensure it's a string appears to fix this problem:

```
brian@brian-2920x:~/github/valvers/valvers-mkdocs$ cat mkdocs.yml 
site_name: valvers.com
site_author: 'Brian Sidebotham'
site_url: 'https://www.valvers.com'
copyright: 'Copyright &copy; 2002-2020 Brian Sidebotham'

nav:
    - Home: index.md
    - 'V6 Manta':
        - Index: v6manta/index.md
        - '2003-07-28': v6manta/2003-07-28-.md
        ...
```

Right, YAML infers types, so you need to force a string with quotes. This is working as it should.

Although, perhaps we should enforce titles being strings in the config validation and raise a validation error if not.
These navs pass validation but then error anyway:

```yaml
nav:
    - User Guide:
        - [{Overview: user-guide/index.md}]
```
```yaml
nav:
    - User Guide:
        - {Overview: user-guide/index.md,
           Installation: user-guide/installation.md}
```

I think a more robust approach would be needed.

Resolving patch:
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -529,23 +529,42 @@ class Nav(OptionallyRequired):
     Validate the Nav config.
     """
 
-    def run_validation(self, value):
-
-        if not isinstance(value, list):
-            raise ValidationError(f"Expected a list, got {type(value)}")
-
-        if len(value) == 0:
-            return
-
-        config_types = {type(item) for item in value}
-        if config_types.issubset({str, dict}):
-            return value
+    def run_validation(self, value, *, top=True):
+        if isinstance(value, list):
+            for subitem in value:
+                self._validate_nav_item(subitem)
+            if top and not value:
+                value = None
+        elif isinstance(value, dict) and value and not top:
+            # TODO: this should be an error.
+            self.warnings.append(f"Expected nav to be a list, got {self._repr_item(value)}")
+            for subitem in value.values():
+                self.run_validation(subitem, top=False)
+        elif isinstance(value, str) and not top:
+            pass
+        else:
+            raise ValidationError(f"Expected nav to be a list, got {self._repr_item(value)}")
+        return value
 
-        types = ', '.join(set(
-            item_type.__name__ for item_type in config_types
-        ))
-        raise ValidationError(
-            f"Invalid navigation config types. Expected str and dict, got: {types}")
+    def _validate_nav_item(self, value):
+        if isinstance(value, str):
+            pass
+        elif isinstance(value, dict):
+            if len(value) != 1:
+                raise ValidationError(f"Expected nav item to be a dict of size 1, got {self._repr_item(value)}")
+            for subnav in value.values():
+                self.run_validation(subnav, top=False)
+        else:
+            raise ValidationError(f"Expected nav item to be a string or dict, got {self._repr_item(value)}")
+
+    @classmethod
+    def _repr_item(cls, value):
+        if isinstance(value, dict) and value:
+            return f"dict with keys {tuple(value.keys())}"
+        elif isinstance(value, (str, type(None))):
+            return repr(value)
+        else:
+            return f"a {type(value).__name__}: {value!r}"
 
     def post_validation(self, config, key_name):
         # TODO: remove this when `pages` config setting is fully deprecated.


Test patch:
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -1,5 +1,6 @@
 import os
 import sys
+import textwrap
 import unittest
 from unittest.mock import patch
 
@@ -7,6 +8,7 @@
 from mkdocs.config import config_options
 from mkdocs.config.base import Config
 from mkdocs.tests.base import tempdir
+from mkdocs.utils import yaml_load
 
 
 class OptionallyRequiredTest(unittest.TestCase):
@@ -733,13 +735,12 @@ def test_post_validation_locale(self):
 class NavTest(unittest.TestCase):
 
     def test_old_format(self):
-
         option = config_options.Nav()
-        with self.assertRaises(config_options.ValidationError):
+        with self.assertRaises(config_options.ValidationError) as cm:
             option.validate([['index.md']])
+        self.assertEqual(str(cm.exception), "Expected nav item to be a string or dict, got a list: ['index.md']")
 
     def test_provided_dict(self):
-
         option = config_options.Nav()
         value = option.validate([
             'index.md',
@@ -748,26 +749,92 @@ def test_provided_dict(self):
         self.assertEqual(['index.md', {'Page': 'page.md'}], value)
 
         option.post_validation({'extra_stuff': []}, 'extra_stuff')
+        self.assertEqual(option.warnings, [])
 
     def test_provided_empty(self):
-
         option = config_options.Nav()
         value = option.validate([])
         self.assertEqual(None, value)
 
         option.post_validation({'extra_stuff': []}, 'extra_stuff')
+        self.assertEqual(option.warnings, [])
 
-    def test_invalid_type(self):
+    def test_normal_nav(self):
+        nav = yaml_load(textwrap.dedent('''\
+            - Home: index.md
+            - getting-started.md
+            - User Guide:
+                - Overview: user-guide/index.md
+                - Installation: user-guide/installation.md
+        ''').encode())
 
         option = config_options.Nav()
-        with self.assertRaises(config_options.ValidationError):
+        self.assertEqual(option.validate(nav), nav)
+        self.assertEqual(option.warnings, [])
+
+    def test_invalid_type_dict(self):
+        option = config_options.Nav()
+        with self.assertRaises(config_options.ValidationError) as cm:
             option.validate({})
+        self.assertEqual(str(cm.exception), "Expected nav to be a list, got a dict: {}")
+
+    def test_invalid_type_int(self):
+        option = config_options.Nav()
+        with self.assertRaises(config_options.ValidationError) as cm:
+            option.validate(5)
+        self.assertEqual(str(cm.exception), "Expected nav to be a list, got a int: 5")
 
-    def test_invalid_config(self):
+    def test_invalid_item_int(self):
+        option = config_options.Nav()
+        with self.assertRaises(config_options.ValidationError) as cm:
+            option.validate([1])
+        self.assertEqual(str(cm.exception), "Expected nav item to be a string or dict, got a int: 1")
 
+    def test_invalid_item_none(self):
         option = config_options.Nav()
-        with self.assertRaises(config_options.ValidationError):
-            option.validate([[], 1])
+        with self.assertRaises(config_options.ValidationError) as cm:
+            option.validate([None])
+        self.assertEqual(str(cm.exception), "Expected nav item to be a string or dict, got None")
+
+    def test_invalid_children_config_int(self):
+        option = config_options.Nav()
+        with self.assertRaises(config_options.ValidationError) as cm:
+            option.validate([{"foo.md": [{"bar.md": 1}]}])
+        self.assertEqual(str(cm.exception), "Expected nav to be a list, got a int: 1")
+
+    def test_invalid_children_config_none(self):
+        option = config_options.Nav()
+        with self.assertRaises(config_options.ValidationError) as cm:
+            option.validate([{"foo.md": None}])
+        self.assertEqual(str(cm.exception), "Expected nav to be a list, got None")
+
+    def test_invalid_children_empty_dict(self):
+        option = config_options.Nav()
+        nav = ['foo', {}]
+        with self.assertRaises(config_options.ValidationError) as cm:
+            option.validate(nav)
+        self.assertEqual(str(cm.exception), "Expected nav item to be a dict of size 1, got a dict: {}")
+
+    def test_invalid_nested_list(self):
+        option = config_options.Nav()
+        nav = [{'aaa': [[{"bbb": "user-guide/index.md"}]]}]
+        with self.assertRaises(config_options.ValidationError) as cm:
+            option.validate(nav)
+        msg = "Expected nav item to be a string or dict, got a list: [{'bbb': 'user-guide/index.md'}]"
+        self.assertEqual(str(cm.exception), msg)
+
+    def test_invalid_children_oversized_dict(self):
+        option = config_options.Nav()
+        nav = [{"aaa": [{"bbb": "user-guide/index.md", "ccc": "user-guide/installation.md"}]}]
+        with self.assertRaises(config_options.ValidationError) as cm:
+            option.validate(nav)
+        msg = "Expected nav item to be a dict of size 1, got dict with keys ('bbb', 'ccc')"
+        self.assertEqual(str(cm.exception), msg)
+
+    def test_warns_for_dict(self):
+        option = config_options.Nav()
+        option.validate([{"a": {"b": "c.md", "d": "e.md"}}])
+        self.assertEqual(option.warnings, ["Expected nav to be a list, got dict with keys ('b', 'd')"])
 
 
 class PrivateTest(unittest.TestCase):