Skip to content

Commit 6cff555

Browse files
authored
fix: further improve file path handling (#479)
- On Windows, the reflection of file URLs to paths requires that we know about the URL hostname too as they could be UNC paths. - Add some tests that verify that we are round-tripping from Path to URL and back again correctly. - Simplify scheme testing for relative URLs, we only need to know about supported schemes. - ci: collect coverage only on Linux
1 parent 212a36a commit 6cff555

File tree

7 files changed

+88
-44
lines changed

7 files changed

+88
-44
lines changed

.github/workflows/python.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ jobs:
4848
# It fails with "Error: Lcov file not found."
4949
# Solution here: https://github.com/coverallsapp/github-action/issues/30#issuecomment-612523058
5050
- name: Coveralls
51+
# TODO: ci: run code coverage on all platforms to fix missing coverage
52+
# Example: https://github.com/andreoliwa/nitpick/pull/479#issuecomment-1082074275
53+
# This condition is needed otherwise it fails with "Error: Container action is only supported on Linux"
5154
if: matrix.os == 'ubuntu-latest'
5255
uses: AndreMiras/coveralls-python-action@develop
5356
with:

src/nitpick/generic.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
from __future__ import annotations
88

99
import sys
10-
from pathlib import Path
10+
from pathlib import Path, PosixPath, WindowsPath
1111
from typing import Iterable
1212

13-
import furl
13+
from furl import furl
1414

1515
from nitpick.constants import DOT, PROJECT_NAME
1616
from nitpick.typedefs import PathOrStr
@@ -89,23 +89,26 @@ def filter_names(iterable: Iterable, *partial_names: str) -> list[str]:
8989
return rv
9090

9191

92-
if sys.platform == "win32":
92+
def _url_to_windows_path(url: furl) -> Path:
93+
"""Convert the segments of a file URL to a path."""
94+
# ref: https://docs.microsoft.com/en-us/dotnet/standard/io/file-path-formats
95+
# UNC file path, \\server\share\path..., with server stored as url.host
96+
# DOS Path, drive_letter:\path...
97+
share_or_drive, *segments = url.path.segments
98+
share_or_drive = rf"\\{url.host}\{share_or_drive}" if url.host else rf"{share_or_drive}\\"
99+
return WindowsPath(share_or_drive, *segments)
93100

94-
def furl_path_to_python_path(path: furl.Path) -> Path:
95-
"""Convert a file URL to a path."""
96-
drive, *segments = path.segments
97-
return Path(f"{drive}/", *segments)
98101

99-
def get_scheme(url: str) -> str | None:
100-
"""Get the scheme of a URL, or None if there is no scheme."""
101-
scheme = furl.get_scheme(url)
102-
# Windows drive letters are not a valid scheme
103-
return scheme if scheme and len(scheme) > 1 else None
102+
def _url_to_posix_path(url: furl) -> Path:
103+
"""Convert the segments of a file URL to a path."""
104+
# ref: https://unix.stackexchange.com/a/1919
105+
# POSIX paths can start with //part/..., which some implementations
106+
# (such as Cygwin) can interpret differently. furl(Path("//part/...").as_uri())
107+
# retains the double slash (but doesn't treat it as a host), and in that case
108+
# `first` will always be an empty string and `segments` will *not* be empty.
109+
first, *segments = url.path.segments
110+
slash_or_double_slash = "//" if not first and segments else "/"
111+
return PosixPath(slash_or_double_slash, first, *segments)
104112

105-
else:
106113

107-
def furl_path_to_python_path(path: furl.Path) -> Path:
108-
"""Convert a file URL to a path."""
109-
return Path("/", *path.segments)
110-
111-
get_scheme = furl.get_scheme
114+
url_to_python_path = _url_to_windows_path if sys.platform == "win32" else _url_to_posix_path

src/nitpick/style/core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
PYPROJECT_TOML,
2828
)
2929
from nitpick.exceptions import QuitComplainingError, pretty_exception
30-
from nitpick.generic import furl_path_to_python_path
30+
from nitpick.generic import url_to_python_path
3131
from nitpick.plugins.base import NitpickPlugin
3232
from nitpick.plugins.info import FileInfo
3333
from nitpick.project import Project, glob_files
@@ -131,7 +131,7 @@ def _include_style(self, style_url: furl) -> Iterator[Fuss]:
131131
# and the URL otherwise.
132132
display_name = style_url.url
133133
if style_url.scheme == "file":
134-
path = furl_path_to_python_path(style_url.path)
134+
path = url_to_python_path(style_url)
135135
with suppress(ValueError):
136136
path = path.relative_to(self.project.root)
137137
display_name = str(path)

