project renamed as 'bauh'

This commit is contained in:
Vinicius Moreira
2019-08-12 11:50:24 -03:00
parent 6d891683da
commit 19a11434fe
69 changed files with 202 additions and 321 deletions

1
bauh/core/__init__.py Executable file
View File

@@ -0,0 +1 @@

6
bauh/core/constants.py Normal file
View File

@@ -0,0 +1,6 @@
from pathlib import Path
from bauh import __app_name__
HOME_PATH = Path.home()
CACHE_PATH = '{}/.cache/{}'.format(HOME_PATH, __app_name__)

335
bauh/core/controller.py Executable file
View File

@@ -0,0 +1,335 @@
from abc import ABC, abstractmethod
from argparse import Namespace
from threading import Thread
from typing import List, Dict
from bauh.core.disk import DiskCacheLoader, DiskCacheLoaderFactory
from bauh.core.model import Application, ApplicationUpdate
from bauh.core.system import BauhProcess
class ApplicationManager(ABC):
def __init__(self, app_args, locale_keys: dict):
self.app_args = app_args
self.locale_keys = locale_keys
@abstractmethod
def search(self, word: str, disk_loader: DiskCacheLoader) -> Dict[str, List[Application]]:
pass
@abstractmethod
def read_installed(self, disk_loader: DiskCacheLoader) -> List[Application]:
pass
@abstractmethod
def downgrade_app(self, app: Application, root_password: str) -> BauhProcess:
pass
@abstractmethod
def clean_cache_for(self, app: Application):
pass
@abstractmethod
def can_downgrade(self):
pass
@abstractmethod
def update_and_stream(self, app: Application) -> BauhProcess:
pass
@abstractmethod
def uninstall_and_stream(self, app: Application, root_password: str) -> BauhProcess:
pass
@abstractmethod
def get_app_type(self):
pass
@abstractmethod
def get_info(self, app: Application) -> dict:
pass
@abstractmethod
def get_history(self, app: Application) -> List[dict]:
pass
@abstractmethod
def install_and_stream(self, app: Application, root_password: str) -> BauhProcess:
pass
@abstractmethod
def is_enabled(self) -> bool:
pass
@abstractmethod
def cache_to_disk(self, app: Application, icon_bytes: bytes, only_icon: bool):
pass
@abstractmethod
def requires_root(self, action: str, app: Application):
pass
@abstractmethod
def refresh(self, app: Application, root_password: str) -> BauhProcess:
pass
@abstractmethod
def prepare(self):
"""
Callback executed before the ApplicationManager starts to work.
:return:
"""
pass
@abstractmethod
def list_updates(self) -> List[ApplicationUpdate]:
pass
@abstractmethod
def list_warnings(self) -> List[str]:
pass
@abstractmethod
def list_suggestions(self, limit: int) -> List[Application]:
pass
class GenericApplicationManager(ApplicationManager):
def __init__(self, managers: List[ApplicationManager], disk_loader_factory: DiskCacheLoaderFactory, app_args: Namespace):
super(ApplicationManager, self).__init__()
self.managers = managers
self.map = {m.get_app_type(): m for m in self.managers}
self.disk_loader_factory = disk_loader_factory
self._enabled_map = {} if app_args.check_packaging_once else None
self.thread_prepare = None
def _wait_to_be_ready(self):
if self.thread_prepare:
self.thread_prepare.join()
self.thread_prepare = None
def _sort(self, apps: List[Application], word: str) -> List[Application]:
exact_name_matches, contains_name_matches, others = [], [], []
for app in apps:
lower_name = app.base_data.name.lower()
if word == lower_name:
exact_name_matches.append(app)
elif word in lower_name:
contains_name_matches.append(app)
else:
others.append(app)
res = []
for app_list in (exact_name_matches, contains_name_matches, others):
app_list.sort(key=lambda a: a.base_data.name.lower())
res.extend(app_list)
return res
def _is_enabled(self, man: ApplicationManager):
if self._enabled_map is not None:
enabled = self._enabled_map.get(man.get_app_type())
if enabled is None:
enabled = man.is_enabled()
self._enabled_map[man.get_app_type()] = enabled
return enabled
else:
return man.is_enabled()
def _search(self, word: str, man: ApplicationManager, disk_loader, res: dict):
if self._is_enabled(man):
apps_found = man.search(word=word, disk_loader=disk_loader)
res['installed'].extend(apps_found['installed'])
res['new'].extend(apps_found['new'])
def search(self, word: str, disk_loader: DiskCacheLoader = None) -> Dict[str, List[Application]]:
self._wait_to_be_ready()
res = {'installed': [], 'new': []}
norm_word = word.strip().lower()
disk_loader = self.disk_loader_factory.new()
disk_loader.start()
threads = []
for man in self.managers:
t = Thread(target=self._search, args=(norm_word, man, disk_loader, res))
t.start()
threads.append(t)
for t in threads:
t.join()
if disk_loader:
disk_loader.stop = True
disk_loader.join()
for key in res:
res[key] = self._sort(res[key], norm_word)
return res
def read_installed(self, disk_loader: DiskCacheLoader = None) -> List[Application]:
self._wait_to_be_ready()
installed = []
disk_loader = None
for man in self.managers:
if self._is_enabled(man):
if not disk_loader:
disk_loader = self.disk_loader_factory.new()
disk_loader.start()
installed.extend(man.read_installed(disk_loader=disk_loader))
if disk_loader:
disk_loader.stop = True
disk_loader.join()
installed.sort(key=lambda a: a.base_data.name.lower())
return installed
def can_downgrade(self):
return True
def downgrade_app(self, app: Application, root_password: str) -> BauhProcess:
man = self._get_manager_for(app)
if man and man.can_downgrade():
return man.downgrade_app(app, root_password)
else:
raise Exception("downgrade is not possible for {}".format(app.__class__.__name__))
def clean_cache_for(self, app: Application):
man = self._get_manager_for(app)
if man:
return man.clean_cache_for(app)
def update_and_stream(self, app: Application) -> BauhProcess:
man = self._get_manager_for(app)
if man:
return man.update_and_stream(app)
def uninstall_and_stream(self, app: Application, root_password: str) -> BauhProcess:
man = self._get_manager_for(app)
if man:
return man.uninstall_and_stream(app, root_password)
def install_and_stream(self, app: Application, root_password: str) -> BauhProcess:
man = self._get_manager_for(app)
if man:
return man.install_and_stream(app, root_password)
def get_info(self, app: Application):
man = self._get_manager_for(app)
if man:
return man.get_info(app)
def get_history(self, app: Application):
man = self._get_manager_for(app)
if man:
return man.get_history(app)
def get_app_type(self):
return None
def is_enabled(self):
return True
def _get_manager_for(self, app: Application) -> ApplicationManager:
man = self.map[app.__class__]
return man if man and self._is_enabled(man) else None
def cache_to_disk(self, app: Application, icon_bytes: bytes, only_icon: bool):
if self.disk_loader_factory.disk_cache and app.supports_disk_cache():
man = self._get_manager_for(app)
if man:
return man.cache_to_disk(app, icon_bytes=icon_bytes, only_icon=only_icon)
def requires_root(self, action: str, app: Application):
man = self._get_manager_for(app)
if man:
return man.requires_root(action, app)
def refresh(self, app: Application, root_password: str) -> BauhProcess:
self._wait_to_be_ready()
man = self._get_manager_for(app)
if man:
return man.refresh(app, root_password)
def _prepare(self):
if self.managers:
for man in self.managers:
if self._is_enabled(man):
man.prepare()
def prepare(self):
self.thread_prepare = Thread(target=self._prepare)
self.thread_prepare.start()
def list_updates(self) -> List[ApplicationUpdate]:
self._wait_to_be_ready()
updates = []
if self.managers:
for man in self.managers:
if self._is_enabled(man):
updates.extend(man.list_updates())
return updates
def list_warnings(self) -> List[str]:
if self.managers:
warnings = None
for man in self.managers:
man_warnings = man.list_warnings()
if man_warnings:
if warnings is None:
warnings = []
warnings.extend(man_warnings)
return warnings
def _fill_suggestions(self, suggestions: list, man: ApplicationManager, limit: int):
if self._is_enabled(man):
man_sugs = man.list_suggestions(limit)
if man_sugs:
suggestions.extend(man_sugs)
def list_suggestions(self, limit: int) -> List[Application]:
if self.managers:
suggestions, threads = [], []
for man in self.managers:
t = Thread(target=self._fill_suggestions, args=(suggestions, man, 6))
t.start()
threads.append(t)
for t in threads:
t.join()
return suggestions

