https://github.com/python/cpython/commit/7e31d6dea276ac91402aefb023c58d239dfd9246
commit: 7e31d6dea276ac91402aefb023c58d239dfd9246
branch: main
author: Barney Gale <barney.g...@gmail.com>
committer: barneygale <barney.g...@gmail.com>
date: 2024-01-26T18:14:24Z
summary:

gh-88569: add `ntpath.isreserved()` (#95486)

Add `ntpath.isreserved()`, which identifies reserved pathnames such as "NUL", 
"AUX" and "CON".

Deprecate `pathlib.PurePath.is_reserved()`.

---------

Co-authored-by: Eryk Sun <eryk...@gmail.com>
Co-authored-by: Brett Cannon <br...@python.org>
Co-authored-by: Steve Dower <steve.do...@microsoft.com>

files:
A Misc/NEWS.d/next/Library/2022-07-31-01-24-40.gh-issue-88569.eU0--b.rst
M Doc/library/os.path.rst
M Doc/library/pathlib.rst
M Doc/whatsnew/3.13.rst
M Lib/ntpath.py
M Lib/pathlib/__init__.py
M Lib/test/test_ntpath.py
M Lib/test/test_pathlib/test_pathlib.py

diff --git a/Doc/library/os.path.rst b/Doc/library/os.path.rst
index 3cab7a260df008..34bc76b231de92 100644
--- a/Doc/library/os.path.rst
+++ b/Doc/library/os.path.rst
@@ -326,6 +326,28 @@ the :mod:`glob` module.)
    .. versionadded:: 3.12
 
 
+.. function:: isreserved(path)
+
+   Return ``True`` if *path* is a reserved pathname on the current system.
+
+   On Windows, reserved filenames include those that end with a space or dot;
+   those that contain colons (i.e. file streams such as "name:stream"),
+   wildcard characters (i.e. ``'*?"<>'``), pipe, or ASCII control characters;
+   as well as DOS device names such as "NUL", "CON", "CONIN$", "CONOUT$",
+   "AUX", "PRN", "COM1", and "LPT1".
+
+   .. note::
+
+      This function approximates rules for reserved paths on most Windows
+      systems. These rules change over time in various Windows releases.
+      This function may be updated in future Python releases as changes to
+      the rules become broadly available.
+
+   .. availability:: Windows.
+
+   .. versionadded:: 3.13
+
+
 .. function:: join(path, *paths)
 
    Join one or more path segments intelligently.  The return value is the
diff --git a/Doc/library/pathlib.rst b/Doc/library/pathlib.rst
index 2f4ff4efec47f8..f1aba793fda03e 100644
--- a/Doc/library/pathlib.rst
+++ b/Doc/library/pathlib.rst
@@ -535,14 +535,13 @@ Pure paths provide the following methods and properties:
    reserved under Windows, ``False`` otherwise.  With :class:`PurePosixPath`,
    ``False`` is always returned.
 
-      >>> PureWindowsPath('nul').is_reserved()
-      True
-      >>> PurePosixPath('nul').is_reserved()
-      False
-
-   File system calls on reserved paths can fail mysteriously or have
-   unintended effects.
+   .. versionchanged:: 3.13
+      Windows path names that contain a colon, or end with a dot or a space,
+      are considered reserved. UNC paths may be reserved.
 
+   .. deprecated-removed:: 3.13 3.15
+      This method is deprecated; use :func:`os.path.isreserved` to detect
+      reserved paths on Windows.
 
 .. method:: PurePath.joinpath(*pathsegments)
 
diff --git a/Doc/whatsnew/3.13.rst b/Doc/whatsnew/3.13.rst
index 8c2bb05920d5b6..985e34b453f63a 100644
--- a/Doc/whatsnew/3.13.rst
+++ b/Doc/whatsnew/3.13.rst
@@ -321,6 +321,9 @@ os
 os.path
 -------
 
+* Add :func:`os.path.isreserved` to check if a path is reserved on the current
+  system. This function is only available on Windows.
+  (Contributed by Barney Gale in :gh:`88569`.)
 * On Windows, :func:`os.path.isabs` no longer considers paths starting with
   exactly one (back)slash to be absolute.
   (Contributed by Barney Gale and Jon Foster in :gh:`44626`.)
@@ -498,6 +501,12 @@ Deprecated
   security and functionality bugs.  This includes removal of the ``--cgi``
   flag to the ``python -m http.server`` command line in 3.15.
 
+* :mod:`pathlib`:
+
+  * :meth:`pathlib.PurePath.is_reserved` is deprecated and scheduled for
+    removal in Python 3.15. Use :func:`os.path.isreserved` to detect reserved
+    paths on Windows.
+
 * :mod:`sys`: :func:`sys._enablelegacywindowsfsencoding` function.
   Replace it with :envvar:`PYTHONLEGACYWINDOWSFSENCODING` environment variable.
   (Contributed by Inada Naoki in :gh:`73427`.)
