Repository: mkdocs/mkdocs
Pull request #3001 (2022-10-13 13:55:07)

URL-encode paths when serving a redirect
When the `site_url` contains utf-8 characters (e.g. http://fake.domain/測試) and uses `mkdocs server` to serve doc, visiting localhost:8000 will cause wsgi encoding error when doing 302 redirects. The error log is as follows:

```
Traceback (most recent call last):
  File "/opt/homebrew/Cellar/python@3.9/3.9.10/Frameworks/Python.framework/Versions/3.9/lib/python3.9/wsgiref/handlers.py", line 138, in run
    self.finish_response()
  File "/opt/homebrew/Cellar/python@3.9/3.9.10/Frameworks/Python.framework/Versions/3.9/lib/python3.9/wsgiref/handlers.py", line 185, in finish_response
    self.finish_content()
  File "/opt/homebrew/Cellar/python@3.9/3.9.10/Frameworks/Python.framework/Versions/3.9/lib/python3.9/wsgiref/handlers.py", line 324, in finish_content
    self.send_headers()
  File "/opt/homebrew/Cellar/python@3.9/3.9.10/Frameworks/Python.framework/Versions/3.9/lib/python3.9/wsgiref/handlers.py", line 347, in send_headers
    self._write(bytes(self.headers))
  File "/opt/homebrew/Cellar/python@3.9/3.9.10/Frameworks/Python.framework/Versions/3.9/lib/python3.9/wsgiref/headers.py", line 142, in __bytes__
    return str(self).encode('iso-8859-1')
UnicodeEncodeError: 'latin-1' codec can't encode characters in position 11-12: ordinal not in range(256)
```

URL encoding those 302 paths could solve this issue. A minimal reproducible `mkdocs.yaml` is as follows:

```yaml
site_name: My Docs
site_url: http://fake.domain/測試
```


Patch:
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index 78e4bd74..1545d5cf 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -966,7 +966,7 @@ class Plugins(OptionallyRequired[plugins.PluginCollection]):
         else:
             # Attempt to load with prepended namespace for the current theme.
             if self.theme_key and self._config:
-                current_theme = self._config[self.theme_key].name
+                current_theme = self._config[self.theme_key]['name']
                 if current_theme:
                     expanded_name = f'{current_theme}/{name}'
                     if expanded_name in self.installed_plugins:
diff --git a/mkdocs/livereload/__init__.py b/mkdocs/livereload/__init__.py
index 540c1755..e0b9dc35 100644
--- a/mkdocs/livereload/__init__.py
+++ b/mkdocs/livereload/__init__.py
@@ -15,6 +15,7 @@ import socketserver
 import string
 import threading
 import time
+import urllib.parse
 import warnings
 import wsgiref.simple_server
 from typing import Any, Callable, Dict, Optional, Tuple
@@ -248,7 +249,7 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
             rel_file_path = posixpath.normpath("/" + rel_file_path).lstrip("/")
             file_path = os.path.join(self.root, rel_file_path)
         elif path == "/":
-            start_response("302 Found", [("Location", self.mount_path)])
+            start_response("302 Found", [("Location", urllib.parse.quote(self.mount_path))])
             return []
         else:
             return None  # Not found
@@ -262,7 +263,7 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
             file = open(file_path, "rb")
         except OSError:
             if not path.endswith("/") and os.path.isfile(os.path.join(file_path, "index.html")):
-                start_response("302 Found", [("Location", path + "/")])
+                start_response("302 Found", [("Location", urllib.parse.quote(path) + "/")])
                 return []
             return None  # Not found
 
diff --git a/mkdocs/localization.py b/mkdocs/localization.py
index 5ec58077..c2eb6fb9 100644
--- a/mkdocs/localization.py
+++ b/mkdocs/localization.py
@@ -33,7 +33,7 @@ class NoBabelExtension(InternationalizationExtension):  # pragma: no cover
         )
 
 
-def parse_locale(locale: str) -> Locale:
+def parse_locale(locale) -> Locale:
     try:
         return Locale.parse(locale, sep='_')
     except (ValueError, UnknownLocaleError, TypeError) as e:
diff --git a/mkdocs/theme.py b/mkdocs/theme.py
index 1475d627..06b69d46 100644
--- a/mkdocs/theme.py
+++ b/mkdocs/theme.py
@@ -31,7 +31,7 @@ class Theme:
 
     def __init__(self, name: Optional[str] = None, **user_config) -> None:
         self.name = name
-        self._vars = {'locale': 'en'}
+        self._vars = {'name': name, 'locale': 'en'}
 
         # MkDocs provided static templates are always included
         package_dir = os.path.abspath(os.path.dirname(__file__))