75
bauh/core/disk.py Normal file
View File

@@ -0,0 +1,75 @@
import json
import os
from pathlib import Path
from threading import Thread, Lock
from typing import List, Dict
from bauh.core.model import Application
from bauh.util.cache import Cache
class DiskCacheLoader(Thread):
def __init__(self, enabled: bool, cache_map: Dict[type, Cache], apps: List[Application] = []):
super(DiskCacheLoader, self).__init__(daemon=True)
self.apps = apps
self.stop = False
self.lock = Lock()
self.cache_map = cache_map
self.enabled = enabled
def run(self):
if self.enabled:
while True:
if self.apps:
self.lock.acquire()
app = self.apps[0]
del self.apps[0]
self.lock.release()
self.fill_cached_data(app)
elif self.stop:
break
def add(self, app: Application):
if self.enabled:
if app and app.supports_disk_cache():
self.lock.acquire()
self.apps.append(app)
self.lock.release()
def fill_cached_data(self, app: Application):
if self.enabled:
if os.path.exists(app.get_disk_data_path()):
with open(app.get_disk_data_path()) as f:
cached_data = json.loads(f.read())
app.fill_cached_data(cached_data)
self.cache_map.get(app.__class__).add_non_existing(app.base_data.id, cached_data)
class DiskCacheLoaderFactory:
def __init__(self, disk_cache: bool, cache_map: Dict[type, Cache]):
self.disk_cache = disk_cache
self.cache_map = cache_map
def new(self):
return DiskCacheLoader(enabled=self.disk_cache, cache_map=self.cache_map)
def save(app: Application, icon_bytes: bytes = None, only_icon: bool = False):
if app.supports_disk_cache():
if not only_icon:
Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True)
data = app.get_data_to_cache()
with open(app.get_disk_data_path(), 'w+') as f:
f.write(json.dumps(data))
if icon_bytes:
Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True)
with open(app.get_disk_icon_path(), 'wb+') as f:
f.write(icon_bytes)

3
bauh/core/exception.py Normal file
View File

@@ -0,0 +1,3 @@
class NoInternetException(Exception):
pass

View File

View File

@@ -0,0 +1,5 @@
from bauh.core.constants import CACHE_PATH
FLATHUB_URL = 'https://flathub.org'
FLATHUB_API_URL = FLATHUB_URL + '/api/v1'
FLATPAK_CACHE_PATH = '{}/flatpak/installed'.format(CACHE_PATH)

View File

