Skip to content

Commit e4ff131

Browse files
GH-44626, GH-105476: Fix ntpath.isabs() handling of part-absolute paths (#113829)
On Windows, `os.path.isabs()` now returns `False` when given a path that starts with exactly one (back)slash. This is more compatible with other functions in `os.path`, and with Microsoft's own documentation. Also adjust `pathlib.PureWindowsPath.is_absolute()` to call `ntpath.isabs()`, which corrects its handling of partial UNC/device paths like `//foo`. Co-authored-by: Jon Foster <jon@jon-foster.co.uk>
1 parent dac1da2 commit e4ff131

File tree

9 files changed

+51
-33
lines changed

9 files changed

+51
-33
lines changed

Doc/library/os.path.rst

+6-2
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,16 @@ the :mod:`glob` module.)
239239
.. function:: isabs(path)
240240

241241
Return ``True`` if *path* is an absolute pathname. On Unix, that means it
242-
begins with a slash, on Windows that it begins with a (back)slash after chopping
243-
off a potential drive letter.
242+
begins with a slash, on Windows that it begins with two (back)slashes, or a
243+
drive letter, colon, and (back)slash together.
244244

245245
.. versionchanged:: 3.6
246246
Accepts a :term:`path-like object`.
247247

248+
.. versionchanged:: 3.13
249+
On Windows, returns ``False`` if the given path starts with exactly one
250+
(back)slash.
251+
248252

249253
.. function:: isfile(path)
250254

Doc/whatsnew/3.13.rst

+7
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,13 @@ os
307307
:c:func:`!posix_spawn_file_actions_addclosefrom_np`.
308308
(Contributed by Jakub Kulik in :gh:`113117`.)
309309

310+
os.path
311+
-------
312+
313+
* On Windows, :func:`os.path.isabs` no longer considers paths starting with
314+
exactly one (back)slash to be absolute.
315+
(Contributed by Barney Gale and Jon Foster in :gh:`44626`.)
316+
310317
pathlib
311318
-------
312319

Lib/ntpath.py

+3-10
Original file line numberDiff line numberDiff line change
@@ -77,29 +77,22 @@ def normcase(s):
7777
return s.replace('/', '\\').lower()
7878

7979

80-
# Return whether a path is absolute.
81-
# Trivial in Posix, harder on Windows.
82-
# For Windows it is absolute if it starts with a slash or backslash (current
83-
# volume), or if a pathname after the volume-letter-and-colon or UNC-resource
84-
# starts with a slash or backslash.
85-
8680
def isabs(s):
8781
"""Test whether a path is absolute"""
8882
s = os.fspath(s)
8983
if isinstance(s, bytes):
9084
sep = b'\\'
9185
altsep = b'/'
9286
colon_sep = b':\\'
87+
double_sep = b'\\\\'
9388
else:
9489
sep = '\\'
9590
altsep = '/'
9691
colon_sep = ':\\'
92+
double_sep = '\\\\'
9793
s = s[:3].replace(altsep, sep)
9894
# Absolute: UNC, device, and paths with a drive and root.
99-
# LEGACY BUG: isabs("/x") should be false since the path has no drive.
100-
if s.startswith(sep) or s.startswith(colon_sep, 1):
101-
return True
102-
return False
95+
return s.startswith(colon_sep, 1) or s.startswith(double_sep)
10396

10497

10598
# Join two (or more) paths.

Lib/pathlib/_abc.py

+1-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import functools
2-
import ntpath
32
import posixpath
43
from errno import ENOENT, ENOTDIR, EBADF, ELOOP, EINVAL
54
from stat import S_ISDIR, S_ISLNK, S_ISREG, S_ISSOCK, S_ISBLK, S_ISCHR, S_ISFIFO
@@ -373,10 +372,7 @@ def parents(self):
373372
def is_absolute(self):
374373
"""True if the path is absolute (has both a root and, if applicable,
375374
a drive)."""
376-
if self.pathmod is ntpath:
377-
# ntpath.isabs() is defective - see GH-44626.
378-
return bool(self.drive and self.root)
379-
elif self.pathmod is posixpath:
375+
if self.pathmod is posixpath:
380376
# Optimization: work with raw paths on POSIX.
381377
for path in self._raw_paths:
382378
if path.startswith('/'):

Lib/test/test_ntpath.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,18 @@ def test_split(self):
227227
tester('ntpath.split("//conky/mountpoint/")', ('//conky/mountpoint/', ''))
228228

229229
def test_isabs(self):
230+
tester('ntpath.isabs("foo\\bar")', 0)
231+
tester('ntpath.isabs("foo/bar")', 0)
230232
tester('ntpath.isabs("c:\\")', 1)
233+
tester('ntpath.isabs("c:\\foo\\bar")', 1)
234+
tester('ntpath.isabs("c:/foo/bar")', 1)
231235
tester('ntpath.isabs("\\\\conky\\mountpoint\\")', 1)
232-
tester('ntpath.isabs("\\foo")', 1)
233-
tester('ntpath.isabs("\\foo\\bar")', 1)
236+
237+
# gh-44626: paths with only a drive or root are not absolute.
238+
tester('ntpath.isabs("\\foo\\bar")', 0)
239+
tester('ntpath.isabs("/foo/bar")', 0)
240+
tester('ntpath.isabs("c:foo\\bar")', 0)
241+
tester('ntpath.isabs("c:foo/bar")', 0)
234242

235243
# gh-96290: normal UNC paths and device paths without trailing backslashes
236244
tester('ntpath.isabs("\\\\conky\\mountpoint")', 1)

Lib/test/test_pathlib/test_pathlib.py

