mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2026-05-17 05:03:29 -04:00
454 lines
16 KiB
Python
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()
|