@@ -0,0 +1,205 @@
import os
import shutil
from argparse import Namespace
from datetime import datetime
from typing import List, Dict
from bauh.core import disk
from bauh.core.controller import ApplicationManager
from bauh.core.disk import DiskCacheLoader
from bauh.core.flatpak import flatpak
from bauh.core.flatpak.model import FlatpakApplication
from bauh.core.flatpak.worker import FlatpakAsyncDataLoader, FlatpakUpdateLoader
from bauh.core.model import ApplicationData, ApplicationUpdate
from bauh.core.system import BauhProcess
from bauh.util.cache import Cache
class FlatpakManager(ApplicationManager):
def __init__(self, app_args: Namespace, api_cache: Cache, disk_cache: bool, http_session, locale_keys: dict):
super(FlatpakManager, self).__init__(app_args=app_args, locale_keys=locale_keys)
self.api_cache = api_cache
self.http_session = http_session
self.disk_cache = disk_cache
def get_app_type(self):
return FlatpakApplication
def _map_to_model(self, app_json: dict, installed: bool, disk_loader: DiskCacheLoader) -> FlatpakApplication:
app = FlatpakApplication(arch=app_json.get('arch'),
branch=app_json.get('branch'),
origin=app_json.get('origin'),
runtime=app_json.get('runtime'),
ref=app_json.get('ref'),
commit=app_json.get('commit'),
base_data=ApplicationData(id=app_json.get('id'),
name=app_json.get('name'),
version=app_json.get('version'),
latest_version=app_json.get('latest_version')))
app.installed = installed
api_data = self.api_cache.get(app_json['id'])
expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow()
if not api_data or expired_data:
if not app_json['runtime']:
if disk_loader:
disk_loader.add(app) # preloading cached disk data
FlatpakAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, http_session=self.http_session).start()
else:
app.fill_cached_data(api_data)
return app
def search(self, word: str, disk_loader: DiskCacheLoader) -> Dict[str, List[FlatpakApplication]]:
res = {'installed': [], 'new': []}
apps_found = flatpak.search(word)
if apps_found:
already_read = set()
installed_apps = self.read_installed(disk_loader=disk_loader)
if installed_apps:
for app_found in apps_found:
for installed_app in installed_apps:
if app_found['id'] == installed_app.base_data.id:
res['installed'].append(installed_app)
already_read.add(app_found['id'])
if len(apps_found) > len(already_read):
for app_found in apps_found:
if app_found['id'] not in already_read:
res['new'].append(self._map_to_model(app_found, False, disk_loader))
return res
def read_installed(self, disk_loader: DiskCacheLoader) -> List[FlatpakApplication]:
installed = flatpak.list_installed()
models = []
if installed:
available_updates = flatpak.list_updates_as_str()
for app_json in installed:
model = self._map_to_model(app_json=app_json, installed=True, disk_loader=disk_loader)
model.update = app_json['id'] in available_updates
models.append(model)
return models
def can_downgrade(self):
return True
def downgrade_app(self, app: FlatpakApplication, root_password: str) -> BauhProcess:
commits = flatpak.get_app_commits(app.ref, app.origin)
commit_idx = commits.index(app.commit)
# downgrade is not possible if the app current commit in the first one:
if commit_idx == len(commits) - 1:
return None
return BauhProcess(subproc=flatpak.downgrade_and_stream(app.ref, commits[commit_idx + 1], root_password),
success_phrase='Updates complete.')
def clean_cache_for(self, app: FlatpakApplication):
self.api_cache.delete(app.base_data.id)
if app.supports_disk_cache() and os.path.exists(app.get_disk_cache_path()):
shutil.rmtree(app.get_disk_cache_path())
def update_and_stream(self, app: FlatpakApplication) -> BauhProcess:
return BauhProcess(subproc=flatpak.update_and_stream(app.ref))
def uninstall_and_stream(self, app: FlatpakApplication, root_password: str = None) -> BauhProcess:
return BauhProcess(subproc=flatpak.uninstall_and_stream(app.ref))
def get_info(self, app: FlatpakApplication) -> dict:
app_info = flatpak.get_app_info_fields(app.base_data.id, app.branch)
app_info['name'] = app.base_data.name
app_info['type'] = 'runtime' if app.runtime else 'app'
app_info['description'] = app.base_data.description
return app_info
def get_history(self, app: FlatpakApplication) -> List[dict]:
return flatpak.get_app_commits_data(app.ref, app.origin)
def install_and_stream(self, app: FlatpakApplication, root_password: str) -> BauhProcess:
return BauhProcess(subproc=flatpak.install_and_stream(app.base_data.id, app.origin))
def is_enabled(self):
return flatpak.is_installed()
def cache_to_disk(self, app: FlatpakApplication, icon_bytes: bytes, only_icon: bool):
if self.disk_cache and app.supports_disk_cache():
disk.save(app, icon_bytes, only_icon)
def requires_root(self, action: str, app: FlatpakApplication):
return action == 'downgrade'
def refresh(self, app: FlatpakApplication, root_password: str) -> BauhProcess:
raise Exception("'refresh' is not supported for {}".format(app.__class__.__name__))
def prepare(self):
pass
def list_updates(self) -> List[ApplicationUpdate]:
updates = []
installed = flatpak.list_installed(extra_fields=False)
if installed:
available_updates = flatpak.list_updates_as_str()
if available_updates:
loaders = None
for app_json in installed:
if app_json['id'] in available_updates:
loader = FlatpakUpdateLoader(app=app_json, http_session=self.http_session)
loader.start()
if loaders is None:
loaders = []
loaders.append(loader)
if loaders:
for loader in loaders:
loader.join()
app = loader.app
updates.append(ApplicationUpdate(app_id='{}:{}'.format(app['id'], app['branch']),
app_type='flatpak',
version=app.get('version')))
return updates
def list_warnings(self) -> List[str]:
if flatpak.is_installed():
if not flatpak.has_remotes_set():
return [self.locale_keys['flatpak.notification.no_remotes']]
def list_suggestions(self, limit: int) -> List[FlatpakApplication]:
res = []
if limit != 0:
for app_id in ('com.spotify.Client', 'com.skype.Client', 'com.dropbox.Client', 'us.zoom.Zoom', 'com.visualstudio.code', 'org.telegram.desktop', 'org.inkscape.Inkscape', 'org.libretro.RetroArch', 'org.kde.kdenlive', 'org.videolan.VLC'):
app_json = flatpak.search(app_id, app_id=True)
if app_json:
res.append(self._map_to_model(app_json[0], False, None))
if len(res) == limit:
break
return res

245
bauh/core/flatpak/flatpak.py Executable file
View File

@@ -0,0 +1,245 @@
import re
import subprocess
from typing import List
from bauh.core import system
from bauh.core.exception import NoInternetException
BASE_CMD = 'flatpak'
def app_str_to_json(line: str, version: str, extra_fields: bool = True) -> dict:
app_array = line.split('\t')
if version >= '1.3.0':
app = {'name': app_array[0],
'id': app_array[1],
'version': app_array[2],
'branch': app_array[3]}
elif '1.0' <= version < '1.1':
ref_data = app_array[0].split('/')
app = {'id': ref_data[0],
'arch': ref_data[1],
'name': ref_data[0].split('.')[-1],
'ref': app_array[0],
'options': app_array[1],
'branch': ref_data[2],
'version': None}
elif '1.2' <= version < '1.3':
app = {'id': app_array[1],
'name': app_array[1].strip().split('.')[-1],
'version': app_array[2],
'branch': app_array[3],
'arch': app_array[4],
'origin': app_array[5]}
else:
raise Exception('Unsupported version')
if extra_fields:
extra_fields = get_app_info_fields(app['id'], app['branch'], ['origin', 'arch', 'ref', 'commit'], check_runtime=True)
app.update(extra_fields)
return app
def get_app_info_fields(app_id: str, branch: str, fields: List[str] = [], check_runtime: bool = False):
info = re.findall(r'\w+:\s.+', get_app_info(app_id, branch))
data = {}
fields_to_retrieve = len(fields) + (1 if check_runtime and 'ref' not in fields else 0)
for field in info:
if fields and fields_to_retrieve == 0:
break
field_val = field.split(':')
field_name = field_val[0].lower()
if not fields or field_name in fields or (check_runtime and field_name == 'ref'):
data[field_name] = field_val[1].strip()
if fields:
fields_to_retrieve -= 1
if check_runtime and field_name == 'ref':
data['runtime'] = data['ref'].startswith('runtime/')
return data
def is_installed():
version = get_version()
return False if version is None else True
def get_version():
res = system.run_cmd('{} --version'.format(BASE_CMD), print_error=False)
return res.split(' ')[1].strip() if res else None
def get_app_info(app_id: str, branch: str):
return system.run_cmd('{} info {} {}'.format(BASE_CMD, app_id, branch))
def list_installed(extra_fields: bool = True) -> List[dict]:
apps_str = system.run_cmd('{} list'.format(BASE_CMD))
if apps_str:
version = get_version()
app_lines = apps_str.split('\n')
return [app_str_to_json(line, version, extra_fields=extra_fields) for line in app_lines if line]
return []
def update_and_stream(app_ref: str):
"""
Updates the app reference and streams Flatpak output,
:param app_ref:
:return:
"""
return system.cmd_to_subprocess([BASE_CMD, 'update', '-y', app_ref])
def uninstall_and_stream(app_ref: str):
"""
Removes the app by its reference
:param app_ref:
:return:
"""
return system.cmd_to_subprocess([BASE_CMD, 'uninstall', app_ref, '-y'])
def list_updates_as_str():
return system.run_cmd('{} update'.format(BASE_CMD), ignore_return_code=True)
def downgrade_and_stream(app_ref: str, commit: str, root_password: str) -> subprocess.Popen:
return system.cmd_as_root([BASE_CMD, 'update', '--commit={}'.format(commit), app_ref, '-y'], root_password)
def get_app_commits(app_ref: str, origin: str) -> List[str]:
log = system.run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref))
if log:
return re.findall(r'Commit+:\s(.+)', log)
else:
raise NoInternetException()
def get_app_commits_data(app_ref: str, origin: str) -> List[dict]:
log = system.run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref))
if not log:
raise NoInternetException()
res = re.findall(r'(Commit|Subject|Date):\s(.+)', log)
commits = []
commit = {}
for idx, data in enumerate(res):
commit[data[0].strip().lower()] = data[1].strip()
if (idx + 1) % 3 == 0:
commits.append(commit)
commit = {}
return commits
def search(word: str, app_id: bool = False) -> List[dict]:
cli_version = get_version()
res = system.run_cmd('{} search {}'.format(BASE_CMD, word))
found = []
split_res = res.split('\n')
if split_res and split_res[0].lower() != 'no matches found':
for info in split_res:
if info:
info_list = info.split('\t')
if cli_version >= '1.3.0':
id_ = info_list[2].strip()
if app_id and id_ != word:
continue
version = info_list[3].strip()
app = {
'name': info_list[0].strip(),
'description': info_list[1].strip(),
'id': id_,
'version': version,
'latest_version': version,
'branch': info_list[4].strip(),
'origin': info_list[5].strip(),
'runtime': False,
'arch': None, # unknown at this moment,
'ref': None # unknown at this moment
}
elif cli_version >= '1.2.0':
id_ = info_list[1].strip()
if app_id and id_ != word:
continue
desc = info_list[0].split('-')
version = info_list[2].strip()
app = {
'name': desc[0].strip(),
'description': desc[1].strip(),
'id': id_,
'version': version,
'latest_version': version,
'branch': info_list[3].strip(),
'origin': info_list[4].strip(),
'runtime': False,
'arch': None, # unknown at this moment,
'ref': None # unknown at this moment
}
else:
id_ = info_list[0].strip()
if app_id and id_ != word:
continue
version = info_list[1].strip()
app = {
'name': '',
'description': info_list[4].strip(),
'id': id_,
'version': version,
'latest_version': version,
'branch': info_list[2].strip(),
'origin': info_list[3].strip(),
'runtime': False,
'arch': None, # unknown at this moment,
'ref': None # unknown at this moment
}
found.append(app)
if app_id and len(found) > 0:
break
return found
def install_and_stream(app_id: str, origin: str):
return system.cmd_to_subprocess([BASE_CMD, 'install', origin, app_id, '-y'])
def set_default_remotes():
system.run_cmd('flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo')
def has_remotes_set() -> bool:
return bool(system.run_cmd('{} remotes'.format(BASE_CMD)).strip())

View File

