diff --git a/README.rst b/README.rst index 167f249..ae826f9 100644 --- a/README.rst +++ b/README.rst @@ -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 `_ diff --git a/robotpy_installer/cli_deploy.py b/robotpy_installer/cli_deploy.py index 641d5bd..2ba0cc6 100644 --- a/robotpy_installer/cli_deploy.py +++ b/robotpy_installer/cli_deploy.py @@ -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"): @@ -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) @@ -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 @@ -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 diff --git a/robotpy_installer/cli_project.py b/robotpy_installer/cli_project.py index ff68ee3..f959367 100644 --- a/robotpy_installer/cli_project.py +++ b/robotpy_installer/cli_project.py @@ -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) diff --git a/robotpy_installer/cli_sync.py b/robotpy_installer/cli_sync.py index c9f859e..8d74582 100644 --- a/robotpy_installer/cli_sync.py +++ b/robotpy_installer/cli_sync.py @@ -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? @@ -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)) @@ -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, diff --git a/robotpy_installer/pyproject.py b/robotpy_installer/pyproject.py index 1a00deb..033fb6b 100644 --- a/robotpy_installer/pyproject.py +++ b/robotpy_installer/pyproject.py @@ -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: @@ -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) @@ -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())) @@ -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" @@ -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})" diff --git a/tests/test_deploy_requirements.py b/tests/test_deploy_requirements.py new file mode 100644 index 0000000..85f6419 --- /dev/null +++ b/tests/test_deploy_requirements.py @@ -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 + ) diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..f70a78d --- /dev/null +++ b/tests/test_project.py @@ -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 diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index b888ada..3496309 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -2,6 +2,8 @@ import pathlib import typing +import pytest + from robotpy_installer import pyproject, pypackages from robotpy_installer.installer import _WPILIB_YEAR as YEAR @@ -31,6 +33,52 @@ def test_ok(): ) +@pytest.mark.parametrize("requires", [[], ["example==1.2"]]) +def test_ignored_version_uses_only_explicit_requirements(requires): + project = load_project(f""" + [tool.robotpy] + robotpy_version = "ignored" + components = ["cscore"] + requires = {requires!r} + """) + + assert project.robotpy_version is None + assert project.get_install_list() == requires + assert project.get_deploy_list({}) == requires + + def unexpected_resolver(req, env): + pytest.fail("ignored RobotPy must not resolve components") + + installed = pypackages.make_packages({"example": "1.2"}) + assert project.are_requirements_met( + installed, pypackages.robot_env(), unexpected_resolver + ) == (True, []) + # Checking requirements must not mutate the explicit requirements. + assert project.get_install_list() == requires + + +def test_ignored_version_still_checks_explicit_requirements(): + project = load_project(""" + [tool.robotpy] + robotpy_version = "ignored" + requires = ["example==1.2"] + """) + + assert project.are_requirements_met({}, {}, null_resolver) == ( + False, + ["example==1.2 (not found)"], + ) + + +@pytest.mark.parametrize("version", ["invalid", "Ignored", "2024.1.0"]) +def test_invalid_or_unsupported_version_is_rejected(version): + with pytest.raises(pyproject.PyprojectError): + load_project(f""" + [tool.robotpy] + robotpy_version = "{version}" + """) + + def test_older_fail(): project = load_project(f""" [tool.robotpy] diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..d9e79bc --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,111 @@ +import argparse +import sys +from unittest.mock import MagicMock + +import pytest +from packaging.version import Version + +from robotpy_installer import cli_sync +from robotpy_installer.installer import _WPILIB_YEAR as YEAR + + +@pytest.mark.parametrize("no_install", [False, True]) +@pytest.mark.parametrize("no_upgrade_project", [False, True]) +@pytest.mark.parametrize("requires", [[], ["example==1.2"], ["robotpy[cscore]"]]) +def test_sync_ignored_only_processes_requires( + tmp_path, monkeypatch, requires, no_install, no_upgrade_project +): + main_file = tmp_path / "robot.py" + main_file.write_text("") + config = ( + '[tool.robotpy]\nrobotpy_version = "ignored"\n' + f'components = ["cscore"]\nrequires = {requires!r}\n' + ) + (tmp_path / "pyproject.toml").write_text(config) + + installer = MagicMock() + monkeypatch.setattr(cli_sync, "RobotpyInstaller", lambda: installer) + + def unexpected_robotpy_access(*args, **kwargs): + pytest.fail("sync must not inspect or update ignored RobotPy") + + installer.get_pypi_version.side_effect = unexpected_robotpy_access + monkeypatch.setattr( + cli_sync.pyproject, "robotpy_installed_version", unexpected_robotpy_access + ) + monkeypatch.setattr(cli_sync, "yesno", unexpected_robotpy_access) + monkeypatch.setattr(cli_sync.sys, "platform", "linux") + execv = MagicMock(side_effect=SystemExit) + monkeypatch.setattr(cli_sync.os, "execv", execv) + + def run(): + return cli_sync.Sync(argparse.ArgumentParser()).run( + project_path=tmp_path, + main_file=main_file, + find_links=None, + no_install=no_install, + no_upgrade_project=no_upgrade_project, + user=False, + use_certifi=False, + ) + + if requires and not no_install: + with pytest.raises(SystemExit): + run() + execv.assert_called_once_with( + sys.executable, + [sys.executable, "-m", "pip", "--disable-pip-version-check", "install"] + + requires, + ) + else: + assert run() is None + execv.assert_not_called() + + installer.download_python.assert_called_once_with(False) + if requires: + installer.pip_download.assert_called_once_with( + no_deps=False, + pre=False, + requirements=[], + packages=requires, + find_links=None, + ) + else: + installer.pip_download.assert_not_called() + installer.pip_wheel.assert_not_called() + assert (tmp_path / "pyproject.toml").read_text() == config + + +def test_sync_normal_version_still_upgrades_project(tmp_path, monkeypatch): + main_file = tmp_path / "robot.py" + main_file.write_text("") + (tmp_path / "pyproject.toml").write_text( + f'[tool.robotpy]\nrobotpy_version = "{YEAR}.1.0"\n' + ) + installer = MagicMock() + installer.get_pypi_version.return_value = Version(f"{YEAR}.2.0") + monkeypatch.setattr(cli_sync, "RobotpyInstaller", lambda: installer) + monkeypatch.setattr( + cli_sync.pyproject, "robotpy_installed_version", lambda: f"{YEAR}.1.0" + ) + monkeypatch.setattr(cli_sync, "yesno", lambda msg: True) + + cli_sync.Sync(argparse.ArgumentParser()).run( + project_path=tmp_path, + main_file=main_file, + find_links=None, + no_install=True, + no_upgrade_project=False, + user=False, + use_certifi=False, + ) + + installer.get_pypi_version.assert_called_once_with("robotpy", False) + installer.pip_download.assert_called_once_with( + no_deps=False, + pre=False, + requirements=[], + packages=[f"robotpy=={YEAR}.2.0"], + find_links=None, + ) + assert cli_sync.pyproject.load(tmp_path).robotpy_version == Version(f"{YEAR}.2.0")