From 4225dc628bd2f022c3c5cc1dd77b5468f4378a56 Mon Sep 17 00:00:00 2001 From: Griffin Della Grotte Date: Wed, 13 May 2026 16:10:37 -0700 Subject: [PATCH] Resolve relative file:// URLs in requires to absolute paths pyproject.toml's `[tool.robotpy] requires` list accepts PEP 508 direct-URL requirements like `bread @ git+https://...`. Relative `file://` URLs are also valid PEP 508 syntax, but pip rejects them: urlparse interprets the first path segment as the netloc, and pip's url_to_path() raises "non-local file URIs are not supported" for any non-empty, non-localhost netloc. This blocked using path dependencies (e.g. `bread @ file://../../lib/bread`) in monorepos, forcing workarounds like symlinks or pre-built wheels dropped into the pip cache. Fix by resolving relative file:// URLs to absolute file:// URLs against the project directory when loading pyproject.toml. Absolute URLs, non-file schemes, and URLs without a base path (e.g. `loads()` without an explicit base_path) are left untouched. --- robotpy_installer/pyproject.py | 53 +++++++++++++++++++-- tests/test_pyproject.py | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) 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"