@@ -0,0 +1,62 @@
from bauh.core import resource
from bauh.core.flatpak.constants import FLATPAK_CACHE_PATH
from bauh.core.model import Application, ApplicationData
class FlatpakApplication(Application):
def __init__(self, base_data: ApplicationData, branch: str, arch: str, origin: str, runtime: bool, ref: str, commit: str):
super(FlatpakApplication, self).__init__(base_data=base_data)
self.ref = ref
self.branch = branch
self.arch = arch
self.origin = origin
self.runtime = runtime
self.commit = commit
def is_incomplete(self):
return self.base_data.description is None and self.base_data.icon_url
def has_history(self):
return self.installed
def has_info(self):
return self.installed
def can_be_downgraded(self):
return self.installed
def can_be_uninstalled(self):
return self.installed
def can_be_installed(self):
return not self.installed
def get_type(self):
return 'flatpak'
def can_be_refreshed(self):
return False
def get_default_icon_path(self):
return resource.get_path('img/flathub.svg')
def is_library(self):
return self.runtime
def get_disk_cache_path(self):
return '{}/{}'.format(FLATPAK_CACHE_PATH, self.base_data.id)
def get_data_to_cache(self):
return {
'description': self.base_data.description,
'icon_url': self.base_data.icon_url,
'latest_version': self.base_data.latest_version,
'version': self.base_data.version,
'name': self.base_data.name
}
def fill_cached_data(self, data: dict):
for attr in self.get_data_to_cache().keys():
if not getattr(self.base_data, attr):
setattr(self.base_data, attr, data[attr])

120
bauh/core/flatpak/worker.py Normal file
View File

@@ -0,0 +1,120 @@
import time
import traceback
from threading import Thread
from colorama import Fore
from bauh.core.controller import ApplicationManager
from bauh.core.flatpak import flatpak
from bauh.core.flatpak.constants import FLATHUB_API_URL, FLATHUB_URL
from bauh.core.flatpak.model import FlatpakApplication
from bauh.core.model import ApplicationStatus
from bauh.core.worker import AsyncDataLoader
from bauh.util.cache import Cache
class FlatpakAsyncDataLoader(AsyncDataLoader):
def __init__(self, app: FlatpakApplication, manager: ApplicationManager, http_session, api_cache: Cache, attempts: int = 2, timeout: int = 30):
super(FlatpakAsyncDataLoader, self).__init__(app=app)
self.manager = manager
self.http_session = http_session
self.attempts = attempts
self.api_cache = api_cache
self.persist = False
self.timeout = timeout
def run(self):
if self.app:
self.app.status = ApplicationStatus.LOADING_DATA
for _ in range(0, self.attempts):
try:
res = self.http_session.get('{}/apps/{}'.format(FLATHUB_API_URL, self.app.base_data.id), timeout=self.timeout)
if res.status_code == 200 and res.text:
data = res.json()
if not self.app.base_data.version:
self.app.base_data.version = data.get('version')
if not self.app.base_data.name:
self.app.base_data.name = data.get('name')
self.app.base_data.description = data.get('description', data.get('summary', None))
self.app.base_data.icon_url = data.get('iconMobileUrl', None)
self.app.base_data.latest_version = data.get('currentReleaseVersion', self.app.base_data.version)
if not self.app.base_data.version and self.app.base_data.latest_version:
self.app.base_data.version = self.app.base_data.latest_version
if not self.app.installed and self.app.base_data.latest_version:
self.app.base_data.version = self.app.base_data.latest_version
if self.app.base_data.icon_url and self.app.base_data.icon_url.startswith('/'):
self.app.base_data.icon_url = FLATHUB_URL + self.app.base_data.icon_url
loaded_data = self.app.get_data_to_cache()
self.api_cache.add(self.app.base_data.id, loaded_data)
self.app.status = ApplicationStatus.READY
self.persist = self.app.supports_disk_cache()
break
else:
self.log_msg("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(
self.app.base_data.id, res.status_code, res.content.decode()), Fore.RED)
except:
self.log_msg("Could not retrieve app data for id '{}'".format(self.app.base_data.id), Fore.YELLOW)
traceback.print_exc()
time.sleep(0.5)
self.app.status = ApplicationStatus.READY
if self.persist:
self.manager.cache_to_disk(app=self.app, icon_bytes=None, only_icon=False)
def clone(self) -> "FlatpakAsyncDataLoader":
return FlatpakAsyncDataLoader(manager=self.manager,
api_cache=self.api_cache,
attempts=self.attempts,
http_session=self.http_session,
timeout=self.timeout,
app=self.app)
class FlatpakUpdateLoader(Thread):
def __init__(self, app: dict, http_session, attempts: int = 2, timeout: int = 20):
super(FlatpakUpdateLoader, self).__init__(daemon=True)
self.app = app
self.http_session = http_session
self.attempts = attempts
self.timeout = timeout
def run(self):
if self.app.get('ref') is None:
self.app.update(flatpak.get_app_info_fields(self.app['id'], self.app['branch'], fields=['ref'], check_runtime=True))
else:
self.app['runtime'] = self.app['ref'].startswith('runtime/')
if not self.app['runtime']:
current_attempts = 0
while current_attempts < self.attempts:
current_attempts += 1
try:
res = self.http_session.get('{}/apps/{}'.format(FLATHUB_API_URL, self.app['id']), timeout=self.timeout)
if res.status_code == 200 and res.text:
data = res.json()
if data.get('currentReleaseVersion'):
self.app['version'] = data['currentReleaseVersion']
break
except:
traceback.print_exc()
time.sleep(0.5)

99
bauh/core/model.py Normal file
View File

