diff --git a/Pipfile b/Pipfile index f9e8032b..06eaf8a3 100644 --- a/Pipfile +++ b/Pipfile @@ -31,6 +31,7 @@ directory-tree = ">=0.0.3.1" yaspin = ">=2.3.0" jsonschema = ">=4.0.0" waiting = ">=1.4.1" +semver = ">=3.0.0" [requires] python_version = "3" diff --git a/Pipfile.lock b/Pipfile.lock index 9a5c7e7c..c36e96d8 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "374a231f04437bf496044afe3f38f7a2866dc105a0c58f6e6b532c23d63c0390" + "sha256": "524b510cf2b931a06bfcfb40a52847146d281ad1f70341b62a296edde40e8ffd" }, "pipfile-spec": 6, "requires": { @@ -426,6 +426,14 @@ "markers": "python_version >= '3.7'", "version": "==2.31.0" }, + "semver": { + "hashes": [ + "sha256:2a23844ba1647362c7490fe3995a86e097bb590d16f0f32dfc383008f19e4cdf", + "sha256:9ec78c5447883c67b97f98c3b6212796708191d22e4ad30f4570f840171cbce1" + ], + "index": "pypi", + "version": "==3.0.1" + }, "setuptools": { "hashes": [ "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f", diff --git a/riocli/bootstrap.py b/riocli/bootstrap.py index f08e80ce..74f24457 100644 --- a/riocli/bootstrap.py +++ b/riocli/bootstrap.py @@ -28,6 +28,7 @@ from riocli.chart import chart from riocli.completion import completion from riocli.config import Configuration +from riocli.constants import Colors, Symbols from riocli.deployment import deployment from riocli.device import device from riocli.disk import disk @@ -42,6 +43,12 @@ from riocli.shell import shell, deprecated_repl from riocli.static_route import static_route from riocli.usergroup import usergroup +from riocli.utils import ( + check_for_updates, + pip_install_cli, + is_pip_installation, + update_appimage, +) from riocli.vpn import vpn @@ -49,8 +56,8 @@ @click.group( invoke_without_command=False, cls=HelpColorsGroup, - help_headers_color="yellow", - help_options_color="green", + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, ) @click.pass_context def cli(ctx: Context, config: str = None): @@ -75,6 +82,38 @@ def version(): return +@cli.command('update') +@click.option('-f', '--force', '--silent', 'silent', is_flag=True, + type=click.BOOL, default=False, + help="Skip confirmation") +def update(silent: bool) -> None: + """ + Update the CLI to the latest version + """ + available, latest = check_for_updates(__version__) + if not available: + click.secho('🎉 You are using the latest version', fg=Colors.GREEN) + return + + click.secho('🎉 A newer version ({}) is available.'.format(latest), + fg=Colors.GREEN) + + if not silent: + click.confirm('Do you want to update?', abort=True, default=False) + + try: + if is_pip_installation(): + pip_install_cli(version=latest) + else: + update_appimage(version=latest) + except Exception as e: + click.secho('{} Failed to update the CLI'.format(e), fg=Colors.RED) + raise SystemExit(1) from e + + click.secho('{} Update successful!'.format(Symbols.SUCCESS), + fg=Colors.GREEN) + + cli.add_command(apply) cli.add_command(chart) cli.add_command(explain) diff --git a/riocli/utils/__init__.py b/riocli/utils/__init__.py index 6d8f5577..b814a693 100644 --- a/riocli/utils/__init__.py +++ b/riocli/utils/__init__.py @@ -12,19 +12,28 @@ # See the License for the specific language governing permissions and # limitations under the License. import json +import os import random import shlex import string import subprocess +import sys import typing +from pathlib import Path from shutil import get_terminal_size +from tempfile import TemporaryDirectory from uuid import UUID import click +import requests +import semver import yaml from click_help_colors import HelpColorsGroup +from munch import munchify from tabulate import tabulate +from riocli.constants import Colors, Symbols + def inspect_with_format(obj: typing.Any, format_type: str): if format_type == 'json': @@ -81,11 +90,13 @@ def run_bash(cmd, bg=False) -> str: def random_string(letter_count, digit_count): - str1 = ''.join((random.choice(string.ascii_letters) for x in range(letter_count))) + str1 = ''.join( + (random.choice(string.ascii_letters) for x in range(letter_count))) str1 += ''.join((random.choice(string.digits) for x in range(digit_count))) sam_list = list(str1) # it converts the string to list. - random.shuffle(sam_list) # It uses a random.shuffle() function to shuffle the string. + random.shuffle( + sam_list) # It uses a random.shuffle() function to shuffle the string. final_string = ''.join(sam_list) return final_string @@ -118,7 +129,8 @@ def is_valid_uuid(uuid_to_test, version=4): return str(uuid_obj) == uuid_to_test -def tabulate_data(data: typing.List[typing.List], headers: typing.List[str] = None): +def tabulate_data(data: typing.List[typing.List], + headers: typing.List[str] = None): """ Prints data in tabular format """ @@ -138,3 +150,93 @@ def print_separator(color: str = 'blue'): """ col, _ = get_terminal_size() click.secho(" " * col, bg=color) + + +def is_pip_installation() -> bool: + return 'python' in sys.executable + + +def check_for_updates(current_version: str) -> tuple[bool, str]: + try: + package_info = requests.get( + 'https://pypi.org/pypi/rapyuta-io-cli/json').json() + except Exception as e: + click.secho('Failed to fetch upstream package info: {}'.format(e), + fg=Colors.RED) + raise SystemExit(1) from e + + upstream_version = package_info.get('info', {}).get('version') + + current_version = semver.Version.parse(current_version) + available = semver.Version.parse(upstream_version).compare(current_version) + + return available > 0, upstream_version + + +def pip_install_cli( + version: str, + force_reinstall: bool = False, +) -> subprocess.CompletedProcess: + """ + Installs the given rapyuta-io-cli version using pip + """ + if not version: + raise ValueError('version cannot by empty.') + + try: + semver.Version.parse(version) + except ValueError as err: + raise err + + package_name = 'rapyuta-io-cli=={}'.format(version) + + # https://pip.pypa.io/en/latest/user_guide/#using-pip-from-your-program + command = [sys.executable, '-m', 'pip', 'install', package_name] + if force_reinstall: + command.append('--force-reinstall') + + return subprocess.run(command, check=True) + + +def update_appimage(version: str): + """ + Updates the AppImage locally + """ + if not version: + raise ValueError('version cannot be empty') + + if os.getuid() != 0: + click.secho( + '{} Please run this as the root user.'.format(Symbols.WARNING), + fg=Colors.YELLOW) + raise SystemExit(1) + + # URL to get the latest release metadata + url = 'https://api.github.com/repos/rapyuta-robotics/rapyuta-io-cli/releases/latest' + + try: + response = requests.get(url) + data = munchify(response.json()) + except Exception as e: + click.secho('Failed to fetch release info: {}'.format(e), + fg=Colors.RED) + raise SystemExit(1) from e + + asset = None + for a in data.get('assets', []): + if 'AppImage' in a.name and version in a.name: + asset = a + break + + if asset is None: + raise Exception( + 'Failed to retrieve the download URL for the latest AppImage') + + with TemporaryDirectory() as tmp: + # Download and save the binary in a temp dir + response = requests.get(asset.browser_download_url) + save_to = Path(tmp) / 'rio' + save_to.write_bytes(response.content) + os.chmod(save_to, 0o755) + # Now replace the current executable with the new file + os.rename(save_to, sys.executable) diff --git a/setup.py b/setup.py index 5d474560..7a7f06e6 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,8 @@ "directory-tree>=0.0.3.1", "yaspin>=2.3.0", "jsonschema>=4.0.0", - "waiting>=1.4.1" + "waiting>=1.4.1", + "semver>=3.0.0", ], setup_requires=["flake8"], )