https://github.com/python/cpython/commit/ff92d4b2b8c534e1357963d843cf4bd7c8cdfad7
commit: ff92d4b2b8c534e1357963d843cf4bd7c8cdfad7
branch: 3.15
author: Miss Islington (bot) <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-10-10T12:38:26+03:00
summary:

[3.15] gh-156713: Use the filesystem encoding in nturl2path (GH-156717) 
(GH-157423)

urllib.request.pathname2url() and url2pathname() use the filesystem encoding
and error handler since gh-85168, but nturl2path, which implements them on
Windows before 3.14, was left unchanged.  Paths containing surrogate
characters raised UnicodeEncodeError.
(cherry picked from commit 01599e203e25610421bb5df413f32fa0848f3ade)

Co-authored-by: Serhiy Storchaka <[email protected]>

* gh-156713: Fix test_nturl2path for non-UTF-8 filesystem encodings

Since nturl2path uses the filesystem encoding, the expected results of
quoting non-ASCII characters depend on it.  Use os_helper.FS_NONASCII
and os_helper.TESTFN_UNDECODABLE, and skip the tests when there is no
suitable character or byte sequence for the current encoding.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
(cherry picked from commit 3e1b5d890440ca4ea8f63b16fa27b13ac2a882ef)

---------

Co-authored-by: Serhiy Storchaka <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-08-31-17-10-00.gh-issue-156713.Nt2URL.rst
M Lib/nturl2path.py
M Lib/test/test_nturl2path.py

diff --git a/Lib/nturl2path.py b/Lib/nturl2path.py
index 57c7858dff0b81..47c49ca2020c5c 100644
--- a/Lib/nturl2path.py
+++ b/Lib/nturl2path.py
@@ -22,7 +22,10 @@ def url2pathname(url):
     #   ///C:/foo/bar/spam.foo
     # become
     #   C:\foo\bar\spam.foo
+    import sys
     import urllib.parse
+    encoding = sys.getfilesystemencoding()
+    errors = sys.getfilesystemencodeerrors()
     if url[:3] == '///':
         # URL has an empty authority section, so the path begins on the third
         # character.
@@ -40,7 +43,8 @@ def url2pathname(url):
         if url[1:2] == '|':
             # Older URLs use a pipe after a drive letter
             url = url[:1] + ':' + url[2:]
