Repository: python/cpython
Pull request: gh-94909: fix joining of absolute and relative Windows paths in pathlib 
Have pathlib use `os.path.join()` to join arguments to the `PurePath` initialiser, which fixes a minor bug when handling relative paths with drives.

Previously:

```python
>>> from pathlib import PureWindowsPath
>>> a = 'C:/a/b'
>>> b = 'C:x/y'
>>> PureWindowsPath(a, b)
PureWindowsPath('C:x/y')
```

Now:

```python
>>> PureWindowsPath(a, b)
PureWindowsPath('C:/a/b/x/y')
```

<!-- gh-issue-number: gh-94909 -->
* Issue: gh-94909
<!-- /gh-issue-number -->

Automerge-Triggered-By: GH:brettcannon

Problem statement:
pathlib: inconsistent handling of rootless Windows paths
```python
>>> from pathlib import PureWindowsPath
>>> a = 'C:/a/b'
>>> b = 'C:x/y'
>>> PureWindowsPath(a, b)
PureWindowsPath('C:x/y')
>>> PureWindowsPath(a) / b
PureWindowsPath('C:/a/b/x/y')
```

Seems to be the only case where the `PureWindowsPath` constuctor and `joinpath()` disagree when given the same arguments. This has implications for future work, so I'd love to make them consistent.

Affects all supported versions of Python.

Hints from issue discussion:
Related to #64107, which improved the `joinpath()` behaviour.
Here's a patch that will be applicable when #31691 lands. It uses `ntpath.join()` to join the path parts, and consequently scans left-to-right without an early exit from the loop. Optimizations are possible, I just went for minimal diff:

```diff
diff --git a/Lib/pathlib.py b/Lib/pathlib.py
index bd86403c8e..ad3c03f1bd 100644
--- a/Lib/pathlib.py
+++ b/Lib/pathlib.py
@@ -266,10 +266,8 @@ def _parse_parts(cls, parts):
         sep = cls._flavour.sep
         altsep = cls._flavour.altsep
         drv = root = ''
-        it = reversed(parts)
-        for part in it:
-            if not part:
-                continue
+        if parts:
+            part = cls._flavour.join(*parts)
             if altsep:
                 part = part.replace(altsep, sep)
             drv, rel = cls._flavour.splitdrive(part)
@@ -289,20 +287,6 @@ def _parse_parts(cls, parts):
             else:
                 if rel and rel != '.':
                     parsed.append(sys.intern(rel))
-            if drv or root:
-                if not drv:
-                    # If no drive is present, try to find one in the previous
-                    # parts. This makes the result of parsing e.g.
-                    # ("C:", "/", "a") reasonably intuitive.
-                    for part in it:
-                        if not part:
-                            continue
-                        if altsep:
-                            part = part.replace(altsep, sep)
-                        drv = cls._flavour.splitdrive(part)[0]
-                        if drv:
-                            break
-                break
         if drv or root:
             parsed.append(drv + root)
         parsed.reverse()
```
I definitely agree that this is sufficiently complex that `pathlib` should use `ntpath` wherever possible.

Commit message: Simplify `parse_parts()` implementation.

Patch to review:
diff --git a/Lib/pathlib.py b/Lib/pathlib.py
index 2aee71742b23e4..5480332b84706f 100644
--- a/Lib/pathlib.py
+++ b/Lib/pathlib.py
@@ -61,41 +61,16 @@ def __init__(self):
         self.join = self.sep.join
 
     def parse_parts(self, parts):
-        parsed = []
+        if not parts:
+            return '', '', []
         sep = self.sep
         altsep = self.altsep
