Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions robotpy_installer/pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("<string>", data)
return _load("<string>", 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"]
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions tests/test_pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading