Skip to content

Commit 7e31d6d

Browse files
barneygaleeryksunbrettcannonzooba
authored
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 <[email protected]> Co-authored-by: Brett Cannon <[email protected]> Co-authored-by: Steve Dower <[email protected]>
1 parent 6c2b419 commit 7e31d6d

File tree

8 files changed

+154
-72
lines changed

8 files changed

+154
-72
lines changed

Doc/library/os.path.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,28 @@ the :mod:`glob` module.)
326326
.. versionadded:: 3.12
327327

328328

329+
.. function:: isreserved(path)
330+
331+
Return ``True`` if *path* is a reserved pathname on the current system.
332+
333+
On Windows, reserved filenames include those that end with a space or dot;
334+
those that contain colons (i.e. file streams such as "name:stream"),
335+
wildcard characters (i.e. ``'*?"<>'``), pipe, or ASCII control characters;
336+
as well as DOS device names such as "NUL", "CON", "CONIN$", "CONOUT$",
337+
"AUX", "PRN", "COM1", and "LPT1".
338+
339+
.. note::
340+
341+
This function approximates rules for reserved paths on most Windows
342+
systems. These rules change over time in various Windows releases.
343+
This function may be updated in future Python releases as changes to
344+
the rules become broadly available.
345+
346+
.. availability:: Windows.
347+
348+
.. versionadded:: 3.13
349+
350+
329351
.. function:: join(path, *paths)
330352

331353
Join one or more path segments intelligently. The return value is the

Doc/library/pathlib.rst

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -535,14 +535,13 @@ Pure paths provide the following methods and properties:
535535
reserved under Windows, ``False`` otherwise. With :class:`PurePosixPath`,
536536
``False`` is always returned.
537537

538-
>>> PureWindowsPath('nul').is_reserved()
539-
True
540-
>>> PurePosixPath('nul').is_reserved()
541-
False
542-
543-
File system calls on reserved paths can fail mysteriously or have
544-
unintended effects.
538+
.. versionchanged:: 3.13
539+
Windows path names that contain a colon, or end with a dot or a space,
540+
are considered reserved. UNC paths may be reserved.
545541

542+
.. deprecated-removed:: 3.13 3.15
543+
This method is deprecated; use :func:`os.path.isreserved` to detect
544+
reserved paths on Windows.
546545

547546
.. method:: PurePath.joinpath(*pathsegments)
548547

Doc/whatsnew/3.13.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,9 @@ os
321321
os.path
322322
-------
323323

324+
* Add :func:`os.path.isreserved` to check if a path is reserved on the current
325+
system. This function is only available on Windows.
326+
(Contributed by Barney Gale in :gh:`88569`.)
324327
* On Windows, :func:`os.path.isabs` no longer considers paths starting with
325328
exactly one (back)slash to be absolute.
326329
(Contributed by Barney Gale and Jon Foster in :gh:`44626`.)
@@ -498,6 +501,12 @@ Deprecated
498501
security and functionality bugs. This includes removal of the ``--cgi``
499502
flag to the ``python -m http.server`` command line in 3.15.
500503

504+
* :mod:`pathlib`:
505+
506+
* :meth:`pathlib.PurePath.is_reserved` is deprecated and scheduled for
507+
removal in Python 3.15. Use :func:`os.path.isreserved` to detect reserved
508+
paths on Windows.
509+
501510
* :mod:`sys`: :func:`sys._enablelegacywindowsfsencoding` function.
502511
Replace it with :envvar:`PYTHONLEGACYWINDOWSFSENCODING` environment variable.
503512
(Contributed by Inada Naoki in :gh:`73427`.)
@@ -709,6 +718,12 @@ Pending Removal in Python 3.15
709718
:func:`locale.getlocale()` instead.
710719
(Contributed by Hugo van Kemenade in :gh:`111187`.)
711720

721+
* :mod:`pathlib`:
722+
723+
* :meth:`pathlib.PurePath.is_reserved` is deprecated and scheduled for
724+
removal in Python 3.15. Use :func:`os.path.isreserved` to detect reserved
725+
paths on Windows.
726+
712727
* :class:`typing.NamedTuple`:
713728

714729
* The undocumented keyword argument syntax for creating NamedTuple classes