src/nitpick/style/fetchers/__init__.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from strenum import LowercaseStrEnum
1212

1313
from nitpick.enums import CachingEnum
14-
from nitpick.generic import get_scheme
1514
from nitpick.style import parse_cache_option
1615

1716
if TYPE_CHECKING:
@@ -40,6 +39,7 @@ class StyleFetcherManager:
4039

4140
session: CachedSession = field(init=False)
4241
fetchers: dict[str, StyleFetcher] = field(init=False)
42+
schemes: tuple[str] = field(init=False)
4343

4444
def __post_init__(self):
4545
"""Initialize dependant properties."""
@@ -52,7 +52,12 @@ def __post_init__(self):
5252
self.session = CachedSession(
5353
str(self.cache_dir / "styles"), expire_after=expire_after, cache_control=cache_control
5454
)
55-
self.fetchers = _get_fetchers(self.session)
55+
self.fetchers = fetchers = _get_fetchers(self.session)
56+
57+
# used to test if a string URL is relative or not. These strings
58+
# *include the colon*.
59+
protocols = {prot for fetcher in fetchers.values() for prot in fetcher.protocols}
60+
self.schemes = tuple(f"{prot}:" for prot in protocols)
5661

5762
def normalize_url(self, url: str | furl, base: furl) -> furl:
5863
"""Normalize a style URL.
@@ -61,12 +66,8 @@ def normalize_url(self, url: str | furl, base: furl) -> furl:
6166
to produce a canonical version of the URL.
6267
6368
"""
64-
# special case: Windows paths can start with a drive letter, which looks
65-
# like a URL scheme.
66-
if isinstance(url, str):
67-
scheme = get_scheme(url)
68-
if not scheme or len(scheme) == 1:
69-
url = self._fetcher_for(base).preprocess_relative_url(url)
69+
if isinstance(url, str) and not url.startswith(self.schemes):
70+
url = self._fetcher_for(base).preprocess_relative_url(url)
7071
absolute = base.copy().join(url)
7172
return self._fetcher_for(absolute).normalize(absolute)
7273

src/nitpick/style/fetchers/base.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass
55
from typing import ClassVar
66

7-
from furl import Path, furl
7+
from furl import furl
88
from requests_cache import CachedSession
99

1010
from nitpick.constants import TOML_EXTENSION
@@ -35,11 +35,12 @@ def preprocess_relative_url(self, url: str) -> str: # pylint: disable=no-self-u
3535
"""
3636
return url
3737

38-
def _normalize_path(self, path: Path) -> Path: # pylint: disable=no-self-use
38+
def _normalize_url_path(self, url: furl) -> furl: # pylint: disable=no-self-use
3939
"""Normalize the path component of a URL."""
40-
if path.segments[-1].endswith(TOML_EXTENSION):
41-
return path
42-
return Path([*path.segments[:-1], f"{path.segments[-1]}.toml"])
40+
if not url.path.segments[-1].endswith(TOML_EXTENSION):
41+
url = url.copy()
42+
url.path.segments[-1] = f"{url.path.segments[-1]}{TOML_EXTENSION}"
43+
return url
4344

4445
def _normalize_scheme(self, scheme: str) -> str: # pylint: disable=no-self-use
4546
"""Normalize the scheme component of a URL."""
@@ -54,8 +55,10 @@ def normalize(self, url: furl) -> furl:
5455
- Individual fetchers can further normalize the path and scheme.
5556
5657
"""
57-
scheme, path = self._normalize_scheme(url.scheme), self._normalize_path(url.path.normalize())
58-
return url.copy().set(scheme=scheme, path=path)
58+
new_scheme = self._normalize_scheme(url.scheme)
59+
if new_scheme != url.scheme:
60+
url = url.copy().set(scheme=new_scheme)
61+
return self._normalize_url_path(url)
5962

