%PDF- %PDF-
| Direktori : /proc/thread-self/root/usr/lib/python3/dist-packages/nala/ |
| Current File : //proc/thread-self/root/usr/lib/python3/dist-packages/nala/dpkg.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/>.
"""Nala dpkg module."""
from __future__ import annotations
import contextlib
import errno
import fcntl
import os
import pty
import re
import signal
import struct
import sys
import termios
from time import sleep, time
from traceback import format_exception
from types import FrameType
from typing import Callable, Match, TextIO
import apt_pkg
from apt.progress import base, text
from pexpect.fdpexpect import fdspawn
from pexpect.utils import poll_ignore_interrupts
from ptyprocess.ptyprocess import _setwinsize
from nala import _, color
from nala.constants import (
CONF_ANSWERS,
CONF_MESSAGE,
DPKG_ERRORS,
DPKG_STATUS,
ERROR_PREFIX,
HANDLER,
NOTICES,
SPAM,
WARNING_PREFIX,
)
from nala.options import arguments
from nala.rich import (
ELLIPSIS,
OVERFLOW,
Group,
Live,
Panel,
RenderableType,
Table,
TaskID,
Thread,
ascii_replace,
dpkg_progress,
from_ansi,
spinner,
)
from nala.utils import dprint, eprint, term, unit_str
VERSION_PATTERN = re.compile(r"\(.*?\)")
PARENTHESIS_PATTERN = re.compile(r"[()]")
notice: list[str] = []
pkgnames: set[str] = set()
unpacked: set[str] = set()
dpkg_error: list[str] = []
update_error: list[str] = []
REMOVING = "Removing"
UNPACKING = "Unpacking"
SETTING_UP = "Setting up"
PROCESSING = "Processing"
GET = "GET"
UPDATED = _("Updated:")
DOWNLOADED = _("Downloaded:")
IGNORED = _("Ignored:")
NO_CHANGE = _("No Change:")
# NOTE: Spacing of following status messages
# NOTE: is to allow the urls to be properly aligned
# NOTE: Especially if your status would come after the package
# NOTE: You do not have to follow this scheme
# NOTE: but do note that the headers will be colored regardless
# NOTE: No Change: http://deb.volian.org/volian scar InRelease
# NOTE: Ignored: http://deb.volian.org/volian scar InRelease
# NOTE: Updated: http://deb.volian.org/volian scar InRelease
NO_CHANGE_MSG = _("{no_change} {info}")
NO_CHANGE_SIZE_MSG = _("{no_change} {info} [{size}]")
IGNORED_MSG = _("{ignored} {info}")
UPDATE_MSG = _("{updated} {info}")
UPDATE_SIZE_MSG = _("{updated} {info} [{size}]")
REMOVING_HEAD = color(_("Removing:"), "RED")
UNPACKING_HEAD = color(_("Unpacking:"), "GREEN")
SETTING_UP_HEAD = color(_("Setting up:"), "GREEN")
PROCESSING_HEAD = color(_("Processing:"), "GREEN")
# NOTE: Spacing of following status messages
# NOTE: is to allow dpkg messages to be properly aligned
# NOTE: Especially if your status would come after the package
# NOTE: You do not have to follow this scheme
# NOTE: but do note that the headers will be colored regardless
# NOTE: Unpacking: neofetch (7.1.0-3)
# NOTE: Setting up: neofetch (7.1.0-3)
# NOTE: Removing: neofetch (7.1.0-3)
# NOTE: Processing: triggers for man-db (2.10.2-1)
# NOTE: You can change the headers and positions as you would like,
# NOTE: but do note that the headers will be colored regardless
SETTING_UP_MSG = _("{setting_up} {dpkg_msg}")
PROCESSING_MSG = _("{processing} {dpkg_msg}")
UNPACKING_MSG = _("{unpacking} {dpkg_msg}")
# NOTE: That's the end of alignment spacing
REMOVING_MSG = _("{removing} {dpkg_msg}")
# NOTE: This translation is separate from the one below
# NOTE: Because we do a check specifically on this string
FETCHED = _("Fetched")
# NOTE: Fetched 81.0 MB in 6s (1448 kB/s)
FETCHED_MSG = _("{fetched} {size} in {elapsed} ({speed}/s)")
class OpProgress(text.OpProgress):
"""Operation progress reporting.
This closely resembles OpTextProgress in libapt-pkg.
"""
# we have to use this string format or else things get buggy
# pylint: disable=consider-using-f-string
def update(self, percent: float | None = None) -> None:
"""Call periodically to update the user interface."""
base.OpProgress.update(self, percent)
if arguments.verbose:
if self.major_change and self.old_op:
self._write(self.old_op)
self._write("%s... %i%%\r" % (self.op, self.percent), False, True)
self.old_op = self.op
def done(self) -> None:
"""Call once an operation has been completed."""
base.OpProgress.done(self)
if arguments.verbose:
if self.old_op:
self._write(_("%c%s... Done") % ("\r", self.old_op), True, True)
self.old_op = ""
class UpdateProgress(text.AcquireProgress):
"""Class for getting cache update status and printing to terminal."""
def __init__(self, live: DpkgLive) -> None:
"""Class for getting cache update status and printing to terminal."""
dprint("Init UpdateProgress")
text.AcquireProgress.__init__(self)
self._file = sys.__stdout__
self._signal: HANDLER = None
self._id = 1
self._width = 80
self.live = live
self.elapsed = 0.0
self.live.scroll_list.clear()
def apt_write(self, msg: str, newline: bool = True, maximize: bool = False) -> None:
"""Write original apt update message."""
self._file.write("\r")
self._file.write(msg)
# Fill remaining stuff with whitespace
if self._width > len(msg):
self._file.write((self._width - len(msg)) * " ")
elif maximize: # Needed for OpProgress.
self._width = max(self._width, len(msg))
if newline:
self._file.write("\n")
else:
self._file.flush()
def _write(self, msg: str, newline: bool = True, maximize: bool = False) -> None:
"""Write the message on the terminal, fill remaining space."""
if arguments.raw_dpkg or not term.can_format():
self.apt_write(msg, newline, maximize)
return
for item in (UPDATED, DOWNLOADED, IGNORED, NO_CHANGE):
if item in msg:
self.table_print(msg, update_spinner=True)
break
else:
if FETCHED in msg:
self.table_print(msg, fetched=True)
return
if ERROR_PREFIX in msg:
for line in msg.splitlines():
update_error.append(line)
eprint(line)
return
spinner.text = from_ansi(msg)
self.table_print(update_spinner=True)
def table_print(
self, msg: str = "", fetched: bool = False, update_spinner: bool = False
) -> None:
"""Update wrapper for the scroll bar."""
if not arguments.scroll and not fetched and msg:
print(msg)
return
self.live.scroll_bar(
msg,
update_spinner=update_spinner,
apt_fetch=self.live.install,
use_bar=False,
)
def ims_hit(self, item: apt_pkg.AcquireItemDesc) -> None:
"""Call when an item is update (e.g. not modified on the server)."""
base.AcquireProgress.ims_hit(self, item)
line = NO_CHANGE_MSG.format(
no_change=color(NO_CHANGE, "GREEN"), info=item.description
)
if item.owner.filesize:
line = NO_CHANGE_SIZE_MSG.format(
no_change=color(NO_CHANGE, "GREEN"),
info=item.description,
size=unit_str(item.owner.filesize).strip(),
)
self._write(line)
def fail(self, item: apt_pkg.AcquireItemDesc) -> None:
"""Call when an item is failed."""
base.AcquireProgress.fail(self, item)
if item.owner.status == item.owner.STAT_DONE:
self._write(
IGNORED_MSG.format(
ignored=color(IGNORED, "YELLOW"), info=item.description
)
)
else:
# This doesn't need to be translated. Just an error dump
self._write(f"{ERROR_PREFIX} {item.description}\n {item.owner.error_text}")
def fetch(self, item: apt_pkg.AcquireItemDesc) -> None:
"""Call when some of the item's data is fetched."""
base.AcquireProgress.fetch(self, item)
# It's complete already (e.g. Hit)
if item.owner.complete:
return
line = UPDATE_MSG.format(
updated=color(DOWNLOADED if self.live.install else UPDATED, "BLUE"),
info=item.description,
)
if item.owner.filesize:
line = UPDATE_SIZE_MSG.format(
updated=color(DOWNLOADED if self.live.install else UPDATED, "BLUE"),
info=item.description,
size=unit_str(item.owner.filesize).strip(),
)
self._write(line)
def _winch(self, *_args: object) -> None:
"""Signal handler for window resize signals."""
if hasattr(self._file, "fileno") and os.isatty(self._file.fileno()):
buf = fcntl.ioctl(self._file, termios.TIOCGWINSZ, 8 * b" ")
dummy, columns, dummy, dummy = struct.unpack("hhhh", buf)
self._width = columns - 1 # 1 for the cursor
def start(self) -> None:
"""Start an Acquire progress.
In this case, the function sets up a signal handler for SIGWINCH, i.e.
window resize signals. And it also sets id to 1.
"""
base.AcquireProgress.start(self)
self.elapsed = time()
self._signal = signal.signal(signal.SIGWINCH, self._winch)
# Get the window size.
self._winch()
self._id = 1
def final_msg(self) -> str:
"""Print closing fetched message."""
return color(
FETCHED_MSG.format(
fetched=FETCHED,
size=unit_str(int(self.fetched_bytes)).strip(),
elapsed=apt_pkg.time_to_str(int(time() - self.elapsed)),
speed=unit_str(int(self.current_cps)).strip(),
)
)
def stop(self) -> None:
"""Invoke when the Acquire process stops running."""
base.AcquireProgress.stop(self)
# We don't want to display fetched Zero if we're in apt fetch.
if self.fetched_bytes != 0 or not self.live.install:
self._write(self.final_msg())
# Delete the signal again.
signal.signal(signal.SIGWINCH, self._signal)
def pulse( # pylint: disable=too-many-branches
self, owner: apt_pkg.Acquire
) -> bool:
"""Periodically invoked while the Acquire process is underway."""
base.AcquireProgress.pulse(self, owner)
# only show progress on a tty to not clutter log files etc
if hasattr(self._file, "fileno") and not os.isatty(self._file.fileno()):
return True
# calculate progress
percent = ((self.current_bytes + self.current_items) * 100.0) / (
self.total_bytes + self.total_items
)
shown = False
tval = f"{percent:.0f}%"
end = ""
if self.current_cps:
eta = int((self.total_bytes - self.current_bytes) / self.current_cps)
end = f" {unit_str(int(self.current_cps)).strip()}/s {apt_pkg.time_to_str(eta)}"
for worker in owner.workers:
val = ""
if not worker.current_item:
if worker.status:
val = f" [{worker.status}]"
if len(tval) + len(val) + len(end) >= self._width - 5:
break
tval += val
shown = True
continue
shown = True
if worker.current_item.owner.id:
val += (
f" [{worker.current_item.owner.id} {worker.current_item.shortdesc}"
)
else:
val += f" [{worker.current_item.description}"
if worker.current_item.owner.active_subprocess:
val += f" {worker.current_item.owner.active_subprocess}"
val += f" {unit_str(worker.current_size).strip()}"
# Add the total size and percent
if worker.total_size and not worker.current_item.owner.complete:
val += (
f"/{unit_str(worker.total_size).strip()}"
f" {(worker.current_size * 100.0) / worker.total_size:.0f}%"
)
val += "]"
if len(tval) + len(val) + len(end) >= self._width - 5:
# Display as many items as screen width
break
tval += val
if not shown:
tval += _(" [Working]")
if self.current_cps:
tval += (self._width - len(end) - len(tval) - 5) * " " + end
self._write(tval, False)
return True
# pylint: disable=too-many-instance-attributes, too-many-public-methods, too-many-arguments, too-many-lines
class InstallProgress(base.InstallProgress):
"""Class for getting dpkg status and printing to terminal."""
def __init__(
self,
dpkg_log: TextIO,
term_log: TextIO,
live: DpkgLive,
task: TaskID,
config_purge: tuple[str, ...],
) -> None:
"""Class for getting dpkg status and printing to terminal."""
dprint("Init InstallProgress")
base.InstallProgress.__init__(self)
self.task = task
self._dpkg_log = dpkg_log
self._term_log = term_log
self.live = live
self.config_purge = config_purge
self.raw = False
self.bug_list = False
self.last_line = b""
self.child: AptExpect
self.child_fd: int
self.child_pid: int
self.line_fix: list[bytes] = []
# Setting environment to xterm seems to work fine for linux terminal
# I don't think we will be supporting much more this this, at least for now
if not term.is_xterm() and not arguments.raw_dpkg:
os.environ["TERM"] = "xterm"
def finish_update(self) -> None:
"""Call when update has finished."""
if not arguments.raw_dpkg:
dpkg_progress.advance(self.task)
self.live.scroll_bar()
def run_install(self, apt: apt_pkg.PackageManager | list[str]) -> int:
"""Install using the `PackageManager` object `obj`.
returns the result of calling `obj.do_install()`
"""
dprint("Forking")
pid, self.child_fd = fork()
if pid == 0:
try:
# PEP-446 implemented in Python 3.4 made all descriptors
# CLOEXEC, but we need to be able to pass writefd to dpkg
# when we spawn it
os.set_inheritable(self.writefd, True)
if not isinstance(apt, apt_pkg.PackageManager):
# pylint: disable=subprocess-run-check
self.dpkg_log("Command Execution:\n")
self.dpkg_log(f"Command = {apt}\n\n")
os._exit(
os.spawnlp( # nosec
os.P_WAIT,
"dpkg",
"dpkg",
"--status-fd",
f"{self.write_stream.fileno()}",
"-i",
*apt,
)
)
# We ignore this with mypy because the attr is there
self.dpkg_log("Apt Do Install\n\n")
os._exit(apt.do_install(self.write_stream.fileno())) # type: ignore[attr-defined]
# We need to catch every exception here.
# If we don't the code continues in the child,
# And bugs will be very confusing
except Exception: # pylint: disable=broad-except
exception = format_exception(*sys.exc_info())
self.dpkg_log(f"{exception}\n")
os._exit(1)
dprint("Dpkg Forked")
self.child_pid = pid
if arguments.raw_dpkg:
return os.WEXITSTATUS(self.wait_child())
# We use fdspawn from pexpect to interact with our dpkg pty
# But we also subclass it to give it the interact method and setwindow
self.child = AptExpect(self.child_fd, timeout=None)
signal.signal(signal.SIGWINCH, self.sigwinch_passthrough)
self.child.interact(self.pre_filter)
return os.WEXITSTATUS(self.wait_child())
def sigwinch_passthrough(
self, _sig_dummy: int, _data_dummy: FrameType | None
) -> None:
"""Pass through sigwinch signals to dpkg."""
buffer = struct.pack("HHHH", 0, 0, 0, 0)
term_size = struct.unpack(
"hhhh", fcntl.ioctl(term.STDIN, termios.TIOCGWINSZ, buffer)
)
if self.child.isalive():
with contextlib.suppress(ValueError):
_setwinsize(self.child_fd, term_size[0], term_size[1])
def conf_check(self, rawline: bytes) -> None:
"""Check if we get a conf prompt."""
if CONF_MESSAGE in rawline:
self.raw_init()
if b"Parsing Found/Fixed information... Done" in rawline and b"bugs" in rawline:
self.bug_list = True
self.raw_init()
def conf_end(self, rawline: bytes) -> bool:
"""Check to see if the conf prompt is over."""
if self.bug_list:
return rawline == term.CRLF and (
b"[Y/n/?/...]" in self.last_line or self.last_line in (b"y", b"Y")
)
return rawline == term.CRLF and (
CONF_MESSAGE in self.last_line or self.last_line in CONF_ANSWERS
)
def dpkg_log(self, msg: str) -> None:
"""Write to dpkg-debug.log and flush."""
self._dpkg_log.write(msg)
self._dpkg_log.flush()
def term_log(self, msg: bytes) -> None:
"""Write to term.log and flush."""
self._term_log.write(f"{msg.decode('utf-8').strip()}\n")
self._term_log.flush()
def dpkg_status(self, data: bytes) -> bool:
"""Handle any status messages."""
for status in DPKG_STATUS:
if status in data:
if status in (
b"[Working]",
b"[Connecting",
b"[Waiting for headers]",
b"[Connected to",
):
return True
statuses = data.split(b"\r")
if len(statuses) > 2:
self.dpkg_log(f"Status_Split = {repr(statuses)}\n")
for msg in statuses:
if msg != b"":
spinner.text = from_ansi(color(msg.decode().strip()))
self.live.scroll_bar(update_spinner=True)
self.dpkg_log(term.LF.decode())
return True
return False
def apt_diff_pulse(self, data: bytes) -> bool:
"""Handle pulse messages from apt-listdifferences."""
if data.startswith(b"\r") and data.endswith(b"s"):
spinner.text = from_ansi(color(fill_pulse(data.decode().split())))
self.live.scroll_bar(update_spinner=True)
return True
return False
def apt_differences(self, data: bytes) -> bool:
"""Handle messages from apt-listdifferences."""
if not data.strip().endswith((b"%", b"%]")):
return False
for line in data.splitlines():
if any(item in line.decode() for item in SPAM) or not line:
continue
if b"Get" in line:
self.dpkg_log(f"Get = [{repr(line)}]\n")
self.format_dpkg_output(line)
continue
pulse = [msg for msg in line.decode().split() if msg]
self.dpkg_log(f"Difference = [{pulse}]\n")
spinner.text = from_ansi(color(" ".join(pulse)))
self.live.scroll_bar(update_spinner=True)
return True
def read_status(self) -> None:
"""Read the status fd and send it to update progress bar."""
try:
status = self.status_stream.read(1024)
except OSError as err:
# Resource temporarily unavailable is ignored
if err.errno not in (errno.EAGAIN, errno.EWOULDBLOCK):
print(err.strerror)
return
for line in status.splitlines():
self.update_progress_bar(line)
def update_progress_bar(self, line: str) -> None:
"""Update the interface."""
pkgname = status = status_str = _percent = base_status = ""
if line.startswith("pm"):
try:
(status, pkgname, _percent, status_str) = line.split(":", 3)
except ValueError:
# Silently ignore lines that can't be parsed
return
elif line.startswith("status"):
try:
(base_status, pkgname, status, status_str) = line.split(":", 3)
except ValueError:
(base_status, pkgname, status) = line.split(":", 2)
# Always strip the status message
pkgname = pkgname.strip()
status_str = status_str.strip()
status = status.strip()
# This is the main branch for apt installs
if status == "pmstatus":
dprint(f"apt: {pkgname} {status_str}")
if status_str.startswith(("Unpacking", "Removing")):
unpacked.add(pkgname)
self.advance_progress()
# Either condition can satisfy this mark provided the package hasn't been advanced
elif (
status_str.startswith(("Installed", "Configuring"))
and pkgname not in pkgnames
and pkgname in unpacked
):
pkgnames.add(pkgname)
self.advance_progress()
# This branch only happens for local .deb installs.
elif base_status == "status":
# Sometimes unpacked is notified twice for one package
# We check against out set to make sure not to over shoot progress
if status == "unpacked" and pkgname not in pkgnames:
self.advance_progress()
pkgnames.add(pkgname)
# Sometimes packages are notified as installed
# But we only care for ones that have been unpacked
if status == "installed" and pkgname in pkgnames:
self.advance_progress()
dprint(f"dpkg: {pkgname} {status}")
def pre_filter(self, data: bytes) -> None:
"""Filter data from interact."""
self.read_status()
self.conf_check(data)
# This is a work around for a hang in non-interactive mode
# https://github.com/liske/needrestart/issues/129
if (
os.environ.get("DEBIAN_FRONTEND") == "noninteractive"
and b"so you should consider rebooting. [Return]" in data
):
os.write(self.child_fd, term.CRLF)
# Save Term and Alt Screen for debconf and Bracked Paste for the start of the shell
if (
term.SAVE_TERM in data
or term.ENABLE_BRACKETED_PASTE in data
or term.ENABLE_ALT_SCREEN in data
):
self.raw_init()
self.dpkg_log(f"Raw = {self.raw}: [{repr(data)}]\n")
if not self.raw:
if self.dpkg_status(data):
return
if self.apt_diff_pulse(data) or self.apt_differences(data):
return
if not data.endswith(term.CRLF):
self.line_fix.append(data)
return
if self.line_fix:
self.dpkg_log(f"line_fix = {repr(self.line_fix)}\n")
data = b"".join(self.line_fix) + data
self.line_fix.clear()
if data.count(b"\r\n") > 1:
self.split_data(data)
return
self.format_dpkg_output(data)
def split_data(self, data: bytes) -> None:
"""Split data into clean single lines to format."""
data_split = data.split(b"\r\n")
error = data_split[0].decode() in dpkg_error
self.dpkg_log(f"Data_Split = {repr(data_split)}\n")
for line in data_split:
for new_line in line.split(b"\r"):
if new_line:
check_error(data, new_line.decode(), error)
self.format_dpkg_output(new_line)
def format_dpkg_output(self, rawline: bytes) -> None:
"""Facilitate what needs to happen to dpkg output."""
# If we made it here that means we're okay to start a new line in the log
self.dpkg_log(term.LF.decode())
if self.raw:
self.rawline_handler(rawline)
return
self.line_handler(rawline)
def line_handler(self, rawline: bytes) -> None:
"""Handle text operations for not a rawline."""
line = ascii_replace(rawline.decode().strip())
if check_line_spam(line, rawline, self.last_line):
return
# Percent is for apt-listdifferences, b'99% [6 1988 kB]'
if line == "" or "% [" in line:
return
if (
self.config_purge
and "Purging configuration files" in line
and any(pkg in line for pkg in self.config_purge)
):
self.advance_progress()
self.term_log(rawline)
# Main format section for making things pretty
msg = msg_formatter(line)
# If verbose we just send it. No bars
if not arguments.scroll:
print(msg)
self.live.scroll_bar()
elif "Fetched:" in msg:
# This is some magic for apt-listdifferences to put
# the fetched message in the spinner since it gets spammy
spinner.text = from_ansi(color(" ".join(line.split()[1:])))
self.live.scroll_bar(msg, update_spinner=True)
else:
self.live.scroll_bar(msg)
sys.__stdout__.flush()
self.set_last_line(rawline)
def rawline_handler(self, rawline: bytes) -> None:
"""Handle text operations for rawline."""
term.write(rawline)
# Once we write we can check if we need to pop out of raw mode
if (
term.RESTORE_TERM in rawline
or term.DISABLE_ALT_SCREEN in rawline
or self.conf_end(rawline)
):
self.raw = False
self.bug_list = False
term.restore_mode()
self.live.start()
self.set_last_line(rawline)
def set_last_line(self, rawline: bytes) -> None:
"""Set the current line to last line if there is no backspace."""
# When at the conf prompt if you press Y, then backspace, then hit enter
# Things get really buggy so instead we check for a backspace
if term.BACKSPACE not in rawline:
self.last_line = rawline
def advance_progress(self) -> None:
"""Advance the dpkg progress bar."""
dpkg_progress.advance(self.task)
if not arguments.scroll:
self.live.update(
Panel.fit(
dpkg_progress.get_renderable(),
border_style="bold green",
padding=(0, 0),
),
refresh=True,
)
def raw_init(self) -> None:
"""Initialize raw terminal output."""
if self.raw:
return
self.live.raw_init()
term.set_raw()
self.raw = True
def check_line_spam(line: str, rawline: bytes, last_line: bytes) -> bool:
"""Check for, and handle, notices and spam."""
for message in NOTICES:
if message in rawline and line not in notice:
notice.append(line)
return False
if b"but it can still be activated by:" in last_line:
notice.append(f" {line}")
return False
return any(item in line for item in SPAM)
def check_error(data: bytes, line: str, error_in_list: bool = False) -> None:
"""Check dpkg errors and store them if we need too."""
# Check so we don't duplicate error messages
if error_in_list:
return
for error in DPKG_ERRORS:
# Make sure that the error is not spam
if error in data and all(item not in line for item in SPAM):
dpkg_error.append(line)
def paren_color(match: Match[str]) -> str:
"""Color parenthesis."""
return color("(") if match.group(0) == "(" else color(")")
def line_replace(line: str, header: str) -> str:
"""Replace wrapper for removing header."""
return line.replace(header, "").strip()
def format_version(match: list[str], line: str) -> str:
"""Format version numbers."""
for ver in match:
version = ver[1:-1]
if version[0].isdigit():
new_ver = ver.replace(version, color(version, "BLUE"))
new_ver = re.sub(PARENTHESIS_PATTERN, paren_color, new_ver)
line = line.replace(ver, new_ver)
return line
def fill_pulse(pulse: list[str]) -> str:
"""Fill the pulse message."""
last = len(pulse) - 1
fill = sum(len(line) for line in pulse) + last
# Set fill width to fit inside the rich panel
fill = (term.columns - fill) - 5 if arguments.verbose else (term.columns - fill) - 7
# Minus 2 more to account for ascii simpleDots on the spinner
if term.console.options.ascii_only:
fill -= 2
# Make sure we insert the filler in the right spot
# In case of extra 1 min as shown below.
# ['2407', 'kB/s', '30s']
# ['895', 'kB/s', '1min', '18s']
index = last - 2
if "/s" in pulse[index]:
index = last - 3
pulse.insert(index, " " * fill)
return " ".join(pulse)
def msg_formatter(line: str) -> str:
"""Format dpkg output."""
if line.endswith("..."):
line = line.replace("...", "")
if line.startswith(REMOVING):
line = REMOVING_MSG.format(
removing=REMOVING_HEAD, dpkg_msg=line_replace(line, REMOVING)
)
elif line.startswith(UNPACKING):
line = UNPACKING_MSG.format(
unpacking=UNPACKING_HEAD, dpkg_msg=line_replace(line, UNPACKING)
)
elif line.startswith(SETTING_UP):
line = SETTING_UP_MSG.format(
setting_up=SETTING_UP_HEAD, dpkg_msg=line_replace(line, SETTING_UP)
)
elif line.startswith(PROCESSING):
line = PROCESSING_MSG.format(
processing=PROCESSING_HEAD, dpkg_msg=line_replace(line, PROCESSING)
)
elif line.startswith(GET):
line = f"{color(f'{FETCHED}:', 'BLUE')} {' '.join(line.split()[1:])}"
if match := re.findall(VERSION_PATTERN, line):
return format_version(match, line)
return line
class DpkgLive(Live):
"""Subclass for dpkg live display."""
def __init__(self, install: bool = True) -> None:
"""Subclass for dpkg live display."""
super().__init__(refresh_per_second=4)
self.install = install
self.scroll_list: list[str] = []
self.scroll_config = (False, False, True)
self.used_scroll: bool = False
def __enter__(self) -> DpkgLive:
"""Start the live display."""
self.start(refresh=self._renderable is not None)
return self
def scroll_bar( # pylint: disable=too-many-arguments
self,
msg: str = "",
apt_fetch: bool = False,
update_spinner: bool = False,
use_bar: bool = True,
rerender: bool = False,
) -> None:
"""Print msg to our scroll bar live display."""
if rerender:
if not self.used_scroll:
return
apt_fetch, update_spinner, use_bar = self.scroll_config
else:
self.used_scroll = True
self.scroll_config = (apt_fetch, update_spinner, use_bar)
if msg:
self.scroll_list.append(msg)
self.slice_list()
table = Table.grid()
table.add_column(no_wrap=True, width=term.columns, overflow=OVERFLOW)
for item in self.scroll_list:
table.add_row(from_ansi(item))
if use_bar or update_spinner:
table.add_row(
Panel(
self.get_group(update_spinner, use_bar),
padding=(0, 0),
border_style="bold blue" if arguments.scroll else "bold green",
)
)
# We don't need to build the extra panel if we're not scrolling
if not arguments.scroll:
self.update(table, refresh=True)
return
self.update(
Panel(
table,
title=self.get_title(self.install, apt_fetch),
title_align="left",
padding=(0, 0),
border_style="bold green",
),
refresh=True,
)
@staticmethod
def get_title(install: bool, apt_fetch: bool) -> str:
"""Get the title for our panel."""
msg = "[bold default]"
if arguments.command and install and not apt_fetch:
if arguments.command in ("remove", "purge", "autoremove", "autopurge"):
if arguments.is_purge():
msg += _("Purging Packages")
else:
msg += _("Removing Packages")
elif arguments.command == "upgrade":
msg += _("Updating Packages")
elif arguments.command == "install":
msg += _("Installing Packages")
elif arguments.command == "history":
title = (
_("History Undo")
if arguments.history == "undo"
else _("History Redo")
)
msg += f"{title} {arguments.history_id}"
return msg
if install and apt_fetch:
return msg + _("Fetching Missed Packages")
if not arguments.command and arguments.fix_broken:
return msg + _("Fixing Broken Packages")
return msg + _("Updating Package List")
@staticmethod
def get_group(update_spinner: bool, use_bar: bool) -> RenderableType:
"""Get the group for our panel."""
if spinner.text.plain and update_spinner: # type: ignore[union-attr]
return (
Group(spinner, dpkg_progress.get_renderable()) if use_bar else spinner
)
return dpkg_progress.get_renderable()
def slice_list(self) -> None:
"""Set scroll bar to take up only 1/2 of the screen."""
scroll_lines = term.lines // 2
size = len(self.scroll_list)
if size > scroll_lines and size > 10:
total = size - max(scroll_lines, 10)
self.scroll_list = self.scroll_list[total:]
def raw_init(self) -> None:
"""Set up the live display to be stopped."""
# Stop the live display from Auto Refreshing
if self._refresh_thread:
self._refresh_thread.stop()
self._refresh_thread = None
# We update the live display to blank before stopping it
self.update("", refresh=True)
self.stop()
def start(self, refresh: bool = False) -> None:
"""Start live rendering display.
Args:
----
refresh (bool, optional): Also refresh. Defaults to False.
"""
with self._lock:
if self._started:
return
self.console.set_live(self)
self._started = True
if self._screen:
self._alt_screen = self.console.set_alt_screen(True)
self.console.show_cursor(False)
self._enable_redirect_io()
self.console.push_render_hook(self)
if refresh:
self.refresh()
if self.auto_refresh:
self._refresh_thread = Thread(self, self.refresh_per_second)
self._refresh_thread.start()
def fork() -> tuple[int, int]:
"""Fork pty or regular."""
return (os.fork(), 0) if arguments.raw_dpkg else pty.fork()
class AptExpect(fdspawn): # type: ignore[misc]
"""Subclass of fdspawn to add the interact method."""
def interact(self, output_filter: Callable[[bytes], None]) -> None:
"""Hacked up interact method.
Because pexpect doesn't want to have one for fdspawn.
This gives control of the child process to the interactive user (the
human at the keyboard). Keystrokes are sent to the child process, and
the stdout and stderr output of the child process is printed. This
simply echos the child stdout and child stderr to the real stdout and
it echos the real stdin to the child stdin.
"""
# Flush the buffer.
self.write_to_stdout(self.buffer)
self.stdout.flush()
self._buffer = self.buffer_type()
_setwinsize(self.child_fd, term.lines, term.columns)
self.interact_copy(output_filter)
def interact_copy(self, output_filter: Callable[[bytes], None]) -> None:
"""Interact with the pty."""
while self.isalive():
try:
ready = poll_ignore_interrupts([self.child_fd, term.STDIN])
if self.child_fd in ready and not self._read(output_filter):
break
if term.STDIN in ready:
self._write()
except KeyboardInterrupt:
term.write(term.CURSER_UP + term.CLEAR_LINE)
eprint(
_("{warning} Quitting now could break your system!").format(
warning=WARNING_PREFIX
)
)
eprint(color(_("Ctrl+C twice quickly will exit") + ELLIPSIS, "RED"))
sleep(0.5)
def _read(self, output_filter: Callable[[bytes], None]) -> bool:
"""Read data from the pty and send it for formatting."""
try:
data = os.read(self.child_fd, 1000)
except OSError as err:
if err.args[0] == errno.EIO:
# Linux-style EOF
return False
raise
if data == b"":
# BSD-style EOF
return False
output_filter(data)
return True
def _write(self) -> None:
"""Write user inputs into the pty."""
data = os.read(term.STDIN, 1000)
while data != b"" and self.isalive():
split = os.write(self.child_fd, data)
data = data[split:]