@@ -709,6 +718,12 @@ Pending Removal in Python 3.15
   :func:`locale.getlocale()` instead.
   (Contributed by Hugo van Kemenade in :gh:`111187`.)
 
+* :mod:`pathlib`:
+
+  * :meth:`pathlib.PurePath.is_reserved` is deprecated and scheduled for
+    removal in Python 3.15. Use :func:`os.path.isreserved` to detect reserved
+    paths on Windows.
+
 * :class:`typing.NamedTuple`:
 
   * The undocumented keyword argument syntax for creating NamedTuple classes
diff --git a/Lib/ntpath.py b/Lib/ntpath.py
index aa0e018eb668c2..e7cbfe17ecb3c8 100644
--- a/Lib/ntpath.py
+++ b/Lib/ntpath.py
@@ -26,8 +26,8 @@
 __all__ = 
["normcase","isabs","join","splitdrive","splitroot","split","splitext",
            "basename","dirname","commonprefix","getsize","getmtime",
            "getatime","getctime", "islink","exists","lexists","isdir","isfile",
-           "ismount", "expanduser","expandvars","normpath","abspath",
-           "curdir","pardir","sep","pathsep","defpath","altsep",
+           "ismount","isreserved","expanduser","expandvars","normpath",
+           "abspath","curdir","pardir","sep","pathsep","defpath","altsep",
            
"extsep","devnull","realpath","supports_unicode_filenames","relpath",
            "samefile", "sameopenfile", "samestat", "commonpath", "isjunction"]
 
@@ -330,6 +330,42 @@ def ismount(path):
         return False
 
 
+_reserved_chars = frozenset(
+    {chr(i) for i in range(32)} |
+    {'"', '*', ':', '<', '>', '?', '|', '/', '\\'}
+)
+
+_reserved_names = frozenset(
+    {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} |
+    {f'COM{c}' for c in '123456789\xb9\xb2\xb3'} |
+    {f'LPT{c}' for c in '123456789\xb9\xb2\xb3'}
+)
+
+def isreserved(path):
+    """Return true if the pathname is reserved by the system."""
+    # Refer to "Naming Files, Paths, and Namespaces":
+    # https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
+    path = os.fsdecode(splitroot(path)[2]).replace(altsep, sep)
+    return any(_isreservedname(name) for name in reversed(path.split(sep)))
+
+def _isreservedname(name):
+    """Return true if the filename is reserved by the system."""
+    # Trailing dots and spaces are reserved.
+    if name.endswith(('.', ' ')) and name not in ('.', '..'):
+        return True
+    # Wildcards, separators, colon, and pipe (*?"<>/\:|) are reserved.
+    # ASCII control characters (0-31) are reserved.
+    # Colon is reserved for file streams (e.g. "name:stream[:type]").
+    if _reserved_chars.intersection(name):
+        return True
+    # DOS device names are reserved (e.g. "nul" or "nul .txt"). The rules
+    # are complex and vary across Windows versions. On the side of
+    # caution, return True for names that may not be reserved.
+    if name.partition('.')[0].rstrip(' ').upper() in _reserved_names:
+        return True
+    return False
+
+
 # Expand paths beginning with '~' or '~user'.
 # '~' means $HOME; '~user' means that user's home directory.
 # If the path doesn't begin with '~', or if the user or $HOME is unknown,
diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py
index eee82ef26bc7e7..cc159edab5796f 100644
--- a/Lib/pathlib/__init__.py
+++ b/Lib/pathlib/__init__.py
@@ -33,15 +33,6 @@
     ]
 
 
-# Reference for Windows paths can be found at
-# https://learn.microsoft.com/en-gb/windows/win32/fileio/naming-a-file .
-_WIN_RESERVED_NAMES = frozenset(
-    {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} |
-    {f'COM{c}' for c in '123456789\xb9\xb2\xb3'} |
-    {f'LPT{c}' for c in '123456789\xb9\xb2\xb3'}
-)
-
-
 class _PathParents(Sequence):
     """This object provides sequence-like access to the logical ancestors
     of a path.  Don't try to construct it yourself."""
@@ -433,18 +424,13 @@ def is_absolute(self):
     def is_reserved(self):
         """Return True if the path contains one of the special names reserved
         by the system, if any."""