-        drv = root = ''
-        it = reversed(parts)
-        for part in it:
-            if not part:
-                continue
-            if altsep:
-                part = part.replace(altsep, sep)
-            drv, root, rel = self.splitroot(part)
-            if sep in rel:
-                for x in reversed(rel.split(sep)):
-                    if x and x != '.':
-                        parsed.append(sys.intern(x))
-            else:
-                if rel and rel != '.':
-                    parsed.append(sys.intern(rel))
-            if drv or root:
-                if not drv:
-                    # If no drive is present, try to find one in the previous
-                    # parts. This makes the result of parsing e.g.
-                    # ("C:", "/", "a") reasonably intuitive.
-                    for part in it:
-                        if not part:
-                            continue
-                        if altsep:
-                            part = part.replace(altsep, sep)
-                        drv = self.splitroot(part)[0]
-                        if drv:
-                            break
-                break
-        if drv or root:
-            parsed.append(drv + root)
-        parsed.reverse()
+        path = self.pathmod.join(*parts)
+        if altsep:
+            path = path.replace(altsep, sep)
+        drv, root, rel = self.splitroot(path)
+        parsed = [drv + root] + rel.split(sep)
+        parsed = [sys.intern(x) for x in parsed if x and x != '.']
         return drv, root, parsed
 
     def join_parsed_parts(self, drv, root, parts, drv2, root2, parts2):
diff --git a/Lib/test/test_pathlib.py b/Lib/test/test_pathlib.py
index e14b0fca55360f..507d95208ff166 100644
--- a/Lib/test/test_pathlib.py
+++ b/Lib/test/test_pathlib.py
@@ -136,6 +136,10 @@ def test_parse_parts(self):
         check(['a', '/b', 'c'],         ('', '\\', ['\\', 'b', 'c']))
         check(['Z:/a', '/b', 'c'],      ('Z:', '\\', ['Z:\\', 'b', 'c']))
         check(['//?/Z:/a', '/b', 'c'],  ('\\\\?\\Z:', '\\', ['\\\\?\\Z:\\', 'b', 'c']))
+        # Joining with the same drive => the first path is appended to if
+        # the second path is relative.
+        check(['c:/a/b', 'c:x/y'], ('c:', '\\', ['c:\\', 'a', 'b', 'x', 'y']))
+        check(['c:/a/b', 'c:/x/y'], ('c:', '\\', ['c:\\', 'x', 'y']))
 
     def test_splitroot(self):
         f = self.flavour.splitroot
diff --git a/Misc/NEWS.d/next/Library/2022-07-29-20-58-37.gh-issue-94909.YjMusj.rst b/Misc/NEWS.d/next/Library/2022-07-29-20-58-37.gh-issue-94909.YjMusj.rst
new file mode 100644
index 00000000000000..b6d853888101c2
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2022-07-29-20-58-37.gh-issue-94909.YjMusj.rst
@@ -0,0 +1,2 @@
+Fix incorrect joining of relative Windows paths with drives in
+:class:`pathlib.PurePath` initializer.


Code review comments:
diff_hunk: @@ -61,41 +61,16 @@ def __init__(self):
         self.join = self.sep.join
 
     def parse_parts(self, parts):
-        parsed = []
+        if not parts:
+            return '', '', []
         sep = self.sep
         altsep = self.altsep
-        drv = root = ''
-        it = reversed(parts)
-        for part in it:
-            if not part:
-                continue
-            if altsep:
-                part = part.replace(altsep, sep)
-            drv, root, rel = self.splitroot(part)
-            if sep in rel:
-                for x in reversed(rel.split(sep)):
-                    if x and x != '.':
-                        parsed.append(sys.intern(x))
-            else:
-                if rel and rel != '.':
-                    parsed.append(sys.intern(rel))
-            if drv or root:
-                if not drv:
-                    # If no drive is present, try to find one in the previous
-                    # parts. This makes the result of parsing e.g.
-                    # ("C:", "/", "a") reasonably intuitive.
-                    for part in it:
-                        if not part:
-                            continue
-                        if altsep:
-                            part = part.replace(altsep, sep)
-                        drv = self.splitroot(part)[0]
-                        if drv:
-                            break
-                break
-        if drv or root:
-            parsed.append(drv + root)
-        parsed.reverse()
+        path = self.pathmod.join(*parts)
+        if altsep:
+            path = path.replace(altsep, sep)
+        drv, root, rel = self.splitroot(path)
+        parsed = [drv + root] + rel.split(sep)
+        parsed = [sys.intern(x) for x in parsed if x and x != '.']
path: Lib/pathlib.py
text: @user1:
```suggestion
        unfiltered_parsed = [drv + root] + rel.split(sep)
        parsed = [sys.intern(x) for x in unfiltered_parsed if x and x != '.']
```