6063
def fetch(self, url: furl) -> str:
6164
"""Fetch a style from a specific fetcher."""

src/nitpick/style/fetchers/file.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
from dataclasses import dataclass
55
from pathlib import Path
66

7-
import furl
7+
from furl import furl
88

9-
from nitpick.generic import furl_path_to_python_path
9+
from nitpick.generic import url_to_python_path
1010
from nitpick.style.fetchers import Scheme
1111
from nitpick.style.fetchers.base import StyleFetcher
1212

@@ -32,10 +32,10 @@ def preprocess_relative_url(self, url: str) -> str:
3232
# cleanly against a file:// base. Relative paths should use POSIX conventions.
3333
return path.as_uri() if path.is_absolute() else path.as_posix()
3434

35-
def _normalize_path(self, path: furl.Path) -> furl.Path:
36-
local_path = furl_path_to_python_path(super()._normalize_path(path))
37-
return furl.furl(local_path.resolve().as_uri()).path
35+
def _normalize_url_path(self, url: furl) -> furl:
36+
local_path = url_to_python_path(super()._normalize_url_path(url))
37+
return furl(local_path.resolve().as_uri())
3838

39-
def fetch(self, url: furl.furl) -> str:
39+
def fetch(self, url: furl) -> str:
4040
"""Fetch a style from a local file."""
41-
return furl_path_to_python_path(url.path).read_text(encoding="UTF-8")
41+
return url_to_python_path(url).read_text(encoding="UTF-8")

tests/test_generic.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""Generic functions tests."""
22
import os
33
import sys
4-
from pathlib import Path
4+
from pathlib import Path, PosixPath, WindowsPath
55
from unittest import mock
66

7+
import pytest
8+
from furl import furl
79
from testfixtures import compare
810

911
from nitpick.constants import EDITOR_CONFIG, TOX_INI
10-
from nitpick.generic import relative_to_current_dir
12+
from nitpick.generic import _url_to_posix_path, _url_to_windows_path, relative_to_current_dir
1113

1214

1315
@mock.patch.object(Path, "cwd")
@@ -57,3 +59,35 @@ def test_relative_to_current_dir(home, cwd):
5759

5860
for path, expected in examples.items():
5961
compare(actual=relative_to_current_dir(path), expected=expected, prefix=f"Path: {path}")
62+
63+
64+
@pytest.mark.skipif(os.name != "nt", reason="Windows-only test")
65+
@pytest.mark.parametrize(
66+
"test_path",
67+
(
68+
"C:\\",
69+
r"C:\path\file.toml",
70+
r"//server/share/path/file.toml",
71+
),
72+
)
73+
def test_url_to_windows_path(test_path):
74+
"""Verify that Path to URL to Path conversion preserves the path."""
75+
path = WindowsPath(test_path)
76+
url = furl(path.as_uri())
77+
assert _url_to_windows_path(url) == path
78+
79+
80+
@pytest.mark.skipif(os.name == "nt", reason="POSIX-only test")
81+
@pytest.mark.parametrize(
82+
"test_path",
83+
(
84+
"/",
85+
"/path/to/file.toml",
86+
"//double/slash/path.toml",
87+
),
88+
)
89+
def test_url_to_posix_path(test_path):
90+
"""Verify that Path to URL to Path conversion preserves the path."""
91+
path = PosixPath(test_path)
92+
url = furl(path.as_uri())
93+
assert _url_to_posix_path(url) == path

0 commit comments

Comments
 (0)