mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2026-05-16 12:51:55 -04:00
737 lines
30 KiB
Python
737 lines
30 KiB
Python
import asyncio
|
|
import dataclasses
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, NoReturn, Optional, Set
|
|
|
|
import click
|
|
from packaging.version import Version
|
|
from python_discovery import PythonInfo
|
|
from rich.markdown import Markdown
|
|
from rich.panel import Panel
|
|
from rich.prompt import Confirm, IntPrompt, Prompt
|
|
from rich.text import Text
|
|
from typing_extensions import Self
|
|
|
|
from redbot.core.utils._internal_utils import (
|
|
AvailableVersion,
|
|
detailed_progress,
|
|
fetch_available_red_versions,
|
|
get_installed_extras,
|
|
)
|
|
|
|
from . import changelog, cmd, common, runner
|
|
from .cog_compatibility_checker import CompatibilitySummary
|
|
from .tui import ChangelogReaderApp, ChangelogReaderResult
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class UpdaterOptions:
|
|
"""Update options specified by the user."""
|
|
|
|
instances: List[str]
|
|
excluded_instances: Set[str]
|
|
ignore_prefix: bool
|
|
backup_dir: Optional[Path]
|
|
no_backup: bool
|
|
red_version: Optional[Version]
|
|
no_major_updates: bool
|
|
no_full_changelog: bool
|
|
no_cog_compatibility_check: bool
|
|
new_python_interpreter: Optional[PythonInfo]
|
|
update_cogs: Optional[bool]
|
|
force_reinstall: bool
|
|
interactive: bool
|
|
|
|
@classmethod
|
|
def from_json_dict(cls, data: Dict[str, Any]) -> Self:
|
|
backup_dir = data["backup_dir"]
|
|
red_version = data["red_version"]
|
|
return cls(
|
|
instances=data["instances"],
|
|
excluded_instances=set(data["excluded_instances"]),
|
|
ignore_prefix=data["ignore_prefix"],
|
|
backup_dir=backup_dir and Path(data["backup_dir"]),
|
|
no_backup=data["no_backup"],
|
|
red_version=red_version and Version(red_version),
|
|
no_major_updates=data["no_major_updates"],
|
|
no_full_changelog=data["no_full_changelog"],
|
|
no_cog_compatibility_check=data["no_cog_compatibility_check"],
|
|
new_python_interpreter=(
|
|
data["new_python_interpreter"]
|
|
and PythonInfo.from_dict(data["new_python_interpreter"])
|
|
),
|
|
update_cogs=data["update_cogs"],
|
|
force_reinstall=data["force_reinstall"],
|
|
interactive=data["interactive"],
|
|
)
|
|
|
|
def to_json_dict(self) -> Dict[str, Any]:
|
|
data = dataclasses.asdict(self)
|
|
data["excluded_instances"] = list(self.excluded_instances)
|
|
data["backup_dir"] = self.backup_dir and str(self.backup_dir)
|
|
data["red_version"] = self.red_version and str(self.red_version)
|
|
data["new_python_interpreter"] = (
|
|
self.new_python_interpreter and self.new_python_interpreter.to_dict()
|
|
)
|
|
return data
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class UpdaterCompatibilitySummary:
|
|
checked: Dict[str, CompatibilitySummary]
|
|
failed: List[str]
|
|
skipped: List[str]
|
|
|
|
@classmethod
|
|
def from_json_dict(cls, data: Dict[str, Any]) -> Self:
|
|
return cls(
|
|
checked={
|
|
instance_name: CompatibilitySummary.from_json_dict(results_data)
|
|
for instance_name, results_data in data["checked"].items()
|
|
},
|
|
failed=data["failed"],
|
|
skipped=data["skipped"],
|
|
)
|
|
|
|
def to_json_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"checked": {
|
|
instance_name: results.to_json_dict()
|
|
for instance_name, results in self.checked.items()
|
|
},
|
|
"failed": self.failed,
|
|
"skipped": self.skipped,
|
|
}
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class BackupResults:
|
|
checked: List[str]
|
|
failed: List[str]
|
|
skipped: List[str] = dataclasses.field(default_factory=list)
|
|
|
|
@classmethod
|
|
def from_json_dict(cls, data: Dict[str, List[str]]) -> Self:
|
|
return cls(checked=data["checked"], failed=data["failed"], skipped=data["skipped"])
|
|
|
|
def to_json_dict(self) -> Dict[str, List[str]]:
|
|
return dataclasses.asdict(self)
|
|
|
|
|
|
_PYTHON_VERSION_PLACEHOLDER = Version("0.0.dev0")
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class UpdaterMetadata:
|
|
"""Metadata about the update process."""
|
|
|
|
# options specified by the user
|
|
options: UpdaterOptions
|
|
# info about Red version to update to (latest available or latest non-major update)
|
|
latest: AvailableVersion
|
|
latest_major: AvailableVersion
|
|
# info about Red/Python versions that we're updating from
|
|
current_version: Version = dataclasses.field(default_factory=common.get_current_red_version)
|
|
current_python_version: Version = dataclasses.field(
|
|
default_factory=common.get_current_python_version
|
|
)
|
|
# details about the interpreter that will be used for the new venv
|
|
interpreter_info: PythonInfo = dataclasses.field(default_factory=PythonInfo.current_system)
|
|
interpreter_version: Version = _PYTHON_VERSION_PLACEHOLDER
|
|
interpreter_exe: str = ""
|
|
# changelogs for version in (current_version, latest> range
|
|
changelogs: changelog.Changelogs = dataclasses.field(default_factory=dict)
|
|
# cog compatibility check results
|
|
cog_compatibility: Optional[UpdaterCompatibilitySummary] = None
|
|
# backup info
|
|
to_backup: List[str] = dataclasses.field(default_factory=list)
|
|
backup_dir: Optional[Path] = None
|
|
backup_results: Optional[BackupResults] = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.interpreter_version is _PYTHON_VERSION_PLACEHOLDER:
|
|
self.interpreter_version = Version(
|
|
".".join(map(str, self.interpreter_info.version_info[:3]))
|
|
)
|
|
if not self.interpreter_exe:
|
|
self.interpreter_exe = self.interpreter_info.system_executable
|
|
|
|
@classmethod
|
|
def from_json_dict(cls, data: Dict[str, Any]) -> Self:
|
|
"""
|
|
Make an instance of this class from a dictionary,
|
|
as returned by the `to_json_dict()` method.
|
|
|
|
This aims to maintain backwards compatibility with data generated by
|
|
earlier Red versions as it may be called with such data
|
|
after the last update step.
|
|
"""
|
|
backup_dir = data.get("backup_dir")
|
|
return cls(
|
|
options=UpdaterOptions.from_json_dict(data["options"]),
|
|
latest=AvailableVersion.from_json_dict(data["latest"]),
|
|
latest_major=AvailableVersion.from_json_dict(data["latest_major"]),
|
|
current_version=Version(data["current_version"]),
|
|
current_python_version=Version(data["current_python_version"]),
|
|
interpreter_version=Version(data["interpreter_version"]),
|
|
interpreter_info=PythonInfo.from_dict(data["interpreter_info"]),
|
|
interpreter_exe=data["interpreter_exe"],
|
|
changelogs={
|
|
Version(raw_version): changelog.VersionChangelog.from_json_dict(raw_changelog)
|
|
for raw_version, raw_changelog in data["changelogs"].items()
|
|
},
|
|
cog_compatibility=UpdaterCompatibilitySummary.from_json_dict(
|
|
data["cog_compatibility"]
|
|
),
|
|
to_backup=data["to_backup"],
|
|
backup_dir=backup_dir and Path(backup_dir),
|
|
backup_results=BackupResults.from_json_dict(data["backup_results"]),
|
|
)
|
|
|
|
def to_json_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"options": self.options.to_json_dict(),
|
|
"latest": self.latest.to_json_dict(),
|
|
"latest_major": self.latest_major.to_json_dict(),
|
|
"current_version": str(self.current_version),
|
|
"current_python_version": str(self.current_python_version),
|
|
"interpreter_version": str(self.interpreter_version),
|
|
"interpreter_info": self.interpreter_info.to_dict(),
|
|
"interpreter_exe": self.interpreter_exe,
|
|
"changelogs": {str(v): c.to_json_dict() for v, c in self.changelogs.items()},
|
|
"cog_compatibility": self.cog_compatibility and self.cog_compatibility.to_json_dict(),
|
|
"to_backup": self.to_backup,
|
|
"backup_dir": self.backup_dir and str(self.backup_dir),
|
|
"backup_results": self.backup_results and self.backup_results.to_json_dict(),
|
|
}
|
|
|
|
@property
|
|
def breaking_update(self) -> bool:
|
|
return self.current_version.release[:2] != self.latest.version.release[:2]
|
|
|
|
|
|
class Updater:
|
|
metadata: UpdaterMetadata
|
|
|
|
def __init__(self, options: UpdaterOptions) -> None:
|
|
self.options = options
|
|
self.console = common.get_console()
|
|
|
|
@property
|
|
def latest(self) -> AvailableVersion:
|
|
return self.metadata.latest
|
|
|
|
@property
|
|
def current_version(self) -> Version:
|
|
return self.metadata.current_version
|
|
|
|
async def run(self) -> None:
|
|
await self._prepare_metadata()
|
|
|
|
new_version_available = self.current_version < self.latest.version
|
|
if not self.options.force_reinstall and not new_version_available:
|
|
if self.current_version >= self.metadata.latest_major.version:
|
|
common.print_with_prefix_column(
|
|
common.ICON_SUCCESS,
|
|
"You are already running the latest available version of Red.",
|
|
)
|
|
else:
|
|
common.print_with_prefix_column(
|
|
common.ICON_INFO,
|
|
"There are no non-major updates available.\n",
|
|
"There is a new major version available: ",
|
|
Text(str(self.metadata.latest_major.version), style="bold"),
|
|
)
|
|
return
|
|
|
|
if new_version_available:
|
|
common.print_with_prefix_column(
|
|
common.ICON_SUCCESS,
|
|
"New version available: ",
|
|
Text(str(self.latest.version), style="bold"),
|
|
)
|
|
|
|
await self._show_changelog()
|
|
self._check_python_requires()
|
|
if self.options.no_cog_compatibility_check:
|
|
self.console.print(
|
|
"Will not make backups as --no-cog-compatibility-check option was passed."
|
|
)
|
|
else:
|
|
await self._check_cog_compatibility()
|
|
|
|
if self.options.no_backup:
|
|
common.print_with_prefix_column(
|
|
common.ICON_INFO, "Will not make backups as --no-backup option was passed."
|
|
)
|
|
else:
|
|
common.print_with_prefix_column(
|
|
common.ICON_INFO,
|
|
"The following instances will be backed up before performing the update: ",
|
|
Text(", ").join(
|
|
Text(instance_name, style="bold") for instance_name in self.metadata.to_backup
|
|
),
|
|
)
|
|
if self.metadata.breaking_update:
|
|
self.console.print(
|
|
"[b]Remember that this is a major release and it may have some breaking changes"
|
|
" that the bot or its cogs may be affected by.[/]"
|
|
)
|
|
if self.options.interactive and not Confirm.ask(
|
|
f"Do you want to continue with the update to [b]Red {self.latest.version}[/]?"
|
|
):
|
|
return
|
|
self.console.print()
|
|
|
|
if self.options.no_backup:
|
|
self.console.print("Will not make backups as --no-backup option was passed.")
|
|
else:
|
|
await self._make_backups()
|
|
|
|
await self._update_with_fresh_venv()
|
|
|
|
async def _prepare_metadata(self) -> None:
|
|
interpreter_info = self.options.new_python_interpreter or PythonInfo.current_system()
|
|
with self.console.status("Checking latest version..."):
|
|
available_versions = await fetch_available_red_versions()
|
|
latest_major = available_versions[0]
|
|
|
|
self.metadata = UpdaterMetadata(
|
|
self.options,
|
|
latest=latest_major,
|
|
latest_major=latest_major,
|
|
interpreter_info=interpreter_info,
|
|
)
|
|
|
|
if self.options.red_version:
|
|
if self.options.red_version <= self.current_version:
|
|
common.print_with_prefix_column(
|
|
common.ICON_ERROR, "You can only update to a newer version of Red."
|
|
)
|
|
raise SystemExit(2)
|
|
if (
|
|
self.options.no_major_updates
|
|
and self.options.red_version.release[:2] != self.current_version.release[:2]
|
|
):
|
|
common.print_with_prefix_column(
|
|
common.ICON_ERROR,
|
|
"Updating to the specified version would be a major update"
|
|
" but --no-major-updates option was specified.",
|
|
)
|
|
raise SystemExit(2)
|
|
for available_version in available_versions:
|
|
if available_version.version == self.options.red_version:
|
|
break
|
|
else:
|
|
common.print_with_prefix_column(
|
|
common.ICON_ERROR, "The provided version does not seem to exist."
|
|
)
|
|
raise SystemExit(2)
|
|
self.metadata.latest = available_version
|
|
elif self.options.no_major_updates:
|
|
for available_version in available_versions:
|
|
if available_version.version.release[:2] == self.current_version.release[:2]:
|
|
self.metadata.latest = available_version
|
|
break
|
|
else:
|
|
if self.current_version < latest_major.version:
|
|
common.print_with_prefix_column(
|
|
common.ICON_ERROR,
|
|
"Could not find any version of Red that would not be a major update.",
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
async def _show_changelog(self) -> None:
|
|
with self.console.status("Fetching changelogs..."):
|
|
changelogs = await changelog.fetch_changelogs()
|
|
self.metadata.changelogs = changelogs = changelog.get_changelogs_between(
|
|
changelogs, self.current_version, self.latest.version
|
|
)
|
|
common.print_with_prefix_column(common.ICON_SUCCESS, "Changelogs fetched.")
|
|
|
|
if not changelogs:
|
|
return
|
|
|
|
if not self.options.interactive or self.options.no_full_changelog:
|
|
self.console.print(Panel(Markdown(changelog.render_markdown(changelogs))))
|
|
if self.options.interactive and not Confirm.ask("Do you want to continue?"):
|
|
raise click.Abort()
|
|
return
|
|
|
|
first_changelog_version = min(changelogs)
|
|
last_changelog_version = max(changelogs)
|
|
parts = []
|
|
if first_changelog_version == last_changelog_version:
|
|
parts.append(
|
|
"You will now be presented with the changelog for"
|
|
f" [b]Red {first_changelog_version}[/]."
|
|
)
|
|
else:
|
|
parts.append(
|
|
"You will now be presented with the changelogs for"
|
|
f" [b]Red {first_changelog_version}[/]-[b]{last_changelog_version}[/]."
|
|
)
|
|
parts.append(
|
|
f"\n[bold]{common.ICON_WARN}"
|
|
' Make sure to read through the [green]"Read before updating"[/] section'
|
|
f" before continuing. {common.ICON_WARN}[/bold]\n"
|
|
)
|
|
if self.metadata.breaking_update:
|
|
parts.append(
|
|
f"[bold]{common.ICON_WARN}"
|
|
" Please note that this is a major release and it may have some changes that"
|
|
" your bot or its cogs are affected by.[/bold]\n"
|
|
)
|
|
parts.append(
|
|
"After the changelog is open and you're ready to continue, hit the [b]Q[/] key"
|
|
" to close the changelog and continue the update process.\n\n"
|
|
"Hit the [b]Enter[/] key to view the changelog."
|
|
)
|
|
self.console.input(Panel("".join(parts)), password=True)
|
|
|
|
viewer = ChangelogReaderApp.from_changelogs(changelogs)
|
|
result = await viewer.run_async()
|
|
if result is None:
|
|
raise RuntimeError("Unexpected state")
|
|
if result is ChangelogReaderResult.QUIT:
|
|
raise click.Abort()
|
|
|
|
self.console.print("Changelog has been closed.\n")
|
|
|
|
def _check_python_requires(self) -> None:
|
|
if self.metadata.interpreter_version in self.latest.requires_python:
|
|
return
|
|
if self.options.new_python_interpreter:
|
|
common.print_with_prefix_column(
|
|
common.ICON_ERROR,
|
|
"The latest version of Red requires a different Python version (",
|
|
Text(str(self.latest.requires_python), style="bold"),
|
|
") from the version of the interpreter passed to with the --new-python-interpreter"
|
|
" option (",
|
|
Text(str(self.metadata.interpreter_version), style="bold"),
|
|
")",
|
|
)
|
|
raise SystemExit(1)
|
|
common.print_with_prefix_column(
|
|
common.ICON_WARN if self.options.interactive else common.ICON_ERROR,
|
|
"The latest version of Red requires a different Python version (",
|
|
Text(str(self.latest.requires_python), style="bold"),
|
|
") from the one that you are currently using (",
|
|
Text(str(self.metadata.interpreter_version), style="bold"),
|
|
")",
|
|
(
|
|
"\nredbot-update will have to recreate the virtual environment"
|
|
" with a compatible version of Python."
|
|
if self.options.interactive
|
|
else ""
|
|
),
|
|
)
|
|
if not self.options.interactive:
|
|
raise SystemExit(1)
|
|
interpreters = common.search_for_interpreters(self.latest.requires_python)
|
|
|
|
def _render_interpreter(interpreter_exe: str, interpreter_version: Version) -> Text:
|
|
return Text.assemble(
|
|
"CPython ",
|
|
(str(interpreter_version), "repr.number"),
|
|
" (",
|
|
(interpreter_exe, "log.path"),
|
|
")",
|
|
)
|
|
|
|
text = Text("Found the following compatible Python interpreters on your system:")
|
|
for idx, (interpreter_exe, interpreter_version, python_info) in enumerate(interpreters, 1):
|
|
text.append_text(Text(f"\n{idx}. ", style="markdown.item.number"))
|
|
text.append_text(_render_interpreter(interpreter_exe, interpreter_version))
|
|
self.console.print(Panel(text))
|
|
|
|
while True:
|
|
result = IntPrompt.ask(
|
|
"\nEnter the number of the Python interpreter above that you want to use"
|
|
" or type 0 to input the path to it yourself. Generally, you should choose"
|
|
" the interpreter with the latest version on the above list.\n"
|
|
"Enter your selection",
|
|
default=1,
|
|
)
|
|
if result < 0 or result > len(interpreters):
|
|
self.console.print("[prompt.invalid] This is not a valid choice.")
|
|
continue
|
|
|
|
if result == 0:
|
|
response = Prompt.ask(
|
|
"Please input the path to the Python interpreter that you want to use"
|
|
)
|
|
if not response:
|
|
self.console.print("[prompt.invalid] No path was provided.")
|
|
continue
|
|
info = PythonInfo.from_exe(response)
|
|
interpreter_version = Version(info.version_str)
|
|
if (
|
|
info.implementation != "CPython"
|
|
or interpreter_version not in self.latest.requires_python
|
|
):
|
|
self.console.print(
|
|
"[prompt.invalid] The provided path points to an incompatible Python"
|
|
" interpreter. Latest version requires CPython"
|
|
f" {self.latest.requires_python} but the provided interpreter is"
|
|
f" {info.implementation} {interpreter_version}."
|
|
)
|
|
continue
|
|
self.metadata.interpreter_version = interpreter_version
|
|
self.metadata.interpreter_info = info
|
|
self.metadata.interpreter_exe = info.executable
|
|
else:
|
|
(
|
|
self.metadata.interpreter_exe,
|
|
self.metadata.interpreter_version,
|
|
self.metadata.interpreter_info,
|
|
) = interpreters[result - 1]
|
|
|
|
self.console.print(
|
|
"\n[b]You selected:[/]",
|
|
_render_interpreter(
|
|
self.metadata.interpreter_exe, self.metadata.interpreter_version
|
|
),
|
|
)
|
|
if Confirm.ask("Do you want to continue with this choice?"):
|
|
self.console.print()
|
|
break
|
|
|
|
async def _check_cog_compatibility(self) -> None:
|
|
outputs = {}
|
|
checked_instances = {}
|
|
skipped_instances = []
|
|
failed_instances = []
|
|
unsupported_storage_instances = []
|
|
for instance_name in self.options.instances:
|
|
if instance_name in self.options.excluded_instances:
|
|
skipped_instances.append(instance_name)
|
|
continue
|
|
exit_code, stdout, results = await cmd.cog_compatibility.call(
|
|
instance_name,
|
|
red_version=self.latest.version,
|
|
python_version=self.metadata.interpreter_version,
|
|
ignore_prefix=self.options.ignore_prefix,
|
|
return_results=True,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
)
|
|
if exit_code == cmd.cog_compatibility.EXIT_INSTANCE_BACKEND_UNSUPPORTED:
|
|
skipped_instances.append(instance_name)
|
|
unsupported_storage_instances.append(instance_name)
|
|
elif exit_code == cmd.cog_compatibility.EXIT_INSTANCE_SITE_PREFIX_MISMATCH:
|
|
skipped_instances.append(instance_name)
|
|
elif exit_code:
|
|
failed_instances.append(instance_name)
|
|
print(stdout, end="")
|
|
Text.assemble(
|
|
"\N{UPWARDS ARROW} " * 3,
|
|
"Failure for ",
|
|
(instance_name, "bold"),
|
|
" instance",
|
|
)
|
|
self.console.rule(
|
|
Text.assemble(
|
|
"\N{UPWARDS ARROW} " * 3,
|
|
"Failure for ",
|
|
(instance_name, "bold"),
|
|
" instance above",
|
|
" \N{UPWARDS ARROW}" * 3,
|
|
),
|
|
style="red",
|
|
)
|
|
else:
|
|
assert results is not None
|
|
outputs[instance_name] = stdout
|
|
checked_instances[instance_name] = results
|
|
if stdout:
|
|
self.console.print()
|
|
self.console.print()
|
|
if not self.options.no_backup:
|
|
self.metadata.to_backup = [*checked_instances, *failed_instances]
|
|
|
|
if outputs:
|
|
for instance_name, stdout in outputs.items():
|
|
self.console.rule(Text(instance_name, style="bold"))
|
|
print(stdout, end="")
|
|
self.console.rule()
|
|
|
|
common.print_with_prefix_column(
|
|
common.ICON_INFO,
|
|
"Finished checking cog compatibility.",
|
|
(
|
|
"\nThe results for each of the checked instances are shown above."
|
|
if checked_instances
|
|
else ""
|
|
),
|
|
)
|
|
if failed_instances:
|
|
common.print_with_prefix_column(
|
|
common.ICON_ERROR,
|
|
"Failure occurred while trying to check compatibility 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 check cog compatibility for.",
|
|
)
|
|
self.console.print()
|
|
|
|
self.metadata.cog_compatibility = UpdaterCompatibilitySummary(
|
|
checked=checked_instances, failed=failed_instances, skipped=skipped_instances
|
|
)
|
|
|
|
async def _make_backups(self) -> None:
|
|
self.metadata.backup_dir = backup_dir = self.options.backup_dir or Path(
|
|
tempfile.mkdtemp(prefix="redbot-update-backup-")
|
|
)
|
|
console = common.get_console()
|
|
console.print("Backups will be created at:", Text(str(backup_dir), style="bold"))
|
|
venv_archive = backup_dir / "venv.tar.gz"
|
|
with console.status("Making a backup of the virtual environment directory..."):
|
|
venv_dir = Path(sys.prefix)
|
|
venv_files = []
|
|
for current_dir, _, filenames in os.walk(venv_dir):
|
|
target_dir = os.path.relpath(current_dir, venv_dir)
|
|
if target_dir == ".":
|
|
target_dir = ""
|
|
for name in filenames:
|
|
venv_files.append(
|
|
(os.path.join(current_dir, name), os.path.join(target_dir, name))
|
|
)
|
|
with tarfile.open(venv_archive, "w:gz", compresslevel=6) as tar:
|
|
with detailed_progress(unit="files") as progress:
|
|
for src, arcname in progress.track(venv_files, description="Compressing..."):
|
|
tar.add(src, arcname=arcname, recursive=False)
|
|
console.print(
|
|
"Created a backup of the virtual environment directory at:",
|
|
Text(str(venv_archive), style="bold"),
|
|
)
|
|
|
|
checked = []
|
|
failed = []
|
|
instance_backups_dir = backup_dir / "instance_backups"
|
|
instance_backups_dir.mkdir()
|
|
for instance_name in self.metadata.to_backup:
|
|
console.print(
|
|
"Making a backup of the", Text(instance_name, style="bold"), "instance..."
|
|
)
|
|
debug_args = (cmd.arg_names.DEBUG,) * common.get_log_cli_level()
|
|
proc = await asyncio.create_subprocess_exec(
|
|
sys.executable,
|
|
"-m",
|
|
"redbot.setup",
|
|
"backup",
|
|
*debug_args,
|
|
instance_name,
|
|
str(instance_backups_dir),
|
|
)
|
|
if await proc.wait():
|
|
failed.append(instance_name)
|
|
else:
|
|
checked.append(instance_name)
|
|
|
|
self.metadata.backup_results = BackupResults(checked=checked, failed=failed)
|
|
if self.metadata.cog_compatibility:
|
|
self.metadata.backup_results.skipped.extend(self.metadata.cog_compatibility.skipped)
|
|
|
|
if failed:
|
|
common.print_with_prefix_column(
|
|
common.ICON_ERROR,
|
|
"The following instances failed during backup: ",
|
|
Text(", ").join(Text(instance_name, style="bold") for instance_name in failed),
|
|
"\nScroll above to find the errors.",
|
|
)
|
|
# If a backup fails, we cannot allow non-interactive update to continue.
|
|
# The user can choose to use options such as `--no-backup`, `--instance`,
|
|
# and `--exclude-instance` to not have the backup step try to backup something
|
|
# that it can't.
|
|
if not self.options.interactive or not Confirm.ask(
|
|
"Do you want to continue with the update regardless?"
|
|
):
|
|
raise SystemExit(1)
|
|
|
|
async def _update_with_fresh_venv(self) -> NoReturn:
|
|
console = common.get_console()
|
|
venv_dir = Path(sys.prefix)
|
|
backup_dir = venv_dir / common.OLD_VENV_BACKUP_DIR_NAME
|
|
try:
|
|
backup_dir.mkdir()
|
|
except FileExistsError:
|
|
console.print(
|
|
"Found that a partial backup of a virtual environment from a past failed update"
|
|
" exists at",
|
|
Text(str(backup_dir), style="bold"),
|
|
"\nThe update will not proceed to avoid overriding it. If you are certain that"
|
|
" you don't need to restore anything from it, remove it and try updating again.",
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
with console.status("Determining extras to install..."):
|
|
try:
|
|
metadata = await self.latest.fetch_core_metadata()
|
|
except TypeError:
|
|
extras = get_installed_extras()
|
|
else:
|
|
known_extras = metadata.provides_extra or []
|
|
extras = [extra for extra in get_installed_extras() if extra in known_extras]
|
|
console.print("Extras to install have been determined.")
|
|
|
|
old_executable = Path(sys.executable)
|
|
rel_executable = old_executable.relative_to(venv_dir)
|
|
new_executable = backup_dir / rel_executable
|
|
wrapper_exe = runner.get_wrapper_executable()
|
|
|
|
with console.status("Moving old virtual environment..."):
|
|
for path in venv_dir.iterdir():
|
|
if path == backup_dir or path == wrapper_exe:
|
|
continue
|
|
path.rename(backup_dir / path.name)
|
|
console.print("Old virtual environment moved.")
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", encoding="utf-8", prefix="redbot-update-metadata-", suffix=".json", delete=False
|
|
) as metadata_file:
|
|
json.dump(self.metadata.to_json_dict(), metadata_file)
|
|
|
|
console.print()
|
|
runner.make_exec_request(
|
|
str(new_executable),
|
|
"reinstall",
|
|
# base executable for venv creation
|
|
self.metadata.interpreter_exe,
|
|
# venv dir
|
|
str(venv_dir),
|
|
# scripts path
|
|
self.metadata.interpreter_info.sysconfig_path("scripts", {"base": str(venv_dir)}),
|
|
# Red dependency specifier
|
|
common.get_red_dependency_specifier(self.latest.version, extras),
|
|
set_env_vars={common.INTERNAL_UPDATER_METADATA_ENV_VAR: metadata_file.name},
|
|
)
|
|
|
|
|
|
def get_updater_metadata() -> UpdaterMetadata:
|
|
with open(os.environ[common.INTERNAL_UPDATER_METADATA_ENV_VAR], encoding="utf-8") as fp:
|
|
return UpdaterMetadata.from_json_dict(json.load(fp))
|