import asyncio import os import subprocess import shutil import sys import sysconfig from pathlib import Path from typing import Tuple import click from rich.markdown import Markdown from rich.panel import Panel from rich.prompt import Confirm from rich.text import Text from redbot import __version__ from redbot.core import _downloader, _drivers, data_manager from redbot.core._cli import asyncio_run, parse_cli_flags from redbot.core.bot import Red from . import changelog, cmd, common, runner from .updater import UpdaterMetadata, get_updater_metadata FINISH_UPDATE_CMD_NAME = "finish-update" _UPDATE_COGS_CMD_NAME = "update-cogs" _UPDATE_REPOS_OPTION_NAME = "--update-repos" _EXIT_INSTANCE_SITE_PREFIX_MISMATCH = 4 _EXIT_INSTANCE_BACKEND_UNSUPPORTED = 5 @click.group(invoke_without_command=True) @click.option(cmd.arg_names.DEBUG, "logging_level", count=True) def cli(logging_level: int) -> None: common.ensure_supported_env() common.configure_logging(logging_level) @cli.command(_UPDATE_COGS_CMD_NAME) @click.argument("instance_name") @click.option(_UPDATE_REPOS_OPTION_NAME, default=False, is_flag=True) def update_cogs(instance_name: str, update_repos: bool) -> None: asyncio.run(_update_cogs(instance_name, update_repos)) async def _update_cogs(instance: str, update_repos: bool) -> None: data_manager.load_basic_configuration(instance) red = Red(cli_flags=parse_cli_flags([instance])) driver_cls = _drivers.get_driver_class() await driver_cls.initialize(**data_manager.storage_details()) try: await _run_cog_update(red, update_repos=update_repos) except _drivers.MissingExtraRequirements: raise SystemExit(_EXIT_INSTANCE_BACKEND_UNSUPPORTED) finally: await driver_cls.teardown() async def _run_cog_update(bot: Red, *, update_repos: bool) -> None: stdout_console = common.get_console() console = common.get_console(stderr=True) instance_name = data_manager.instance_name() last_known_prefix = await bot._config.last_system_info.python_prefix() same_install = False if last_known_prefix is not None: try: same_install = os.path.samefile(last_known_prefix, sys.prefix) except OSError: pass if not same_install: raise SystemExit(_EXIT_INSTANCE_SITE_PREFIX_MISMATCH) common.print_with_prefix_column( common.ICON_INFO, "Started updating cogs for the ", Text(instance_name, style="bold"), " instance.", console=console, ) status = Text.assemble( "Update cogs installed on the ", (instance_name, "bold"), " instance..." ) with console.status(status): await _downloader._init_without_bot(bot._cog_mgr) result = await _downloader.update_cogs(update_repos=update_repos) common.print_with_prefix_column( common.ICON_INFO, "Finished updating cogs for the ", Text(instance_name, style="bold"), " instance.", console=console, ) if not result.checked_cogs: stdout_console.print("There were no cogs to check.") return if not result.updates_available: stdout_console.print("All installed cogs are already up to date.") return current_cog_versions_map = {cog.name: cog for cog in result.checked_cogs} if result.failed_reqs: console.print( "Failed to install requirements:", Text(", ").join(Text(req, style="bold") for req in result.failed_reqs), ) return message = Text("Cog update completed successfully.") if result.updated_cogs: cogs_with_changed_eud_statement = set() for cog in result.updated_cogs: current_eud_statement = current_cog_versions_map[cog.name].end_user_data_statement if current_eud_statement != cog.end_user_data_statement: cogs_with_changed_eud_statement.add(cog.name) message.append("\nUpdated: ") message.append_text( Text(", ").join(Text(cog.name, style="bold") for cog in result.updated_cogs) ) if cogs_with_changed_eud_statement: message.append("\nEnd user data statements of these cogs have changed: ") message.append_text( Text(", ").join( Text(cog_name, style="bold") for cog_name in cogs_with_changed_eud_statement ) ) message.append("\nYou can use ") message.append("[p]cog info ", style="bold") message.append(" to see the updated statements.\n") # If the bot has any slash commands enabled, warn them to sync enabled_slash = await bot.list_enabled_app_commands() if any(enabled_slash.values()): message.append("\nYou may need to resync your slash commands with ") message.append("[p]slash sync") message.append(".") if result.failed_cogs: message.append("\nFailed to update cogs: ") message.append_text( Text(", ").join(Text(cog.name, style="bold") for cog in result.failed_cogs) ) if not result.outdated_cogs: message = Text("No cogs were updated.") if result.failed_libs: message.append("\nFailed to install shared libraries: ") message.append_text( Text(", ").join(Text(lib.name, style="bold") for lib in result.failed_libs) ) stdout_console.print(message) @cli.command(FINISH_UPDATE_CMD_NAME) def finish_update() -> None: """ Entrypoint for finishing up the update that runs with the new version of Red. """ asyncio_run(_finish_update()) async def _finish_update() -> None: assert runner.get_request_output().request_type is runner.RequestType.exec updater_metadata = get_updater_metadata() console = common.get_console() console.print() if updater_metadata.options.interactive and not updater_metadata.options.update_cogs: msg = Text("It is highly recommended to update 3rd-party cogs after updating Red") if updater_metadata.breaking_update: msg.append(", especially after a major update") msg.append(".") console.print(msg) cog_compatibility = updater_metadata.cog_compatibility if cog_compatibility is not None: unsupported_cogs = set() cogs_with_improved_compatibility = set() unaffected_cogs = set() for summary in cog_compatibility.checked.values(): for before in summary.before_update.values(): cog_name = before.name after = summary.after_update[cog_name] if after.compatibility_status.unsupported: unsupported_cogs.add(cog_name) elif after.compatibility_status.explicitly_supported: if before.compatibility_status.explicitly_supported: unaffected_cogs.add(cog_name) else: cogs_with_improved_compatibility.add(cog_name) elif before.compatibility_status.unsupported: cogs_with_improved_compatibility.add(cog_name) else: unaffected_cogs.add(cog_name) if cogs_with_improved_compatibility: common.print_with_prefix_column( common.ICON_INFO, "Updating will improve compatibility of ", Text(str(len(cogs_with_improved_compatibility)), style="bold"), " cogs.", ) if unsupported_cogs: common.print_with_prefix_column( common.ICON_WARN, Text(str(len(unsupported_cogs)), style="bold"), " cogs will remain unsupported after updating:\n", Text(", ").join( Text(cog_name, style="bold") for cog_name in sorted(unsupported_cogs) ), ) update_cogs = updater_metadata.options.update_cogs if update_cogs is None: if updater_metadata.options.interactive: update_cogs = Confirm.ask("Do you want to update all your cogs?", default=True) else: update_cogs = True if update_cogs: await _handle_cog_updates(updater_metadata) with console.status("Cleaning up..."): backup_dir = Path(sys.prefix) / common.OLD_VENV_BACKUP_DIR_NAME shutil.rmtree(backup_dir) changelog_markdown = changelog.render_markdown(updater_metadata.changelogs) if changelog_markdown: console.print(Panel(Markdown(changelog_markdown))) console.print() common.print_with_prefix_column( common.ICON_SUCCESS, "Update to Red ", Text(__version__, style="bold"), " has been finished!", ) if changelog_markdown: common.print_with_prefix_column( common.ICON_INFO, 'Remember to follow instructions from the "Read before updating" section,' " if any were provided.", ) if updater_metadata.backup_dir: additional_text = "" if not updater_metadata.options.backup_dir: additional_text = ( "\nNote that this is a temporary directory and may eventually get auto-removed" " by your system." ) common.print_with_prefix_column( common.ICON_INFO, "If needed, you can find the backups of the virtual environment" " and the instances at: ", Text(str(updater_metadata.backup_dir), style="bold"), additional_text, ) async def _handle_cog_updates(updater_metadata: UpdaterMetadata) -> None: cog_compatibility = updater_metadata.cog_compatibility console = common.get_console() instances = ( list(cog_compatibility.checked) if cog_compatibility is not None else updater_metadata.options.instances ) checked_instances = {} failed_instances = [] unsupported_storage_instances = [] for instance_name in instances: if instance_name in updater_metadata.options.excluded_instances: continue exit_code, stdout = await _call_cog_update( instance_name, update_repos=cog_compatibility is None ) if exit_code == _EXIT_INSTANCE_BACKEND_UNSUPPORTED: unsupported_storage_instances.append(instance_name) elif exit_code == _EXIT_INSTANCE_SITE_PREFIX_MISMATCH: pass elif exit_code: failed_instances.append(instance_name) print(stdout, end="") Text.assemble( "\N{UPWARDS ARROW} " * 3, "Failure for ", (instance_name, "bold"), " instance" ) console.rule( Text.assemble( "\N{UPWARDS ARROW} " * 3, "Failure for ", (instance_name, "bold"), " instance above", " \N{UPWARDS ARROW}" * 3, ), style="red", ) else: checked_instances[instance_name] = stdout if stdout: console.print() if checked_instances: for instance_name, stdout in checked_instances.items(): console.rule(Text(instance_name, style="bold")) print(stdout, end="") console.rule() common.print_with_prefix_column( common.ICON_INFO, "Finished updating cogs.", "\nThe results for each instance are shown above." if checked_instances else "", ) if failed_instances: common.print_with_prefix_column( common.ICON_ERROR, "Failure occurred while trying to perform update for following instances: ", Text(", ").join( Text(instance_name, style="bold") for instance_name in failed_instances ), "\nScroll above to find the errors.", ) if unsupported_storage_instances: common.print_with_prefix_column( common.ICON_INFO, "The following instances were skipped as they use a storage backend that is" " not supported by the current Red installation (some requirements are missing): ", Text(", ").join( Text(instance_name, style="bold") for instance_name in unsupported_storage_instances ), ) if not checked_instances: common.print_with_prefix_column( common.ICON_INFO, "There were no", (" other" if failed_instances or unsupported_storage_instances else ""), " instances to update cogs for.", ) async def _call_cog_update(instance_name: str, *, update_repos: bool) -> Tuple[int, str]: debug_args = (cmd.arg_names.DEBUG,) * common.get_log_cli_level() args = [ "-m", "redbot._update.internal", *debug_args, _UPDATE_COGS_CMD_NAME, instance_name, ] if update_repos: args.append(_UPDATE_REPOS_OPTION_NAME) env = os.environ.copy() # terminal woes console = common.get_console() if console.is_terminal: env["TTY_COMPATIBLE"] = "1" # Rich only checks stdout for Windows console features: # https://github.com/Textualize/rich/blob/fc41075a3206d2a5fd846c6f41c4d2becab814fa/rich/_windows.py#L46 env[common.INTERNAL_LEGACY_WINDOWS_ENV_VAR] = "1" if console.legacy_windows else "0" else: # Rich does not set legacy_windows correctly when is_terminal is False # https://github.com/Textualize/rich/issues/3647 env[common.INTERNAL_LEGACY_WINDOWS_ENV_VAR] = "0" env["PYTHONIOENCODING"] = sys.stdout.encoding proc = await asyncio.create_subprocess_exec( sys.executable, *args, env=env, stdout=asyncio.subprocess.PIPE ) stdout_data, _ = await proc.communicate() decoded_stdout = stdout_data.decode() exit_code = await proc.wait() return exit_code, decoded_stdout @cli.command() @click.argument("base_executable") @click.argument("venv_dir", type=click.Path(path_type=Path)) @click.argument("scripts_path", type=click.Path(path_type=Path)) @click.argument("dependency_specifier") def reinstall( base_executable: str, venv_dir: Path, scripts_path: Path, dependency_specifier: str ) -> None: assert runner.get_request_output().request_type is runner.RequestType.exec console = common.get_console() with console.status("Creating a new virtual environment..."): subprocess.check_call((base_executable, "-m", "venv", str(venv_dir))) console.print("Created a new virtual environment.") executable = str(scripts_path / f"python{sysconfig.get_config_var('EXE')}") common.print_with_prefix_column(common.ICON_INFO, "Starting the install process...") try: subprocess.check_call((executable, "-m", "pip", "install", "-U", "pip")) subprocess.check_call((executable, "-m", "pip", "install", dependency_specifier)) except subprocess.CalledProcessError: console.print() common.print_with_prefix_column( common.ICON_ERROR, "Failed to install new version of Red.", ) status = console.status("Attempting to restore old virtual environment...") status.start() try: _remove_new_venv(venv_dir) except Exception: status.stop() common.print_with_prefix_column( common.ICON_ERROR, "Failed to remove newly created virtual environment." ) raise SystemExit(1) try: _restore_old_venv(venv_dir) except Exception: status.stop() common.print_with_prefix_column( common.ICON_ERROR, "Failed to restore old virtual environment." ) else: common.print_with_prefix_column( common.ICON_INFO, "The old virtual environment has been restored." ) raise SystemExit(1) # NOTE: this will run with the updated version of Red runner.make_exec_request(executable, "finish-update") def _remove_new_venv(venv_dir: Path) -> None: backup_dir = venv_dir / common.OLD_VENV_BACKUP_DIR_NAME wrapper_exe = runner.get_wrapper_executable() for path in venv_dir.iterdir(): if path == backup_dir or path == wrapper_exe: continue if path.is_dir(): shutil.rmtree(path) else: path.unlink() def _restore_old_venv(venv_dir: Path) -> None: backup_dir = venv_dir / common.OLD_VENV_BACKUP_DIR_NAME for path in backup_dir.iterdir(): path.rename(venv_dir / path.name) if __name__ == "__main__": cli()