1import os
2import sys
3import unittest
4from unittest.mock import patch
5
6import mkdocs
7from mkdocs.config import config_options
8from mkdocs.config.base import Config
9from mkdocs.tests.base import tempdir
10
11
12class OptionallyRequiredTest(unittest.TestCase):
13
14    def test_empty(self):
15
16        option = config_options.OptionallyRequired()
17        value = option.validate(None)
18        self.assertEqual(value, None)
19
20        self.assertEqual(option.is_required(), False)
21
22    def test_required(self):
23
24        option = config_options.OptionallyRequired(required=True)
25        self.assertRaises(config_options.ValidationError,
26                          option.validate, None)
27
28        self.assertEqual(option.is_required(), True)
29
30    def test_required_no_default(self):
31
32        option = config_options.OptionallyRequired(required=True)
33        value = option.validate(2)
34        self.assertEqual(2, value)
35
36    def test_default(self):
37
38        option = config_options.OptionallyRequired(default=1)
39        value = option.validate(None)
40        self.assertEqual(1, value)
41
42    def test_replace_default(self):
43
44        option = config_options.OptionallyRequired(default=1)
45        value = option.validate(2)
46        self.assertEqual(2, value)
47
48
49class TypeTest(unittest.TestCase):
50
51    def test_single_type(self):
52
53        option = config_options.Type(str)
54        value = option.validate("Testing")
55        self.assertEqual(value, "Testing")
56
57    def test_multiple_types(self):
58        option = config_options.Type((list, tuple))
59
60        value = option.validate([1, 2, 3])
61        self.assertEqual(value, [1, 2, 3])
62
63        value = option.validate((1, 2, 3))
64        self.assertEqual(value, (1, 2, 3))
65
66        self.assertRaises(config_options.ValidationError,
67                          option.validate, {'a': 1})
68
69    def test_length(self):
70        option = config_options.Type(str, length=7)
71
72        value = option.validate("Testing")
73        self.assertEqual(value, "Testing")
74
75        self.assertRaises(config_options.ValidationError,
76                          option.validate, "Testing Long")
77
78
79class ChoiceTest(unittest.TestCase):
80
81    def test_valid_choice(self):
82        option = config_options.Choice(('python', 'node'))
83        value = option.validate('python')
84        self.assertEqual(value, 'python')
85
86    def test_invalid_choice(self):
87        option = config_options.Choice(('python', 'node'))
88        self.assertRaises(
89            config_options.ValidationError, option.validate, 'go')
90
91    def test_invalid_choices(self):
92        self.assertRaises(ValueError, config_options.Choice, '')
93        self.assertRaises(ValueError, config_options.Choice, [])
94        self.assertRaises(ValueError, config_options.Choice, 5)
95
96
97class DeprecatedTest(unittest.TestCase):
98
99    def test_deprecated_option_simple(self):
100        option = config_options.Deprecated()
101        option.pre_validation({'d': 'value'}, 'd')
102        self.assertEqual(len(option.warnings), 1)
103        option.validate('value')
104
105    def test_deprecated_option_message(self):
106        msg = 'custom message for {} key'
107        option = config_options.Deprecated(message=msg)
108        option.pre_validation({'d': 'value'}, 'd')
109        self.assertEqual(len(option.warnings), 1)
110        self.assertEqual(option.warnings[0], msg.format('d'))
111
112    def test_deprecated_option_with_type(self):
113        option = config_options.Deprecated(option_type=config_options.Type(str))
114        option.pre_validation({'d': 'value'}, 'd')
115        self.assertEqual(len(option.warnings), 1)
116        option.validate('value')
117
118    def test_deprecated_option_with_invalid_type(self):
119        option = config_options.Deprecated(option_type=config_options.Type(list))
120        config = {'d': 'string'}
121        option.pre_validation({'d': 'value'}, 'd')
122        self.assertEqual(len(option.warnings), 1)
123        self.assertRaises(
124            config_options.ValidationError,
125            option.validate,
126            config['d']
127        )
128
129    def test_deprecated_option_with_type_undefined(self):
130        option = config_options.Deprecated(option_type=config_options.Type(str))
131        option.validate(None)
132
133    def test_deprecated_option_move(self):
134        option = config_options.Deprecated(moved_to='new')
135        config = {'old': 'value'}
136        option.pre_validation(config, 'old')
137        self.assertEqual(len(option.warnings), 1)
138        self.assertEqual(config, {'new': 'value'})
139
140    def test_deprecated_option_move_complex(self):
141        option = config_options.Deprecated(moved_to='foo.bar')
142        config = {'old': 'value'}
143        option.pre_validation(config, 'old')
144        self.assertEqual(len(option.warnings), 1)
145        self.assertEqual(config, {'foo': {'bar': 'value'}})
146
147    def test_deprecated_option_move_existing(self):
148        option = config_options.Deprecated(moved_to='foo.bar')
149        config = {'old': 'value', 'foo': {'existing': 'existing'}}
150        option.pre_validation(config, 'old')
151        self.assertEqual(len(option.warnings), 1)
152        self.assertEqual(config, {'foo': {'existing': 'existing', 'bar': 'value'}})
153
154    def test_deprecated_option_move_invalid(self):
155        option = config_options.Deprecated(moved_to='foo.bar')
156        config = {'old': 'value', 'foo': 'wrong type'}
157        option.pre_validation(config, 'old')
158        self.assertEqual(len(option.warnings), 1)
159        self.assertEqual(config, {'old': 'value', 'foo': 'wrong type'})
160
161
162class IpAddressTest(unittest.TestCase):
163
164    def test_valid_address(self):
165        addr = '127.0.0.1:8000'
166
167        option = config_options.IpAddress()
168        value = option.validate(addr)
169        self.assertEqual(str(value), addr)
170        self.assertEqual(value.host, '127.0.0.1')
171        self.assertEqual(value.port, 8000)
172
173    def test_valid_IPv6_address(self):
174        addr = '::1:8000'
175
176        option = config_options.IpAddress()
177        value = option.validate(addr)
178        self.assertEqual(str(value), addr)
179        self.assertEqual(value.host, '::1')
180        self.assertEqual(value.port, 8000)
181
182    def test_named_address(self):
183        addr = 'localhost:8000'
184
185        option = config_options.IpAddress()
186        value = option.validate(addr)
187        self.assertEqual(str(value), addr)
188        self.assertEqual(value.host, 'localhost')
189        self.assertEqual(value.port, 8000)
190
191    def test_default_address(self):
192        addr = '127.0.0.1:8000'
193
194        option = config_options.IpAddress(default=addr)
195        value = option.validate(None)
196        self.assertEqual(str(value), addr)
197        self.assertEqual(value.host, '127.0.0.1')
198        self.assertEqual(value.port, 8000)
199
200    @unittest.skipIf(
201        sys.version_info < (3, 9, 5),
202        "Leading zeros allowed in IP addresses before Python3.9.5",
203    )
204    def test_invalid_leading_zeros(self):
205        addr = '127.000.000.001:8000'
206        option = config_options.IpAddress(default=addr)
207        self.assertRaises(
208            config_options.ValidationError,
209            option.validate, addr
210        )
211
212    def test_invalid_address_range(self):
213        option = config_options.IpAddress()
214        self.assertRaises(
215            config_options.ValidationError,
216            option.validate, '277.0.0.1:8000'
217        )
218
219    def test_invalid_address_format(self):
220        option = config_options.IpAddress()
221        self.assertRaises(
222            config_options.ValidationError,
223            option.validate, '127.0.0.18000'
224        )
225
226    def test_invalid_address_type(self):
227        option = config_options.IpAddress()
228        self.assertRaises(
229            config_options.ValidationError,
230            option.validate, 123
231        )
232
233    def test_invalid_address_port(self):
234        option = config_options.IpAddress()
235        self.assertRaises(
236            config_options.ValidationError,
237            option.validate, '127.0.0.1:foo'
238        )
239
240    def test_invalid_address_missing_port(self):
241        option = config_options.IpAddress()
242        self.assertRaises(
243            config_options.ValidationError,
244            option.validate, '127.0.0.1'
245        )
246
247    def test_unsupported_address(self):
248        option = config_options.IpAddress()
249        value = option.validate('0.0.0.0:8000')
250        option.post_validation({'dev_addr': value}, 'dev_addr')
251        self.assertEqual(len(option.warnings), 1)
252
253    def test_unsupported_IPv6_address(self):
254        option = config_options.IpAddress()
255        value = option.validate(':::8000')
256        option.post_validation({'dev_addr': value}, 'dev_addr')
257        self.assertEqual(len(option.warnings), 1)
258
259    def test_invalid_IPv6_address(self):
260        # The server will error out with this so we treat it as invalid.
261        option = config_options.IpAddress()
262        self.assertRaises(
263            config_options.ValidationError,
264            option.validate, '[::1]:8000'
265        )
266
267
268class URLTest(unittest.TestCase):
269
270    def test_valid_url(self):
271        option = config_options.URL()
272
273        self.assertEqual(option.validate("https://mkdocs.org"), "https://mkdocs.org")
274        self.assertEqual(option.validate(""), "")
275
276    def test_valid_url_is_dir(self):
277        option = config_options.URL(is_dir=True)
278
279        self.assertEqual(option.validate("http://mkdocs.org/"), "http://mkdocs.org/")
280        self.assertEqual(option.validate("https://mkdocs.org"), "https://mkdocs.org/")
281
282    def test_invalid_url(self):
283        option = config_options.URL()
284
285        self.assertRaises(config_options.ValidationError,
286                          option.validate, "www.mkdocs.org")
287        self.assertRaises(config_options.ValidationError,
288                          option.validate, "//mkdocs.org/test")
289        self.assertRaises(config_options.ValidationError,
290                          option.validate, "http:/mkdocs.org/")
291        self.assertRaises(config_options.ValidationError,
292                          option.validate, "/hello/")
293
294    def test_invalid_type(self):
295        option = config_options.URL()
296        self.assertRaises(config_options.ValidationError,
297                          option.validate, 1)
298
299
300class RepoURLTest(unittest.TestCase):
301
302    def test_repo_name_github(self):
303
304        option = config_options.RepoURL()
305        config = {'repo_url': "https://github.com/mkdocs/mkdocs"}
306        option.post_validation(config, 'repo_url')
307        self.assertEqual(config['repo_name'], "GitHub")
308
309    def test_repo_name_bitbucket(self):
310
311        option = config_options.RepoURL()
312        config = {'repo_url': "https://bitbucket.org/gutworth/six/"}
313        option.post_validation(config, 'repo_url')
314        self.assertEqual(config['repo_name'], "Bitbucket")
315
316    def test_repo_name_gitlab(self):
317
318        option = config_options.RepoURL()
319        config = {'repo_url': "https://gitlab.com/gitlab-org/gitlab-ce/"}
320        option.post_validation(config, 'repo_url')
321        self.assertEqual(config['repo_name'], "GitLab")
322
323    def test_repo_name_custom(self):
324
325        option = config_options.RepoURL()
326        config = {'repo_url': "https://launchpad.net/python-tuskarclient"}
327        option.post_validation(config, 'repo_url')
328        self.assertEqual(config['repo_name'], "Launchpad")
329
330    def test_edit_uri_github(self):
331
332        option = config_options.RepoURL()
333        config = {'repo_url': "https://github.com/mkdocs/mkdocs"}
334        option.post_validation(config, 'repo_url')
335        self.assertEqual(config['edit_uri'], 'edit/master/docs/')
336
337    def test_edit_uri_bitbucket(self):
338
339        option = config_options.RepoURL()
340        config = {'repo_url': "https://bitbucket.org/gutworth/six/"}
341        option.post_validation(config, 'repo_url')
342        self.assertEqual(config['edit_uri'], 'src/default/docs/')
343
344    def test_edit_uri_gitlab(self):
345
346        option = config_options.RepoURL()
347        config = {'repo_url': "https://gitlab.com/gitlab-org/gitlab-ce/"}
348        option.post_validation(config, 'repo_url')
349        self.assertEqual(config['edit_uri'], 'edit/master/docs/')
350
351    def test_edit_uri_custom(self):
352
353        option = config_options.RepoURL()
354        config = {'repo_url': "https://launchpad.net/python-tuskarclient"}
355        option.post_validation(config, 'repo_url')
356        self.assertEqual(config.get('edit_uri'), '')
357
358    def test_repo_name_custom_and_empty_edit_uri(self):
359
360        option = config_options.RepoURL()
361        config = {'repo_url': "https://github.com/mkdocs/mkdocs",
362                  'repo_name': 'mkdocs'}
363        option.post_validation(config, 'repo_url')
364        self.assertEqual(config.get('edit_uri'), 'edit/master/docs/')
365
366
367class DirTest(unittest.TestCase):
368
369    def test_valid_dir(self):
370
371        d = os.path.dirname(__file__)
372        option = config_options.Dir(exists=True)
373        value = option.validate(d)
374        self.assertEqual(d, value)
375
376    def test_missing_dir(self):
377
378        d = os.path.join("not", "a", "real", "path", "I", "hope")
379        option = config_options.Dir()
380        value = option.validate(d)
381        self.assertEqual(os.path.abspath(d), value)
382
383    def test_missing_dir_but_required(self):
384
385        d = os.path.join("not", "a", "real", "path", "I", "hope")
386        option = config_options.Dir(exists=True)
387        self.assertRaises(config_options.ValidationError,
388                          option.validate, d)
389
390    def test_file(self):
391        d = __file__
392        option = config_options.Dir(exists=True)
393        self.assertRaises(config_options.ValidationError,
394                          option.validate, d)
395
396    def test_incorrect_type_attribute_error(self):
397        option = config_options.Dir()
398        self.assertRaises(config_options.ValidationError,
399                          option.validate, 1)
400
401    def test_incorrect_type_type_error(self):
402        option = config_options.Dir()
403        self.assertRaises(config_options.ValidationError,
404                          option.validate, [])
405
406    def test_dir_unicode(self):
407        cfg = Config(
408            [('dir', config_options.Dir())],
409            config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
410        )
411
412        test_config = {
413            'dir': 'юникод'
414        }
415
416        cfg.load_dict(test_config)
417
418        fails, warns = cfg.validate()
419
420        self.assertEqual(len(fails), 0)
421        self.assertEqual(len(warns), 0)
422        self.assertIsInstance(cfg['dir'], str)
423
424    def test_dir_filesystemencoding(self):
425        cfg = Config(
426            [('dir', config_options.Dir())],
427            config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
428        )
429
430        test_config = {
431            'dir': 'Übersicht'.encode(encoding=sys.getfilesystemencoding())
432        }
433
434        cfg.load_dict(test_config)
435
436        fails, warns = cfg.validate()
437
438        # str does not include byte strings so validation fails
439        self.assertEqual(len(fails), 1)
440        self.assertEqual(len(warns), 0)
441
442    def test_dir_bad_encoding_fails(self):
443        cfg = Config(
444            [('dir', config_options.Dir())],
445            config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
446        )
447
448        test_config = {
449            'dir': 'юникод'.encode(encoding='ISO 8859-5')
450        }
451
452        cfg.load_dict(test_config)
453
454        fails, warns = cfg.validate()
455
456        self.assertEqual(len(fails), 1)
457        self.assertEqual(len(warns), 0)
458
459    def test_config_dir_prepended(self):
460        base_path = os.path.abspath('.')
461        cfg = Config(
462            [('dir', config_options.Dir())],
463            config_file_path=os.path.join(base_path, 'mkdocs.yml'),
464        )
465
466        test_config = {
467            'dir': 'foo'
468        }
469
470        cfg.load_dict(test_config)
471
472        fails, warns = cfg.validate()
473
474        self.assertEqual(len(fails), 0)
475        self.assertEqual(len(warns), 0)
476        self.assertIsInstance(cfg['dir'], str)
477        self.assertEqual(cfg['dir'], os.path.join(base_path, 'foo'))
478
479    def test_dir_is_config_dir_fails(self):
480        cfg = Config(
481            [('dir', config_options.Dir())],
482            config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
483        )
484
485        test_config = {
486            'dir': '.'
487        }
488
489        cfg.load_dict(test_config)
490
491        fails, warns = cfg.validate()
492
493        self.assertEqual(len(fails), 1)
494        self.assertEqual(len(warns), 0)
495
496
497class SiteDirTest(unittest.TestCase):
498
499    def validate_config(self, config):
500        """ Given a config with values for site_dir and doc_dir, run site_dir post_validation. """
501        site_dir = config_options.SiteDir()
502        docs_dir = config_options.Dir()
503
504        fname = os.path.join(os.path.abspath('..'), 'mkdocs.yml')
505
506        config['docs_dir'] = docs_dir.validate(config['docs_dir'])
507        config['site_dir'] = site_dir.validate(config['site_dir'])
508
509        schema = [
510            ('site_dir', site_dir),
511            ('docs_dir', docs_dir),
512        ]
513        cfg = Config(schema, fname)
514        cfg.load_dict(config)
515        failed, warned = cfg.validate()
516
517        if failed:
518            raise config_options.ValidationError(failed)
519
520        return True
521
522    def test_doc_dir_in_site_dir(self):
523
524        j = os.path.join
525        # The parent dir is not the same on every system, so use the actual dir name
526        parent_dir = mkdocs.__file__.split(os.sep)[-3]
527
528        test_configs = (
529            {'docs_dir': j('site', 'docs'), 'site_dir': 'site'},
530            {'docs_dir': 'docs', 'site_dir': '.'},
531            {'docs_dir': '.', 'site_dir': '.'},
532            {'docs_dir': 'docs', 'site_dir': ''},
533            {'docs_dir': '', 'site_dir': ''},
534            {'docs_dir': j('..', parent_dir, 'docs'), 'site_dir': 'docs'},
535            {'docs_dir': 'docs', 'site_dir': '/'}
536        )
537
538        for test_config in test_configs:
539            self.assertRaises(config_options.ValidationError,
540                              self.validate_config, test_config)
541
542    def test_site_dir_in_docs_dir(self):
543
544        j = os.path.join
545
546        test_configs = (
547            {'docs_dir': 'docs', 'site_dir': j('docs', 'site')},
548            {'docs_dir': '.', 'site_dir': 'site'},
549            {'docs_dir': '', 'site_dir': 'site'},
550            {'docs_dir': '/', 'site_dir': 'site'},
551        )
552
553        for test_config in test_configs:
554            self.assertRaises(config_options.ValidationError,
555                              self.validate_config, test_config)
556
557    def test_common_prefix(self):
558        """ Legitimate settings with common prefixes should not fail validation. """
559
560        test_configs = (
561            {'docs_dir': 'docs', 'site_dir': 'docs-site'},
562            {'docs_dir': 'site-docs', 'site_dir': 'site'},
563        )
564
565        for test_config in test_configs:
566            assert self.validate_config(test_config)
567
568
569class ThemeTest(unittest.TestCase):
570
571    def test_theme_as_string(self):
572
573        option = config_options.Theme()
574        value = option.validate("mkdocs")
575        self.assertEqual({'name': 'mkdocs'}, value)
576
577    def test_uninstalled_theme_as_string(self):
578
579        option = config_options.Theme()
580        self.assertRaises(config_options.ValidationError,
581                          option.validate, "mkdocs2")
582
583    def test_theme_default(self):
584        option = config_options.Theme(default='mkdocs')
585        value = option.validate(None)
586        self.assertEqual({'name': 'mkdocs'}, value)
587
588    def test_theme_as_simple_config(self):
589
590        config = {
591            'name': 'mkdocs'
592        }
593        option = config_options.Theme()
594        value = option.validate(config)
595        self.assertEqual(config, value)
596
597    def test_theme_as_complex_config(self):
598
599        config = {
600            'name': 'mkdocs',
601            'custom_dir': 'custom',
602            'static_templates': ['sitemap.html'],
603            'show_sidebar': False
604        }
605        option = config_options.Theme()
606        value = option.validate(config)
607        self.assertEqual(config, value)
608
609    def test_theme_name_is_none(self):
610
611        config = {
612            'name': None
613        }
614        option = config_options.Theme()
615        value = option.validate(config)
616        self.assertEqual(config, value)
617
618    def test_theme_config_missing_name(self):
619
620        config = {
621            'custom_dir': 'custom',
622        }
623        option = config_options.Theme()
624        self.assertRaises(config_options.ValidationError,
625                          option.validate, config)
626
627    def test_uninstalled_theme_as_config(self):
628
629        config = {
630            'name': 'mkdocs2'
631        }
632        option = config_options.Theme()
633        self.assertRaises(config_options.ValidationError,
634                          option.validate, config)
635
636    def test_theme_invalid_type(self):
637
638        config = ['mkdocs2']
639        option = config_options.Theme()
640        self.assertRaises(config_options.ValidationError,
641                          option.validate, config)
642
643    def test_post_validation_none_theme_name_and_missing_custom_dir(self):
644
645        config = {
646            'theme': {
647                'name': None
648            }
649        }
650        option = config_options.Theme()
651        self.assertRaises(config_options.ValidationError,
652                          option.post_validation, config, 'theme')
653
654    @tempdir()
655    def test_post_validation_inexisting_custom_dir(self, abs_base_path):
656
657        config = {
658            'theme': {
659                'name': None,
660                'custom_dir': abs_base_path + '/inexisting_custom_dir',
661            }
662        }
663        option = config_options.Theme()
664        self.assertRaises(config_options.ValidationError,
665                          option.post_validation, config, 'theme')
666
667    def test_post_validation_locale_none(self):
668
669        config = {
670            'theme': {
671                'name': 'mkdocs',
672                'locale': None
673            }
674        }
675        option = config_options.Theme()
676        self.assertRaises(config_options.ValidationError,
677                          option.post_validation, config, 'theme')
678
679    def test_post_validation_locale_invalid_type(self):
680
681        config = {
682            'theme': {
683                'name': 'mkdocs',
684                'locale': 0
685            }
686        }
687        option = config_options.Theme()
688        self.assertRaises(config_options.ValidationError,
689                          option.post_validation, config, 'theme')
690
691    def test_post_validation_locale(self):
692
693        config = {
694            'theme': {
695                'name': 'mkdocs',
696                'locale': 'fr'
697            }
698        }
699        option = config_options.Theme()
700        option.post_validation(config, 'theme')
701        self.assertEqual('fr', config['theme']['locale'].language)
702
703
704class NavTest(unittest.TestCase):
705
706    def test_old_format(self):
707
708        option = config_options.Nav()
709        self.assertRaises(
710            config_options.ValidationError,
711            option.validate,
712            [['index.md', ], ]
713        )
714
715    def test_provided_dict(self):
716
717        option = config_options.Nav()
718        value = option.validate([
719            'index.md',
720            {"Page": "page.md"}
721        ])
722        self.assertEqual(['index.md', {'Page': 'page.md'}], value)
723
724        option.post_validation({'extra_stuff': []}, 'extra_stuff')
725
726    def test_provided_empty(self):
727
728        option = config_options.Nav()
729        value = option.validate([])
730        self.assertEqual(None, value)
731
732        option.post_validation({'extra_stuff': []}, 'extra_stuff')
733
734    def test_invalid_type(self):
735
736        option = config_options.Nav()
737        self.assertRaises(config_options.ValidationError,
738                          option.validate, {})
739
740    def test_invalid_config(self):
741
742        option = config_options.Nav()
743        self.assertRaises(config_options.ValidationError,
744                          option.validate, [[], 1])
745
746
747class PrivateTest(unittest.TestCase):
748
749    def test_defined(self):
750
751        option = config_options.Private()
752        self.assertRaises(config_options.ValidationError,
753                          option.validate, 'somevalue')
754
755
756class MarkdownExtensionsTest(unittest.TestCase):
757
758    @patch('markdown.Markdown')
759    def test_simple_list(self, mockMd):
760        option = config_options.MarkdownExtensions()
761        config = {
762            'markdown_extensions': ['foo', 'bar']
763        }
764        config['markdown_extensions'] = option.validate(config['markdown_extensions'])
765        option.post_validation(config, 'markdown_extensions')
766        self.assertEqual({
767            'markdown_extensions': ['foo', 'bar'],
768            'mdx_configs': {}
769        }, config)
770
771    @patch('markdown.Markdown')
772    def test_list_dicts(self, mockMd):
773        option = config_options.MarkdownExtensions()
774        config = {
775            'markdown_extensions': [
776                {'foo': {'foo_option': 'foo value'}},
777                {'bar': {'bar_option': 'bar value'}},
778                {'baz': None}
779            ]
780        }
781        config['markdown_extensions'] = option.validate(config['markdown_extensions'])
782        option.post_validation(config, 'markdown_extensions')
783        self.assertEqual({
784            'markdown_extensions': ['foo', 'bar', 'baz'],
785            'mdx_configs': {
786                'foo': {'foo_option': 'foo value'},
787                'bar': {'bar_option': 'bar value'}
788            }
789        }, config)
790
791    @patch('markdown.Markdown')
792    def test_mixed_list(self, mockMd):
793        option = config_options.MarkdownExtensions()
794        config = {
795            'markdown_extensions': [
796                'foo',
797                {'bar': {'bar_option': 'bar value'}}
798            ]
799        }
800        config['markdown_extensions'] = option.validate(config['markdown_extensions'])
801        option.post_validation(config, 'markdown_extensions')
802        self.assertEqual({
803            'markdown_extensions': ['foo', 'bar'],
804            'mdx_configs': {
805                'bar': {'bar_option': 'bar value'}
806            }
807        }, config)
808
809    @patch('markdown.Markdown')
810    def test_dict_of_dicts(self, mockMd):
811        option = config_options.MarkdownExtensions()
812        config = {
813            'markdown_extensions': {
814                'foo': {'foo_option': 'foo value'},
815                'bar': {'bar_option': 'bar value'},
816                'baz': {}
817            }
818        }
819        config['markdown_extensions'] = option.validate(config['markdown_extensions'])
820        option.post_validation(config, 'markdown_extensions')
821        self.assertEqual({
822            'markdown_extensions': ['foo', 'bar', 'baz'],
823            'mdx_configs': {
824                'foo': {'foo_option': 'foo value'},
825                'bar': {'bar_option': 'bar value'}
826            }
827        }, config)
828
829    @patch('markdown.Markdown')
830    def test_builtins(self, mockMd):
831        option = config_options.MarkdownExtensions(builtins=['meta', 'toc'])
832        config = {
833            'markdown_extensions': ['foo', 'bar']
834        }
835        config['markdown_extensions'] = option.validate(config['markdown_extensions'])
836        option.post_validation(config, 'markdown_extensions')
837        self.assertEqual({
838            'markdown_extensions': ['meta', 'toc', 'foo', 'bar'],
839            'mdx_configs': {}
840        }, config)
841
842    def test_duplicates(self):
843        option = config_options.MarkdownExtensions(builtins=['meta', 'toc'])
844        config = {
845            'markdown_extensions': ['meta', 'toc']
846        }
847        config['markdown_extensions'] = option.validate(config['markdown_extensions'])
848        option.post_validation(config, 'markdown_extensions')
849        self.assertEqual({
850            'markdown_extensions': ['meta', 'toc'],
851            'mdx_configs': {}
852        }, config)
853
854    def test_builtins_config(self):
855        option = config_options.MarkdownExtensions(builtins=['meta', 'toc'])
856        config = {
857            'markdown_extensions': [
858                {'toc': {'permalink': True}}
859            ]
860        }
861        config['markdown_extensions'] = option.validate(config['markdown_extensions'])
862        option.post_validation(config, 'markdown_extensions')
863        self.assertEqual({
864            'markdown_extensions': ['meta', 'toc'],
865            'mdx_configs': {'toc': {'permalink': True}}
866        }, config)
867
868    @patch('markdown.Markdown')
869    def test_configkey(self, mockMd):
870        option = config_options.MarkdownExtensions(configkey='bar')
871        config = {
872            'markdown_extensions': [
873                {'foo': {'foo_option': 'foo value'}}
874            ]
875        }
876        config['markdown_extensions'] = option.validate(config['markdown_extensions'])
877        option.post_validation(config, 'markdown_extensions')
878        self.assertEqual({
879            'markdown_extensions': ['foo'],
880            'bar': {
881                'foo': {'foo_option': 'foo value'}
882            }
883        }, config)
884
885    def test_none(self):
886        option = config_options.MarkdownExtensions(default=[])
887        config = {
888            'markdown_extensions': None
889        }
890        config['markdown_extensions'] = option.validate(config['markdown_extensions'])
891        option.post_validation(config, 'markdown_extensions')
892        self.assertEqual({
893            'markdown_extensions': [],
894            'mdx_configs': {}
895        }, config)
896
897    @patch('markdown.Markdown')
898    def test_not_list(self, mockMd):
899        option = config_options.MarkdownExtensions()
900        self.assertRaises(config_options.ValidationError,
901                          option.validate, 'not a list')
902
903    @patch('markdown.Markdown')
904    def test_invalid_config_option(self, mockMd):
905        option = config_options.MarkdownExtensions()
906        config = {
907            'markdown_extensions': [
908                {'foo': 'not a dict'}
909            ]
910        }
911        self.assertRaises(
912            config_options.ValidationError,
913            option.validate, config['markdown_extensions']
914        )
915
916    @patch('markdown.Markdown')
917    def test_invalid_config_item(self, mockMd):
918        option = config_options.MarkdownExtensions()
919        config = {
920            'markdown_extensions': [
921                ['not a dict']
922            ]
923        }
924        self.assertRaises(
925            config_options.ValidationError,
926            option.validate, config['markdown_extensions']
927        )
928
929    @patch('markdown.Markdown')
930    def test_invalid_dict_item(self, mockMd):
931        option = config_options.MarkdownExtensions()
932        config = {
933            'markdown_extensions': [
934                {'key1': 'value', 'key2': 'too many keys'}
935            ]
936        }
937        self.assertRaises(
938            config_options.ValidationError,
939            option.validate, config['markdown_extensions']
940        )
941
942    def test_unknown_extension(self):
943        option = config_options.MarkdownExtensions()
944        config = {
945            'markdown_extensions': ['unknown']
946        }
947        self.assertRaises(
948            config_options.ValidationError,
949            option.validate, config['markdown_extensions']
950        )
951