diff --git a/robotpy_installer/pyproject.py b/robotpy_installer/pyproject.py index 6088667..1a00deb 100644 --- a/robotpy_installer/pyproject.py +++ b/robotpy_installer/pyproject.py @@ -3,6 +3,7 @@ import inspect import pathlib import typing +from urllib.parse import urlparse from packaging.requirements import Requirement from packaging.version import Version, InvalidVersion @@ -461,16 +462,56 @@ def load( with open(pyproject_path, "rb") as fp: data = tomli.load(fp) - return _load(str(pyproject_path), data) + return _load(str(pyproject_path), data, base_path=project_path) -def loads(content: str): +def loads(content: str, base_path: typing.Optional[pathlib.Path] = None): data = tomli.loads(content) - return _load("", data) + return _load("", data, base_path=base_path) + + +def _resolve_relative_file_url(req: Requirement, base_path: pathlib.Path) -> None: + """Resolve a relative ``file://`` URL in a requirement to an absolute path. + + Relative ``file://`` URLs (e.g. ``file://../lib/bread``) are not usable by + pip: ``urlparse`` interprets the first path segment as the netloc, and pip + rejects any non-empty, non-localhost netloc as a non-local file URI. Even + when the URL parses cleanly, pip resolves relative paths against its own + working directory, which is generally not the project directory. + + We rewrite such URLs to absolute ``file://`` URLs rooted at ``base_path``, + so a ``requires`` entry like ``bread @ file://../../lib/bread`` in + ``pyproject.toml`` works the same way a path dependency does in a normal + Python project. + """ + if not req.url: + return + + parsed = urlparse(req.url) + if parsed.scheme != "file": + return + + # If the first segment of a relative path ended up as the netloc + # (e.g. ``file://../lib`` → netloc=``..``, path=``/lib``), recover the + # original path by recombining. A netloc of "localhost" is the one + # well-defined case where we want to keep it stripped. + if parsed.netloc and parsed.netloc != "localhost": + raw_path = parsed.netloc + parsed.path + else: + raw_path = parsed.path + + if raw_path.startswith("/"): + return + + abs_path = (base_path / raw_path).resolve() + req.url = abs_path.as_uri() def _load( - pyproject_path: str, data: typing.Dict[str, typing.Any] + pyproject_path: str, + data: typing.Dict[str, typing.Any], + *, + base_path: typing.Optional[pathlib.Path] = None, ) -> RobotPyProjectToml: try: robotpy_data = data["tool"]["robotpy"] @@ -523,6 +564,10 @@ def _load( else: requires = [] + if base_path is not None: + for req in requires: + _resolve_relative_file_url(req, base_path) + return RobotPyProjectToml( robotpy_version=robotpy_version, components=components, diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index 1eeb548..b888ada 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -152,3 +152,90 @@ def test_get_deploy_list_requires_wheel_for_direct_url(): assert False except KeyError as e: assert "not as a wheel" in str(e) + + +def test_relative_file_url_resolved_against_project(tmp_path): + lib_dir = tmp_path / "lib" / "bread" + lib_dir.mkdir(parents=True) + + project_dir = tmp_path / "robots" / "template" + project_dir.mkdir(parents=True) + + content = inspect.cleandoc(f""" + [tool.robotpy] + robotpy_version = "{YEAR}.1.1.2" + requires = [ + "bread @ file://../../lib/bread", + ] + """) + + project = pyproject.loads(content, base_path=project_dir) + + assert len(project.requires) == 1 + bread = project.requires[0] + assert bread.url == lib_dir.resolve().as_uri() + + +def test_relative_file_url_with_dot_segment(tmp_path): + lib_dir = tmp_path / "lib" / "bread" + lib_dir.mkdir(parents=True) + + project_dir = tmp_path / "robots" / "template" + project_dir.mkdir(parents=True) + + # "file://./foo" form — netloc captures ".", which pip would reject. + content = inspect.cleandoc(f""" + [tool.robotpy] + robotpy_version = "{YEAR}.1.1.2" + requires = [ + "bread @ file://./../../lib/bread", + ] + """) + + project = pyproject.loads(content, base_path=project_dir) + assert project.requires[0].url == lib_dir.resolve().as_uri() + + +def test_absolute_file_url_left_alone(tmp_path): + lib_dir = tmp_path / "lib" / "bread" + lib_dir.mkdir(parents=True) + abs_uri = lib_dir.as_uri() + + content = inspect.cleandoc(f""" + [tool.robotpy] + robotpy_version = "{YEAR}.1.1.2" + requires = [ + "bread @ {abs_uri}", + ] + """) + + project = pyproject.loads(content, base_path=tmp_path) + assert project.requires[0].url == abs_uri + + +def test_non_file_url_left_alone(tmp_path): + git_url = "git+https://github.com/FRC-Team3484/FRC3484_Lib_Python.git@main" + + content = inspect.cleandoc(f""" + [tool.robotpy] + robotpy_version = "{YEAR}.1.1.2" + requires = [ + "frc3484 @ {git_url}", + ] + """) + + project = pyproject.loads(content, base_path=tmp_path) + assert project.requires[0].url == git_url + + +def test_loads_without_base_path_preserves_url(): + content = inspect.cleandoc(f""" + [tool.robotpy] + robotpy_version = "{YEAR}.1.1.2" + requires = [ + "bread @ file://../../lib/bread", + ] + """) + + project = pyproject.loads(content) + assert project.requires[0].url == "file://../../lib/bread"