From 2f494eb734291354c3be9bc25e9d3f21200e7e8b Mon Sep 17 00:00:00 2001 From: Dustin Spicuzza Date: Fri, 5 Jan 2024 23:37:56 -0500 Subject: [PATCH] Add `robotpy sync` command to download project requirements - First part of fix for #70 --- robotpy_installer/cli_sync.py | 119 ++++++++++++++++++++++++ robotpy_installer/pyproject.py | 163 +++++++++++++++++++++++++++++++++ setup.cfg | 3 + 3 files changed, 285 insertions(+) create mode 100644 robotpy_installer/cli_sync.py create mode 100644 robotpy_installer/pyproject.py diff --git a/robotpy_installer/cli_sync.py b/robotpy_installer/cli_sync.py new file mode 100644 index 0000000..52af23e --- /dev/null +++ b/robotpy_installer/cli_sync.py @@ -0,0 +1,119 @@ +import argparse +import logging +import os +import pathlib +import sys + +from .utils import handle_cli_error + + +from .installer import RobotpyInstaller +from . import pyproject + +logger = logging.getLogger("sync") + + +class Sync: + """ + Downloads RoboRIO requirements and installs requirements locally + + The project requirements are determined by reading pyproject.toml. An + example pyproject.toml is: + + [tool.robotpy] + + # Version of robotpy this project depends on + robotpy_version = "{robotpy_version}" + + # Which extras should be installed + # -> equivalent to `pip install robotpy[extra1, ...] + robotpy_extras = [] + + # Other pip packages to install (each element is equivalent to + # a line in requirements.txt) + requires = [] + + If no pyproject.toml exists, a default is created using the current robotpy + package version. + + You must be connected to the internet for this to work. + """ + + def __init__(self, parser: argparse.ArgumentParser): + parser.add_argument( + "--user", + "-u", + default=False, + action="store_true", + help="Use `pip install --user` to install packages", + ) + + parser.add_argument( + "--use-certifi", + action="store_true", + default=False, + help="Use SSL certificates from certifi", + ) + + @handle_cli_error + def run( + self, + project_path: pathlib.Path, + main_file: pathlib.Path, + user: bool, + use_certifi: bool, + ): + if not main_file.exists(): + print( + f"ERROR: is this a robot project? {main_file} does not exist", + file=sys.stderr, + ) + return 1 + + installer = RobotpyInstaller() + + # parse pyproject.toml to determine the requirements + project = pyproject.load(project_path, write_if_missing=True) + + packages = [str(project.robotpy_requires)] + packages.extend([str(req) for req in project.requires]) + + logger.info("Robot project requirements:") + for package in packages: + logger.info("- %s", package) + + # + # First, download requirements for RoboRIO + # + + logger.info("Downloading Python for RoboRIO") + installer.download_python(use_certifi) + + logger.info("Downloading RoboRIO python packages") + installer.pip_download( + no_deps=False, + pre=False, + requirements=[], + packages=packages, + ) + + # + # Local requirement installation + # - On windows we can experience sharing violations if this package + # is upgraded, so we exit with the pip installation + # + + logger.info("Installing requirements in local python interpreter") + + pip_args = [ + sys.executable, + "-m", + "pip", + "--disable-pip-version-check", + "install", + ] + if user: + pip_args.append("--user") + pip_args.extend(packages) + + os.execv(sys.executable, pip_args) diff --git a/robotpy_installer/pyproject.py b/robotpy_installer/pyproject.py new file mode 100644 index 0000000..ee3a1f9 --- /dev/null +++ b/robotpy_installer/pyproject.py @@ -0,0 +1,163 @@ +import dataclasses +from importlib.metadata import metadata, PackageNotFoundError +import inspect +import pathlib +import typing + +from packaging.requirements import Requirement +from packaging.version import Version, InvalidVersion +import tomli + +from .errors import Error + + +class PyprojectError(Error): + pass + + +def _pyproject_toml_path(project_path: pathlib.Path): + return project_path / "pyproject.toml" + + +@dataclasses.dataclass +class RobotPyProjectToml: + """ + The results of parsing a ``pyproject.toml`` file in a RobotPy project. This + only parses fields that matter for deploy/install (TODO: this probably + should live in a single unified place?) + + .. code-block:: toml + + [tool.robotpy] + + # equivalent to `robotpy==2024.0.0b4` + robotpy_version = "2024.0.0b4" + + # equivalent to `robotpy[cscore, ...]` + robotpy_extras = ["cscore"] + + # Other pip installable requirement lines + requires = [ + "numpy" + ] + + """ + + #: Requirement for the robotpy meta package -- all RobotPy projects must + #: depend on it + robotpy_requires: Requirement + + #: Requirements for + requires: typing.List[Requirement] = dataclasses.field(default_factory=list) + + +def write_default_pyproject( + project_path: pathlib.Path, +): + """ + Using the current environment, write a minimal pyproject.toml + + :param project_path: Path to robot project + """ + + # this is a bit weird because this project doesn't depend on robotpy, it's + # the other way around.. but oh well? + try: + robotpy_version = metadata("robotpy")["Version"] + except PackageNotFoundError: + raise PyprojectError( + "cannot infer default robotpy package version: robotpy package not installed " + "(do `pip install robotpy`)" + ) from None + + with open(_pyproject_toml_path(project_path), "w") as fp: + fp.write( + inspect.cleandoc( + f""" + + # + # Use this configuration file to control what RobotPy packages are installed + # on your RoboRIO + # + + [tool.robotpy] + + # Version of robotpy this project depends on + robotpy_version = "{robotpy_version}" + + # Which extras should be installed + # -> equivalent to `pip install robotpy[extra1, ...] + robotpy_extras = [] + + # Other pip packages to install + requires = [] + + """ + ) + + "\n" + ) + + +def load( + project_path: pathlib.Path, *, write_if_missing: bool = False +) -> RobotPyProjectToml: + """ + Reads a pyproject.toml file for a RobotPy project. Raises FileNotFoundError + if the file isn't present + + :param project_path: Path to robot project + """ + + pyproject_path = _pyproject_toml_path(project_path) + if write_if_missing and not pyproject_path.exists(): + write_default_pyproject(project_path) + + with open(pyproject_path, "rb") as fp: + data = tomli.load(fp) + + try: + robotpy_data = data["tool"]["robotpy"] + if not isinstance(robotpy_data, dict): + raise KeyError() + except KeyError: + raise PyprojectError( + f"{pyproject_path} must have [tool.robotpy] section" + ) from None + + try: + robotpy_version = Version(robotpy_data["robotpy_version"]) + except KeyError: + raise PyprojectError( + f"{pyproject_path} missing required tools.robotpy.robotpy_version" + ) from None + except InvalidVersion: + raise PyprojectError( + f"{pyproject_path}: tools.robotpy.robotpy_version is not a valid version" + ) from None + + robotpy_extras_any = robotpy_data.get("robotpy_extras") + if isinstance(robotpy_extras_any, list): + robotpy_extras = list(map(str, robotpy_extras_any)) + elif not robotpy_extras_any: + robotpy_extras = [] + else: + robotpy_extras = [str(robotpy_extras_any)] + + # Construct the full requirement + robotpy_pkg = "robotpy" + if robotpy_extras: + extras_s = ",".join(robotpy_extras) + robotpy_pkg = f"robotpy[{extras_s}]" + robotpy_requires = Requirement(f"{robotpy_pkg}=={robotpy_version}") + + requires_any = robotpy_data.get("requires") + if isinstance(requires_any, list): + requires = [] + for req in requires_any: + requires.append(Requirement(req)) + elif requires_any: + requires = [Requirement(str(requires_any))] + else: + requires = [] + + return RobotPyProjectToml(robotpy_requires=robotpy_requires, requires=requires) diff --git a/setup.cfg b/setup.cfg index 068b879..21f8941 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,9 +24,11 @@ zip_safe = False include_package_data = True packages = find: install_requires = + packaging paramiko pynetconsole~=2.0.2 robotpy-cli~=2024.0b + tomli setup_requires = setuptools_scm > 6 python_requires = >=3.8 @@ -38,4 +40,5 @@ robotpy = deploy = robotpy_installer.cli_deploy:Deploy deploy-info = robotpy_installer.cli_deploy_info:DeployInfo installer = robotpy_installer.cli_installer:Installer + sync = robotpy_installer.cli_sync:Sync undeploy = robotpy_installer.cli_undeploy:Undeploy