@@ -0,0 +1,99 @@
from abc import ABC, abstractmethod
from enum import Enum
from bauh.core import resource
class ApplicationStatus(Enum):
READY = 1
LOADING_DATA = 2
class ApplicationData:
def __init__(self, id: str, version: str, name: str = None, description: str = None, latest_version: str = None, icon_url: str = None):
self.id = id
self.name = name
self.version = version
self.description = description
self.latest_version = latest_version
self.icon_url = icon_url
class Application(ABC):
def __init__(self, base_data: ApplicationData, status: ApplicationStatus = ApplicationStatus.READY, installed: bool = False, update: bool = False):
self.base_data = base_data
self.status = status
self.installed = installed
self.update = update
@abstractmethod
def has_history(self):
pass
@abstractmethod
def has_info(self):
pass
@abstractmethod
def can_be_downgraded(self):
pass
@abstractmethod
def can_be_uninstalled(self):
pass
@abstractmethod
def can_be_installed(self):
pass
@abstractmethod
def can_be_refreshed(self):
return self.installed
@abstractmethod
def get_type(self):
pass
def get_default_icon_path(self):
return resource.get_path('img/logo.svg')
@abstractmethod
def is_library(self):
pass
def supports_disk_cache(self):
return self.installed and not self.is_library()
@abstractmethod
def get_disk_cache_path(self):
pass
def get_disk_icon_path(self):
return '{}/icon.png'.format(self.get_disk_cache_path())
def get_disk_data_path(self):
return '{}/data.json'.format(self.get_disk_cache_path())
@abstractmethod
def get_data_to_cache(self):
pass
@abstractmethod
def fill_cached_data(self, data: dict):
pass
def __str__(self):
return '{} (id={}, name={})'.format(self.__class__.__name__, self.base_data.id, self.base_data.name)
class ApplicationUpdate:
def __init__(self, app_id: str, version: str, app_type: str):
self.id = app_id
self.version = version
self.type = app_type
def __str__(self):
return '{} (id={}, type={}, new_version={})'.format(self.__class__.__name__, self.id, self.type, self.type)

6
bauh/core/resource.py Executable file
View File

@@ -0,0 +1,6 @@
from bauh import ROOT_DIR
def get_path(resource_path):
return ROOT_DIR + '/resources/' + resource_path

View File

View File

@@ -0,0 +1,4 @@
from bauh.core.constants import CACHE_PATH
SNAP_API_URL = 'https://search.apps.ubuntu.com/api/v1'
SNAP_CACHE_PATH = '{}/snap/installed'.format(CACHE_PATH)

View File

@@ -0,0 +1,154 @@
import os
import shutil
from argparse import Namespace
from datetime import datetime
from typing import Dict, List
from bauh.core import disk
from bauh.core.controller import ApplicationManager
from bauh.core.disk import DiskCacheLoader
from bauh.core.model import ApplicationData, Application, ApplicationUpdate
from bauh.core.snap import snap
from bauh.core.snap.model import SnapApplication
from bauh.core.snap.worker import SnapAsyncDataLoader
from bauh.core.system import BauhProcess
from bauh.util.cache import Cache
class SnapManager(ApplicationManager):
def __init__(self, app_args: Namespace, api_cache: Cache, disk_cache: bool, http_session, locale_keys: dict):
super(SnapManager, self).__init__(app_args=app_args, locale_keys=locale_keys)
self.api_cache = api_cache
self.http_session = http_session
self.disk_cache = disk_cache
def map_json(self, app_json: dict, installed: bool, disk_loader: DiskCacheLoader) -> SnapApplication:
app = SnapApplication(publisher=app_json.get('publisher'),
rev=app_json.get('rev'),
notes=app_json.get('notes'),
app_type=app_json.get('type'),
base_data=ApplicationData(id=app_json.get('name'),
name=app_json.get('name'),
version=app_json.get('version'),
latest_version=app_json.get('version'),
description=app_json.get('description')
))
if app.publisher:
app.publisher = app.publisher.replace('*', '')
app.installed = installed
api_data = self.api_cache.get(app_json['name'])
expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow()
if (not api_data or expired_data) and not app.is_library():
if disk_loader and app.installed:
disk_loader.add(app)
SnapAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, http_session=self.http_session, download_icons=self.app_args.download_icons).start()
else:
app.fill_cached_data(api_data)
return app
def search(self, word: str, disk_loader: DiskCacheLoader) -> Dict[str, List[SnapApplication]]:
installed = self.read_installed(disk_loader)
res = {'installed': [], 'new': []}
for app_json in snap.search(word):
already_installed = None
if installed:
already_installed = [i for i in installed if i.base_data.id == app_json.get('name')]
already_installed = already_installed[0] if already_installed else None
if already_installed:
res['installed'].append(already_installed)
else:
res['new'].append(self.map_json(app_json, installed=False, disk_loader=disk_loader))
return res
def read_installed(self, disk_loader: DiskCacheLoader) -> List[SnapApplication]:
return [self.map_json(app_json, installed=True, disk_loader=disk_loader) for app_json in snap.read_installed()]
def downgrade_app(self, app: Application, root_password: str) -> BauhProcess:
return BauhProcess(subproc=snap.downgrade_and_stream(app.base_data.name, root_password), wrong_error_phrase=None)
def clean_cache_for(self, app: SnapApplication):
self.api_cache.delete(app.base_data.name)
if app.supports_disk_cache() and os.path.exists(app.get_disk_cache_path()):
shutil.rmtree(app.get_disk_cache_path())
def can_downgrade(self):
return True
def update_and_stream(self, app: SnapApplication) -> BauhProcess:
pass
def uninstall_and_stream(self, app: SnapApplication, root_password: str) -> BauhProcess:
return BauhProcess(subproc=snap.uninstall_and_stream(app.base_data.name, root_password))
def get_app_type(self):
return SnapApplication
def get_info(self, app: SnapApplication) -> dict:
info = snap.get_info(app.base_data.name, attrs=('license', 'contact', 'commands', 'snap-id', 'tracking', 'installed'))
info['description'] = app.base_data.description
info['publisher'] = app.publisher
info['revision'] = app.rev
info['name'] = app.base_data.name
if info.get('commands'):
info['commands'] = ' '.join(info['commands'])
return info
def get_history(self, app: Application) -> List[dict]:
return []
def install_and_stream(self, app: SnapApplication, root_password: str) -> BauhProcess:
return BauhProcess(subproc=snap.install_and_stream(app.base_data.name, app.confinement, root_password))
def is_enabled(self) -> bool:
return snap.is_installed()
def cache_to_disk(self, app: Application, icon_bytes: bytes, only_icon: bool):
if self.disk_cache and app.supports_disk_cache():
disk.save(app, icon_bytes, only_icon)
def requires_root(self, action: str, app: SnapApplication):
return action != 'search'
def refresh(self, app: SnapApplication, root_password: str) -> BauhProcess:
return BauhProcess(subproc=snap.refresh_and_stream(app.base_data.name, root_password))
def prepare(self):
pass
def list_updates(self) -> List[ApplicationUpdate]:
return []
def list_warnings(self) -> List[str]:
if snap.get_snapd_version() == 'unavailable':
return [self.locale_keys['snap.notification.snapd_unavailable']]
def list_suggestions(self, limit: int) -> List[SnapApplication]:
suggestions = []
if limit != 0:
for name in ('whatsdesk', 'slack', 'yakyak', 'instagraph', 'pycharm-professional', 'eclipse', 'gimp', 'supertuxkart'):
res = snap.search(name, exact_name=True)
if res:
suggestions.append(self.map_json(res[0], installed=False, disk_loader=None))
if len(suggestions) == limit:
break
return suggestions

