Files
bearhub/bauh/gems/debian/index.py
2022-12-12 07:44:27 -03:00

224 lines
8.1 KiB
Python

import json
import os
import re
import traceback
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from json import JSONDecodeError
from logging import Logger
from pathlib import Path
from typing import Optional, Set, Generator, Iterable
from bauh.commons import system
from bauh.gems.debian import APP_INDEX_FILE
from bauh.gems.debian.model import DebianApplication
class ApplicationIndexError(Exception):
def __init__(self, cause: Optional[str] = None):
self.cause = cause
class ApplicationIndexer:
def __init__(self, logger: Logger, index_file_path: str = APP_INDEX_FILE):
self._log = logger
self._file_path = index_file_path
self._file_timestamp_path = f'{self._file_path}.ts'
def is_expired(self, deb_config: dict) -> bool:
try:
exp_minutes = int(deb_config.get('index_apps.exp', 0))
except ValueError:
self._log.error(f"Invalid value for Debian configuration property 'index_apps.exp': "
f"{deb_config['index_apps.exp']}")
return True
if exp_minutes <= 0:
self._log.warning("Debian applications index will always be updated ('index_apps.exp' <= 0 )'")
return True
if not os.path.exists(self._file_path):
self._log.info(f"Debian applications index not found. A new one must be generated ({self._file_path})")
return True
try:
with open(self._file_timestamp_path) as f:
timestamp_str = f.read().strip()
except FileNotFoundError:
self._log.info(f"Debian applications index timestamp not found ({self._file_timestamp_path})")
return True
try:
timestamp = datetime.fromtimestamp(float(timestamp_str))
except Exception:
self._log.error(f'Could not parse the Debian applications index timestamp: {timestamp_str} '
f'({self._file_timestamp_path})')
traceback.print_exc()
return True
expired = timestamp + timedelta(minutes=exp_minutes) <= datetime.utcnow()
if expired:
self._log.info("Debian applications index has expired. A new one must be generated.")
else:
self._log.info("Debian applications index is up-to-date")
return expired
def read_index(self) -> Generator[DebianApplication, None, None]:
try:
with open(self._file_path) as f:
idx_str = f.read().strip()
if not idx_str:
self._log.warning(f"Debian applications index is empty ({self._file_path})")
else:
try:
for name, data in json.loads(idx_str).items():
exe_path, icon_path = data.get('exe_path'), data.get('icon_path')
if not all((name, exe_path, icon_path)):
self._log.warning(f"Invalid entry in the Debian applications index ({self._file_path}): name={name}, exe_path={exe_path}, icon_path={icon_path}")
else:
categories = data.get('categories')
yield DebianApplication(name=name, exe_path=exe_path, icon_path=icon_path,
categories=tuple(categories) if categories else None)
except JSONDecodeError:
self._log.error(f"The Debian applications index is corrupted ({self._file_path}). "
f"Could not decode the JSON.")
except FileNotFoundError:
self._log.warning(f"Debian applications index not found ({self._file_path})")
except OSError as e:
self._log.error(f"Debian applications index could not be read ({self._file_path}). OSError: {e.errno}")
def update_index(self, apps: Set[DebianApplication], update_timestamp: bool = True):
idx_dir = os.path.dirname(self._file_path)
try:
Path(idx_dir).mkdir(exist_ok=True, parents=True)
except OSError:
self._log.error(f"Could not create directory '{idx_dir}'")
raise ApplicationIndexError()
idx = {}
if apps:
for app in apps:
idx.update(app.to_index())
try:
with open(self._file_path, 'w+') as f:
if idx:
f.write(json.dumps(idx, sort_keys=True, indent=4))
else:
f.write('')
except OSError:
self._log.error(f"Could not write to the Debian applications index file: {self._file_path}")
raise ApplicationIndexError()
if update_timestamp:
index_timestamp = datetime.utcnow().timestamp()
try:
with open(self._file_timestamp_path, 'w+') as f:
f.write(str(index_timestamp))
self._log.info("Debian applications index timestamp updated")
except OSError:
self._log.error(f"Could not write to the Debian applications index timestamp file: "
f"{self._file_timestamp_path}")
raise ApplicationIndexError()
class ApplicationsMapper:
def __init__(self, logger: Logger, workers: int = 10):
self._log = logger
self._re_desktop_file = re.compile(r'(.+):\s+(/usr/share/applications/.+\.desktop)')
self._re_desktop_file_fields = re.compile('(Exec|TryExec|Icon|Categories|NoDisplay|Terminal)\s*=\s*(.+)')
self._workers = workers
def _read_file(self, file_path: str) -> Optional[str]:
try:
with open(file_path) as file:
return file.read()
except (FileNotFoundError, OSError) as e:
self._log.error(f"Error when checking desktop file '{file_path}' ({file_path}):"
f" {e.__class__.__name__}")
def _add_if_application_desktop_file(self, pkg_name: str, desktop_files: Iterable[str], output: Set[DebianApplication]):
for file_path in sorted(desktop_files):
content = self._read_file(file_path)
if content:
data = {}
gui_app = True
for f, v in self._re_desktop_file_fields.findall(content):
if f in ('NoDisplay', 'Terminal') and v.strip().lower() == 'true':
gui_app = False
break
if f not in data:
data[f.strip()] = v.strip()
if not gui_app:
continue
exe = data.get('Exec')
if not exe:
exe = data.get('TryExec')
if not exe:
continue
icon = data.get('Icon')
if not icon:
continue
categories = data.get('Categories')
if categories:
categories = tuple(sorted({c.strip() for c in categories.split(';') if c}))
output.add(DebianApplication(name=pkg_name, exe_path=exe, icon_path=icon, categories=categories))
break
def map_executable_applications(self) -> Optional[Set[DebianApplication]]:
exitcode, output = system.execute('dpkg-query -S .desktop', shell=True)
if exitcode == 0 and output:
pkg_files = dict()
for found in self._re_desktop_file.findall(output):
pkg_name = found[0].strip()
files = pkg_files.get(pkg_name)
if files is None:
files = set()
pkg_files[pkg_name] = files
files.add(found[1].strip())
if pkg_files:
apps_found, check_jobs = set(), []
with ThreadPoolExecutor(self._workers) as pool:
for pkg_name, files in pkg_files.items():
check_jobs.append(pool.submit(self._add_if_application_desktop_file,
pkg_name, files, apps_found))
for job in check_jobs:
job.done()
return apps_found