-    return urllib.parse.unquote(url.replace('/', '\\'))
+    return urllib.parse.unquote(url.replace('/', '\\'),
+                                encoding=encoding, errors=errors)
 
 def pathname2url(p):
     """OS-specific conversion from a file system path to a relative URL
@@ -50,7 +54,10 @@ def pathname2url(p):
     # becomes
     #   ///C:/foo/bar/spam.foo
     import ntpath
+    import sys
     import urllib.parse
+    encoding = sys.getfilesystemencoding()
+    errors = sys.getfilesystemencodeerrors()
     # First, clean up some special forms. We are going to sacrifice
     # the additional information anyway
     p = p.replace('\\', '/')
@@ -65,10 +72,11 @@ def pathname2url(p):
             # an authority section with a zero-length authority, and a path
             # section starting with a single slash.
             drive = f'///{drive}'
-        drive = urllib.parse.quote(drive, safe='/:')
+        drive = urllib.parse.quote(drive, encoding=encoding, errors=errors,
+                                   safe='/:')
     elif root:
         # Add explicitly empty authority to path beginning with one slash.
         root = f'//{root}'
 
-    tail = urllib.parse.quote(tail)
+    tail = urllib.parse.quote(tail, encoding=encoding, errors=errors)
     return drive + root + tail
diff --git a/Lib/test/test_nturl2path.py b/Lib/test/test_nturl2path.py
index a6a3422a0f75b2..56e47aef5fe4c5 100644
--- a/Lib/test/test_nturl2path.py
+++ b/Lib/test/test_nturl2path.py
@@ -1,5 +1,9 @@
+import os
+import sys
 import unittest
+import urllib.parse
 
+from test.support import os_helper
 from test.support import warnings_helper
 
 
@@ -34,7 +38,6 @@ def test_pathname2url(self):
         self.assertEqual(fn('C:\\a\\b.c\\'), '///C:/a/b.c/')
         self.assertEqual(fn('C:\\a\\\\b.c'), '///C:/a//b.c')
         self.assertEqual(fn('C:\\a\\b%#c'), '///C:/a/b%25%23c')
-        self.assertEqual(fn('C:\\a\\b\xe9'), '///C:/a/b%C3%A9')
         self.assertEqual(fn('C:\\foo\\bar\\spam.foo'), 
"///C:/foo/bar/spam.foo")
         # NTFS alternate data streams
         self.assertEqual(fn('C:\\foo:bar'), '///C:/foo%3Abar')
@@ -45,7 +48,7 @@ def test_pathname2url(self):
         self.assertEqual(fn("\\\\\\folder\\test\\"), '///folder/test/')
         self.assertEqual(fn('\\\\some\\share\\'), '//some/share/')
         self.assertEqual(fn('\\\\some\\share\\a\\b.c'), '//some/share/a/b.c')
-        self.assertEqual(fn('\\\\some\\share\\a\\b%#c\xe9'), 
'//some/share/a/b%25%23c%C3%A9')
+        self.assertEqual(fn('\\\\some\\share\\a\\b%#c'), 
'//some/share/a/b%25%23c')
         # Alternate path separator
         self.assertEqual(fn('C:/a/b.c'), '///C:/a/b.c')
         self.assertEqual(fn('//some/share/a/b.c'), '//some/share/a/b.c')
@@ -58,6 +61,29 @@ def test_pathname2url(self):
         for url in urls:
             self.assertEqual(fn(nturl2path.url2pathname(url)), url)
 
+    @unittest.skipUnless(os_helper.FS_NONASCII, 'need os_helper.FS_NONASCII')
+    def test_pathname2url_nonascii(self):
+        encoding = sys.getfilesystemencoding()
+        errors = sys.getfilesystemencodeerrors()
+        char = os_helper.FS_NONASCII
+        quoted = urllib.parse.quote(char, encoding=encoding, errors=errors)
+        self.assertEqual(nturl2path.pathname2url(f'C:\\a\\b{char}'),
+                         '///C:/a/b' + quoted)
+        
self.assertEqual(nturl2path.pathname2url(f'\\\\some\\share\\a\\b{char}'),
+                         '//some/share/a/b' + quoted)
+
+    @unittest.skipUnless(os_helper.TESTFN_UNDECODABLE,
+                         'need os_helper.TESTFN_UNDECODABLE')
+    def test_pathname2url_surrogates(self):
+        # gh-156713: the filesystem encoding and error handler are used,
+        # so that paths containing surrogate characters can be converted.
+        encoding = sys.getfilesystemencoding()
+        errors = sys.getfilesystemencodeerrors()
+        path = os.fsdecode(os_helper.TESTFN_UNDECODABLE)
+        url = urllib.parse.quote(path, encoding=encoding, errors=errors)
+        self.assertEqual(nturl2path.pathname2url('C:\\' + path),
+                         '///C:/' + url)
+
     def test_url2pathname(self):
         fn = nturl2path.url2pathname
         self.assertEqual(fn('/'), '\\')
@@ -103,5 +129,18 @@ def test_url2pathname(self):
             self.assertEqual(fn(nturl2path.pathname2url(path)), path)
 
 
+    @unittest.skipUnless(os_helper.TESTFN_UNDECODABLE,
+                         'need os_helper.TESTFN_UNDECODABLE')
+    def test_url2pathname_surrogates(self):
+        # gh-156713: the filesystem encoding and error handler are used, so
+        # that URLs containing percent-encoded surrogates can be converted.
+        encoding = sys.getfilesystemencoding()
+        errors = sys.getfilesystemencodeerrors()
+        path = os.fsdecode(os_helper.TESTFN_UNDECODABLE)
+        url = urllib.parse.quote(path, encoding=encoding, errors=errors)
+        self.assertEqual(nturl2path.url2pathname('///C:/' + url),
+                         'C:\\' + path)
+
+
 if __name__ == '__main__':
     unittest.main()
diff --git 
a/Misc/NEWS.d/next/Library/2026-08-31-17-10-00.gh-issue-156713.Nt2URL.rst 
b/Misc/NEWS.d/next/Library/2026-08-31-17-10-00.gh-issue-156713.Nt2URL.rst
new file mode 100644
index 00000000000000..1d21fc70fd9256
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-31-17-10-00.gh-issue-156713.Nt2URL.rst
@@ -0,0 +1,4 @@
+Fix :func:`!nturl2path.pathname2url` and :func:`!nturl2path.url2pathname`:
+the filesystem encoding and error handler are now used for percent-encoding
+and decoding, as in :mod:`urllib.request`.  Previously paths containing
+surrogate characters raised :exc:`UnicodeEncodeError`.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to