60
bauh/core/snap/model.py Normal file
View File

@@ -0,0 +1,60 @@
from bauh.core import resource
from bauh.core.model import Application, ApplicationData
from bauh.core.snap.constants import SNAP_CACHE_PATH
class SnapApplication(Application):
def __init__(self, base_data: ApplicationData, publisher: str, rev: str, notes: str, app_type: str, confinement: str = None):
super(SnapApplication, self).__init__(base_data=base_data)
self.publisher = publisher
self.rev = rev
self.notes = notes
self.type = app_type
self.confinement = confinement
def has_history(self):
return False
def has_info(self):
return True
def can_be_downgraded(self):
return self.installed
def can_be_uninstalled(self):
return self.installed
def can_be_installed(self):
return not self.installed
def can_be_refreshed(self):
return self.installed
def get_type(self):
return 'snap'
def get_default_icon_path(self):
return resource.get_path('img/snapcraft.png')
def is_library(self):
return self.type in ('core', 'base', 'snapd') or self.base_data.name.startswith('gtk-') or self.base_data.name.startswith('gnome-')
def get_disk_cache_path(self):
return '{}/{}'.format(SNAP_CACHE_PATH, self.base_data.name)
def get_data_to_cache(self):
return {
"icon_url": self.base_data.icon_url,
'confinement': self.confinement,
'description': self.base_data.description
}
def fill_cached_data(self, data: dict):
if data:
for base_attr in ('icon_url', 'description'):
if data.get(base_attr):
setattr(self.base_data, base_attr, data[base_attr])
if data.get('confinement'):
self.confinement = data['confinement']

141
bauh/core/snap/snap.py Normal file
View File

@@ -0,0 +1,141 @@
import re
import subprocess
from typing import List
from bauh.core import system
BASE_CMD = 'snap'
def is_installed():
version = get_snapd_version()
return False if version is None or version == 'unavailable' else True
def get_version():
res = system.run_cmd('{} --version'.format(BASE_CMD), print_error=False)
return res.split('\n')[0].split(' ')[-1].strip() if res else None
def get_snapd_version():
res = system.run_cmd('{} --version'.format(BASE_CMD), print_error=False)
if not res:
return None
else:
lines = res.split('\n')
if lines and len(lines) >= 2:
version = lines[1].split(' ')[-1].strip()
return version.lower() if version else None
else:
return None
def app_str_to_json(app: str) -> dict:
app_data = [word for word in app.split(' ') if word]
app_json = {
'name': app_data[0],
'version': app_data[1],
'rev': app_data[2],
'tracking': app_data[3],
'publisher': app_data[4] if len(app_data) >= 5 else None,
'notes': app_data[5] if len(app_data) >= 6 else None
}
app_json.update(get_info(app_json['name'], ('summary', 'type', 'description')))
return app_json
def get_info(app_name: str, attrs: tuple = None):
full_info_lines = system.run_cmd('{} info {}'.format(BASE_CMD, app_name))
data = {}
if full_info_lines:
re_attrs = r'\w+' if not attrs else '|'.join(attrs)
info_map = re.findall(r'({}):\s+(.+)'.format(re_attrs), full_info_lines)
for info in info_map:
data[info[0]] = info[1].strip()
if not attrs or 'description' in attrs:
desc = re.findall(r'\|\n+((\s+.+\n+)+)', full_info_lines)
data['description'] = ''.join([w.strip() for w in desc[0][0].strip().split('\n')]).replace('.', '.\n') if desc else None
if not attrs or 'commands' in attrs:
commands = re.findall(r'commands:\s*\n*((\s+-\s.+\s*\n)+)', full_info_lines)
data['commands'] = commands[0][0].replace('-', '').strip().split('\n') if commands else None
return data
def read_installed() -> List[dict]:
res = system.run_cmd('{} list'.format(BASE_CMD), print_error=False)
apps = []
if res and len(res) > 0:
lines = res.split('\n')
if not lines[0].startswith('error'):
for idx, app_str in enumerate(lines):
if idx > 0 and app_str:
apps.append(app_str_to_json(app_str))
return apps
def search(word: str, exact_name: bool = False) -> List[dict]:
apps = []
res = system.run_cmd('{} find "{}"'.format(BASE_CMD, word), print_error=False)
if res:
res = res.split('\n')
if not res[0].startswith('No matching'):
for idx, app_str in enumerate(res):
if idx > 0 and app_str:
app_data = [word for word in app_str.split(' ') if word]
if exact_name and app_data[0] != word:
continue
apps.append({
'name': app_data[0],
'version': app_data[1],
'publisher': app_data[2],
'notes': app_data[3] if app_data[3] != '-' else None,
'summary': app_data[4] if len(app_data) == 5 else '',
'rev': None,
'tracking': None,
'type': None
})
if exact_name and len(apps) > 0:
break
return apps
def uninstall_and_stream(app_name: str, root_password: str):
return system.cmd_as_root([BASE_CMD, 'remove', app_name], root_password)
def install_and_stream(app_name: str, confinement: str, root_password: str) -> subprocess.Popen:
install_cmd = [BASE_CMD, 'install', app_name] # default
if confinement == 'classic':
install_cmd.append('--classic')
return system.cmd_as_root(install_cmd, root_password)
def downgrade_and_stream(app_name: str, root_password: str) -> subprocess.Popen:
return system.cmd_as_root([BASE_CMD, 'revert', app_name], root_password)
def refresh_and_stream(app_name: str, root_password: str) -> subprocess.Popen:
return system.cmd_as_root([BASE_CMD, 'refresh', app_name], root_password)