-        if self.pathmod is not ntpath or not self.name:
-            return False
-
-        # NOTE: the rules for reserved names seem somewhat complicated
-        # (e.g. r"..\NUL" is reserved but not r"foo\NUL" if "foo" does not
-        # exist). We err on the side of caution and return True for paths
-        # which are not considered reserved by Windows.
-        if self.drive.startswith('\\\\'):
-            # UNC paths are never reserved.
-            return False
-        name = self.name.partition('.')[0].partition(':')[0].rstrip(' ')
-        return name.upper() in _WIN_RESERVED_NAMES
+        msg = ("pathlib.PurePath.is_reserved() is deprecated and scheduled "
+               "for removal in Python 3.15. Use os.path.isreserved() to "
+               "detect reserved paths on Windows.")
+        warnings.warn(msg, DeprecationWarning, stacklevel=2)
+        if self.pathmod is ntpath:
+            return self.pathmod.isreserved(self)
+        return False
 
     def as_uri(self):
         """Return the path as a URI."""
diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py
index aefcb98f1c30eb..9cb03e3cd5de8d 100644
--- a/Lib/test/test_ntpath.py
+++ b/Lib/test/test_ntpath.py
@@ -981,6 +981,62 @@ def test_ismount(self):
             self.assertTrue(ntpath.ismount(b"\\\\localhost\\c$"))
             self.assertTrue(ntpath.ismount(b"\\\\localhost\\c$\\"))
 
+    def test_isreserved(self):
+        self.assertFalse(ntpath.isreserved(''))
+        self.assertFalse(ntpath.isreserved('.'))
+        self.assertFalse(ntpath.isreserved('..'))
+        self.assertFalse(ntpath.isreserved('/'))
+        self.assertFalse(ntpath.isreserved('/foo/bar'))
+        # A name that ends with a space or dot is reserved.
+        self.assertTrue(ntpath.isreserved('foo.'))
+        self.assertTrue(ntpath.isreserved('foo '))
+        # ASCII control characters are reserved.
+        self.assertTrue(ntpath.isreserved('\foo'))
+        # Wildcard characters, colon, and pipe are reserved.
+        self.assertTrue(ntpath.isreserved('foo*bar'))
+        self.assertTrue(ntpath.isreserved('foo?bar'))
+        self.assertTrue(ntpath.isreserved('foo"bar'))
+        self.assertTrue(ntpath.isreserved('foo<bar'))
+        self.assertTrue(ntpath.isreserved('foo>bar'))
+        self.assertTrue(ntpath.isreserved('foo:bar'))
+        self.assertTrue(ntpath.isreserved('foo|bar'))
+        # Case-insensitive DOS-device names are reserved.
+        self.assertTrue(ntpath.isreserved('nul'))
+        self.assertTrue(ntpath.isreserved('aux'))
+        self.assertTrue(ntpath.isreserved('prn'))
+        self.assertTrue(ntpath.isreserved('con'))
+        self.assertTrue(ntpath.isreserved('conin$'))
+        self.assertTrue(ntpath.isreserved('conout$'))
+        # COM/LPT + 1-9 or + superscript 1-3 are reserved.
+        self.assertTrue(ntpath.isreserved('COM1'))
+        self.assertTrue(ntpath.isreserved('LPT9'))
+        self.assertTrue(ntpath.isreserved('com\xb9'))
+        self.assertTrue(ntpath.isreserved('com\xb2'))
+        self.assertTrue(ntpath.isreserved('lpt\xb3'))
+        # DOS-device name matching ignores characters after a dot or
+        # a colon and also ignores trailing spaces.
+        self.assertTrue(ntpath.isreserved('NUL.txt'))
+        self.assertTrue(ntpath.isreserved('PRN  '))
+        self.assertTrue(ntpath.isreserved('AUX  .txt'))
+        self.assertTrue(ntpath.isreserved('COM1:bar'))
+        self.assertTrue(ntpath.isreserved('LPT9   :bar'))
+        # DOS-device names are only matched at the beginning
+        # of a path component.
+        self.assertFalse(ntpath.isreserved('bar.com9'))
+        self.assertFalse(ntpath.isreserved('bar.lpt9'))
+        # The entire path is checked, except for the drive.
+        self.assertTrue(ntpath.isreserved('c:/bar/baz/NUL'))
+        self.assertTrue(ntpath.isreserved('c:/NUL/bar/baz'))
+        self.assertFalse(ntpath.isreserved('//./NUL'))
+        # Bytes are supported.
+        self.assertFalse(ntpath.isreserved(b''))
+        self.assertFalse(ntpath.isreserved(b'.'))
+        self.assertFalse(ntpath.isreserved(b'..'))
+        self.assertFalse(ntpath.isreserved(b'/'))
+        self.assertFalse(ntpath.isreserved(b'/foo/bar'))
+        self.assertTrue(ntpath.isreserved(b'foo.'))
+        self.assertTrue(ntpath.isreserved(b'nul'))
+
     def assertEqualCI(self, s1, s2):
         """Assert that two strings are equal ignoring case differences."""
         self.assertEqual(s1.lower(), s2.lower())
