Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create offline bundle #120

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
189 changes: 189 additions & 0 deletions robotpy_installer/_bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
#!/usr/bin/env python3

from __future__ import annotations

description = """
Standalone bootstrap script that can install from a bundle created with make-offline.
It will ignore your local installation and install whatever is in the bundle
"""


import argparse
import dataclasses
import json
import os.path
import pathlib
import shutil
import subprocess
import sys
import tempfile
import typing
import zipfile

METADATA_VERSION = 1
METADATA_JSON = "rpybundle.json"


@dataclasses.dataclass
class Metadata:
"""
Describes content of METADATA_JSON in offline bundle
"""

#: metadata version
version: int

#: robotpy-installer version
installer_version: str

#: wpilib year
wpilib_year: str

#: python ipk name
python_ipk: str

#: python wheel tags supported by this bundle
wheel_tags: typing.List[str]

# #: list of packages derived from pyproject.toml
# packages: typing.List[str]

def dumps(self) -> str:
data = dataclasses.asdict(self)
return json.dumps(data)

@classmethod
def loads(cls, s: str) -> Metadata:
data = json.loads(s)
if not isinstance(data, dict):
raise ValueError("invalid metadata")
version = data.get("version", None)
if not isinstance(version, int):
raise ValueError(f"invalid metadata version {version!r}")
if version > METADATA_VERSION:
raise ValueError(
f"can only understand metadata < {METADATA_VERSION}, got {version}"
)

installer_version = data.get("installer_version", None)
if not isinstance(installer_version, str):
raise ValueError(f"invalid installer version {installer_version!r}")

wpilib_year = data.get("wpilib_year", None)
if not isinstance(wpilib_year, str):
raise ValueError(f"invalid wpilib_year {wpilib_year}")

python_ipk = data.get("python_ipk", None)
if not python_ipk or not isinstance(python_ipk, str):
raise ValueError(f"invalid python_ipk value")

wheel_tags = data.get("wheel_tags", None)
if not isinstance(wheel_tags, list) or len(wheel_tags) == 0:
raise ValueError(f"no wheel tags present")
# packages = data.get("packages")
# if not isinstance(packages, list):
# raise ValueError(f"invalid package list {packages!r}")

return Metadata(
version=version,
installer_version=installer_version,
wpilib_year=wpilib_year,
python_ipk=python_ipk,
wheel_tags=wheel_tags, # packages=packages
)


if __name__ == "__main__":

# If is running from a zipfile, identify it
bundle_path = None
if pathlib.Path(__file__).parent.is_file():
bundle_path = pathlib.Path(__file__).parent

parser = argparse.ArgumentParser(
description=description, formatter_class=argparse.RawDescriptionHelpFormatter
)

parser.add_argument(
"--user",
"-u",
default=False,
action="store_true",
help="Use `pip install --user` to install packages",
)

if bundle_path is None:
parser.add_argument("bundle", type=pathlib.Path, help="Bundle file")

# parser.add_argument(
# "--no-robotpy-installer",
# default=False,
# action="store_true",
# help="Do not install robotpy-installer",
# )
args = parser.parse_args()

if bundle_path is None:
bundle_path= args.bundle

with zipfile.ZipFile(bundle_path, "r") as zfp:

# extract metadata
raw_metadata = zfp.read(METADATA_JSON).decode("utf-8")
metadata = Metadata.loads(raw_metadata)

cache_root = pathlib.Path.home() / "wpilib" / metadata.wpilib_year / "robotpy"

# extract pip cache to a temporary directory
with tempfile.TemporaryDirectory() as t:
pip_cache = pathlib.Path(t)


print("Extracting wheels to temporary path...")

for info in zfp.infolist():
p = pathlib.Path(info.filename.replace("/", os.path.sep))
if p.parts[0] == "pip_cache":
with zfp.open(info) as sfp, open(pip_cache / p.name, "wb") as dfp:
shutil.copyfileobj(sfp, dfp)

# TODO: when doing local dev, how do I embed the right
# robotpy-installer? or just ignore it

# if not args.no_robotpy_installer:
# print("Installing robotpy-installer", metadata.installer_version)

# # install robotpy-installer offline from temporary directory
# # do == to force pip to install this one
# pip_args = [
# sys.executable,
# "-m",
# "pip",
# "install",
# "--disable-pip-version-check",
# "--no-index",
# "--find-links",
# t,
# f"robotpy-installer=={metadata.installer_version}",
# ]
# print("+", *pip_args)
# subprocess.check_call(pip_args)