Test patch:
diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py
index 7144e75f..e0981789 100644
--- a/mkdocs/tests/config/config_tests.py
+++ b/mkdocs/tests/config/config_tests.py
@@ -108,6 +108,7 @@ class ConfigTests(unittest.TestCase):
                 'dirs': [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir],
                 'static_templates': ['404.html', 'sitemap.xml'],
                 'vars': {
+                    'name': 'mkdocs',
                     'locale': parse_locale('en'),
                     'include_search_page': False,
                     'search_index_only': False,
@@ -124,6 +125,7 @@ class ConfigTests(unittest.TestCase):
                 'dirs': [os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir],
                 'static_templates': ['404.html', 'sitemap.xml'],
                 'vars': {
+                    'name': 'readthedocs',
                     'locale': parse_locale('en'),
                     'include_search_page': True,
                     'search_index_only': False,
@@ -143,6 +145,7 @@ class ConfigTests(unittest.TestCase):
                 'dirs': [os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir],
                 'static_templates': ['404.html', 'sitemap.xml'],
                 'vars': {
+                    'name': 'readthedocs',
                     'locale': parse_locale('en'),
                     'include_search_page': True,
                     'search_index_only': False,
@@ -161,12 +164,13 @@ class ConfigTests(unittest.TestCase):
             {
                 'dirs': [mytheme, mkdocs_templates_dir],
                 'static_templates': ['sitemap.xml'],
-                'vars': {'locale': parse_locale('en')},
+                'vars': {'name': None, 'locale': parse_locale('en')},
             },
             {
                 'dirs': [custom, os.path.join(theme_dir, 'readthedocs'), mkdocs_templates_dir],
                 'static_templates': ['404.html', 'sitemap.xml'],
                 'vars': {
+                    'name': 'readthedocs',
                     'locale': parse_locale('en'),
                     'include_search_page': True,
                     'search_index_only': False,
@@ -186,6 +190,7 @@ class ConfigTests(unittest.TestCase):
                 'dirs': [os.path.join(theme_dir, 'mkdocs'), mkdocs_templates_dir],
                 'static_templates': ['404.html', 'sitemap.xml', 'foo.html'],
                 'vars': {
+                    'name': 'mkdocs',
                     'locale': parse_locale('fr'),
                     'show_sidebar': False,
                     'some_var': 'bar',
diff --git a/mkdocs/tests/livereload_tests.py b/mkdocs/tests/livereload_tests.py
index 7c2d762d..91586f6b 100644
--- a/mkdocs/tests/livereload_tests.py
+++ b/mkdocs/tests/livereload_tests.py
@@ -332,7 +332,12 @@ class BuildTests(unittest.TestCase):
                 headers, _ = do_request(server, "GET /foo/index.html/")
             self.assertEqual(headers["_status"], "404 Not Found")
 
-    @tempdir({"foo/bar/index.html": "<body>aaa</body>"})
+    @tempdir(
+        {
+            "foo/bar/index.html": "<body>aaa</body>",
+            "foo/測試/index.html": "<body>bbb</body>",
+        }
+    )
     def test_redirects_to_directory(self, site_dir):
         with testing_server(site_dir, mount_path="/sub") as server:
             with self.assertLogs("mkdocs.livereload"):
@@ -340,6 +345,16 @@ class BuildTests(unittest.TestCase):
             self.assertEqual(headers["_status"], "302 Found")
             self.assertEqual(headers.get("location"), "/sub/foo/bar/")
 
+            with self.assertLogs("mkdocs.livereload"):
+                headers, _ = do_request(server, "GET /sub/foo/測試")
+            self.assertEqual(headers["_status"], "302 Found")
+            self.assertEqual(headers.get("location"), "/sub/foo/%E6%B8%AC%E8%A9%A6/")
+
+            with self.assertLogs("mkdocs.livereload"):
+                headers, _ = do_request(server, "GET /sub/foo/%E6%B8%AC%E8%A9%A6")
+            self.assertEqual(headers["_status"], "302 Found")
+            self.assertEqual(headers.get("location"), "/sub/foo/%E6%B8%AC%E8%A9%A6/")
+
     @tempdir({"я.html": "<body>aaa</body>", "测试2/index.html": "<body>bbb</body>"})
     def test_serves_with_unicode_characters(self, site_dir):
         with testing_server(site_dir) as server:
@@ -472,6 +487,14 @@ class BuildTests(unittest.TestCase):
             self.assertEqual(headers["_status"], "302 Found")
             self.assertEqual(headers.get("location"), "/mount/path/")
 
+    @tempdir()
+    def test_redirects_to_unicode_mount_path(self, site_dir):
+        with testing_server(site_dir, mount_path="/mount/測試") as server:
+            with self.assertLogs("mkdocs.livereload"):
+                headers, _ = do_request(server, "GET /")
+            self.assertEqual(headers["_status"], "302 Found")
+            self.assertEqual(headers.get("location"), "/mount/%E6%B8%AC%E8%A9%A6/")
+
     @tempdir({"mkdocs.yml": "original", "mkdocs2.yml": "original"}, prefix="tmp_dir")
     @tempdir(prefix="origin_dir")
     @tempdir({"subdir/foo.md": "original"}, prefix="dest_docs_dir")
diff --git a/mkdocs/tests/theme_tests.py b/mkdocs/tests/theme_tests.py
index ad8a8d4f..213e2644 100644
--- a/mkdocs/tests/theme_tests.py
+++ b/mkdocs/tests/theme_tests.py
@@ -29,6 +29,7 @@ class ThemeTests(unittest.TestCase):
         self.assertEqual(
             get_vars(theme),
             {
+                'name': 'mkdocs',
                 'locale': parse_locale('en'),
                 'include_search_page': False,
                 'search_index_only': False,