85
bauh/core/snap/worker.py Normal file
View File

@@ -0,0 +1,85 @@
import time
import traceback
from colorama import Fore
from bauh.core.controller import ApplicationManager
from bauh.core.model import ApplicationStatus
from bauh.core.snap import snap
from bauh.core.snap.constants import SNAP_API_URL
from bauh.core.snap.model import SnapApplication
from bauh.core.worker import AsyncDataLoader
from bauh.util.cache import Cache
class SnapAsyncDataLoader(AsyncDataLoader):
def __init__(self, app: SnapApplication, manager: ApplicationManager, http_session, api_cache: Cache, download_icons: bool, attempts: int = 2, timeout: int = 30):
super(SnapAsyncDataLoader, self).__init__(app=app)
self.manager = manager
self.http_session = http_session
self.attempts = attempts
self.api_cache = api_cache
self.timeout = timeout
self.persist = False
self.download_icons = download_icons
def run(self):
if self.app:
self.app.status = ApplicationStatus.LOADING_DATA
for _ in range(0, self.attempts):
try:
res = self.http_session.get('{}/search?q={}'.format(SNAP_API_URL, self.app.base_data.name), timeout=self.timeout)
if res.status_code == 200 and res.text:
try:
snap_list = res.json()['_embedded']['clickindex:package']
except:
self.log_msg('Snap API response responded differently from expected for app: {}'.format(self.app.base_data.name))
break
if not snap_list:
break
snap_data = snap_list[0]
api_data = {
'confinement': snap_data.get('confinement'),
'description': snap_data.get('description'),
'icon_url': snap_data.get('icon_url') if self.download_icons else None
}
self.api_cache.add(self.app.base_data.id, api_data)
self.app.confinement = api_data['confinement']
self.app.base_data.icon_url = api_data['icon_url']
if not api_data.get('description'):
api_data['description'] = snap.get_info(self.app.base_data.name, ('description',)).get('description')
self.app.base_data.description = api_data['description']
self.app.status = ApplicationStatus.READY
self.persist = self.app.supports_disk_cache()
break
else:
self.log_msg("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(self.app.base_data.id, res.status_code, res.content.decode()), Fore.RED)
except:
self.log_msg("Could not retrieve app data for id '{}'".format(self.app.base_data.id), Fore.YELLOW)
traceback.print_exc()
time.sleep(0.5)
self.app.status = ApplicationStatus.READY
if self.persist:
self.manager.cache_to_disk(app=self.app, icon_bytes=None, only_icon=False)
def clone(self) -> "SnapAsyncDataLoader":
return SnapAsyncDataLoader(manager=self.manager,
api_cache=self.api_cache,
attempts=self.attempts,
http_session=self.http_session,
timeout=self.timeout,
app=self.app)

51
bauh/core/system.py Normal file
View File

@@ -0,0 +1,51 @@
import os
import subprocess
from typing import List
from bauh import __app_name__
from bauh.core import resource
class BauhProcess:
def __init__(self, subproc: subprocess.Popen, success_phrase: str = None, wrong_error_phrase: str = '[sudo] password for'):
self.subproc = subproc
self.success_pgrase = success_phrase
self.wrong_error_phrase = wrong_error_phrase
def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False, print_error: bool = True) -> str:
args = {
"shell": True,
"stdout": subprocess.PIPE,
"env": {'LANG': 'en'}
}
if not print_error:
args["stderr"] = subprocess.DEVNULL
res = subprocess.run(cmd, **args)
return res.stdout.decode() if ignore_return_code or res.returncode == expected_code else None
def stream_cmd(cmd: List[str]):
return cmd_to_subprocess(cmd).stdout
def cmd_to_subprocess(cmd: List[str]):
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env={'LANG': 'en'})
def notify_user(msg: str, icon_path: str = resource.get_path('img/logo.svg')):
os.system("notify-send -a {} {} '{}'".format(__app_name__, "-i {}".format(icon_path) if icon_path else '', msg))
def cmd_as_root(cmd: List[str], root_password: str) -> subprocess.Popen:
pwdin, final_cmd = None, []
if root_password is not None:
final_cmd.extend(['sudo', '-S'])
pwdin = stream_cmd(['echo', root_password])
final_cmd.extend(cmd)
return subprocess.Popen(final_cmd, stdin=pwdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

31
bauh/core/worker.py Normal file
View File

@@ -0,0 +1,31 @@
from io import StringIO
from threading import Thread
from colorama import Fore
from bauh.core.model import Application
class AsyncDataLoader(Thread):
def __init__(self, app: Application):
super(AsyncDataLoader, self).__init__(daemon=True)
self.id_ = '{}#{}'.format(self.__class__.__name__, id(self))
self.app = app
def log_msg(self, msg: str, color: int = None):
final_msg = StringIO()
if color:
final_msg.write(str(color))
final_msg.write('[{}] '.format(self.id_))
final_msg.write(msg)
if color:
final_msg.write(Fore.RESET)
final_msg.seek(0)
print(final_msg.read())