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
14 changes: 14 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,19 @@ It can install downloaded packages on your SystemCore without requiring internet
access, which makes it usable in a competition (provided you downloaded the
necessary packages when you were connected to the internet).

To manage only explicitly listed requirements, set ``robotpy_version`` to
``"ignored"`` in your project's ``pyproject.toml``::

[tool.robotpy]
robotpy_version = "ignored"
requires = ["numpy"]

In this mode, sync and deploy do not automatically install or check the RobotPy
package, and ``components`` is ignored. Explicit requirements and their dependencies
are still installed normally. With an empty ``requires`` list, package installation
and deploy's package clearing are skipped, even with ``--force-install``. With
nonempty ``requires``, deploy retains its normal package-clearing behavior
(including respecting ``--no-uninstall``).

For more information about installing and using the RobotPy installer, see
`http://robotpy.readthedocs.io/en/stable/install/packages.html <http://robotpy.readthedocs.io/en/stable/install/packages.html>`_
10 changes: 6 additions & 4 deletions robotpy_installer/cli_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,8 @@ def _ensure_requirements(
):
python_exists = False
python_invalid: typing.Union[bool, str] = False
requirements_installed = False
no_requirements = project is not None and not project.get_install_list()
requirements_installed = no_requirements

# does c++/java exist
with wrap_ssh_error("removing c++/java user programs"):
Expand Down Expand Up @@ -445,7 +446,7 @@ def _ensure_requirements(
raise Error("User declined reinstallation")

if python_exists:
if no_install:
if no_install or no_requirements:
requirements_installed = True
elif not force_install:
pkgdata = self._get_robot_packages(ssh)
Expand Down Expand Up @@ -473,7 +474,7 @@ def _ensure_requirements(
# Install requirements
#

if force_install:
if force_install and not no_requirements:
requirements_installed = False
elif python_exists and not requirements_installed:
# if this is a pre-existing robotpy install, warn the user
Expand Down Expand Up @@ -515,7 +516,8 @@ def _ensure_requirements(

if python_invalid:
with wrap_ssh_error("uninstalling python"):
self._clear_pip_packages(installer)
if not no_requirements:
self._clear_pip_packages(installer)
logger.info("Uninstalling %s from robot", python_invalid)
installer.uninstall_python()
python_exists = False
Expand Down
5 changes: 4 additions & 1 deletion robotpy_installer/cli_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ def run(self, project_path: pathlib.Path, use_certifi: bool) -> bool:
logger.error("Could not load pyproject.toml")
return False

print("Project robotpy version is", project.robotpy_version)
print("Project robotpy version is", project.robotpy_version or "ignored")
if project.robotpy_version is None:
logger.error("Cannot update RobotPy while robotpy_version is 'ignored'")
return False

installer = RobotpyInstaller(log_startup=False)

Expand Down
69 changes: 40 additions & 29 deletions robotpy_installer/cli_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Sync:
[tool.robotpy]

# Version of robotpy this project depends on
# Set to "ignored" to install only requires (components are ignored)
robotpy_version = "{robotpy_version}"

# Which optional RobotPy components should be installed?
Expand Down Expand Up @@ -104,39 +105,45 @@ def run(
# parse pyproject.toml to determine the requirements
project = pyproject.load(project_path, write_if_missing=True)
logger.info(
"RobotPy version in `pyproject.toml` is '%s'", project.robotpy_version
"RobotPy version in `pyproject.toml` is '%s'",
project.robotpy_version or "ignored",
)

# Check for upgrade
if not no_upgrade_project:
latest_robotpy_version = installer.get_pypi_version("robotpy", use_certifi)
logger.info("Latest version of RobotPy is '%s'", latest_robotpy_version)
if project.robotpy_version < latest_robotpy_version:
msg = f"Update robotpy_version in `pyproject.toml` to {latest_robotpy_version}?"
if yesno(msg):
pyproject.set_robotpy_version(project_path, latest_robotpy_version)
project.robotpy_version = latest_robotpy_version

# Get the local version and don't accidentally downgrade them
try:
local_robotpy_version = Version(pyproject.robotpy_installed_version())
if project.robotpy_version < local_robotpy_version:
logger.warning(
"pyproject.toml robotpy version is older than currently installed version"
if project.robotpy_version is not None:
# Check for upgrade
if not no_upgrade_project:
latest_robotpy_version = installer.get_pypi_version(
"robotpy", use_certifi
)
print()
msg = (
f"Version currently installed: {local_robotpy_version}\n"
f"Version in `pyproject.toml`: {project.robotpy_version}\n"
"- Should we downgrade robotpy?"
)
if not yesno(msg):
print(
"Please update your pyproject.toml with the desired version of robotpy"
logger.info("Latest version of RobotPy is '%s'", latest_robotpy_version)
if project.robotpy_version < latest_robotpy_version:
msg = f"Update robotpy_version in `pyproject.toml` to {latest_robotpy_version}?"
if yesno(msg):
pyproject.set_robotpy_version(
project_path, latest_robotpy_version
)
project.robotpy_version = latest_robotpy_version

# Get the local version and don't accidentally downgrade them
try:
local_robotpy_version = Version(pyproject.robotpy_installed_version())
if project.robotpy_version < local_robotpy_version:
logger.warning(
"pyproject.toml robotpy version is older than currently installed version"
)
print()
msg = (
f"Version currently installed: {local_robotpy_version}\n"
f"Version in `pyproject.toml`: {project.robotpy_version}\n"
"- Should we downgrade robotpy?"
)
return False
except pyproject.NoRobotpyError:
pass
if not yesno(msg):
print(
"Please update your pyproject.toml with the desired version of robotpy"
)
return False
except pyproject.NoRobotpyError:
pass

install_reqs = project.get_install_reqs()
packages = list(map(str, install_reqs))
Expand All @@ -153,6 +160,10 @@ def run(
logger.info("Downloading Python for SystemCore")
installer.download_python(use_certifi)

if not packages:
logger.info("No Python packages to download or install")
return

logger.info("Downloading SystemCore python packages")
installer.pip_download(
no_deps=False,
Expand Down
29 changes: 16 additions & 13 deletions robotpy_installer/pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,16 @@ class RobotPyProjectToml:

"""

#: Version of robotpy that is depended on
robotpy_version: Version
#: Version of robotpy that is depended on, or None when configured as "ignored"
robotpy_version: typing.Optional[Version]

components: typing.List[str] = dataclasses.field(default_factory=list)

#: Requirement for the robotpy meta package -- all RobotPy projects must
#: depend on it
#: Requirement for the robotpy meta package, unless explicitly ignored
@property
def robotpy_requires(self) -> Requirement:
def robotpy_requires(self) -> typing.Optional[Requirement]:
if self.robotpy_version is None:
return None
if self.components:
components = f"[{','.join(self.components)}]"
else:
Expand All @@ -89,11 +90,9 @@ def are_requirements_met(
this project
"""
reqs = self.get_install_reqs()
assert reqs and reqs[0].name == "robotpy"
robotpy_req = reqs[0]

# Extra requirements from the extra resolver
reqs.extend(extra_resolver(robotpy_req, env))
if self.robotpy_version is not None:
# Extra requirements from the extra resolver
reqs.extend(extra_resolver(reqs[0], env))

return pypackages.are_requirements_met(reqs, packages, env)

Expand All @@ -110,7 +109,10 @@ def are_local_requirements_met(
)

def get_install_reqs(self) -> typing.List[Requirement]:
return [self.robotpy_requires] + self.requires
robotpy_req = self.robotpy_requires
if robotpy_req is None:
return self.requires.copy()
return [robotpy_req] + self.requires

def get_install_list(self) -> typing.List[str]:
return list(map(str, self.get_install_reqs()))
Expand Down Expand Up @@ -523,7 +525,8 @@ def _load(
) from None

try:
robotpy_version = Version(robotpy_data["robotpy_version"])
version = robotpy_data["robotpy_version"]
robotpy_version = None if version == "ignored" else Version(version)
except KeyError:
raise PyprojectError(
f"{pyproject_path} missing required tools.robotpy.robotpy_version"
Expand All @@ -534,7 +537,7 @@ def _load(
) from None

supported_year = int(installer._WPILIB_YEAR)
if robotpy_version.major != supported_year:
if robotpy_version is not None and robotpy_version.major != supported_year:
msg = (
f"Only RobotPy {supported_year}.x is supported by this version "
f"of robotpy-installer ({pyproject_path} has {robotpy_version})"
Expand Down
93 changes: 93 additions & 0 deletions tests/test_deploy_requirements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import argparse
from unittest.mock import MagicMock

import pytest

from robotpy_installer import cli_deploy, pyproject, pypackages
from robotpy_installer.installer import _WPILIB_YEAR as YEAR


@pytest.fixture
def deployment(monkeypatch):
deploy = cli_deploy.Deploy(argparse.ArgumentParser())
installer = MagicMock()
installer.is_python_installed.return_value = True
installer.get_python_version.return_value = cli_deploy.required_pyversion
ssh = MagicMock()
ssh.sftp_remote_file_exists.return_value = True
monkeypatch.setattr(cli_deploy.robot_utils, "uninstall_cpp_java", lambda ssh: True)
monkeypatch.setattr(deploy, "_get_robot_packages", lambda ssh: {})
monkeypatch.setattr(
deploy,
"_get_cached_packages",
lambda installer: pypackages.make_packages(
{"example": "1.2", "robotpy": f"{YEAR}.1.0"}
),
)
return deploy, installer, ssh


@pytest.mark.parametrize("force_install", [False, True])
@pytest.mark.parametrize("python_state", ["current", "missing", "outdated"])
def test_ignored_empty_requires_never_clears_or_installs_packages(
deployment, force_install, python_state
):
deploy, installer, ssh = deployment
project = pyproject.loads('[tool.robotpy]\nrobotpy_version = "ignored"\n')
if python_state == "missing":
installer.is_python_installed.return_value = False
elif python_state == "outdated":
installer.get_python_version.return_value = (3, 8)

deploy._ensure_requirements(
project,
installer,
ssh,
no_install=False,
force_install=force_install,
no_uninstall=False,
assume_yes=True,
)

installer.uninstall_venv.assert_not_called()
installer.pip_install.assert_not_called()
if python_state == "current":
installer.install_python.assert_not_called()
else:
installer.install_python.assert_called_once_with()


@pytest.mark.parametrize("force_install", [False, True])
@pytest.mark.parametrize("no_uninstall", [False, True])
@pytest.mark.parametrize(
"version, packages",
[
("ignored", ["example==1.2"]),
(f"{YEAR}.1.0", [f"robotpy=={YEAR}.1.0", "example==1.2"]),
],
)
def test_nonempty_requires_retains_normal_clearing_behavior(
deployment, no_uninstall, force_install, version, packages
):
deploy, installer, ssh = deployment
project = pyproject.loads(
f'[tool.robotpy]\nrobotpy_version = "{version}"\nrequires = ["example==1.2"]\n'
)

deploy._ensure_requirements(
project,
installer,
ssh,
no_install=False,
force_install=force_install,
no_uninstall=no_uninstall,
assume_yes=True,
)

if no_uninstall:
installer.uninstall_venv.assert_not_called()
else:
installer.uninstall_venv.assert_called_once_with()
installer.pip_install.assert_called_once_with(
False, False, False, False, [], packages
)
17 changes: 17 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import argparse
from unittest.mock import MagicMock

from robotpy_installer import cli_project


def test_update_robotpy_leaves_ignored_version_unchanged(tmp_path, monkeypatch):
config = '[tool.robotpy]\nrobotpy_version = "ignored"\n'
(tmp_path / "pyproject.toml").write_text(config)
installer = MagicMock()
monkeypatch.setattr(cli_project, "RobotpyInstaller", installer)

result = cli_project.UpdateRobotpy(argparse.ArgumentParser()).run(tmp_path, False)

assert result is False
installer.assert_not_called()
assert (tmp_path / "pyproject.toml").read_text() == config
Loading
Loading