Repository: scikit-hep/uproot5

Issue:
Improper parsing of a `file://` uri on Windows
Python includes a number of URL parsing utilities. One benefit is that the format for URLs in python is standard across libraries. It currently looks like the [URL parsing](https://github.com/scikit-hep/uproot4/blob/cec62a63a6639238179d622b437af9587dbc5284/src/uproot/_util.py#L171-L201) code for the `file` scheme in uproot4 is not conformant on Windows (but works fine on Linux).

An example of a valid windows URL: `file:///g:/mydir/file.root`. The above code will parse this, on windows, to `/g:/mydir/file.root` which cannot be opened by python's `open` function. However, `g:/mydir/file.root` works just fine.

There is another possible issue - in that URLs aren't unquoted during processing by the code (though I did not test this).

To work around this problem I've used some [discussion on stack overflow](https://stackoverflow.com/questions/5977576/is-there-a-convenient-way-to-map-a-file-uri-to-os-path#) to help me with the following code which works in python 3+, and is cross-platform:

```
from urllib.parse import urlparse, unquote
from urllib.request import url2pathname

p = urlparse(file_url)
file_path = url2pathname(unquote(p.path))
```

Note that even that discussion is a bit confused: it is important to read the answer comments to interpret the answers. The above example was pulled from the last answer, and then the python2 code was stripped out.

If I might be so bold, one way to do testing on this is using `pathlib`. For example:

```
from pathlib import Path

test_file = Path('./test_data/myfile.root')   # Should work on all platforms.
test_file_url = test_file.absolute().as_uri()

data = uproot4.open(test_file_url)
```

Or similar. But this will make sure your file URL parsing works on all platforms and uses python "standards" to make sure it all works.


Discussion:

Resolving patch:
diff --git a/src/uproot/_util.py b/src/uproot/_util.py
--- a/src/uproot/_util.py
+++ b/src/uproot/_util.py
@@ -10,6 +10,7 @@
 import glob
 import numbers
 import os
+import platform
 import re
 import sys
 
@@ -18,9 +19,9 @@
 except ImportError:
     from collections import Iterable
 try:
-    from urllib.parse import urlparse
+    from urllib.parse import unquote, urlparse
 except ImportError:
-    from urlparse import urlparse
+    from urlparse import urlparse, unquote
 
 import numpy
 
@@ -28,7 +29,7 @@
 py26 = py2 and sys.version_info[1] <= 6
 py27 = py2 and not py26
 py35 = not py2 and sys.version_info[1] <= 5
-win = os.name == "nt"
+win = platform.system().lower().startswith("win")
 
 
 if py2:
@@ -163,8 +164,8 @@ def regularize_path(path):
 
 
 _windows_drive_letter_ending = re.compile(r".*\b[A-Za-z]$")
-_windows_absolute_path_pattern = re.compile(r"^[A-Za-z]:\\")
-_windows_absolute_path_pattern_slash = re.compile(r"^/[A-Za-z]:\\")
+_windows_absolute_path_pattern = re.compile(r"^[A-Za-z]:[\\/]")
+_windows_absolute_path_pattern_slash = re.compile(r"^[\\/][A-Za-z]:[\\/]")
 _might_be_port = re.compile(r"^[0-9].*")
 
 
@@ -192,10 +193,7 @@ def file_object_path_split(path):
 
         if file_path.upper() in ("FILE", "HTTP", "HTTPS", "ROOT"):
             return path, None
-        elif (
-            os.name == "nt"
-            and _windows_drive_letter_ending.match(file_path) is not None
-        ):
+        elif win and _windows_drive_letter_ending.match(file_path) is not None:
             return path, None
         else:
             return file_path, object_path
@@ -227,29 +225,32 @@ def file_path_to_source_class(file_path, options):
         return out, file_path
 
     windows_absolute_path = None
-
-    if os.name == "nt":
+    if win:
         if _windows_absolute_path_pattern.match(file_path) is not None:
             windows_absolute_path = file_path
 
     parsed_url = urlparse(file_path)
+    if parsed_url.scheme.upper() == "FILE":
+        parsed_url_path = unquote(parsed_url.path)
+    else:
+        parsed_url_path = parsed_url.path
 
-    if os.name == "nt" and windows_absolute_path is None:
-        if _windows_absolute_path_pattern.match(parsed_url.path) is not None:
-            windows_absolute_path = parsed_url.path
-        elif _windows_absolute_path_pattern_slash.match(parsed_url.path) is not None:
-            windows_absolute_path = parsed_url.path[1:]
+    if win and windows_absolute_path is None:
+        if _windows_absolute_path_pattern.match(parsed_url_path) is not None:
+            windows_absolute_path = parsed_url_path
+        elif _windows_absolute_path_pattern_slash.match(parsed_url_path) is not None:
+            windows_absolute_path = parsed_url_path[1:]
 
     if (
         parsed_url.scheme.upper() == "FILE"
         or len(parsed_url.scheme) == 0
-        or windows_absolute_path
+        or windows_absolute_path is not None
     ):
         if windows_absolute_path is None:
             if parsed_url.netloc.upper() == "LOCALHOST":
-                file_path = parsed_url.path
+                file_path = parsed_url_path
             else:
-                file_path = parsed_url.netloc + parsed_url.path
+                file_path = parsed_url.netloc + parsed_url_path
         else:
             file_path = windows_absolute_path
 


Test patch:
diff --git a/tests/test_0325-fix-windows-file-uris.py b/tests/test_0325-fix-windows-file-uris.py
new file mode 100644
--- /dev/null
+++ b/tests/test_0325-fix-windows-file-uris.py
@@ -0,0 +1,92 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/uproot4/blob/main/LICENSE
+
+import numpy as np
+import pytest
+
+import uproot._util
+import uproot.reading
+
+
+@pytest.mark.skipif(
+    not uproot._util.win, reason="Drive letters only parsed on Windows."
+)
+def test_windows_drive_letters():
+    assert (
+        uproot._util.file_path_to_source_class(
+            "file:///g:/mydir/file.root", uproot.reading.open.defaults
+        )[1]
+        == "g:/mydir/file.root"
+    )
+
+    assert (
+        uproot._util.file_path_to_source_class(
+            "file:/g:/mydir/file.root", uproot.reading.open.defaults
+        )[1]
+        == "g:/mydir/file.root"
+    )
+
+    assert (
+        uproot._util.file_path_to_source_class(
+            "file:g:/mydir/file.root", uproot.reading.open.defaults
+        )[1]
+        == "g:/mydir/file.root"
+    )
+
+    assert (
+        uproot._util.file_path_to_source_class(
+            "/g:/mydir/file.root", uproot.reading.open.defaults
+        )[1]
+        == "g:/mydir/file.root"
+    )
+
+    assert (
+        uproot._util.file_path_to_source_class(
+            r"\g:/mydir/file.root", uproot.reading.open.defaults
+        )[1]
+        == "g:/mydir/file.root"
+    )
+
+    assert (
+        uproot._util.file_path_to_source_class(
+            r"g:\mydir\file.root", uproot.reading.open.defaults
+        )[1]
+        == r"g:\mydir\file.root"
+    )
+
+    assert (
+        uproot._util.file_path_to_source_class(
+            r"\g:\mydir\file.root", uproot.reading.open.defaults
+        )[1]
+        == r"g:\mydir\file.root"
+    )
+
+
+def test_escaped_uri_codes():
+    # If they're file:// paths, yes we should unquote the % signs.
+    assert (
+        uproot._util.file_path_to_source_class(
+            "file:///my%20file.root", uproot.reading.open.defaults
+        )[1]
+        == "/my file.root"
+    )
+    if not uproot._util.py2:
+        assert (
+            uproot._util.file_path_to_source_class(
+                "file:///my%E2%80%92file.root", uproot.reading.open.defaults
+            )[1]
+            == u"/my\u2012file.root"
+        )
+
+    # Otherwise, no we should not.
+    assert (
+        uproot._util.file_path_to_source_class(
+            "/my%20file.root", uproot.reading.open.defaults
+        )[1]
+        == "/my%20file.root"
+    )
+    assert (
+        uproot._util.file_path_to_source_class(
+            "/my%E2%80%92file.root", uproot.reading.open.defaults
+        )[1]
+        == "/my%E2%80%92file.root"
+    )