diff --git a/tests/small/test_settings.py b/tests/small/test_settings.py index 971f9810..20954bf9 100644 --- a/tests/small/test_settings.py +++ b/tests/small/test_settings.py @@ -20,6 +20,7 @@ """Tests the umake settings handler""" import os +import re import shutil import tempfile from ..tools import get_data_dir, LoggedTestCase @@ -97,3 +98,24 @@ def test_version_git_not_installed(self, path_join_result): path_join_result.side_effect = self.return_fake_version_path os.environ["PATH"] = "" self.assertEqual(settings.get_version(), "42.02+unknown") + + def test_get_latest_version(self): + class DartSdk: + def __init__(self): + self.package_url = 'https://storage.googleapis.com/dart-archive/channels/stable/release/3.2.4/sdk/dartsdk-linux-x64-release.zip' + self.version_regex = r'/(\d+\.\d+\.\d+)' + + def get_latest_version(self): + print(self.version_regex, self.package_url) + return (re.search(self.version_regex, self.package_url).group(1).replace('_', '.') + if self.package_url and self.version_regex else None) + + framework = DartSdk() + self.assertEqual(framework.get_latest_version(), '3.2.4') + + @patch("os.path.join") + def test_get_current_user_version(self, path_join_result): + # 1) install dart-sdk or a dummy framework and store the install_path + # 2) Initiate a framework object + # 3) assertEqual(framework.get_current_user_version(install_path), '3.2.4') + pass diff --git a/umake/__init__.py b/umake/__init__.py index 89aeb112..756580a3 100644 --- a/umake/__init__.py +++ b/umake/__init__.py @@ -132,7 +132,8 @@ def main(): add_help=False) parser.add_argument('--help', action=_HelpAction, help=_('Show this help')) # add custom help parser.add_argument("-v", "--verbose", action="count", default=0, help=_("Increase output verbosity (2 levels)")) - + parser.add_argument('-u', '--update', action='store_true', help=_('Update installed frameworks')) + parser.add_argument('-y', '--assume-yes', action='store_true', help=_('Assume yes at interactive prompts')) parser.add_argument('-r', '--remove', action="store_true", help=_("Remove specified framework if installed")) list_group = parser.add_argument_group("List frameworks").add_mutually_exclusive_group() diff --git a/umake/frameworks/__init__.py b/umake/frameworks/__init__.py index eefd1019..ea840b6f 100644 --- a/umake/frameworks/__init__.py +++ b/umake/frameworks/__init__.py @@ -30,6 +30,7 @@ import pkgutil import sys import subprocess +import re from umake.network.requirements_handler import RequirementsHandler from umake.settings import DEFAULT_INSTALL_TOOLS_PATH, UMAKE_FRAMEWORKS_ENVIRON_VARIABLE, DEFAULT_BINARY_LINK_PATH from umake.tools import ConfigHandler, NoneDict, classproperty, get_current_arch, get_current_distro_version,\ @@ -140,7 +141,8 @@ class BaseFramework(metaclass=abc.ABCMeta): def __init__(self, name, description, category, force_loading=False, logo_path=None, is_category_default=False, install_path_dir=None, only_on_archs=None, only_ubuntu=False, only_ubuntu_version=None, packages_requirements=None, only_for_removal=False, expect_license=False, - need_root_access=False, json=False, override_install_path=None): + need_root_access=False, json=False, override_install_path=None, + version_regex=None, supports_update=False): self.name = name self.description = description self.logo_path = None @@ -153,6 +155,8 @@ def __init__(self, name, description, category, force_loading=False, logo_path=N self.packages_requirements.extend(self.category.packages_requirements) self.only_for_removal = only_for_removal self.expect_license = expect_license + self.version_regex = version_regex + self.supports_update = supports_update # self.override_install_path = "" if override_install_path is None else override_install_path # don't detect anything for completion mode (as we need to be quick), so avoid opening apt cache and detect @@ -321,15 +325,27 @@ def run_for(self, args): install_path = None auto_accept_license = False dry_run = False + assume_yes = False if args.destdir: install_path = os.path.abspath(os.path.expanduser(args.destdir)) if self.expect_license and args.accept_license: auto_accept_license = True if args.dry_run: dry_run = True + if args.assume_yes: + assume_yes = True self.setup(install_path=install_path, auto_accept_license=auto_accept_license, - dry_run=dry_run) + dry_run=dry_run, + assume_yes=assume_yes) + + def get_latest_version(self): + return (re.search(self.version_regex, self.package_url).group(1).replace('_', '.') + if self.package_url and self.version_regex else None) + + @staticmethod + def get_current_user_version(install_path): + return None class MainCategory(BaseCategory): diff --git a/umake/frameworks/android.py b/umake/frameworks/android.py index c810d005..a8d73837 100644 --- a/umake/frameworks/android.py +++ b/umake/frameworks/android.py @@ -19,7 +19,7 @@ """Android module""" - +import json from contextlib import suppress from gettext import gettext as _ import logging @@ -86,7 +86,10 @@ def __init__(self, **kwargs): checksum_type=ChecksumType.sha256, dir_to_decompress_in_tarball="android-studio", desktop_filename="android-studio.desktop", - required_files_path=[os.path.join("bin", "studio.sh")], **kwargs) + required_files_path=[os.path.join("bin", "studio.sh")], + version_regex=r'(\d+\.\d+)', + supports_update=True, + **kwargs) def parse_license(self, line, license_txt, in_license): """Parse Android Studio download page for license""" @@ -108,6 +111,16 @@ def post_install(self): categories="Development;IDE;", extra="StartupWMClass=jetbrains-studio")) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + version_not_formatted = data.get('dataDirectoryName') + return re.search(r'\d+\.\d+', version_not_formatted).group() if version_not_formatted else None + except FileNotFoundError: + return + class AndroidSDK(umake.frameworks.baseinstaller.BaseInstaller): diff --git a/umake/frameworks/baseinstaller.py b/umake/frameworks/baseinstaller.py index 3ee73fdc..d6750434 100644 --- a/umake/frameworks/baseinstaller.py +++ b/umake/frameworks/baseinstaller.py @@ -61,6 +61,7 @@ def __init__(self, *args, **kwargs): """The Downloader framework isn't instantiated directly, but is useful to inherit from for all frameworks having a set of downloads to proceed, some eventual supported_archs.""" + self.package_url = None self.download_page = kwargs["download_page"] self.checksum_type = kwargs.get("checksum_type", None) self.dir_to_decompress_in_tarball = kwargs.get("dir_to_decompress_in_tarball", "") @@ -102,18 +103,22 @@ def is_installed(self): logger.debug("{} is installed".format(self.name)) return True - def setup(self, install_path=None, auto_accept_license=False, dry_run=False): + def setup(self, install_path=None, auto_accept_license=False, dry_run=False, assume_yes=False): self.arg_install_path = install_path self.auto_accept_license = auto_accept_license self.dry_run = dry_run + self.assume_yes = assume_yes super().setup() # first step, check if installed or dry_run if self.dry_run: self.download_provider_page() elif self.is_installed: - UI.display(YesNo("{} is already installed on your system, do you want to reinstall " - "it anyway?".format(self.name), self.reinstall, UI.return_main_screen)) + if self.assume_yes: + self.reinstall() + else: + UI.display(YesNo("{} is already installed on your system, do you want to reinstall " + "it anyway?".format(self.name), self.reinstall, UI.return_main_screen)) else: self.confirm_path(self.arg_install_path) @@ -162,6 +167,10 @@ def set_exec_path(self): def confirm_path(self, path_dir=""): """Confirm path dir""" + if self.assume_yes: + UI.display(DisplayMessage("Assuming default path: " + self.install_path)) + path_dir = self.install_path + if not path_dir: logger.debug("No installation path provided. Requesting one.") UI.display(InputText("Choose installation path:", self.confirm_path, self.install_path)) @@ -211,6 +220,78 @@ def parse_download_link(self, line, in_download): ((url, md5sum), in_download=True/False)""" pass + def store_package_url(self, result): + logger.debug("Parse download metadata") + self.auto_accept_license = True + self.dry_run = True + + error_msg = result[self.download_page].error + if error_msg: + logger.error("An error occurred while downloading {}: {}".format(self.download_page, error_msg)) + + self.new_download_url = None + self.shasum_read_method = hasattr(self, 'get_sha_and_start_download') + with StringIO() as license_txt: + url, checksum = self.get_metadata(result, license_txt) + self.package_url = url + + def get_metadata(self, result, license_txt): + + url, checksum = (None, None) + page = result[self.download_page] + if self.json is True: + logger.debug("Using json parser") + try: + latest = json.loads(page.buffer.read().decode()) + # On a download from github, if the page is not .../releases/latest + # we want to download the latest version (beta/development) + # So we get the first element in the json tree. + # In the framework we only change the url and this condition is satisfied. + if self.download_page.startswith("https://api.github.com") and \ + not self.download_page.endswith("/latest"): + latest = latest[0] + url = None + in_download = False + (url, in_download) = self.parse_download_link(latest, in_download) + if not url: + if not self.url: + raise IndexError + else: + logger.debug("We set a temporary url while fetching the checksum") + url = self.url + except (json.JSONDecodeError, IndexError): + logger.error("Can't parse the download URL from the download page.") + UI.return_main_screen(status_code=1) + logger.debug("Found download URL: " + url) + + else: + in_license = False + in_download = False + for line in page.buffer: + line_content = line.decode() + + if self.expect_license and not self.auto_accept_license: + in_license = self.parse_license(line_content, license_txt, in_license) + + # always take the first valid (url, checksum) if not match_last_link is set to True: + download = None + # if not in_download: + if (url is None or (self.checksum_type and not checksum) or + self.match_last_link) and \ + not (self.shasum_read_method and self.new_download_url): + (download, in_download) = self.parse_download_link(line_content, in_download) + + if download is not None: + (newurl, new_checksum) = download + url = newurl if newurl is not None else url + checksum = new_checksum if new_checksum is not None else checksum + if url is not None: + if self.checksum_type and checksum: + logger.debug("Found download link for {}, checksum: {}".format(url, checksum)) + elif not self.checksum_type: + logger.debug("Found download link for {}".format(url)) + return url, checksum + @MainLoop.in_mainloop_thread def get_metadata_and_check_license(self, result): """Download files to download + license and check it""" @@ -224,59 +305,7 @@ def get_metadata_and_check_license(self, result): self.new_download_url = None self.shasum_read_method = hasattr(self, 'get_sha_and_start_download') with StringIO() as license_txt: - url, checksum = (None, None) - page = result[self.download_page] - if self.json is True: - logger.debug("Using json parser") - try: - latest = json.loads(page.buffer.read().decode()) - # On a download from github, if the page is not .../releases/latest - # we want to download the latest version (beta/development) - # So we get the first element in the json tree. - # In the framework we only change the url and this condition is satisfied. - if self.download_page.startswith("https://api.github.com") and\ - not self.download_page.endswith("/latest"): - latest = latest[0] - url = None - in_download = False - (url, in_download) = self.parse_download_link(latest, in_download) - if not url: - if not self.url: - raise IndexError - else: - logger.debug("We set a temporary url while fetching the checksum") - url = self.url - except (json.JSONDecodeError, IndexError): - logger.error("Can't parse the download URL from the download page.") - UI.return_main_screen(status_code=1) - logger.debug("Found download URL: " + url) - - else: - in_license = False - in_download = False - for line in page.buffer: - line_content = line.decode() - - if self.expect_license and not self.auto_accept_license: - in_license = self.parse_license(line_content, license_txt, in_license) - - # always take the first valid (url, checksum) if not match_last_link is set to True: - download = None - # if not in_download: - if (url is None or (self.checksum_type and not checksum) or - self.match_last_link) and\ - not(self.shasum_read_method and self.new_download_url): - (download, in_download) = self.parse_download_link(line_content, in_download) - if download is not None: - (newurl, new_checksum) = download - url = newurl if newurl is not None else url - checksum = new_checksum if new_checksum is not None else checksum - if url is not None: - if self.checksum_type and checksum: - logger.debug("Found download link for {}, checksum: {}".format(url, checksum)) - elif not self.checksum_type: - logger.debug("Found download link for {}".format(url)) - + url, checksum = self.get_metadata(result, license_txt) if hasattr(self, 'get_sha_and_start_download'): logger.debug('Run get_sha_and_start_download') DownloadCenter(urls=[DownloadItem(self.new_download_url, None)], diff --git a/umake/frameworks/dart.py b/umake/frameworks/dart.py index efc7af1c..5b4298f8 100644 --- a/umake/frameworks/dart.py +++ b/umake/frameworks/dart.py @@ -57,7 +57,10 @@ def __init__(self, **kwargs): "stable/release/latest/VERSION", dir_to_decompress_in_tarball="dart-sdk", required_files_path=[os.path.join("bin", "dart")], - json=True, **kwargs) + json=True, + version_regex=r'/(\d+\.\d+\.\d+)', + supports_update=True, + **kwargs) arch_trans = { "amd64": "x64", @@ -79,6 +82,14 @@ def post_install(self): add_env_to_user(self.name, {"PATH": {"value": os.path.join(self.install_path, "bin")}}) UI.delayed_display(DisplayMessage(self.RELOGIN_REQUIRE_MSG.format(self.name))) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'version'), 'r') as file: + return file.readline().strip() if file else None + except FileNotFoundError: + return + class FlutterLang(umake.frameworks.baseinstaller.BaseInstaller): diff --git a/umake/frameworks/devops.py b/umake/frameworks/devops.py index 527ffd9b..592319e9 100644 --- a/umake/frameworks/devops.py +++ b/umake/frameworks/devops.py @@ -19,7 +19,8 @@ """Devops module""" - +import re +import subprocess from gettext import gettext as _ import logging import os @@ -45,7 +46,10 @@ def __init__(self, **kwargs): download_page="https://api.github.com/repos/hashicorp/terraform/releases/latest", dir_to_decompress_in_tarball=".", required_files_path=["terraform"], - json=True, **kwargs) + json=True, + version_regex=r'/(\d+\.\d+\.\d+)', + supports_update=True, + **kwargs) arch_trans = { "amd64": "amd64", @@ -67,3 +71,14 @@ def post_install(self): """Add Terraform necessary env variables""" add_env_to_user(self.name, {"PATH": {"value": os.path.join(self.install_path)}}) UI.delayed_display(DisplayMessage(self.RELOGIN_REQUIRE_MSG.format(self.name))) + + @staticmethod + def get_current_user_version(install_path): + file = os.path.join(install_path, 'terraform') + command = f"{file} --version" + try: + result = subprocess.check_output(command, shell=True, text=True) + match = re.search(r'Terraform\s+v(\d+\.\d+\.\d+)', result) + return match.group(1) if match else None + except subprocess.CalledProcessError: + return diff --git a/umake/frameworks/electronics.py b/umake/frameworks/electronics.py index 330fca47..779572f5 100644 --- a/umake/frameworks/electronics.py +++ b/umake/frameworks/electronics.py @@ -185,6 +185,8 @@ def __init__(self, **kwargs): desktop_filename="eagle.desktop", required_files_path=["eagle"], dir_to_decompress_in_tarball="eagle-*", + version_regex=r'/(\d+(?:_\d+)*)/', + supports_update=True, **kwargs) def parse_download_link(self, line, in_download): @@ -205,6 +207,14 @@ def post_install(self): comment=self.description, categories="Development;")) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'bin', 'eagle.def'), 'r') as file: + return re.search(r'(\d+(\.\d+)+)', next(file)).group(1) if file else None + except FileNotFoundError: + return + class Fritzing(umake.frameworks.baseinstaller.BaseInstaller): diff --git a/umake/frameworks/games.py b/umake/frameworks/games.py index e230e5c1..4ba026db 100644 --- a/umake/frameworks/games.py +++ b/umake/frameworks/games.py @@ -62,9 +62,12 @@ def parse_download_link(self, line, in_download): """Parse Blender download links""" url = None if 'linux-x64.tar.xz' in line: - p = re.search(r'href=\"https:\/\/www\.blender\.org\/download(.*linux-x64\.tar\.xz).?"', line) - url = "https://mirrors.dotsrc.org/blender/" + p.group(1) - print(url) + p = re.search(r'href=\"(https:\/\/www\.blender\.org\/.*linux-x64\.tar\.xz).?"', line) + with suppress(AttributeError): + url = p.group(1) + filename = 'release' + re.search('blender-(.*)-linux', url).group(1).replace('.', '') + '.md5' + self.checksum_url = os.path.join(os.path.dirname(url), + filename).replace('download', 'release').replace('www', 'download') return ((url, None), in_download) def post_install(self): @@ -140,7 +143,10 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='superpowers*', desktop_filename="superpowers.desktop", required_files_path=["Superpowers"], - json=True, **kwargs) + json=True, + version_regex='/v(\d+\.\d+\.\d+)', + supports_update=True, + **kwargs) arch_trans = { "amd64": "x64", @@ -165,6 +171,14 @@ def post_install(self): comment=self.description, categories="Development;IDE;")) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'version'), 'r') as file: + return file.readline().strip() if file else None + except FileNotFoundError: + return + class GDevelop(umake.frameworks.baseinstaller.BaseInstaller): diff --git a/umake/frameworks/go.py b/umake/frameworks/go.py index ad2ed43e..ee016839 100644 --- a/umake/frameworks/go.py +++ b/umake/frameworks/go.py @@ -49,6 +49,8 @@ def __init__(self, **kwargs): checksum_type=ChecksumType.sha256, dir_to_decompress_in_tarball="go", required_files_path=[os.path.join("bin", "go")], + version_regex=r'go(\d+(\.\d+)+)', + supports_update=True, **kwargs) arch_trans = { @@ -93,3 +95,11 @@ def post_install(self): add_env_to_user(self.name, {"PATH": {"value": os.path.join(self.install_path, "bin")}, "GOROOT": {"value": self.install_path, "keep": False}}) UI.delayed_display(DisplayMessage(self.RELOGIN_REQUIRE_MSG.format(self.name))) + + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'VERSION'), 'r') as file: + return re.search(r'go(\d+(\.\d+)+)', next(file)).group(1) if file else None + except FileNotFoundError: + return diff --git a/umake/frameworks/ide.py b/umake/frameworks/ide.py index 9cd59caa..2f5571da 100644 --- a/umake/frameworks/ide.py +++ b/umake/frameworks/ide.py @@ -20,6 +20,8 @@ """Generic IDE module.""" +import json +import subprocess from abc import ABCMeta, abstractmethod from contextlib import suppress from gettext import gettext as _ @@ -276,8 +278,19 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='pycharm-community-*', desktop_filename='jetbrains-pycharm-ce.desktop', icon_filename='pycharm.png', + version_regex=r'(\d+\.\d+)', **kwargs) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + version_not_formatted = data.get('dataDirectoryName') + return re.search(r'\d+\.\d+', version_not_formatted).group() if version_not_formatted else None + except FileNotFoundError: + return + class PyCharmEducational(BaseJetBrains): """The JetBrains PyCharm Educational Edition distribution.""" @@ -292,9 +305,21 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='pycharm-edu*', desktop_filename='jetbrains-pycharm-edu.desktop', icon_filename='pycharm.png', + version_regex=r'(\d+\.\d+)', **kwargs) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + version_not_formatted = data.get('dataDirectoryName') + return re.search(r'\d+\.\d+', version_not_formatted).group() if version_not_formatted else None + except FileNotFoundError: + return + + class PyCharmProfessional(BaseJetBrains): """The JetBrains PyCharm Professional Edition distribution.""" download_keyword = 'PCP' @@ -308,9 +333,21 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='pycharm-*', desktop_filename='jetbrains-pycharm.desktop', icon_filename='pycharm.png', + version_regex=r'(\d+\.\d+)', **kwargs) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + version_not_formatted = data.get('dataDirectoryName') + return re.search(r'\d+\.\d+', version_not_formatted).group() if version_not_formatted else None + except FileNotFoundError: + return + + class Idea(BaseJetBrains): """The JetBrains IntelliJ Idea Community Edition distribution.""" download_keyword = 'IIC' @@ -323,8 +360,19 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='idea-IC-*', desktop_filename='jetbrains-idea-ce.desktop', icon_filename='idea.png', + version_regex=r'(\d+\.\d+)', **kwargs) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + version_not_formatted = data.get('dataDirectoryName') + return re.search(r'\d+\.\d+', version_not_formatted).group() if version_not_formatted else None + except FileNotFoundError: + return + class IdeaUltimate(BaseJetBrains): """The JetBrains IntelliJ Idea Ultimate Edition distribution.""" @@ -338,8 +386,19 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='idea-IU-*', desktop_filename='jetbrains-idea.desktop', icon_filename='idea.png', + version_regex=r'(\d+\.\d+)', **kwargs) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + version_not_formatted = data.get('dataDirectoryName') + return re.search(r'\d+\.\d+', version_not_formatted).group() if version_not_formatted else None + except FileNotFoundError: + return + class RubyMine(BaseJetBrains): """The JetBrains RubyMine IDE""" @@ -369,8 +428,18 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='WebStorm-*', desktop_filename='jetbrains-webstorm.desktop', icon_filename='webstorm.svg', + version_regex=r'WebStorm-(\d+(\.\d+)+)', **kwargs) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + return data.get('version') + except FileNotFoundError: + return + class PhpStorm(BaseJetBrains): """The JetBrains PhpStorm IDE""" @@ -384,8 +453,18 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='PhpStorm-*', desktop_filename='jetbrains-phpstorm.desktop', icon_filename='phpstorm.png', + version_regex=r'-(\d+\.\d+\.\d+)', **kwargs) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + return data.get('version') + except FileNotFoundError: + return + class CLion(BaseJetBrains): """The JetBrains CLion IDE""" @@ -399,8 +478,18 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='clion-*', desktop_filename='jetbrains-clion.desktop', icon_filename='clion.svg', + version_regex=r'-(\d+\.\d+\.\d+)', **kwargs) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + return data.get('version') + except FileNotFoundError: + return + class DataGrip(BaseJetBrains): """The JetBrains DataGrip IDE""" @@ -414,8 +503,18 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='DataGrip-*', desktop_filename='jetbrains-datagrip.desktop', icon_filename='datagrip.png', + version_regex=r'-(\d+\.\d+\.\d+)', **kwargs) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + return data.get('version') + except FileNotFoundError: + return + class GoLand(BaseJetBrains): """The JetBrains GoLand IDE""" @@ -429,8 +528,18 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball='GoLand-*', desktop_filename='jetbrains-goland.desktop', icon_filename='goland.png', + version_regex=r'-(\d+\.\d+\.\d+)', **kwargs) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'product-info.json'), 'r') as file: + data = json.load(file) + return data.get('version') + except FileNotFoundError: + return + class Rider(BaseJetBrains): """The JetBrains cross-platform .NET IDE""" @@ -572,7 +681,10 @@ def __init__(self, **kwargs): desktop_filename="lighttable.desktop", required_files_path=["LightTable"], dir_to_decompress_in_tarball="lighttable-*", - json=True, **kwargs) + json=True, + version_regex=r'(\d+\.\d+)', + supports_update=True, + **kwargs) def parse_download_link(self, line, in_download): url = None @@ -592,6 +704,15 @@ def post_install(self): comment=_("LightTable code editor"), categories="Development;IDE;")) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'resources', 'app', 'package.json'), 'r') as file: + data = json.load(file) + return data.get('version') + except FileNotFoundError: + return + class Atom(umake.frameworks.baseinstaller.BaseInstaller): @@ -643,6 +764,8 @@ def __init__(self, **kwargs): desktop_filename="sublime-text.desktop", required_files_path=["sublime_text"], dir_to_decompress_in_tarball="sublime_text", + version_regex=r'_build_(\d+)', + supports_update=True, **kwargs) arch_trans = { @@ -668,6 +791,16 @@ def post_install(self): comment=_("Sophisticated text editor for code, markup and prose"), categories="Development;TextEditor;")) + @staticmethod + def get_current_user_version(install_path): + try: + command = f"{os.path.join(install_path, 'sublime_text')} --version" + result = subprocess.check_output(command, shell=True, text=True) + match = re.search(r'(\d+)', result) + return match.group(1) if match else None + except subprocess.CalledProcessError: + return + class SpringToolsSuite(umake.frameworks.baseinstaller.BaseInstaller): def __init__(self, **kwargs): @@ -718,7 +851,10 @@ def __init__(self, **kwargs): desktop_filename="processing.desktop", required_files_path=["processing"], dir_to_decompress_in_tarball="processing-*", - json=True, **kwargs) + json=True, + version_regex=r'(\d+\.\d+)', + supports_update=True, + **kwargs) arch_trans = { "amd64": "64", @@ -742,6 +878,16 @@ def post_install(self): comment=_("Processing is a flexible software sketchbook"), categories="Development;IDE;")) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'revisions.txt'), 'r') as file: + first_line = file.readline().strip() + match = re.search(r'(\d+\.\d+\.\d+)', first_line) + return match.group(1) if match else None + except FileNotFoundError: + return + class LiteIDE(umake.frameworks.baseinstaller.BaseInstaller): @@ -753,7 +899,10 @@ def __init__(self, **kwargs): desktop_filename="liteide.desktop", required_files_path=["bin/liteide"], dir_to_decompress_in_tarball="liteide", - json=True, **kwargs) + json=True, + version_regex=r'(\d+\.\d+)', + supports_update=True, + **kwargs) arch_trans = { "amd64": "64", @@ -778,6 +927,16 @@ def post_install(self): comment=_("LiteIDE is a simple, open source, cross-platform Go IDE."), categories="Development;IDE;")) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'README.md'), 'r') as file: + content = ''.join(file.readline() for _ in range(15)) + match = re.search(r'(\d+\.\d+)', content) + return match.group(1) if match else None + except FileNotFoundError: + return + class RStudio(umake.frameworks.baseinstaller.BaseInstaller): diff --git a/umake/frameworks/logic.py b/umake/frameworks/logic.py index 87e2fcd1..edca9b35 100644 --- a/umake/frameworks/logic.py +++ b/umake/frameworks/logic.py @@ -47,6 +47,7 @@ def __init__(self, **kwargs): dir_to_decompress_in_tarball="Protege-*", required_files_path=["protege"], desktop_filename="protege.desktop", + version_regex=r'/protege-(\d+\.\d+\.\d+)/', json=True, **kwargs) def parse_download_link(self, line, in_download): diff --git a/umake/frameworks/nodejs.py b/umake/frameworks/nodejs.py index 323dd584..2eac8cd7 100644 --- a/umake/frameworks/nodejs.py +++ b/umake/frameworks/nodejs.py @@ -19,7 +19,7 @@ """Nodejs module""" - +import subprocess from contextlib import suppress from gettext import gettext as _ import logging @@ -126,3 +126,13 @@ def run_for(self, args): if not args.remove: print('Download from {}'.format(self.download_page)) super().run_for(args) + + @staticmethod + def get_current_user_version(install_path): + try: + command = f"{os.path.join(install_path, 'bin', 'node')} --version" + result = subprocess.check_output(command, shell=True, text=True) + match = re.search(r'v(\d+\.\d+\.\d+)', result) + return match.group(1) if match else None + except subprocess.CalledProcessError: + return diff --git a/umake/frameworks/rust.py b/umake/frameworks/rust.py index f7fa4e58..cbb86487 100644 --- a/umake/frameworks/rust.py +++ b/umake/frameworks/rust.py @@ -49,6 +49,8 @@ def __init__(self, **kwargs): only_on_archs=['i386', 'amd64'], download_page="https://www.rust-lang.org/en-US/other-installers.html", dir_to_decompress_in_tarball="rust-*", + version_regex=r'rust-(\d+(\.\d+)+)', + supports_update=True, **kwargs) arch_trans = { "amd64": "x86_64", @@ -82,3 +84,11 @@ def post_install(self): os.path.join(arch_dest_lib_folder, f)) UI.delayed_display(DisplayMessage(self.RELOGIN_REQUIRE_MSG.format(self.name))) + + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'version'), 'r') as file: + return re.search(r'(\d+(\.\d+)+)', next(file)).group(1) if file else None + except FileNotFoundError: + return diff --git a/umake/frameworks/web.py b/umake/frameworks/web.py index cd8e0907..af5ac3f0 100644 --- a/umake/frameworks/web.py +++ b/umake/frameworks/web.py @@ -19,7 +19,7 @@ """Web module""" - +import subprocess from contextlib import suppress from functools import partial from gettext import gettext as _ @@ -150,6 +150,8 @@ def __init__(self, **kwargs): download_page="http://phantomjs.org/download.html", dir_to_decompress_in_tarball="phantomjs*", required_files_path=[os.path.join("bin", "phantomjs")], + version_regex=r'(\d+\.\d+)', + supports_update=True, **kwargs) arch_trans = { @@ -177,6 +179,16 @@ def post_install(self): add_env_to_user(self.name, {"PATH": {"value": os.path.join(self.install_path, "bin")}}) UI.delayed_display(DisplayMessage(self.RELOGIN_REQUIRE_MSG.format(self.name))) + @staticmethod + def get_current_user_version(install_path): + try: + with open(os.path.join(install_path, 'ChangeLog'), 'r') as file: + lines = ''.join(file.readline() for _ in range(3)) + match = re.search(r'(\d+\.\d+)', lines) + return match.group(1) if match else None + except FileNotFoundError: + return + class Geckodriver(umake.frameworks.baseinstaller.BaseInstaller): @@ -188,6 +200,8 @@ def __init__(self, **kwargs): download_page="https://api.github.com/repos/mozilla/geckodriver/releases/latest", dir_to_decompress_in_tarball=".", required_files_path=["geckodriver"], + version_regex=r'v(\d+\.\d+\.\d+)', + supports_update=True, json=True, **kwargs) arch_trans = { @@ -209,6 +223,18 @@ def post_install(self): add_env_to_user(self.name, {"PATH": {"value": os.path.join(self.install_path)}}) UI.delayed_display(DisplayMessage(self.RELOGIN_REQUIRE_MSG.format(self.name))) + @staticmethod + def get_current_user_version(install_path): + try: + command = f"{os.path.join(install_path, 'geckodriver')} --version" + result = subprocess.check_output(command, shell=True, text=True) + first_line = result.split('\n')[0] + match = re.search(r'geckodriver\s+(\d+\.\d+\.\d+)', first_line) + return match.group(1) if match else None + except subprocess.CalledProcessError: + return + + class Chromedriver(umake.frameworks.baseinstaller.BaseInstaller): @@ -218,6 +244,8 @@ def __init__(self, **kwargs): download_page="https://chromedriver.storage.googleapis.com/LATEST_RELEASE", dir_to_decompress_in_tarball=".", required_files_path=["chromedriver"], + version_regex=r'/(\d+\.\d+\.\d+\.\d+)', + supports_update=True, **kwargs) def parse_download_link(self, line, in_download): @@ -231,3 +259,13 @@ def post_install(self): """Add Chromedriver necessary env variables""" add_env_to_user(self.name, {"PATH": {"value": os.path.join(self.install_path)}}) UI.delayed_display(DisplayMessage(self.RELOGIN_REQUIRE_MSG.format(self.name))) + + @staticmethod + def get_current_user_version(install_path): + try: + command = f"{os.path.join(install_path, 'chromedriver')} --version" + result = subprocess.check_output(command, shell=True, text=True) + match = re.search(r'ChromeDriver\s+([\d.]+)', result) + return match.group(1) if match else None + except subprocess.CalledProcessError: + return diff --git a/umake/network/download_center.py b/umake/network/download_center.py index 4b4dd8fa..dfa5de36 100644 --- a/umake/network/download_center.py +++ b/umake/network/download_center.py @@ -206,6 +206,7 @@ def _done(self): """ logger.info("All pending downloads for {} done".format(self._urls)) self._done_callback(self._downloaded_content) + self._wired_report('all downloads finished') @classmethod def _checksum_for_fd(cls, algorithm, f, block_size=2 ** 20): diff --git a/umake/ui/cli/__init__.py b/umake/ui/cli/__init__.py index f28fa23d..6fa744f6 100644 --- a/umake/ui/cli/__init__.py +++ b/umake/ui/cli/__init__.py @@ -19,6 +19,7 @@ """Module for loading the command line interface""" +import threading import argcomplete from contextlib import suppress from gettext import gettext as _ @@ -28,6 +29,7 @@ import readline import sys from umake.interactions import InputText, TextWithChoices, LicenseAgreement, DisplayMessage, UnknownProgress +from umake.network.download_center import DownloadItem, DownloadCenter from umake.ui import UI from umake.frameworks import BaseCategory, list_frameworks from umake.tools import InputError, MainLoop @@ -118,6 +120,9 @@ def mangle_args_for_default_framework(args): if not category_name and arg in ("--remove", "-r"): args_to_append.append(arg) continue + if arg in ("--assume-yes", '-y'): + args_to_append.append(arg) + continue if not arg.startswith('-') and not skip_all: if not category_name: if arg in BaseCategory.categories.keys(): @@ -220,6 +225,53 @@ def get_frameworks_list_output(args): return print_result +def is_first_version_higher(version1, version2): + if version2 is None: + return True + elif version1 is None: + return False + + v1_parts = list(map(int, version1.split('.'))) + v2_parts = list(map(int, version2.split('.'))) + for v1, v2 in zip(v1_parts, v2_parts): + if v1 > v2: + return True + elif v1 < v2: + return False + return len(v1_parts) > len(v2_parts) + + +def pretty_print_versions(data): + max_name_length = max(len(item['framework_name']) for item in data) + max_version_length = max(len(item['latest_version']) for item in data) + supports_color = os.getenv('TERM') and os.getenv('TERM') != 'dumb' + + reset_color = '' + if supports_color: + reset_color = '\033[m' + + for item in data: + latest_version = item['latest_version'] + user_version = item['user_version'] + latest_version_color = '' + user_version_color = '' + symbol = '+' + if supports_color: + latest_version_color = '\033[32m' + user_version_color = '\033[31m' + + latest_version_formatted = f"{latest_version_color}{latest_version}{reset_color}" + latest_version_padding = len(latest_version_formatted) + latest_version_formatted = latest_version_formatted.ljust( + latest_version_padding - + len(latest_version) + + max_version_length + ) + print(f"{item['framework_name'].ljust(max_name_length)} | " + f"Latest Version: {latest_version_formatted} | " + f"User Version: {user_version_color}{user_version} {symbol}{reset_color}") + + def main(parser): """Main entry point of the cli command""" categories_parser = parser.add_subparsers(help='Developer environment', dest="category") @@ -234,6 +286,7 @@ def main(parser): # manipulate sys.argv for default frameworks: arg_to_parse = mangle_args_for_default_framework(arg_to_parse) args = parser.parse_args(arg_to_parse) + assume_yes = args.assume_yes if args.list or args.list_installed or args.list_available: print(get_frameworks_list_output(args)) @@ -243,6 +296,54 @@ def main(parser): print(get_version()) sys.exit(0) + if args.update: + frameworks = list_frameworks() + installed_frameworks = sorted([ + {'framework_name': framework['framework_name'], + 'install_path': framework['install_path'], + 'category_name': category['category_name']} + for category in frameworks + for framework in category['frameworks'] if framework['is_installed'] + ], key=lambda x: x['framework_name']) + outdated_frameworks = [] + for installed_framework in installed_frameworks: + category_name = installed_framework['category_name'] + framework_name = installed_framework['framework_name'] + if category_name == 'java' or framework_name == 'firefox-dev': + continue + install_path = installed_framework['install_path'] + framework = BaseCategory.categories[category_name].frameworks[framework_name] + if framework.supports_update: + fetch_package_url = threading.Event() + DownloadCenter([DownloadItem(framework.download_page)], framework.store_package_url, + download=False, + report=lambda arg: fetch_package_url.set() if arg == 'all downloads finished' else None) + fetch_package_url.wait() + user_version = framework.get_current_user_version(install_path) + latest_version = framework.get_latest_version() + is_outdated = is_first_version_higher(latest_version, user_version) \ + if (latest_version is not None and user_version is not None) else False + if is_outdated: + outdated_frameworks.append({ + 'framework_name': framework_name, + 'category_name': category_name, + 'user_version': user_version, + 'latest_version': latest_version, + 'is_outdated': is_outdated, + }) + if len(outdated_frameworks) == 0: + print('All packages are up-to-date.') + sys.exit(0) + else: + pretty_print_versions(outdated_frameworks) + for outdated_framework in outdated_frameworks: + args = parser.parse_args([outdated_framework['category_name'], outdated_framework['framework_name']]) + args.assume_yes = assume_yes + CliUI() + run_command_for_args(args) + return + sys.exit(0) + if not args.category: parser.print_help() sys.exit(0)