%PDF- %PDF-
Direktori : /lib/python3/dist-packages/nala/ |
Current File : //lib/python3/dist-packages/nala/history.py |
# __ # ____ _____ | | _____ # / \\__ \ | | \__ \ # | | \/ __ \| |__/ __ \_ # |___| (____ /____(____ / # \/ \/ \/ # # Copyright (C) 2021, 2022 Blake Lee # # This file is part of nala # # nala is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # nala is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with nala. If not, see <https://www.gnu.org/licenses/>. """Functions for handling Nala History.""" from __future__ import annotations import json import sys from getpass import getuser from json.decoder import JSONDecodeError from os import environ, getuid from pwd import getpwnam from typing import Generator, Iterable, Union, cast import typer from apt.package import Package from nala import _, color from nala.cache import Cache from nala.constants import ERROR_PREFIX, NALA_HISTORY, WARNING_PREFIX from nala.options import ( ASSUME_YES, AUTO_REMOVE, DEBUG, DOWNLOAD_ONLY, FIX_BROKEN, MAN_HELP, OPTION, PURGE, RAW_DPKG, RECOMMENDS, REMOVE_ESSENTIAL, SUGGESTS, UPDATE, VERBOSE, arguments, history_typer, ) from nala.rich import ELLIPSIS, OVERFLOW, Column, Table from nala.summary import print_update_summary from nala.utils import ( DelayedKeyboardInterrupt, NalaPackage, PackageHandler, ask, command_help, dprint, eprint, get_date, term, ) USER: str = environ.get("DOAS_USER", "") UID: int = 0 if USER: UID = getpwnam(USER).pw_uid else: USER = environ.get("SUDO_USER", getuser()) UID = int(environ.get("SUDO_UID", getuid())) HistoryFile = dict[str, dict[str, Union[str, bool, list[str], list[list[str]]]]] HistoryEntry = dict[str, Union[str, bool, list[str], list[list[str]]]] NOT_SUPPORTED = _( "{error} '{command}' for operations other than install or remove are not currently supported" ) HISTORY_HELP = _( "Show transaction history.\n\n" "Running `nala history` with no subcommands prints an overview of all transactions." ) def load_history_file() -> HistoryFile: """Load Nala history.""" try: return cast( HistoryFile, json.loads(NALA_HISTORY.read_text(encoding="utf-8")), ) except JSONDecodeError: sys.exit( _( "{error} History file seems corrupt. You should try removing {file}" ).format(error=ERROR_PREFIX, file=NALA_HISTORY) ) def write_history_file(data: HistoryFile) -> None: """Write history to file.""" with DelayedKeyboardInterrupt(): with open(NALA_HISTORY, "w", encoding="utf-8") as file: file.write(json.dumps(data, separators=(",", ":"))) def get_history(hist_id: str) -> HistoryEntry: """Get the history from file.""" dprint(f"Getting History Entry: {hist_id}") if not NALA_HISTORY.exists(): sys.exit(_("{error} No history exists.").format(error=ERROR_PREFIX)) if transaction := load_history_file().get(hist_id): return transaction sys.exit( _("{error} Transaction {num} doesn't exist.").format( error=ERROR_PREFIX, num=hist_id ) ) def nala_installed(value: bool) -> None: """Print packages that are explicitly installed by Nala.""" if not value: return user_installed = get_list(get_history("Nala"), "User-Installed") for pkg in user_installed: print(pkg) sys.exit() def get_nala_packages(hist_entry: HistoryEntry, key: str) -> list[NalaPackage]: """Type cast history package is list of lists.""" nala_pkgs = [] for pkg_list in get_packages(hist_entry, key): dprint(f"{key} List: {pkg_list}") if len(pkg_list) == 4: try: name, new_version, size, old_version = pkg_list nala_pkgs.append(NalaPackage(name, new_version, int(size), old_version)) except ValueError: name, old_version, new_version, size = pkg_list nala_pkgs.append(NalaPackage(name, new_version, int(size), old_version)) continue name, new_version, size = pkg_list nala_pkgs.append(NalaPackage(name, new_version, int(size))) return nala_pkgs def get_packages(hist_entry: HistoryEntry, key: str) -> list[list[str]]: """Type cast history packages is list of strings.""" return cast(list[list[str]], hist_entry.get(key, [[]])) def get_list(hist_entry: HistoryEntry, key: str) -> list[str]: """Type cast history command is list of strings.""" item = cast(list[str], hist_entry.get(key, [])) dprint(f"Getting List {key} {item}") return item def get_bool(hist_entry: HistoryEntry, key: str) -> bool: """Type cast history entry is bool.""" return cast(bool, hist_entry.get(key, False)) def get_str(hist_entry: HistoryEntry, key: str) -> str: """Type cast history entry is str.""" return cast(str, hist_entry.get(key, "")) def pop_nala(hist_file: HistoryFile) -> HistoryEntry: """Pop the Nala field from the history.""" return hist_file.pop("Nala", {}) def get_last(hist_file: HistoryFile) -> HistoryEntry: """Return the last entry in the history file.""" internal = hist_file.copy() # Pop the Nala system information as to not count it. pop_nala(internal) # Return the last entry in the history file. return hist_file.get(f"{len(internal)}", {}) def set_user_installed( cache: Cache, user_explicit: list[Package], user_installed: set[str] ) -> None: """Set the User-Installed field.""" if user_explicit: for pkg in user_explicit: if pkg.marked_install: user_installed.add(pkg.name) if pkg.marked_delete: user_installed.discard(pkg.name) user_installed = { pkg_name for pkg_name in user_installed if pkg_name in cache and (cache[pkg_name].installed or cache[pkg_name].marked_install) } def write_history(cache: Cache, handler: PackageHandler, operation: str) -> None: """Prepare history for writing.""" history_dict = load_history_file() if NALA_HISTORY.exists() else {} nala_dict = pop_nala(history_dict) user_installed = set(get_list(nala_dict, "User-Installed")) # Make sure the numbers of the history entries are concurrent. # Otherwise a history entry will forever be overwritten each transaction. history_dict = { f"{num + 1}": value for num, value in enumerate(history_dict.values()) } hist_id = f"{len(history_dict) + 1 if history_dict else 1}" altered = ( len(handler.delete_pkgs + handler.delete_config) + len(handler.autoremove_pkgs + handler.autoremove_config) + len(handler.install_pkgs) + len(handler.upgrade_pkgs) + len(handler.downgrade_pkgs) + len(handler.reinstall_pkgs) + len(handler.configure_pkgs) + len(handler.local_debs) ) set_user_installed(cache, handler.user_explicit, user_installed) transaction: HistoryEntry = { "Date": get_date(), "Requested-By": f"{USER} ({UID})", "Command": sys.argv[1:], "Altered": f"{altered}", "Purged": arguments.is_purge(), "Operation": operation, "Explicit": [pkg.name for pkg in handler.user_explicit], "Removed": [ [pkg.name, pkg.version, f"{pkg.size}"] for pkg in handler.delete_pkgs + handler.delete_config ], "Auto-Removed": [ [pkg.name, pkg.version, f"{pkg.size}"] for pkg in handler.autoremove_pkgs + handler.autoremove_config ], "Installed": [ [pkg.name, pkg.version, f"{pkg.size}"] for pkg in handler.install_pkgs ], "Reinstalled": [ [pkg.name, pkg.version, f"{pkg.size}"] for pkg in handler.reinstall_pkgs ], "Upgraded": [ [pkg.name, pkg.version, f"{pkg.size}", f"{pkg.old_version}"] for pkg in handler.upgrade_pkgs ], "Downgraded": [ [pkg.name, pkg.version, f"{pkg.size}", f"{pkg.old_version}"] for pkg in handler.downgrade_pkgs ], } history_dict[hist_id] = transaction nala_dict["History-Version"] = "1" nala_dict["User-Installed"] = list(user_installed) history_dict["Nala"] = nala_dict write_history_file(history_dict) def hist_id_completion() -> Generator[tuple[str, str], None, None]: """Complete history ID arguments.""" if not NALA_HISTORY.exists(): return history_file = load_history_file() pop_nala(history_file) for key, entry in history_file.items(): if (command := get_list(entry, "Command"))[0] in ("update", "upgrade"): command.extend(pkg.name for pkg in get_nala_packages(entry, "Upgraded")) yield key, " ".join(command) HIST_ID = typer.Argument( ..., metavar="ID", help=_("Transaction number"), autocompletion=hist_id_completion ) @history_typer.callback(invoke_without_command=True, help=HISTORY_HELP) # pylint: disable=unused-argument def history_summary( ctx: typer.Context, installed: bool = typer.Option( False, "--installed", callback=nala_installed, help=_("Show packages that were explicitly installed with Nala"), ), debug: bool = DEBUG, verbose: bool = VERBOSE, man_help: bool = MAN_HELP, ) -> None: """Show transaction history. Running `nala history` with no subcommands prints an overview of all translations. """ # Stop this function from running if we do `nala history info` if ctx.invoked_subcommand: return if not NALA_HISTORY.exists(): sys.exit(_("{error} No history exists.").format(error=ERROR_PREFIX)) history_file = load_history_file() pop_nala(history_file) names: list[Iterable[str]] = [] for key, entry in history_file.items(): dprint(f"History ID {key}") if (command := get_list(entry, "Command"))[0] in ("update", "upgrade"): command.extend(pkg.name for pkg in get_nala_packages(entry, "Upgraded")) names.append( ( key, " ".join(command), get_str(entry, "Date"), get_str(entry, "Altered"), get_str(entry, "Requested-By"), ) ) if not names: sys.exit(_("{error} No history exists.").format(error=ERROR_PREFIX)) max_width = term.columns - 69 history_table = Table( Column("ID"), Column("Command", no_wrap=True, max_width=max_width, overflow=OVERFLOW), Column("Date and Time", no_wrap=True), Column("Altered", justify="right"), Column("Requested-By"), padding=(0, 2), box=None, ) for item in names: history_table.add_row(*item) term.console.print(history_table) @history_typer.command("info", help=_("Show information about a specific transaction.")) @history_typer.command("show", hidden=True) # pylint: disable=unused-argument def history_info( ctx: typer.Context, hist_id: str = HIST_ID, debug: bool = DEBUG, verbose: bool = VERBOSE, ) -> None: """Show information about a specific transaction.""" arguments.history = ctx.command.name command_help("show", "history info", None) hist_entry = ( get_last(load_history_file()) if hist_id.lower() == "last" else get_history(hist_id) ) dprint(f"History Entry: {hist_entry}") arguments.purge = get_bool(hist_entry, "Purged") nala_pkgs = PackageHandler() nala_pkgs.autoremove_pkgs = get_nala_packages(hist_entry, "Auto-Removed") nala_pkgs.delete_pkgs = get_nala_packages(hist_entry, "Removed") nala_pkgs.install_pkgs = get_nala_packages(hist_entry, "Installed") nala_pkgs.reinstall_pkgs = get_nala_packages(hist_entry, "Reinstalled") nala_pkgs.upgrade_pkgs = get_nala_packages(hist_entry, "Upgraded") nala_pkgs.downgrade_pkgs = get_nala_packages(hist_entry, "Downgraded") print_update_summary(nala_pkgs) def history_sudo( redo: bool = False, clear: bool = False, ) -> None: """Check if we need sudo.""" if not term.is_su(): if clear: sys.exit(" ".join([ERROR_PREFIX, _("Nala needs root to clear history")])) if redo: sys.exit(" ".join([ERROR_PREFIX, _("Nala needs root to redo history")])) sys.exit(" ".join([ERROR_PREFIX, _("Nala needs root to undo history")])) def unlink_history(value: bool) -> None: """Remove the history file.""" if not value: return history_sudo(clear=True) dprint("History clear all") history_dict = {"Nala": get_history("Nala")} NALA_HISTORY.unlink(missing_ok=True) write_history_file(history_dict) print(_("History has been cleared")) sys.exit() @history_typer.command("clear", help=_("Clear a transaction or the entire history.")) # pylint: disable=unused-argument def history_clear( _hist_id: int = HIST_ID, _all: bool = typer.Option( # pylint: disable=unused-argument False, "--all", callback=unlink_history, help=_("Clear the entire history.") ), debug: bool = DEBUG, verbose: bool = VERBOSE, ) -> None: """Clear a transaction or the entire history.""" hist_id = f"{_hist_id}" dprint(f"History clear {hist_id}") if not NALA_HISTORY.exists(): eprint(_("No history exists to clear") + ELLIPSIS) return if hist_id not in (history_file := load_history_file()).keys(): sys.exit( _("{error} ID: {hist_id} does not exist in the history").format( error=ERROR_PREFIX, hist_id=color(hist_id, "YELLOW") ) ) history_edit: HistoryFile = {} nala_dict = pop_nala(history_file) num = 0 # Using sum increments to relabled the IDs so when you remove just one # There isn't a gap in ID numbers and it looks concurrent. for key, value in history_file.items(): if key != hist_id: num += 1 history_edit[f"{num}"] = value history_edit["Nala"] = nala_dict write_history_file(history_edit) print(_("History has been altered") + ELLIPSIS) @history_typer.command("undo", help=_("Undo a transaction.")) @history_typer.command("redo", help=_("Redo a transaction.")) # pylint: disable=unused-argument,too-many-arguments,too-many-locals def history_undo( ctx: typer.Context, hist_id: str = HIST_ID, purge: bool = PURGE, debug: bool = DEBUG, raw_dpkg: bool = RAW_DPKG, download_only: bool = DOWNLOAD_ONLY, remove_essential: bool = REMOVE_ESSENTIAL, update: bool = UPDATE, auto_remove: bool = AUTO_REMOVE, install_recommends: bool = RECOMMENDS, install_suggests: bool = SUGGESTS, fix_broken: bool = FIX_BROKEN, assume_yes: bool = ASSUME_YES, dpkg_option: list[str] = OPTION, verbose: bool = VERBOSE, ) -> None: """History undo/redo commands.""" from nala.nala import ( # pylint: disable=cyclic-import, import-outside-toplevel _install, _remove, ) arguments.history = ctx.command.name arguments.history_id = hist_id redo = ctx.command.name == "redo" history_sudo(redo=redo) dprint(f"History: {ctx.command.name} {hist_id}") transaction = ( get_last(load_history_file()) if hist_id.lower() == "last" else get_history(hist_id) ) dprint(f"Transaction: {transaction}") if not arguments.purge: if purge := get_bool(transaction, "Purged"): eprint( _("{warning} This history entry was a purge.").format( warning=WARNING_PREFIX ) ) if ask(_("Do you want to continue with purge enabled?")): arguments.purge = purge explicit = get_list(transaction, "Explicit") operation = get_str(transaction, "Operation") command = get_list(transaction, "Command")[0] if operation == "remove" or command in ( "remove", "purge", "autoremove", "autopurge", ): pkgs = [pkg[0] for pkg in get_packages(transaction, "Removed")] pkgs.extend([pkg[0] for pkg in get_packages(transaction, "Auto-Removed")]) if redo: _remove(pkgs) return _install(explicit or pkgs, ctx) return if operation == "install" or command == "install": pkgs = [pkg[0] for pkg in get_packages(transaction, "Installed")] if redo: _install(explicit or pkgs, ctx) return _remove(pkgs) return sys.exit( NOT_SUPPORTED.format( error=ERROR_PREFIX, command=f"{arguments.command} {ctx.command.name}" ) )