+4
Original file line numberDiff line numberDiff line change
@@ -1011,10 +1011,14 @@ def test_is_absolute(self):
10111011
self.assertTrue(P('c:/a').is_absolute())
10121012
self.assertTrue(P('c:/a/b/').is_absolute())
10131013
# UNC paths are absolute by definition.
1014+
self.assertTrue(P('//').is_absolute())
1015+
self.assertTrue(P('//a').is_absolute())
10141016
self.assertTrue(P('//a/b').is_absolute())
10151017
self.assertTrue(P('//a/b/').is_absolute())
10161018
self.assertTrue(P('//a/b/c').is_absolute())
10171019
self.assertTrue(P('//a/b/c/d').is_absolute())
1020+
self.assertTrue(P('//?/UNC/').is_absolute())
1021+
self.assertTrue(P('//?/UNC/spam').is_absolute())
10181022

10191023
def test_join(self):
10201024
P = self.cls

Lib/test/test_unittest/test_program.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -459,8 +459,8 @@ def _join(name):
459459

460460
def testParseArgsAbsolutePathsThatCannotBeConverted(self):
461461
program = self.program
462-
# even on Windows '/...' is considered absolute by os.path.abspath
463-
argv = ['progname', '/foo/bar/baz.py', '/green/red.py']
462+
drive = os.path.splitdrive(os.getcwd())[0]
463+
argv = ['progname', f'{drive}/foo/bar/baz.py', f'{drive}/green/red.py']
464464
self._patch_isfile(argv)
465465

466466
program.createTests = lambda: None

Lib/test/test_zoneinfo/test_zoneinfo.py

+13-12
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
TEMP_DIR = None
3737
DATA_DIR = pathlib.Path(__file__).parent / "data"
3838
ZONEINFO_JSON = DATA_DIR / "zoneinfo_data.json"
39+
DRIVE = os.path.splitdrive('x:')[0]
3940

4041
# Useful constants
4142
ZERO = timedelta(0)
@@ -1679,8 +1680,8 @@ def test_env_variable(self):
16791680
"""Tests that the environment variable works with reset_tzpath."""
16801681
new_paths = [
16811682
("", []),
1682-
("/etc/zoneinfo", ["/etc/zoneinfo"]),
1683-
(f"/a/b/c{os.pathsep}/d/e/f", ["/a/b/c", "/d/e/f"]),
1683+
(f"{DRIVE}/etc/zoneinfo", [f"{DRIVE}/etc/zoneinfo"]),
1684+
(f"{DRIVE}/a/b/c{os.pathsep}{DRIVE}/d/e/f", [f"{DRIVE}/a/b/c", f"{DRIVE}/d/e/f"]),
16841685
]
16851686

16861687
for new_path_var, expected_result in new_paths:
@@ -1694,22 +1695,22 @@ def test_env_variable_relative_paths(self):
16941695
test_cases = [
16951696
[("path/to/somewhere",), ()],
16961697
[
1697-
("/usr/share/zoneinfo", "path/to/somewhere",),
1698-
("/usr/share/zoneinfo",),
1698+
(f"{DRIVE}/usr/share/zoneinfo", "path/to/somewhere",),
1699+
(f"{DRIVE}/usr/share/zoneinfo",),
16991700
],
17001701
[("../relative/path",), ()],
17011702
[
1702-
("/usr/share/zoneinfo", "../relative/path",),
1703-
("/usr/share/zoneinfo",),
1703+
(f"{DRIVE}/usr/share/zoneinfo", "../relative/path",),
1704+
(f"{DRIVE}/usr/share/zoneinfo",),
17041705
],
17051706
[("path/to/somewhere", "../relative/path",), ()],
17061707
[
17071708
(
1708-
"/usr/share/zoneinfo",
1709+
f"{DRIVE}/usr/share/zoneinfo",
17091710
"path/to/somewhere",
17101711
"../relative/path",
17111712
),
1712-
("/usr/share/zoneinfo",),
1713+
(f"{DRIVE}/usr/share/zoneinfo",),
17131714
],
17141715
]
17151716

@@ -1727,9 +1728,9 @@ def test_env_variable_relative_paths(self):
17271728
self.assertSequenceEqual(tzpath, expected_paths)
17281729

17291730
def test_reset_tzpath_kwarg(self):
1730-
self.module.reset_tzpath(to=["/a/b/c"])
1731+
self.module.reset_tzpath(to=[f"{DRIVE}/a/b/c"])
17311732

1732-
self.assertSequenceEqual(self.module.TZPATH, ("/a/b/c",))
1733+
self.assertSequenceEqual(self.module.TZPATH, (f"{DRIVE}/a/b/c",))
17331734

17341735
def test_reset_tzpath_relative_paths(self):
17351736
bad_values = [
@@ -1758,8 +1759,8 @@ def test_tzpath_type_error(self):
17581759
self.module.reset_tzpath(bad_value)
17591760

17601761
def test_tzpath_attribute(self):
1761-
tzpath_0 = ["/one", "/two"]
1762-
tzpath_1 = ["/three"]
1762+
tzpath_0 = [f"{DRIVE}/one", f"{DRIVE}/two"]
1763+
tzpath_1 = [f"{DRIVE}/three"]
17631764

17641765
with self.tzpath_context(tzpath_0):
17651766
query_0 = self.module.TZPATH
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix :func:`os.path.isabs` incorrectly returning ``True`` when given a path
2+
that starts with exactly one (back)slash on Windows.
3+
4+
Fix :meth:`pathlib.PureWindowsPath.is_absolute` incorrectly returning
5+
``False`` for some paths beginning with two (back)slashes.

0 commit comments

Comments
 (0)