mirror of
https://github.com/spalencsar/bearhub.git
synced 2026-07-07 00:04:15 +02:00
431 lines
18 KiB
Python
431 lines
18 KiB
Python
import re
|
|
import time
|
|
from contextlib import contextmanager
|
|
from enum import Enum
|
|
from logging import Logger
|
|
from math import ceil
|
|
from queue import Queue
|
|
from threading import Thread
|
|
from typing import Iterable, Optional, Pattern, Dict, Set, Tuple, Generator, Collection
|
|
|
|
from bauh.api.abstract.handler import ProcessWatcher
|
|
from bauh.commons import system
|
|
from bauh.commons.html import bold
|
|
from bauh.commons.system import SimpleProcess
|
|
from bauh.commons.util import size_to_byte
|
|
from bauh.gems.debian.common import strip_maintainer_email, strip_section
|
|
from bauh.gems.debian.model import DebianPackage, DebianTransaction
|
|
from bauh.view.util.translation import I18n
|
|
|
|
|
|
def map_package_name(string: str):
|
|
name_split = string.split(':')
|
|
|
|
if len(name_split) > 2:
|
|
return ':'.join(name_split[0:-1])
|
|
|
|
return name_split[0]
|
|
|
|
|
|
class AptitudeAction(Enum):
|
|
INSTALL = 0
|
|
UPGRADE = 1
|
|
REMOVE = 3
|
|
|
|
|
|
class Aptitude:
|
|
|
|
def __init__(self, logger: Logger):
|
|
self._log = logger
|
|
self._re_show_attr: Optional[Pattern] = None
|
|
self._env: Optional[Dict[str, str]] = None
|
|
self._list_attrs: Optional[Set[str]] = None
|
|
self._re_to_install: Optional[Pattern] = None
|
|
self._re_transaction_pkg: Optional[Pattern] = None
|
|
self._size_attrs: Optional[Tuple[str]] = None
|
|
self._default_lang = ''
|
|
self._ignored_fields: Optional[Set[str]] = None
|
|
self._re_none: Optional[Pattern] = None
|
|
self._vars_fixes: Optional[Dict[str, str]] = None
|
|
self._preserve_env = {'DEBIAN_FRONTEND'}
|
|
|
|
def show(self, pkgs: Iterable[str], attrs: Optional[Collection[str]] = None, verbose: bool = False) \
|
|
-> Optional[Dict[str, Dict[str, object]]]:
|
|
|
|
if pkgs:
|
|
force_verbose = verbose if verbose else ('compressed size' in attrs if attrs else False)
|
|
code, output = system.execute(f"aptitude show -q {' '.join(pkgs)}{' -v' if force_verbose else ''}",
|
|
shell=True, custom_env=self.env)
|
|
|
|
if code == 0 and output:
|
|
info, pkg = dict(), None
|
|
for field, val in self.re_show_attr.findall('\n' + output):
|
|
final_field, final_val = field.strip().lower(), val.strip()
|
|
|
|
if final_field == 'package':
|
|
pkg = {}
|
|
info[final_val] = pkg
|
|
elif final_field not in self.ignored_fields and (not attrs or final_field in attrs):
|
|
if final_val:
|
|
if final_field in self.list_attrs:
|
|
final_val = tuple((v.strip() for v in final_val.split(',') if v))
|
|
elif final_field in self.size_attrs:
|
|
size_split = final_val.split(' ')
|
|
|
|
if len(size_split) >= 1:
|
|
unit = size_split[1].upper() if len(size_split) >= 2 else 'B'
|
|
final_val = size_to_byte(size_split[0], unit, self._log)
|
|
else:
|
|
self._log.warning(f"Unhandled value ({val}) for attribute '{field}'")
|
|
final_val = None
|
|
|
|
pkg[final_field] = final_val
|
|
|
|
return info
|
|
|
|
def simulate_removal(self, packages: Iterable[str], purge: bool = False) -> Optional[DebianTransaction]:
|
|
code, output = system.execute(self.gen_remove_cmd(packages, purge, simulate=True), shell=True,
|
|
custom_env=self.env)
|
|
|
|
if code == 0 and output:
|
|
return self.map_transaction_output(output)
|
|
|
|
def map_transaction_output(self, output: str) -> DebianTransaction:
|
|
to_install, to_upgrade, to_remove = None, None, None
|
|
current_collection = None
|
|
|
|
for line in output.split('\n'):
|
|
if line.startswith('The following NEW packages will be installed:'):
|
|
to_install = set()
|
|
current_collection = to_install
|
|
elif line.startswith('The following packages will be upgraded:'):
|
|
to_upgrade = set()
|
|
current_collection = to_upgrade
|
|
elif line.startswith('The following packages will be REMOVED:'):
|
|
to_remove = set()
|
|
current_collection = to_remove
|
|
elif line.startswith('Would download/install/remove packages'):
|
|
break
|
|
elif current_collection is not None and line.startswith(' '):
|
|
for n, _, v, __, lv, ___, size in self.re_transaction_pkg.findall(line):
|
|
pkg = DebianPackage(name=n, version=v, latest_version=lv if lv else v, transaction_size=0)
|
|
|
|
if size:
|
|
size_split = size.strip().split(' ')
|
|
unit = size_split[1][0].upper() if len(size_split) >= 2 else 'B'
|
|
pkg.transaction_size = size_to_byte(size_split[0], unit, self._log)
|
|
|
|
current_collection.add(pkg)
|
|
|
|
return DebianTransaction(to_install=tuple(to_install) if to_install else tuple(),
|
|
to_remove=tuple(to_remove) if to_remove else tuple(),
|
|
to_upgrade=tuple(to_upgrade) if to_upgrade else tuple())
|
|
|
|
def simulate_upgrade(self, packages: Iterable[str]) -> DebianTransaction:
|
|
code, output = system.execute(self.gen_transaction_cmd('upgrade', packages, simulate=True),
|
|
shell=True, custom_env=self.env)
|
|
|
|
if code == 0 and output:
|
|
return self.map_transaction_output(output)
|
|
|
|
def upgrade(self, packages: Iterable[str], root_password: Optional[str]) -> SimpleProcess:
|
|
cmd = self.gen_transaction_cmd('upgrade', packages).split(' ')
|
|
return SimpleProcess(cmd=cmd, shell=True, root_password=root_password, extra_env=self.vars_fixes,
|
|
preserve_env=self._preserve_env)
|
|
|
|
def update(self, root_password: Optional[str]) -> SimpleProcess:
|
|
return SimpleProcess(('aptitude', 'update'), root_password=root_password, shell=True)
|
|
|
|
def simulate_installation(self, packages: Iterable[str]) -> Optional[DebianTransaction]:
|
|
code, output = system.execute(self.gen_transaction_cmd('install', packages, simulate=True),
|
|
shell=True, custom_env=self.env)
|
|
|
|
if code == 0 and output:
|
|
return self.map_transaction_output(output)
|
|
|
|
def install(self, packages: Iterable[str], root_password: Optional[str]) -> SimpleProcess:
|
|
cmd = self.gen_transaction_cmd('install', packages).split(' ')
|
|
return SimpleProcess(cmd=cmd, root_password=root_password, extra_env=self.vars_fixes,
|
|
preserve_env=self._preserve_env)
|
|
|
|
def read_installed(self) -> Generator[DebianPackage, None, None]:
|
|
yield from self.search(query='~i')
|
|
|
|
def read_updates(self) -> Generator[Tuple[str, str], None, None]:
|
|
_, output = system.execute(f"aptitude search ~U -q -F '%p^%V' --disable-columns --no-gui",
|
|
shell=True,
|
|
custom_env=self.env)
|
|
|
|
if output:
|
|
for line in output.split('\n'):
|
|
line_split = line.strip().split('^')
|
|
|
|
if len(line_split) == 2 and line_split[1] != '<none>':
|
|
yield line_split[0], line_split[1]
|
|
|
|
def search(self, query: str, fill_size: bool = False) -> Generator[DebianPackage, None, None]:
|
|
attrs = f"%p^%v^%V^%m^%s^{'%I^' if fill_size else ''}%d"
|
|
_, output = system.execute(f"aptitude search {query} -q -F '{attrs}' --disable-columns", shell=True)
|
|
|
|
if output:
|
|
no_attrs = 7 if fill_size else 6
|
|
|
|
for line in output.split('\n'):
|
|
line_split = line.strip().split('^', maxsplit=no_attrs - 1)
|
|
|
|
if len(line_split) == no_attrs:
|
|
latest_version = line_split[2] if not self.re_none.match(line_split[2]) else None
|
|
|
|
size = None
|
|
|
|
if fill_size:
|
|
size_split = line_split[no_attrs - 2].split(' ')
|
|
unit = size_split[1][0].upper() if len(size_split) >= 2 else 'B'
|
|
size = size_to_byte(size_split[0], unit, self._log)
|
|
|
|
if latest_version is not None:
|
|
installed_version = line_split[1] if not self.re_none.match(line_split[1]) else None
|
|
section = strip_section(line_split[4])
|
|
|
|
yield DebianPackage(name=line_split[0],
|
|
version=installed_version if installed_version else latest_version,
|
|
latest_version=latest_version,
|
|
installed=bool(installed_version),
|
|
update=installed_version is not None and installed_version != latest_version,
|
|
maintainer=strip_maintainer_email(line_split[3]),
|
|
categories=(section,) if section else None,
|
|
uncompressed_size=size,
|
|
description=line_split[no_attrs - 1])
|
|
|
|
def search_by_name(self, names: Iterable[str], fill_size: bool = False) -> Generator[DebianPackage, None, None]:
|
|
query = f"'({'|'.join(f'?exact-name({n})' for n in names)})'"
|
|
yield from self.search(query=query, fill_size=fill_size)
|
|
|
|
def remove(self, packages: Iterable[str], root_password: Optional[str], purge: bool = False) -> SimpleProcess:
|
|
return SimpleProcess(cmd=self.gen_remove_cmd(packages, purge).split(' '), shell=True,
|
|
root_password=root_password, extra_env=self.vars_fixes, preserve_env=self._preserve_env)
|
|
|
|
def read_installed_names(self) -> Generator[str, None, None]:
|
|
code, output = system.execute("aptitude search ~i -q -F '%p' --disable-columns",
|
|
shell=True,
|
|
custom_env=self.env)
|
|
|
|
if output:
|
|
for line in output.split('\n'):
|
|
if line:
|
|
yield line
|
|
|
|
@property
|
|
def re_show_attr(self) -> Pattern:
|
|
if self._re_show_attr is None:
|
|
self._re_show_attr = re.compile(r'(\n\w+[a-zA-Z0-9\-\s]*):\s+(.+)')
|
|
|
|
return self._re_show_attr
|
|
|
|
@property
|
|
def env(self) -> Dict[str, str]:
|
|
if self._env is None:
|
|
self._env = system.gen_env(global_interpreter=system.USE_GLOBAL_INTERPRETER)
|
|
self._env['LC_NUMERIC'] = ''
|
|
|
|
return self._env
|
|
|
|
@property
|
|
def list_attrs(self) -> Set[str]:
|
|
if self._list_attrs is None:
|
|
self._list_attrs = {'depends', 'provides', 'replaces', 'recommends', 'suggests', 'conflicts',
|
|
'state', 'predepends', 'breaks'}
|
|
|
|
return self._list_attrs
|
|
|
|
@property
|
|
def re_transaction_pkg(self) -> Pattern:
|
|
if self._re_transaction_pkg is None:
|
|
self._re_transaction_pkg = re.compile(r'([a-zA-Z0-9\-_@~.+:]+)({\w+})?\s*\[([a-zA-Z0-9\-_@~.+:]+)'
|
|
r'(\s+->\s+([a-zA-Z0-9\-_@~.+:]+))?](\s*<([\-+]?[0-9.,]+\s+\w+)>)?')
|
|
|
|
return self._re_transaction_pkg
|
|
|
|
@property
|
|
def size_attrs(self) -> Tuple[str]:
|
|
if self._size_attrs is None:
|
|
self._size_attrs = {'compressed size', 'uncompressed size'}
|
|
|
|
return self._size_attrs
|
|
|
|
@property
|
|
def ignored_fields(self) -> Set[str]:
|
|
if self._ignored_fields is None:
|
|
self._ignored_fields = {'sha1', 'sha256', 'sha512', 'checksum-filesize'}
|
|
|
|
return self._ignored_fields
|
|
|
|
@classmethod
|
|
def gen_remove_cmd(cls, packages: Iterable[str], purge: bool, simulate: bool = False) -> str:
|
|
return cls.gen_transaction_cmd(type_='purge' if purge else 'remove', packages=packages,
|
|
simulate=simulate)
|
|
|
|
@staticmethod
|
|
def gen_transaction_cmd(type_: str, packages: Iterable[str], simulate: bool = False,
|
|
delete_unused: bool = False) -> str:
|
|
return f"aptitude {type_} -q -y --no-gui --full-resolver {' '.join(packages)}" \
|
|
f" -o Aptitude::ProblemResolver::RemoveScore=9999999" \
|
|
f" -o Aptitude::ProblemResolver::EssentialRemoveScore=9999999" \
|
|
f" -o Aptitude::Delete-Unused={str(delete_unused).lower()}" \
|
|
f"{' -V -s -Z' if simulate else ''}"
|
|
|
|
@property
|
|
def re_none(self) -> Pattern:
|
|
if self._re_none is None:
|
|
self._re_none = re.compile(r'^<\w+>$')
|
|
|
|
return self._re_none
|
|
|
|
@property
|
|
def vars_fixes(self) -> Dict[str, str]:
|
|
if self._vars_fixes is None:
|
|
self._vars_fixes = {'LC_NUMERIC': '', 'DEBIAN_FRONTEND': 'noninteractive'}
|
|
|
|
return self._vars_fixes
|
|
|
|
|
|
class AptitudeOutputHandler(Thread):
|
|
|
|
def __init__(self, i18n: I18n, targets: Iterable[str], re_download: Pattern, watcher: ProcessWatcher,
|
|
action: AptitudeAction):
|
|
super(AptitudeOutputHandler, self).__init__()
|
|
self._i18n = i18n
|
|
self._re_download = re_download
|
|
self._watcher = watcher
|
|
self._targets = set(targets) if targets is not None else None
|
|
self._unpacking = 0
|
|
self._removing = 0
|
|
self._downloading = 0
|
|
self._to_process = Queue()
|
|
self._work = True
|
|
self._action = action
|
|
|
|
def stop_working(self):
|
|
self._work = False
|
|
|
|
def handle(self, string: str):
|
|
self._to_process.put(string)
|
|
|
|
@property
|
|
def total_targets(self) -> int:
|
|
return len(self._targets) if self._targets else 0
|
|
|
|
@property
|
|
def processed(self) -> int:
|
|
return self._removing if self._action == AptitudeAction.REMOVE else self._unpacking
|
|
|
|
def _get_progress(self, current: int) -> str:
|
|
if self.total_targets > 0:
|
|
if self._action == AptitudeAction.REMOVE:
|
|
total = self._removing
|
|
else:
|
|
if self.total_targets == self._unpacking:
|
|
total = self.total_targets
|
|
else:
|
|
total = ceil((self._unpacking + self._downloading) / 2)
|
|
|
|
return f'({total / self.total_targets * 100:.2f}%) [{current}/{self.total_targets}] '
|
|
|
|
return ''
|
|
|
|
def run(self):
|
|
while self._work:
|
|
time.sleep(0.001)
|
|
|
|
if self._to_process.empty():
|
|
continue
|
|
|
|
string = self._to_process.get()
|
|
|
|
if self.total_targets > 0 and self.total_targets == self._unpacking:
|
|
self._watcher.change_substatus(self._i18n['debian.output.finishing'])
|
|
continue
|
|
|
|
if string:
|
|
if self._action != AptitudeAction.REMOVE and string.startswith('Unpacking '):
|
|
unpacking = string.split(' ')
|
|
|
|
if len(unpacking) >= 2 and unpacking[1]:
|
|
pkg = map_package_name(unpacking[1].strip())
|
|
|
|
if self._targets and pkg in self._targets:
|
|
self._unpacking += 1
|
|
|
|
msg = f"{self._get_progress(self._unpacking)}" \
|
|
f"{self._i18n['debian.output.unpacking'].format(pkg=bold(pkg))}"
|
|
|
|
self._watcher.change_substatus(msg)
|
|
|
|
continue
|
|
|
|
if self._action == AptitudeAction.REMOVE and string.startswith('Removing '):
|
|
unpacking = string.split(' ')
|
|
|
|
if len(unpacking) >= 2 and unpacking[1]:
|
|
pkg = unpacking[1].strip()
|
|
|
|
if self._targets and pkg in self._targets:
|
|
self._removing += 1
|
|
|
|
msg = f"{self._get_progress(self._removing)}" \
|
|
f"{self._i18n['debian.output.removing'].format(pkg=bold(pkg))}"
|
|
|
|
self._watcher.change_substatus(msg)
|
|
|
|
continue
|
|
|
|
download = self._re_download.findall(string)
|
|
|
|
if download:
|
|
data = download[0].split(' ')
|
|
|
|
if len(data) >= 4:
|
|
pkg = data[3].strip()
|
|
|
|
if self._targets and pkg in self._targets:
|
|
self._downloading += 1
|
|
|
|
msg = f"{self._get_progress(self._downloading)}" \
|
|
f"{self._i18n['debian.output.downloading'].format(pkg=bold(pkg))}"
|
|
|
|
self._watcher.change_substatus(msg)
|
|
|
|
continue
|
|
|
|
_processed = self.processed
|
|
if self._targets and _processed > 0:
|
|
self._watcher.change_substatus(self._get_progress(_processed).strip())
|
|
continue
|
|
|
|
self._watcher.change_substatus(' ')
|
|
|
|
|
|
class AptitudeOutputHandlerFactory:
|
|
|
|
def __init__(self, i18n: I18n):
|
|
self._i18n = i18n
|
|
self._re_download: Optional[Pattern] = None
|
|
|
|
@property
|
|
def re_download(self) -> Pattern:
|
|
if self._re_download is None:
|
|
self._re_download = re.compile(r'Get:\s+\d+\s+https?://(.+)')
|
|
|
|
return self._re_download
|
|
|
|
@contextmanager
|
|
def start(self, watcher: ProcessWatcher, targets: Iterable[str], action: AptitudeAction):
|
|
handler = AptitudeOutputHandler(i18n=self._i18n, re_download=self.re_download,
|
|
watcher=watcher, targets=targets, action=action)
|
|
handler.start()
|
|
|
|
yield handler.handle
|
|
|
|
handler.stop_working()
|
|
handler.join()
|