Lib/ntpath.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
__all__ = ["normcase","isabs","join","splitdrive","splitroot","split","splitext",
2727
"basename","dirname","commonprefix","getsize","getmtime",
2828
"getatime","getctime", "islink","exists","lexists","isdir","isfile",
29-
"ismount", "expanduser","expandvars","normpath","abspath",
30-
"curdir","pardir","sep","pathsep","defpath","altsep",
29+
"ismount","isreserved","expanduser","expandvars","normpath",
30+
"abspath","curdir","pardir","sep","pathsep","defpath","altsep",
3131
"extsep","devnull","realpath","supports_unicode_filenames","relpath",
3232
"samefile", "sameopenfile", "samestat", "commonpath", "isjunction"]
3333

@@ -330,6 +330,42 @@ def ismount(path):
330330
return False
331331

332332

333+
_reserved_chars = frozenset(
334+
{chr(i) for i in range(32)} |
335+
{'"', '*', ':', '<', '>', '?', '|', '/', '\\'}
336+
)
337+
338+
_reserved_names = frozenset(
339+
{'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} |
340+
{f'COM{c}' for c in '123456789\xb9\xb2\xb3'} |
341+
{f'LPT{c}' for c in '123456789\xb9\xb2\xb3'}
342+
)
343+
344+
def isreserved(path):
345+
"""Return true if the pathname is reserved by the system."""
346+
# Refer to "Naming Files, Paths, and Namespaces":
347+
# https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
348+
path = os.fsdecode(splitroot(path)[2]).replace(altsep, sep)
349+
return any(_isreservedname(name) for name in reversed(path.split(sep)))
350+
351+
def _isreservedname(name):
352+
"""Return true if the filename is reserved by the system."""
353+
# Trailing dots and spaces are reserved.
354+
if name.endswith(('.', ' ')) and name not in ('.', '..'):
355+
return True
356+
# Wildcards, separators, colon, and pipe (*?"<>/\:|) are reserved.
357+
# ASCII control characters (0-31) are reserved.
358+
# Colon is reserved for file streams (e.g. "name:stream[:type]").
359+
if _reserved_chars.intersection(name):
360+
return True
361+
# DOS device names are reserved (e.g. "nul" or "nul .txt"). The rules
362+
# are complex and vary across Windows versions. On the side of
363+
# caution, return True for names that may not be reserved.
364+
if name.partition('.')[0].rstrip(' ').upper() in _reserved_names:
365+
return True
366+
return False
367+
368+
333369
# Expand paths beginning with '~' or '~user'.
334370
# '~' means $HOME; '~user' means that user's home directory.
335371
# If the path doesn't begin with '~', or if the user or $HOME is unknown,

Lib/pathlib/__init__.py

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,6 @@
3333
]
3434

3535