# If this is part of robotpy-installer, on Windows need
# to worry about sharing violation

sync_args = [
sys.executable,
"-m",
"robotpy",
"sync",
"--from",
str(bundle_path)
]

if args.user:
sync_args.append("--user")

result = subprocess.run(sync_args)
sys.exit(result.returncode)

4 changes: 3 additions & 1 deletion robotpy_installer/_pipstub.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import os
import sys

rio_python_version = "3.12.0"


if __name__ == "__main__":
# Setup environment for what the RoboRIO python would have
Expand All @@ -18,6 +20,6 @@
platform.machine = lambda: "roborio"
platform.python_implementation = lambda: "CPython"
platform.system = lambda: "Linux"
platform.python_version = lambda: "3.12.0"
platform.python_version = lambda: rio_python_version

runpy.run_module("pip", run_name="__main__")
137 changes: 137 additions & 0 deletions robotpy_installer/cli_makeoffline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import argparse
import json
import logging
import pathlib
import shutil
import subprocess
import sys
import tempfile
import zipfile

import packaging.tags

from . import pyproject
from .errors import Error
from .installer import RobotpyInstaller, _WPILIB_YEAR
from .utils import handle_cli_error

from . import _bootstrap

logger = logging.getLogger("bundler")


class MakeOffline:
"""
Creates a bundle that can be used to install RobotPy dependencies
without internet access.

To install from the bundle,
"""

def __init__(self, parser: argparse.ArgumentParser):
parser.add_argument(
"--use-certifi",
action="store_true",
default=False,
help="Use SSL certificates from certifi",
)
parser.add_argument(
"bundle_path",
type=pathlib.Path
)

@handle_cli_error
def run(
self, project_path: pathlib.Path, use_certifi: bool, bundle_path: pathlib.Path
):

installer = RobotpyInstaller()
# local_cache = installer.cache_root
local_pip_cache = installer.pip_cache

bootstrap_py_path = pathlib.Path(_bootstrap.__file__)

# collect deps from project, or use installed version
project = pyproject.load(
project_path, write_if_missing=False, default_if_missing=True
)
packages = project.get_install_list()

logger.info("Robot project requirements:")
for package in packages:
logger.info("- %s", package)

# Download python ipk to original cache
python_ipk = installer.download_python(use_certifi)

# Make temporary directory to download to
with tempfile.TemporaryDirectory() as t:
tpath = pathlib.Path(t)
installer.set_cache_root(tpath)
whl_path = tpath / "pip_cache"

if True:
whl_path.mkdir(parents=True, exist_ok=True)
if False:
# Download rio deps (use local cache to speed it up)
installer.pip_download(False, False, [], packages, local_pip_cache)

# Download local deps
pip_args = [
sys.executable,
"-m",
"pip",
"--disable-pip-version-check",
"download",
"-d",
str(whl_path),
] + packages

logger.debug("Using pip to download: %s", pip_args)
retval = subprocess.call(pip_args)
if retval != 0:
raise Error("pip download failed")

from .version import version
# TODO: it's possible for the bundle to include a version of robotpy-installer
# that does not match the current version. Need to not do that.

metadata = _bootstrap.Metadata(
version=_bootstrap.METADATA_VERSION,
installer_version=version,
wpilib_year=_WPILIB_YEAR,
python_ipk=python_ipk.name,
wheel_tags=[
str(next(packaging.tags.sys_tags())), # sys tag
# roborio tag
],
)

logger.info("Bundle supported wheel tags:")
for tag in metadata.wheel_tags:
logger.info("+ %s", tag)

logger.info("Making bundle at '%s'", bundle_path)

# zip it all up
with zipfile.ZipFile(bundle_path, "w") as zfp:

# descriptor
logger.info("+ %s", _bootstrap.METADATA_JSON)
zfp.writestr(_bootstrap.METADATA_JSON, metadata.dumps())

# bootstrapper
logger.info("+ __main__.py")
zfp.write(bootstrap_py_path, "__main__.py")

# ipk
logger.info("+ %s", python_ipk.name)
zfp.write(python_ipk, python_ipk.name)

# pip cache
for f in whl_path.iterdir():
logger.info("+ pip_cache/%s", f.name)
zfp.write(f, f"pip_cache/{f.name}")

st = bundle_path.stat()
logger.info("Bundle is %d bytes", st.st_size)
Loading