Files
Red-DiscordBot/redbot/_update/internal.py
T
2026-05-13 14:14:43 -08:00

454 lines
16 KiB
Python

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 <repo> <cog>", 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()