diff --git a/Lib/test/test_pathlib/test_pathlib.py 
b/Lib/test/test_pathlib/test_pathlib.py
index bdbe92369639ef..2da3afdd198015 100644
--- a/Lib/test/test_pathlib/test_pathlib.py
+++ b/Lib/test/test_pathlib/test_pathlib.py
@@ -349,6 +349,12 @@ def test_is_relative_to_several_args(self):
         with self.assertWarns(DeprecationWarning):
             p.is_relative_to('a', 'b')
 
+    def test_is_reserved_deprecated(self):
+        P = self.cls
+        p = P('a/b')
+        with self.assertWarns(DeprecationWarning):
+            p.is_reserved()
+
     def test_match_empty(self):
         P = self.cls
         self.assertRaises(ValueError, P('a').match, '')
@@ -414,13 +420,6 @@ def test_is_absolute(self):
         self.assertTrue(P('//a').is_absolute())
         self.assertTrue(P('//a/b').is_absolute())
 
-    def test_is_reserved(self):
-        P = self.cls
-        self.assertIs(False, P('').is_reserved())
-        self.assertIs(False, P('/').is_reserved())
-        self.assertIs(False, P('/foo/bar').is_reserved())
-        self.assertIs(False, P('/dev/con/PRN/NUL').is_reserved())
-
     def test_join(self):
         P = self.cls
         p = P('//a')
@@ -1082,41 +1081,6 @@ def test_div(self):
         self.assertEqual(p / P('./dd:s'), P('C:/a/b/dd:s'))
         self.assertEqual(p / P('E:d:s'), P('E:d:s'))
 
-    def test_is_reserved(self):
-        P = self.cls
-        self.assertIs(False, P('').is_reserved())
-        self.assertIs(False, P('/').is_reserved())
-        self.assertIs(False, P('/foo/bar').is_reserved())
-        # UNC paths are never reserved.
-        self.assertIs(False, P('//my/share/nul/con/aux').is_reserved())
-        # Case-insensitive DOS-device names are reserved.
-        self.assertIs(True, P('nul').is_reserved())
-        self.assertIs(True, P('aux').is_reserved())
-        self.assertIs(True, P('prn').is_reserved())
-        self.assertIs(True, P('con').is_reserved())
-        self.assertIs(True, P('conin$').is_reserved())
-        self.assertIs(True, P('conout$').is_reserved())
-        # COM/LPT + 1-9 or + superscript 1-3 are reserved.
-        self.assertIs(True, P('COM1').is_reserved())
-        self.assertIs(True, P('LPT9').is_reserved())
-        self.assertIs(True, P('com\xb9').is_reserved())
-        self.assertIs(True, P('com\xb2').is_reserved())
-        self.assertIs(True, P('lpt\xb3').is_reserved())
-        # DOS-device name mataching ignores characters after a dot or
-        # a colon and also ignores trailing spaces.
-        self.assertIs(True, P('NUL.txt').is_reserved())
-        self.assertIs(True, P('PRN  ').is_reserved())
-        self.assertIs(True, P('AUX  .txt').is_reserved())
-        self.assertIs(True, P('COM1:bar').is_reserved())
-        self.assertIs(True, P('LPT9   :bar').is_reserved())
-        # DOS-device names are only matched at the beginning
-        # of a path component.
-        self.assertIs(False, P('bar.com9').is_reserved())
-        self.assertIs(False, P('bar.lpt9').is_reserved())
-        # Only the last path component matters.
-        self.assertIs(True, P('c:/baz/con/NUL').is_reserved())
-        self.assertIs(False, P('c:/NUL/con/baz').is_reserved())
-
 
 class PurePathSubclassTest(PurePathTest):
     class cls(pathlib.PurePath):
diff --git 
a/Misc/NEWS.d/next/Library/2022-07-31-01-24-40.gh-issue-88569.eU0--b.rst 
b/Misc/NEWS.d/next/Library/2022-07-31-01-24-40.gh-issue-88569.eU0--b.rst
new file mode 100644
index 00000000000000..31dd985bb5c3b6
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2022-07-31-01-24-40.gh-issue-88569.eU0--b.rst
@@ -0,0 +1,4 @@
+Add :func:`os.path.isreserved`, which identifies reserved pathnames such
+as "NUL", "AUX" and "CON". This function is only available on Windows.
+
+Deprecate :meth:`pathlib.PurePath.is_reserved`.

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-le...@python.org
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: arch...@mail-archive.com

Reply via email to