36-
# Reference for Windows paths can be found at
37-
# https://learn.microsoft.com/en-gb/windows/win32/fileio/naming-a-file .
38-
_WIN_RESERVED_NAMES = frozenset(
39-
{'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} |
40-
{f'COM{c}' for c in '123456789\xb9\xb2\xb3'} |
41-
{f'LPT{c}' for c in '123456789\xb9\xb2\xb3'}
42-
)
43-
44-
4536
class _PathParents(Sequence):
4637
"""This object provides sequence-like access to the logical ancestors
4738
of a path. Don't try to construct it yourself."""
@@ -433,18 +424,13 @@ def is_absolute(self):
433424
def is_reserved(self):
434425
"""Return True if the path contains one of the special names reserved
435426
by the system, if any."""
436-
if self.pathmod is not ntpath or not self.name:
437-
return False
438-
439-
# NOTE: the rules for reserved names seem somewhat complicated
440-
# (e.g. r"..\NUL" is reserved but not r"foo\NUL" if "foo" does not
441-
# exist). We err on the side of caution and return True for paths
442-
# which are not considered reserved by Windows.
443-
if self.drive.startswith('\\\\'):
444-
# UNC paths are never reserved.
445-
return False
446-
name = self.name.partition('.')[0].partition(':')[0].rstrip(' ')
447-
return name.upper() in _WIN_RESERVED_NAMES
427+
msg = ("pathlib.PurePath.is_reserved() is deprecated and scheduled "
428+
"for removal in Python 3.15. Use os.path.isreserved() to "
429+
"detect reserved paths on Windows.")
430+
warnings.warn(msg, DeprecationWarning, stacklevel=2)
431+
if self.pathmod is ntpath:
432+
return self.pathmod.isreserved(self)
433+
return False
448434

449435
def as_uri(self):
450436
"""Return the path as a URI."""

Lib/test/test_ntpath.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,62 @@ def test_ismount(self):
981981
self.assertTrue(ntpath.ismount(b"\\\\localhost\\c$"))
982982
self.assertTrue(ntpath.ismount(b"\\\\localhost\\c$\\"))
983983

984+
def test_isreserved(self):
985+
self.assertFalse(ntpath.isreserved(''))
986+
self.assertFalse(ntpath.isreserved('.'))
987+
self.assertFalse(ntpath.isreserved('..'))
988+
self.assertFalse(ntpath.isreserved('/'))
989+
self.assertFalse(ntpath.isreserved('/foo/bar'))
990+
# A name that ends with a space or dot is reserved.
991+
self.assertTrue(ntpath.isreserved('foo.'))
992+
self.assertTrue(ntpath.isreserved('foo '))
993+
# ASCII control characters are reserved.
994+
self.assertTrue(ntpath.isreserved('\foo'))
995+
# Wildcard characters, colon, and pipe are reserved.
996+
self.assertTrue(ntpath.isreserved('foo*bar'))
997+
self.assertTrue(ntpath.isreserved('foo?bar'))
998+
self.assertTrue(ntpath.isreserved('foo"bar'))
999+
self.assertTrue(ntpath.isreserved('foo<bar'))
1000+
self.assertTrue(ntpath.isreserved('foo>bar'))
1001+
self.assertTrue(ntpath.isreserved('foo:bar'))
1002+
self.assertTrue(ntpath.isreserved('foo|bar'))
1003+
# Case-insensitive DOS-device names are reserved.
1004+
self.assertTrue(ntpath.isreserved('nul'))
1005+
self.assertTrue(ntpath.isreserved('aux'))
1006+
self.assertTrue(ntpath.isreserved('prn'))
1007+
self.assertTrue(ntpath.isreserved('con'))
1008+
self.assertTrue(ntpath.isreserved('conin$'))
1009+
self.assertTrue(ntpath.isreserved('conout$'))
1010+
# COM/LPT + 1-9 or + superscript 1-3 are reserved.
1011+
self.assertTrue(ntpath.isreserved('COM1'))
1012+
self.assertTrue(ntpath.isreserved('LPT9'))
1013+
self.assertTrue(ntpath.isreserved('com\xb9'))
1014+
self.assertTrue(ntpath.isreserved('com\xb2'))
1015+
self.assertTrue(ntpath.isreserved('lpt\xb3'))
1016+
# DOS-device name matching ignores characters after a dot or
1017+
# a colon and also ignores trailing spaces.
1018+
self.assertTrue(ntpath.isreserved('NUL.txt'))
1019+
self.assertTrue(ntpath.isreserved('PRN '))
1020+
self.assertTrue(ntpath.isreserved('AUX .txt'))
1021+
self.assertTrue(ntpath.isreserved('COM1:bar'))
1022+
self.assertTrue(ntpath.isreserved('LPT9 :bar'))
1023+
# DOS-device names are only matched at the beginning
1024+
# of a path component.
1025+
self.assertFalse(ntpath.isreserved('bar.com9'))
1026+
self.assertFalse(ntpath.isreserved('bar.lpt9'))
1027+
# The entire path is checked, except for the drive.
1028+
self.assertTrue(ntpath.isreserved('c:/bar/baz/NUL'))
1029+
self.assertTrue(ntpath.isreserved('c:/NUL/bar/baz'))
1030+
self.assertFalse(ntpath.isreserved('//./NUL'))
1031+
# Bytes are supported.
1032+
self.assertFalse(ntpath.isreserved(b''))
1033+
self.assertFalse(ntpath.isreserved(b'.'))
1034+
self.assertFalse(ntpath.isreserved(b'..'))
1035+
self.assertFalse(ntpath.isreserved(b'/'))
1036+
self.assertFalse(ntpath.isreserved(b'/foo/bar'))
1037+
self.assertTrue(ntpath.isreserved(b'foo.'))
1038+
self.assertTrue(ntpath.isreserved(b'nul'))
1039+
9841040
def assertEqualCI(self, s1, s2):
9851041
"""Assert that two strings are equal ignoring case differences."""
9861042
self.assertEqual(s1.lower(), s2.lower())

Lib/test/test_pathlib/test_pathlib.py

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,12 @@ def test_is_relative_to_several_args(self):
349349
with self.assertWarns(DeprecationWarning):
350350
p.is_relative_to('a', 'b')
351351

352+
def test_is_reserved_deprecated(self):
353+
P = self.cls
354+
p = P('a/b')
355+
with self.assertWarns(DeprecationWarning):
356+
p.is_reserved()
357+
352358
def test_match_empty(self):
353359
P = self.cls
354360
self.assertRaises(ValueError, P('a').match, '')
@@ -414,13 +420,6 @@ def test_is_absolute(self):
414420
self.assertTrue(P('//a').is_absolute())
415421
self.assertTrue(P('//a/b').is_absolute())
416422

417-
def test_is_reserved(self):
418-
P = self.cls
419-
self.assertIs(False, P('').is_reserved())
420-
self.assertIs(False, P('/').is_reserved())
421-
self.assertIs(False, P('/foo/bar').is_reserved())
422-
self.assertIs(False, P('/dev/con/PRN/NUL').is_reserved())
423-
424423
def test_join(self):
425424
P = self.cls
426425
p = P('//a')
@@ -1082,41 +1081,6 @@ def test_div(self):
10821081
self.assertEqual(p / P('./dd:s'), P('C:/a/b/dd:s'))
10831082
self.assertEqual(p / P('E:d:s'), P('E:d:s'))
10841083

1085-
def test_is_reserved(self):
1086-
P = self.cls
1087-
self.assertIs(False, P('').is_reserved())
1088-
self.assertIs(False, P('/').is_reserved())
1089-
self.assertIs(False, P('/foo/bar').is_reserved())
1090-
# UNC paths are never reserved.
1091-
self.assertIs(False, P('//my/share/nul/con/aux').is_reserved())
1092-
# Case-insensitive DOS-device names are reserved.
1093-
self.assertIs(True, P('nul').is_reserved())
1094-
self.assertIs(True, P('aux').is_reserved())
1095-
self.assertIs(True, P('prn').is_reserved())
1096-
self.assertIs(True, P('con').is_reserved())
1097-
self.assertIs(True, P('conin$').is_reserved())
1098-
self.assertIs(True, P('conout$').is_reserved())
1099-
# COM/LPT + 1-9 or + superscript 1-3 are reserved.
1100-
self.assertIs(True, P('COM1').is_reserved())
1101-
self.assertIs(True, P('LPT9').is_reserved())
1102-
self.assertIs(True, P('com\xb9').is_reserved())
1103-
self.assertIs(True, P('com\xb2').is_reserved())
1104-
self.assertIs(True, P('lpt\xb3').is_reserved())
1105-
# DOS-device name mataching ignores characters after a dot or
1106-
# a colon and also ignores trailing spaces.
1107-
self.assertIs(True, P('NUL.txt').is_reserved())
1108-
self.assertIs(True, P('PRN ').is_reserved())
1109-
self.assertIs(True, P('AUX .txt').is_reserved())
1110-
self.assertIs(True, P('COM1:bar').is_reserved())
1111-
self.assertIs(True, P('LPT9 :bar').is_reserved())
1112-
# DOS-device names are only matched at the beginning
1113-
# of a path component.
1114-
self.assertIs(False, P('bar.com9').is_reserved())
1115-
self.assertIs(False, P('bar.lpt9').is_reserved())
1116-
# Only the last path component matters.
1117-
self.assertIs(True, P('c:/baz/con/NUL').is_reserved())
1118-
self.assertIs(False, P('c:/NUL/con/baz').is_reserved())
1119-
11201084

11211085
class PurePathSubclassTest(PurePathTest):
11221086
class cls(pathlib.PurePath):
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Add :func:`os.path.isreserved`, which identifies reserved pathnames such
2+
as "NUL", "AUX" and "CON". This function is only available on Windows.
3+
4+
Deprecate :meth:`pathlib.PurePath.is_reserved`.

0 commit comments

Comments
 (0)