Skip to content

Commit

Permalink
Add robotpy sync command to download project requirements
Browse files Browse the repository at this point in the history
- First part of fix for #70
  • Loading branch information
virtuald committed Jan 6, 2024
1 parent 8d22670 commit 2f494eb
Show file tree
Hide file tree
Showing 3 changed files with 285 additions and 0 deletions.
119 changes: 119 additions & 0 deletions robotpy_installer/cli_sync.py
Original file line number Diff line number Diff line change
@@ -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)
163 changes: 163 additions & 0 deletions robotpy_installer/pyproject.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

0 comments on commit 2f494eb

Please sign in to comment.