From 72023030b4437caa9760a143ca09605b3445ad95 Mon Sep 17 00:00:00 2001 From: Julien Date: Fri, 25 Nov 2022 22:03:46 +0100 Subject: [PATCH 01/26] Add wip --- .../jobs/job_plugins_synchronizer.py | 185 ++++++++++++++++++ qgis_deployment_toolbelt/jobs/orchestrator.py | 2 + .../plugins/downloader.py | 27 +++ 3 files changed, 214 insertions(+) create mode 100644 qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py create mode 100644 qgis_deployment_toolbelt/plugins/downloader.py diff --git a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py new file mode 100644 index 00000000..ee3db05d --- /dev/null +++ b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py @@ -0,0 +1,185 @@ +#! python3 # noqa: E265 + +""" + Manage plugins listed into profiles. + + Author: Julien Moura (https://github.com/guts) +""" + + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import logging +from configparser import ConfigParser +from pathlib import Path +from sys import platform as opersys + +# package +from qgis_deployment_toolbelt.constants import OS_CONFIG +from qgis_deployment_toolbelt.profiles.qdt_profile import QdtProfile + +# ############################################################################# +# ########## Globals ############### +# ################################## + +# logs +logger = logging.getLogger(__name__) + + +# ############################################################################# +# ########## Classes ############### +# ################################## + + +class JobPluginsManager: + """ + Job to download and synchronize plugins. + """ + + ID: str = "plugins-manager" + OPTIONS_SCHEMA: dict = { + "action": { + "type": str, + "required": False, + "default": "create_or_restore", + "possible_values": ("create", "create_or_restore", "remove"), + "condition": "in", + } + } + + def __init__(self, options: dict) -> None: + """Instantiate the class. + + :param dict options: profiles source (remote, can be a local network) and + destination (local). + """ + self.options: dict = self.validate_options(options) + + # profile folder + if opersys not in OS_CONFIG: + raise OSError( + f"Your operating system {opersys} is not supported. " + f"Supported platforms: {','.join(OS_CONFIG.keys())}." + ) + self.qgis_profiles_path: Path = Path(OS_CONFIG.get(opersys).profiles_path) + if not self.qgis_profiles_path.exists(): + logger.warning( + f"QGIS profiles folder not found: {self.qgis_profiles_path}. " + "Creating it to properly run the job." + ) + self.qgis_profiles_path.mkdir(parents=True) + + def run(self) -> None: + """Execute job logic.""" + li_installed_profiles_path = [ + d + for d in self.qgis_profiles_path.iterdir() + if d.is_dir() and not d.name.startswith(".") + ] + + if self.options.get("action") in ("create", "create_or_restore"): + for profile_dir in li_installed_profiles_path: + + # default absolute splash screen path + splash_screen_filepath = profile_dir / self.DEFAULT_SPLASH_FILEPATH + + # target QGIS configuration files + cfg_qgis_base = profile_dir / "QGIS/QGIS3.ini" + cfg_qgis_custom = profile_dir / "QGIS/QGISCUSTOMIZATION3.ini" + + # case where splash image is specified into the profile.json + if Path(profile_dir / "profile.json").is_file(): + qdt_profile = QdtProfile.from_json( + profile_json_path=Path(profile_dir / "profile.json"), + profile_folder=profile_dir.resolve(), + ) + + # if the splash image referenced into the profile.json exists, make + # sure it complies QGIS splash screen rules + if ( + isinstance(qdt_profile.splash, Path) + and qdt_profile.splash.is_file() + ): + # make sure that the filename complies with what QGIS expects + if qdt_profile.splash.name != splash_screen_filepath.name: + splash_filepath = qdt_profile.splash.with_name( + self.SPLASH_FILENAME + ) + qdt_profile.splash.replace(splash_filepath) + logger.debug( + f"Specified splash screen renamed into {splash_filepath}." + ) + else: + # homogeneize filepath var name + logger.debug( + f"Splash screen already exists at {splash_screen_filepath}" + ) + else: + logger.debug(f"No profile.json found for profile '{profile_dir}") + + # now, splash screen image should be at {profile_dir}/images/splash.png + if not splash_screen_filepath.is_file(): + # TODO: check image size to fit QGIS restrictions + logger.debug( + f"No splash screen found or defined for profile {profile_dir.name}" + ) + continue + else: + raise NotImplementedError + + logger.debug(f"Job {self.ID} ran successfully.") + + # -- INTERNAL LOGIC ------------------------------------------------------ + def validate_options(self, options: dict) -> bool: + """Validate options. + + :param dict options: options to validate. + :return bool: True if options are valid. + """ + for option in options: + if option not in self.OPTIONS_SCHEMA: + raise Exception( + f"Job: {self.ID}. Option '{option}' is not valid." + f" Valid options are: {self.OPTIONS_SCHEMA.keys()}" + ) + + option_in = options.get(option) + option_def: dict = self.OPTIONS_SCHEMA.get(option) + # check value type + if not isinstance(option_in, option_def.get("type")): + raise Exception( + f"Job: {self.ID}. Option '{option}' has an invalid value." + f"\nExpected {option_def.get('type')}, got {type(option_in)}" + ) + # check value condition + if option_def.get("condition") == "startswith" and not option_in.startswith( + option_def.get("possible_values") + ): + raise Exception( + f"Job: {self.ID}. Option '{option}' has an invalid value." + "\nExpected: starts with one of: " + f"{', '.join(option_def.get('possible_values'))}" + ) + elif option_def.get( + "condition" + ) == "in" and option_in not in option_def.get("possible_values"): + raise Exception( + f"Job: {self.ID}. Option '{option}' has an invalid value." + f"\nExpected: one of: {', '.join(option_def.get('possible_values'))}" + ) + else: + pass + + return options + + +# ############################################################################# +# ##### Stand alone program ######## +# ################################## + +if __name__ == "__main__": + """Standalone execution.""" + pass diff --git a/qgis_deployment_toolbelt/jobs/orchestrator.py b/qgis_deployment_toolbelt/jobs/orchestrator.py index affa41a3..d5d387e1 100644 --- a/qgis_deployment_toolbelt/jobs/orchestrator.py +++ b/qgis_deployment_toolbelt/jobs/orchestrator.py @@ -20,6 +20,7 @@ from qgis_deployment_toolbelt.jobs.job_environment_variables import ( JobEnvironmentVariables, ) +from qgis_deployment_toolbelt.jobs.job_plugins_synchronizer import JobPluginsManager from qgis_deployment_toolbelt.jobs.job_profiles_synchronizer import ( JobProfilesDownloader, ) @@ -43,6 +44,7 @@ class JobsOrchestrator: JOBS = ( JobEnvironmentVariables, + JobPluginsManager, JobProfilesDownloader, JobShortcutsManager, JobSplashScreenManager, diff --git a/qgis_deployment_toolbelt/plugins/downloader.py b/qgis_deployment_toolbelt/plugins/downloader.py new file mode 100644 index 00000000..17aebd8d --- /dev/null +++ b/qgis_deployment_toolbelt/plugins/downloader.py @@ -0,0 +1,27 @@ +import concurrent.futures +import io +import zipfile + +import requests + +urls = [ + "http://mlg.ucd.ie/files/datasets/multiview_data_20130124.zip", + "http://mlg.ucd.ie/files/datasets/movielists_20130821.zip", + "http://mlg.ucd.ie/files/datasets/bbcsport.zip", + "http://mlg.ucd.ie/files/datasets/movielists_20130821.zip", + "http://mlg.ucd.ie/files/datasets/3sources.zip", +] + + +def download_zips(url): + file_name = url.split("/")[-1] + response = requests.get(url) + sourceZip = zipfile.ZipFile(io.BytesIO(response.content)) + print("\n Downloaded {} ".format(file_name)) + sourceZip.extractall(filePath) + print("extracted {} \n".format(file_name)) + sourceZip.close() + + +with concurrent.futures.ThreadPoolExecutor() as exector: + exector.map(download_zip, urls) From 51a78bd0e90b08be0a02aa40df3c98beeda8c063 Mon Sep 17 00:00:00 2001 From: Julien M Date: Mon, 16 Jan 2023 16:57:02 +0100 Subject: [PATCH 02/26] Add abstraction class for QGIS plugin --- qgis_deployment_toolbelt/plugins/__init__.py | 0 qgis_deployment_toolbelt/plugins/plugin.py | 119 +++++++++++++++++++ tests/test_qplugin_object.py | 62 ++++++++++ 3 files changed, 181 insertions(+) create mode 100644 qgis_deployment_toolbelt/plugins/__init__.py create mode 100644 qgis_deployment_toolbelt/plugins/plugin.py create mode 100644 tests/test_qplugin_object.py diff --git a/qgis_deployment_toolbelt/plugins/__init__.py b/qgis_deployment_toolbelt/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/qgis_deployment_toolbelt/plugins/plugin.py b/qgis_deployment_toolbelt/plugins/plugin.py new file mode 100644 index 00000000..541fd1ae --- /dev/null +++ b/qgis_deployment_toolbelt/plugins/plugin.py @@ -0,0 +1,119 @@ +#! python3 # noqa: E265 + +""" + Plugin object model. + + Author: Julien Moura (https://github.com/guts) +""" + + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import logging +from dataclasses import dataclass +from enum import Enum +from sys import version_info + +# Imports depending on Python version +if version_info[1] < 11: + from typing_extensions import Self +else: + from typing import Self + +# ############################################################################# +# ########## Globals ############### +# ################################## + +# logs +logger = logging.getLogger(__name__) + +# ############################################################################# +# ########## Classes ############### +# ################################## + + +class QgisPluginLocation(Enum): + local = 1 + remote = 2 + + +@dataclass +class QgisPlugin: + """Model describing a QGIS plugin.""" + + # optional mapping on attributes names. + # {attribute_name_in_output_object: attribute_name_from_input_file} + ATTR_MAP = { + "location": "type", + } + + OFFICIAL_REPOSITORY_URL_BASE = "https://plugins.qgis.org/" + OFFICIAL_REPOSITORY_XML = "https://plugins.qgis.org/plugins/plugins.xml" + + name: str = None + version: str = "latest" + location: QgisPluginLocation = "remote" + url: str = None + repository_url_xml: str = None + official_repository: bool = None + + @classmethod + def from_dict(cls, input_dict: dict) -> Self: + # map attributes names + for k, v in cls.ATTR_MAP.items(): + if v in input_dict.keys(): + input_dict[k] = input_dict.pop(v, None) + + # official repository autodetection + if input_dict.get("repository_url_xml") == cls.OFFICIAL_REPOSITORY_XML: + input_dict["official_repository"] = True + elif input_dict.get("url") and input_dict.get("url").startswith( + cls.OFFICIAL_REPOSITORY_URL_BASE + ): + input_dict["official_repository"] = True + else: + pass + + # URL auto build + if input_dict.get("official_repository") is True: + input_dict["url"] = ( + f"{cls.OFFICIAL_REPOSITORY_URL_BASE}/" + f"plugins/{input_dict.get('name')}/{input_dict.get('version')}/download" + ) + input_dict["repository_url_xml"] = cls.OFFICIAL_REPOSITORY_XML + + # return new instance with loaded object + return cls( + **input_dict, + ) + + +# ############################################################################# +# ##### Stand alone program ######## +# ################################## + +if __name__ == "__main__": + """Standalone execution.""" + sample_plugin_complete = { + "name": "french_locator_filter", + "version": "1.0.4", + "url": "https://plugins.qgis.org/plugins/french_locator_filter/version/1.0.4/download/", + "type": "remote", + } + + plugin_obj_one = QgisPlugin.from_dict(sample_plugin_complete) + print(plugin_obj_one) + + sample_plugin_incomplete = { + "name": "french_locator_filter", + "version": "1.0.4", + "official_repository": True, + } + + plugin_obj_two = QgisPlugin.from_dict(sample_plugin_incomplete) + print(plugin_obj_two) + + assert plugin_obj_one == plugin_obj_two diff --git a/tests/test_qplugin_object.py b/tests/test_qplugin_object.py new file mode 100644 index 00000000..1ec2c10e --- /dev/null +++ b/tests/test_qplugin_object.py @@ -0,0 +1,62 @@ +#! python3 # noqa E265 + +""" + Usage from the repo root folder: + + .. code-block:: bash + # for whole tests + python -m unittest tests.test_qplugin_object + # for specific test + python -m unittest tests.test_qplugin_object.TestQgisPluginObject.test_profile_load_from_json_basic +""" + +# standard +import unittest +from pathlib import Path + +# project +from qgis_deployment_toolbelt.plugins.plugin import QgisPlugin + +# ############################################################################ +# ########## Classes ############# +# ################################ + + +class TestQgisPluginObject(unittest.TestCase): + """Test QGIS Plugin abstraction class.""" + + # -- Standard methods -------------------------------------------------------- + @classmethod + def setUpClass(cls): + """Executed when module is loaded before any test.""" + cls.good_profiles_files = sorted( + Path("tests/fixtures/").glob("profiles/good_*.json") + ) + + def test_qplugin_load_from_dict(self): + """Test profile loading from JSON.""" + sample_plugin_complete = { + "name": "french_locator_filter", + "version": "1.0.4", + "url": "https://plugins.qgis.org/plugins/french_locator_filter/version/1.0.4/download/", + "type": "remote", + } + + plugin_obj_one = QgisPlugin.from_dict(sample_plugin_complete) + + sample_plugin_incomplete = { + "name": "french_locator_filter", + "version": "1.0.4", + "official_repository": True, + } + + plugin_obj_two = QgisPlugin.from_dict(sample_plugin_incomplete) + + self.assertEqual(plugin_obj_one, plugin_obj_two) + + +# ############################################################################ +# ####### Stand-alone run ######## +# ################################ +if __name__ == "__main__": + unittest.main() From faa8fb4301ca3b0c02b37be5509d308bff02ee77 Mon Sep 17 00:00:00 2001 From: Julien M Date: Mon, 16 Jan 2023 16:58:46 +0100 Subject: [PATCH 03/26] Use plugin object --- qgis_deployment_toolbelt/profiles/qdt_profile.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/qgis_deployment_toolbelt/profiles/qdt_profile.py b/qgis_deployment_toolbelt/profiles/qdt_profile.py index 51054b5f..b7df0976 100644 --- a/qgis_deployment_toolbelt/profiles/qdt_profile.py +++ b/qgis_deployment_toolbelt/profiles/qdt_profile.py @@ -27,6 +27,7 @@ # Package from qgis_deployment_toolbelt.constants import OS_CONFIG +from qgis_deployment_toolbelt.plugins.plugin import QgisPlugin from qgis_deployment_toolbelt.utils.check_path import check_path # ############################################################################# @@ -43,6 +44,8 @@ class QdtProfile: """Object definition for QGIS Profile handled by QDT.""" + # optional mapping on attributes names. + # {attribute_name_in_output_object: attribute_name_from_input_file} ATTR_MAP = { "qgis_maximum_version": "qgisMaximumVersion", "qgis_minimum_version": "qgisMinimumVersion", @@ -230,6 +233,14 @@ def name(self) -> Path: """ return self._name + @property + def plugins(self) -> list[dict]: + """Returns the plugins associated with the profile. + + :return list[dict]: list of plugins + """ + return [QgisPlugin.from_dict(p) for p in self._plugins] + @property def splash(self) -> Union[str, Path]: """Returns the profile splash image as path if can be resolved or as string. From 859f7bc22a7c86e7f2aa0f7badb05b596d2d6f00 Mon Sep 17 00:00:00 2001 From: Julien M Date: Mon, 16 Jan 2023 16:58:59 +0100 Subject: [PATCH 04/26] Cleaning up --- .../jobs/job_plugins_synchronizer.py | 42 ++++--------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py index ee3db05d..fb027850 100644 --- a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py +++ b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py @@ -39,7 +39,7 @@ class JobPluginsManager: Job to download and synchronize plugins. """ - ID: str = "plugins-manager" + ID: str = "qplugins-manager" OPTIONS_SCHEMA: dict = { "action": { "type": str, @@ -83,13 +83,6 @@ def run(self) -> None: if self.options.get("action") in ("create", "create_or_restore"): for profile_dir in li_installed_profiles_path: - # default absolute splash screen path - splash_screen_filepath = profile_dir / self.DEFAULT_SPLASH_FILEPATH - - # target QGIS configuration files - cfg_qgis_base = profile_dir / "QGIS/QGIS3.ini" - cfg_qgis_custom = profile_dir / "QGIS/QGISCUSTOMIZATION3.ini" - # case where splash image is specified into the profile.json if Path(profile_dir / "profile.json").is_file(): qdt_profile = QdtProfile.from_json( @@ -97,36 +90,15 @@ def run(self) -> None: profile_folder=profile_dir.resolve(), ) - # if the splash image referenced into the profile.json exists, make - # sure it complies QGIS splash screen rules - if ( - isinstance(qdt_profile.splash, Path) - and qdt_profile.splash.is_file() - ): - # make sure that the filename complies with what QGIS expects - if qdt_profile.splash.name != splash_screen_filepath.name: - splash_filepath = qdt_profile.splash.with_name( - self.SPLASH_FILENAME - ) - qdt_profile.splash.replace(splash_filepath) - logger.debug( - f"Specified splash screen renamed into {splash_filepath}." - ) - else: - # homogeneize filepath var name - logger.debug( - f"Splash screen already exists at {splash_screen_filepath}" - ) + # plugins + profile_plugins_dir = profile_dir / "python/plugins" + + print(qdt_profile.plugins) + else: logger.debug(f"No profile.json found for profile '{profile_dir}") - - # now, splash screen image should be at {profile_dir}/images/splash.png - if not splash_screen_filepath.is_file(): - # TODO: check image size to fit QGIS restrictions - logger.debug( - f"No splash screen found or defined for profile {profile_dir.name}" - ) continue + else: raise NotImplementedError From f7cdf9e9e61be10ef2f4ff08ac43e7e3aef95290 Mon Sep 17 00:00:00 2001 From: Julien M Date: Mon, 16 Jan 2023 19:42:44 +0100 Subject: [PATCH 05/26] Replace downloader by file downloader --- .../plugins/downloader.py | 27 ------------------- .../utils/file_downloader.py | 2 +- 2 files changed, 1 insertion(+), 28 deletions(-) delete mode 100644 qgis_deployment_toolbelt/plugins/downloader.py diff --git a/qgis_deployment_toolbelt/plugins/downloader.py b/qgis_deployment_toolbelt/plugins/downloader.py deleted file mode 100644 index 17aebd8d..00000000 --- a/qgis_deployment_toolbelt/plugins/downloader.py +++ /dev/null @@ -1,27 +0,0 @@ -import concurrent.futures -import io -import zipfile - -import requests - -urls = [ - "http://mlg.ucd.ie/files/datasets/multiview_data_20130124.zip", - "http://mlg.ucd.ie/files/datasets/movielists_20130821.zip", - "http://mlg.ucd.ie/files/datasets/bbcsport.zip", - "http://mlg.ucd.ie/files/datasets/movielists_20130821.zip", - "http://mlg.ucd.ie/files/datasets/3sources.zip", -] - - -def download_zips(url): - file_name = url.split("/")[-1] - response = requests.get(url) - sourceZip = zipfile.ZipFile(io.BytesIO(response.content)) - print("\n Downloaded {} ".format(file_name)) - sourceZip.extractall(filePath) - print("extracted {} \n".format(file_name)) - sourceZip.close() - - -with concurrent.futures.ThreadPoolExecutor() as exector: - exector.map(download_zip, urls) diff --git a/qgis_deployment_toolbelt/utils/file_downloader.py b/qgis_deployment_toolbelt/utils/file_downloader.py index 59ac6e44..78ba4b6e 100644 --- a/qgis_deployment_toolbelt/utils/file_downloader.py +++ b/qgis_deployment_toolbelt/utils/file_downloader.py @@ -59,7 +59,7 @@ def download_remote_file_to_local( chunk_size (int): size of each chunk to read and write in bytes. Returns: - Path: path to the local index file (should be the same as local_file_path) + Path: path to the local file (should be the same as local_file_path) """ # content search index if local_file_path.exists(): From 65cf03d08a743eff667bfca882d8b0234050a340 Mon Sep 17 00:00:00 2001 From: Julien M Date: Mon, 16 Jan 2023 19:42:58 +0100 Subject: [PATCH 06/26] Add util to retrieve QDT working folder --- qgis_deployment_toolbelt/constants.py | 42 +++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/qgis_deployment_toolbelt/constants.py b/qgis_deployment_toolbelt/constants.py index 34313737..8b0e25b6 100644 --- a/qgis_deployment_toolbelt/constants.py +++ b/qgis_deployment_toolbelt/constants.py @@ -14,8 +14,9 @@ # Standard library import logging from dataclasses import dataclass -from os import getenv -from os.path import expandvars +from functools import lru_cache +from os import PathLike, getenv +from os.path import expanduser, expandvars from pathlib import Path from typing import Tuple @@ -26,6 +27,43 @@ # logs logger = logging.getLogger(__name__) +# ############################################################################# +# ########## Functions ############# +# ################################## + + +@lru_cache(maxsize=128) +def get_qdt_working_directory( + specific_value: PathLike = None, identifier: str = "default" +) -> Path: + """Get QDT working directory. + + Args: + specific_value (PathLike, optional): a specific path to use. If set it's \ + expanded and returned. Defaults to None. + identifier (str, optional): used to make the folder unique. If not set, \ + 'default' (sure, not so unique...) is used. Defaults to None. + + Returns: + Path: path to the QDT working directory + """ + if specific_value: + return Path(expandvars(expanduser(specific_value))) + elif getenv("QDT_PROFILES_PATH"): + return Path(expandvars(expanduser(getenv("QDT_PROFILES_PATH")))) + else: + return Path( + expandvars( + expanduser( + getenv( + "LOCAL_QDT_WORKDIR", + f"~/.cache/qgis-deployment-toolbelt/{identifier}", + ), + ) + ) + ) + + # ############################################################################# # ########## Classes ############### # ################################## From 54c19f26d82389bad5e821086355e7ccc53c8ca0 Mon Sep 17 00:00:00 2001 From: Julien M Date: Mon, 16 Jan 2023 19:44:01 +0100 Subject: [PATCH 07/26] print local woking directory --- qgis_deployment_toolbelt/cli.py | 9 +++++---- scenario.qdt.yml | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/qgis_deployment_toolbelt/cli.py b/qgis_deployment_toolbelt/cli.py index 08e40b77..523a21ec 100644 --- a/qgis_deployment_toolbelt/cli.py +++ b/qgis_deployment_toolbelt/cli.py @@ -20,6 +20,7 @@ # submodules from qgis_deployment_toolbelt.__about__ import __version__ from qgis_deployment_toolbelt.commands import cli_check, cli_clean, cli_upgrade +from qgis_deployment_toolbelt.constants import get_qdt_working_directory from qgis_deployment_toolbelt.jobs import JobsOrchestrator from qgis_deployment_toolbelt.scenarios import ScenarioReader from qgis_deployment_toolbelt.utils.bouncer import exit_cli_error, exit_cli_normal @@ -123,10 +124,6 @@ def qgis_deployment_toolbelt( if result_scenario_validity is not None: exit_cli_error(result_scenario_validity) - # let's be clear or not - if clear: - click.clear() - # -- LOG/VERBOSITY MANAGEMENT ------------------------------------------------------ # if verbose, override conf value if verbose: @@ -172,6 +169,10 @@ def qgis_deployment_toolbelt( else: logger.debug(f"Ignored None value: {var}.") + logger.info( + f"QDT working folder: {get_qdt_working_directory(specific_value=scenario.settings.get('LOCAL_QDT_WORKDIR'), identifier=scenario.metadata.get('id'))}" + ) + # -- STEPS JOBS steps_ok = [] orchestrator = JobsOrchestrator() diff --git a/scenario.qdt.yml b/scenario.qdt.yml index cd9da2ad..a9e42651 100644 --- a/scenario.qdt.yml +++ b/scenario.qdt.yml @@ -10,6 +10,7 @@ metadata: # Toolbelt settings settings: + LOCAL_QDT_WORKDIR: ~/.cache/qgis-deployment-toolbelt/Oslandia/ SCENARIO_VALIDATION: true # Deployment workflow, step by step From bfab1e6dab0bc835085b07469a16695a45c54069 Mon Sep 17 00:00:00 2001 From: Julien M Date: Mon, 16 Jan 2023 19:44:09 +0100 Subject: [PATCH 08/26] WIP plugins manager --- .../jobs/job_plugins_synchronizer.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py index fb027850..fe6422e6 100644 --- a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py +++ b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py @@ -13,13 +13,17 @@ # Standard library import logging +from concurrent.futures import ThreadPoolExecutor from configparser import ConfigParser +from os import getenv from pathlib import Path from sys import platform as opersys # package -from qgis_deployment_toolbelt.constants import OS_CONFIG +from qgis_deployment_toolbelt.__about__ import __title_clean__ +from qgis_deployment_toolbelt.constants import OS_CONFIG, get_qdt_working_directory from qgis_deployment_toolbelt.profiles.qdt_profile import QdtProfile +from qgis_deployment_toolbelt.utils.file_downloader import download_remote_file_to_local # ############################################################################# # ########## Globals ############### @@ -58,6 +62,10 @@ def __init__(self, options: dict) -> None: """ self.options: dict = self.validate_options(options) + # local QDT folder + self.qdt_working_folder = get_qdt_working_directory() + logger.debug(f"Working folder: {self.qdt_working_folder}") + # profile folder if opersys not in OS_CONFIG: raise OSError( @@ -93,7 +101,20 @@ def run(self) -> None: # plugins profile_plugins_dir = profile_dir / "python/plugins" - print(qdt_profile.plugins) + with ThreadPoolExecutor( + max_workers=5, thread_name_prefix=f"{__title_clean__}" + ) as executor: + for plugin in qdt_profile.plugins: + # local path + + # submit download to pool + executor.submit( + # func to execute + download_remote_file_to_local, + # func parameters + remote_url_to_download=plugin.url, + content_type="application/zip", + ) else: logger.debug(f"No profile.json found for profile '{profile_dir}") From aa6dfe12349b9f6ce5316bde389cf5c7eb2cf635 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 14:45:20 +0100 Subject: [PATCH 09/26] Add doc about new job --- docs/jobs/index.md | 1 + docs/jobs/plugins_manager.md | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 docs/jobs/plugins_manager.md diff --git a/docs/jobs/index.md b/docs/jobs/index.md index 23162455..5f677cf0 100644 --- a/docs/jobs/index.md +++ b/docs/jobs/index.md @@ -5,5 +5,6 @@ caption: Jobs maxdepth: 1 --- +plugins_manager.md splash_screen_manager.md ``` diff --git a/docs/jobs/plugins_manager.md b/docs/jobs/plugins_manager.md new file mode 100644 index 00000000..6e28c30a --- /dev/null +++ b/docs/jobs/plugins_manager.md @@ -0,0 +1,87 @@ +# Plugins manager + +This job download plugins into QDT local folder and synchronize them with installed profiles. + +---- + +## Use it + +Sample job configuration in your scenario file: + +```yaml + - name: Synchronize plugins + uses: qplugins-manager + with: + action: create_or_restore +``` + +---- + +## Options + +### action + +Tell the job what to do with splash screens: + +Possible_values: + +- `create`: add splash screen if not set +- `create_or_restore`: add splash screen if not set and replace eventual existing one +- `remove`: remove splash screen + +---- + +## How does it work + +### Specify the file to use in the `profile.json` + +Add the image file to the profile folder and specify the relative filepath under the `splash` attribute: + +```json +{ + [...] + "plugins": [ + { + "name": "french_locator_filter", + "version": "1.0.4", + "official_repository": true, + }, + { + "name": "pg_metadata", + "version": "1.2.1", + "url": "https://plugins.qgis.org/plugins/pg_metadata/version/1.2.1/download/", + "location": "remote", + "official_repository": true, + }, + { + "name": "Geotuileur", + "version": "1.0.0", + "url": "https://oslandia.gitlab.io/qgis/ign-geotuileur/geotuileur.1.0.0.zip", + "location": "remote", + "official_repository": false, + "repository_url_xml": "https://oslandia.gitlab.io/qgis/ign-geotuileur/plugins.xml" + } + [...] +} +``` + +### Store the image file under the default path + +If the path is not specified into the `profile.json`, the job looks for the default filepath `images/splash.png`. If the file exists, it will be used as splash screen image. + +### Workflow + +1. Create a subfolder `plugins` into the local QDT working directory. Default: `~/.cache/qgis-deployment-toolbelt/plugins` +1. Parse profiles downloaded by QDT (not the installed) +1. Create an unified list of used plugins +1. Download, if not already existing, every plugin into the plugins subfolder with this structure: `plugins/{plugin-name-slufigied}/{plugin-version}/{plugin-name-version}.zip` +1. Unzip plugins in installed profiles + +```mermaid +graph TD + A[Christmas] -->|Get money| B(Go shopping) + B --> C{Let me think} + C -->|One| D[Laptop] + C -->|Two| E[iPhone] + C -->|Three| F[fa:fa-car Car] +``` From 1a9c240d7a67f7f72520435464854781bed6d4c9 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 14:45:39 +0100 Subject: [PATCH 10/26] clean up --- qgis_deployment_toolbelt/commands/cli_upgrade.py | 1 - 1 file changed, 1 deletion(-) diff --git a/qgis_deployment_toolbelt/commands/cli_upgrade.py b/qgis_deployment_toolbelt/commands/cli_upgrade.py index 8e83c521..94a3509f 100644 --- a/qgis_deployment_toolbelt/commands/cli_upgrade.py +++ b/qgis_deployment_toolbelt/commands/cli_upgrade.py @@ -31,7 +31,6 @@ exit_cli_success, ) from qgis_deployment_toolbelt.utils.file_downloader import download_remote_file_to_local -from qgis_deployment_toolbelt.utils.slugger import sluggy # ############################################################################# # ########## Globals ############### From a9cc0186aafc58ac12a378e0b534daf51938546a Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 14:46:03 +0100 Subject: [PATCH 11/26] Add new use cases --- tests/fixtures/profiles/good_profile_complete.json | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/profiles/good_profile_complete.json b/tests/fixtures/profiles/good_profile_complete.json index c77d465c..96db6cf6 100644 --- a/tests/fixtures/profiles/good_profile_complete.json +++ b/tests/fixtures/profiles/good_profile_complete.json @@ -14,7 +14,8 @@ "name": "french_locator_filter", "version": "1.0.4", "url": "https://plugins.qgis.org/plugins/french_locator_filter/version/1.0.4/download/", - "type": "remote" + "type": "remote", + "plugin_id": 1846 }, { "name": "pg_metadata", @@ -27,6 +28,14 @@ "version": "v2.0.6", "url": "https://plugins.qgis.org/plugins/menu_from_project/version/v2.0.6/download/", "type": "remote" + }, + { + "name": "Geotuileur", + "version": "1.0.0", + "url": "https://oslandia.gitlab.io/qgis/ign-geotuileur/geotuileur.1.0.0.zip", + "location": "remote", + "official_repository": false, + "repository_url_xml": "https://oslandia.gitlab.io/qgis/ign-geotuileur/plugins.xml" } ] -} +} \ No newline at end of file From cc9be1e600b7d276652887185e1a2c2952a0a909 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 14:46:29 +0100 Subject: [PATCH 12/26] Add new attributes and methods: url and id_with_version --- qgis_deployment_toolbelt/plugins/plugin.py | 54 +++++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/qgis_deployment_toolbelt/plugins/plugin.py b/qgis_deployment_toolbelt/plugins/plugin.py index 541fd1ae..beefd6be 100644 --- a/qgis_deployment_toolbelt/plugins/plugin.py +++ b/qgis_deployment_toolbelt/plugins/plugin.py @@ -16,6 +16,9 @@ from dataclasses import dataclass from enum import Enum from sys import version_info +from urllib.parse import urlsplit, urlunsplit + +from qgis_deployment_toolbelt.utils.slugger import sluggy # Imports depending on Python version if version_info[1] < 11: @@ -53,12 +56,40 @@ class QgisPlugin: OFFICIAL_REPOSITORY_URL_BASE = "https://plugins.qgis.org/" OFFICIAL_REPOSITORY_XML = "https://plugins.qgis.org/plugins/plugins.xml" - name: str = None - version: str = "latest" + name: str location: QgisPluginLocation = "remote" - url: str = None - repository_url_xml: str = None official_repository: bool = None + plugin_id: int = None + repository_url_xml: str = None + url: str = None + version: str = "latest" + + @property + def id_with_version(self) -> str: + """Unique identifier using plugin_id (if set) and name + version slugified. + + Returns: + str: plugin identifier meant to be unique per version + """ + if self.plugin_id: + return f"{self.plugin_id}_{sluggy(self.name)}_{sluggy(self.version.replace('.', '-'))}" + else: + return f"{sluggy(self.name)}_{sluggy(self.version.replace('.', '-'))}" + + def guess_download_url(self) -> str: + """Try to guess download URL if it's not set during the object init. + + Returns: + str: download URL + """ + if self.url: + return self.url + elif self.repository_url_xml and self.name and self.version: + split_url = urlsplit(self.repository_url_xml) + new_url = split_url._replace(path=split_url.path.replace("plugins.xml", "")) + return f"{urlunsplit(new_url)}/{self.name}.{self.version}.zip" + else: + return None @classmethod def from_dict(cls, input_dict: dict) -> Self: @@ -84,6 +115,7 @@ def from_dict(cls, input_dict: dict) -> Self: f"plugins/{input_dict.get('name')}/{input_dict.get('version')}/download" ) input_dict["repository_url_xml"] = cls.OFFICIAL_REPOSITORY_XML + input_dict["location"] = "remote" # return new instance with loaded object return cls( @@ -107,13 +139,23 @@ def from_dict(cls, input_dict: dict) -> Self: plugin_obj_one = QgisPlugin.from_dict(sample_plugin_complete) print(plugin_obj_one) - sample_plugin_incomplete = { + sample_plugin_minimal = { "name": "french_locator_filter", "version": "1.0.4", "official_repository": True, } - plugin_obj_two = QgisPlugin.from_dict(sample_plugin_incomplete) + plugin_obj_two = QgisPlugin.from_dict(sample_plugin_minimal) print(plugin_obj_two) assert plugin_obj_one == plugin_obj_two + + sample_plugin_unofficial = { + "name": "Geotuileur", + "version": "1.0.0", + "official_repository": False, + "repository_url_xml": "https://oslandia.gitlab.io/qgis/ign-geotuileur/plugins.xml", + } + + plugin_obj_three = QgisPlugin.from_dict(sample_plugin_unofficial) + print(plugin_obj_three) From 343c94a64dcca7575a0dddb143a60d061fb2b3e8 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 14:46:51 +0100 Subject: [PATCH 13/26] Use qdt working folder --- .../jobs/job_profiles_synchronizer.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/qgis_deployment_toolbelt/jobs/job_profiles_synchronizer.py b/qgis_deployment_toolbelt/jobs/job_profiles_synchronizer.py index d6ef58b9..6b622a28 100644 --- a/qgis_deployment_toolbelt/jobs/job_profiles_synchronizer.py +++ b/qgis_deployment_toolbelt/jobs/job_profiles_synchronizer.py @@ -20,7 +20,7 @@ from typing import Tuple # package -from qgis_deployment_toolbelt.constants import OS_CONFIG +from qgis_deployment_toolbelt.constants import OS_CONFIG, get_qdt_working_directory from qgis_deployment_toolbelt.profiles import RemoteGitHandler # ############################################################################# @@ -90,6 +90,10 @@ def __init__(self, options: dict) -> None: """ self.options: dict = self.validate_options(options) + # local QDT folder + self.qdt_working_folder = get_qdt_working_directory() + logger.debug(f"Working folder: {self.qdt_working_folder}") + # profile folder if opersys not in OS_CONFIG: raise OSError( @@ -114,11 +118,8 @@ def __init__(self, options: dict) -> None: ] # prepare local destination - self.local_path: Path = Path( - expandvars(expanduser(self.options.get("local_destination"))) - ) - if not self.local_path.exists(): - self.local_path.mkdir(parents=True, exist_ok=True) + if not self.qdt_working_folder.exists(): + self.qdt_working_folder.mkdir(parents=True, exist_ok=True) def run(self) -> None: """Execute job logic.""" @@ -132,7 +133,7 @@ def run(self) -> None: url=self.options.get("source"), branch=self.options.get("branch", "master"), ) - downloader.download(local_path=self.local_path) + downloader.download(local_path=self.qdt_working_folder) else: raise NotImplementedError @@ -161,13 +162,13 @@ def filter_profiles_folder(self) -> Tuple[Path] or None: """ # first, try to get folders containing a profile.json qgis_profiles_folder = [ - f.parent for f in self.local_path.glob("**/profile.json") + f.parent for f in self.qdt_working_folder.glob("**/profile.json") ] if len(qgis_profiles_folder): return tuple(qgis_profiles_folder) # if empty, try to identify if a folder is a QGIS profile - but unsure - for d in self.local_path.glob("**"): + for d in self.qdt_working_folder.glob("**"): if ( d.is_dir() and d.parent.name == "profiles" From 71c886176fad726b4bb1f5e6db5372c7b3efd696 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 14:47:14 +0100 Subject: [PATCH 14/26] Add step to list referenced plugins through different plugins --- .../jobs/job_plugins_synchronizer.py | 144 +++++++++++++----- 1 file changed, 103 insertions(+), 41 deletions(-) diff --git a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py index fe6422e6..82384836 100644 --- a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py +++ b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py @@ -22,8 +22,10 @@ # package from qgis_deployment_toolbelt.__about__ import __title_clean__ from qgis_deployment_toolbelt.constants import OS_CONFIG, get_qdt_working_directory +from qgis_deployment_toolbelt.plugins.plugin import QgisPlugin from qgis_deployment_toolbelt.profiles.qdt_profile import QdtProfile from qgis_deployment_toolbelt.utils.file_downloader import download_remote_file_to_local +from qgis_deployment_toolbelt.utils.slugger import sluggy # ############################################################################# # ########## Globals ############### @@ -66,7 +68,12 @@ def __init__(self, options: dict) -> None: self.qdt_working_folder = get_qdt_working_directory() logger.debug(f"Working folder: {self.qdt_working_folder}") - # profile folder + # where QDT downloads plugins + self.qdt_plugins_folder = self.qdt_working_folder.parent / "plugins" + self.qdt_plugins_folder.mkdir(exist_ok=True, parents=True) + logger.info(f"QDT plugins folder: {self.qdt_plugins_folder}") + + # destination profiles folder if opersys not in OS_CONFIG: raise OSError( f"Your operating system {opersys} is not supported. " @@ -82,49 +89,104 @@ def __init__(self, options: dict) -> None: def run(self) -> None: """Execute job logic.""" - li_installed_profiles_path = [ - d - for d in self.qgis_profiles_path.iterdir() - if d.is_dir() and not d.name.startswith(".") - ] - - if self.options.get("action") in ("create", "create_or_restore"): - for profile_dir in li_installed_profiles_path: - - # case where splash image is specified into the profile.json - if Path(profile_dir / "profile.json").is_file(): - qdt_profile = QdtProfile.from_json( - profile_json_path=Path(profile_dir / "profile.json"), - profile_folder=profile_dir.resolve(), - ) - - # plugins - profile_plugins_dir = profile_dir / "python/plugins" - - with ThreadPoolExecutor( - max_workers=5, thread_name_prefix=f"{__title_clean__}" - ) as executor: - for plugin in qdt_profile.plugins: - # local path - - # submit download to pool - executor.submit( - # func to execute - download_remote_file_to_local, - # func parameters - remote_url_to_download=plugin.url, - content_type="application/zip", - ) - - else: - logger.debug(f"No profile.json found for profile '{profile_dir}") - continue - - else: - raise NotImplementedError + # list plugins through different profiles + qdt_referenced_plugins = self.list_referenced_plugins( + parent_folder=self.qdt_working_folder + ) + if not len(qdt_referenced_plugins): + logger.info( + f"No plugin found in profile.json files within {self.qdt_working_folder}" + ) + return + + # print(qdt_referenced_plugins) + + # li_installed_profiles_path = [ + # d + # for d in self.qgis_profiles_path.iterdir() + # if d.is_dir() and not d.name.startswith(".") + # ] + + # if self.options.get("action") in ("create", "create_or_restore"): + # for profile_dir in li_installed_profiles_path: + + # # case where splash image is specified into the profile.json + # if Path(profile_dir / "profile.json").is_file(): + # qdt_profile = QdtProfile.from_json( + # profile_json_path=Path(profile_dir / "profile.json"), + # profile_folder=profile_dir.resolve(), + # ) + # print("hop") + # # plugins + # # profile_plugins_dir = profile_dir / "python/plugins" + + # with ThreadPoolExecutor( + # max_workers=5, thread_name_prefix=f"{__title_clean__}" + # ) as executor: + # for plugin in qdt_profile.plugins: + # # local path + # qdt_dest_plugin_path = Path( + # self.qdt_plugins_folder, + # sluggy(plugin.name), + # f"{sluggy(plugin.version)}.zip", + # ) + + # # submit download to pool + # executor.submit( + # # func to execute + # download_remote_file_to_local, + # # func parameters + # local_file_path=qdt_dest_plugin_path, + # remote_url_to_download=plugin.url, + # content_type="application/zip", + # ) + + # else: + # logger.debug(f"No profile.json found for profile '{profile_dir}") + # continue + + # else: + # raise NotImplementedError logger.debug(f"Job {self.ID} ran successfully.") + def list_referenced_plugins(self, parent_folder: Path) -> list[QgisPlugin]: + """Return a list of plugins referenced in profile.json files found within a \ + parent folder and sorted by unique id with version. + + Args: + parent_folder (Path): folder to start searching for profile.json files + + Returns: + list[QgisPlugin]: list of plugins referenced within profile.json files + """ + unique_profiles_identifiers: list = [] + all_profiles: list[QgisPlugin] = [] + + profile_json_counter: int = 0 + for profile_json in parent_folder.glob("**/*/profile.json"): + # increment counter + profile_json_counter += 1 + + # read profile.json + qdt_profile = QdtProfile.from_json( + profile_json_path=profile_json, + profile_folder=profile_json.parent, + ) + + # parse profile plugins + for plugin in qdt_profile.plugins: + if plugin.id_with_version not in unique_profiles_identifiers: + unique_profiles_identifiers.append(plugin.id_with_version) + all_profiles.append(plugin) + + logger.debug( + f"{len(unique_profiles_identifiers)} unique plugins referenced in " + f"{profile_json_counter} profiles.json in {parent_folder.resolve()}: " + f"{','.join(sorted(unique_profiles_identifiers))}" + ) + return sorted(all_profiles, key=lambda x: x.id_with_version) + # -- INTERNAL LOGIC ------------------------------------------------------ def validate_options(self, options: dict) -> bool: """Validate options. From bc6e716a4d486d05e6dc10c412aacc21a447230e Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 14:47:37 +0100 Subject: [PATCH 15/26] Set Google style as docstring format --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 00b27dd6..6910bc9a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,7 +36,7 @@ "python.testing.pytestEnabled": true, // extensions "autoDocstring.guessTypes": true, - "autoDocstring.docstringFormat": "one-line-sphinx", + "autoDocstring.docstringFormat": "google", "autoDocstring.generateDocstringOnEnter": false, "yaml.schemas": { "docs/schemas/schema.json": "tests/fixtures/scenarios/*.qdt.yml", From b356d6012673240bc5d8a7f0de39aeff78968468 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 15:24:04 +0100 Subject: [PATCH 16/26] Add method to filter plugins to download --- .../jobs/job_plugins_synchronizer.py | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py index 82384836..67e0bf78 100644 --- a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py +++ b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py @@ -24,6 +24,7 @@ from qgis_deployment_toolbelt.constants import OS_CONFIG, get_qdt_working_directory from qgis_deployment_toolbelt.plugins.plugin import QgisPlugin from qgis_deployment_toolbelt.profiles.qdt_profile import QdtProfile +from qgis_deployment_toolbelt.utils.check_path import check_path from qgis_deployment_toolbelt.utils.file_downloader import download_remote_file_to_local from qgis_deployment_toolbelt.utils.slugger import sluggy @@ -99,7 +100,44 @@ def run(self) -> None: ) return - # print(qdt_referenced_plugins) + # filter plugins to download, filtering out those which are not already present locally + qdt_plugins_to_download = self.filter_list_downloadable_plugins( + input_list=qdt_referenced_plugins + ) + if not len(qdt_plugins_to_download): + logger.info( + f"All referenced plugins are already present in {self.qdt_plugins_folder}. " + "Skipping download step." + ) + return + + # download plugins into the QDT local folder - only those which are not already present + + # if self.options.get("action") in ("create", "create_or_restore"): + + # for plugin in qdt_referenced_plugins: + # # destination + + # with ThreadPoolExecutor( + # max_workers=5, thread_name_prefix=f"{__title_clean__}" + # ) as executor: + # for plugin in qdt_profile.plugins: + # # local path + # qdt_dest_plugin_path = Path( + # self.qdt_plugins_folder, + # sluggy(plugin.name), + # f"{sluggy(plugin.version)}.zip", + # ) + + # # submit download to pool + # executor.submit( + # # func to execute + # download_remote_file_to_local, + # # func parameters + # local_file_path=qdt_dest_plugin_path, + # remote_url_to_download=plugin.url, + # content_type="application/zip", + # ) # li_installed_profiles_path = [ # d @@ -187,6 +225,38 @@ def list_referenced_plugins(self, parent_folder: Path) -> list[QgisPlugin]: ) return sorted(all_profiles, key=lambda x: x.id_with_version) + def filter_list_downloadable_plugins( + self, input_list: list[QgisPlugin] + ) -> list[QgisPlugin]: + """Filter input list of plugins keeping only those which are not present within \ + the local QDT plugins folder. + + Args: + input_list (list[QgisPlugin]): list of plugins to filter + + Returns: + list[QgisPlugin]: list of plugins to download + """ + plugins_to_download = [] + + for plugin in input_list: + # build destination path + plugin_download_path = Path( + self.qdt_plugins_folder, f"{plugin.id_with_version}.zip" + ) + + # check if file already exists + if plugin_download_path.is_file(): + logger.debug( + f"Plugin already exists at {plugin_download_path}, so it " + "won't be downloaded." + ) + continue + + plugins_to_download.append(plugin) + + return plugins_to_download + # -- INTERNAL LOGIC ------------------------------------------------------ def validate_options(self, options: dict) -> bool: """Validate options. From 2d0e8d16f5da6855d1985f6b414c1592ee889ec5 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 15:54:38 +0100 Subject: [PATCH 17/26] Fix url build --- qgis_deployment_toolbelt/plugins/plugin.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/qgis_deployment_toolbelt/plugins/plugin.py b/qgis_deployment_toolbelt/plugins/plugin.py index beefd6be..37c3f62a 100644 --- a/qgis_deployment_toolbelt/plugins/plugin.py +++ b/qgis_deployment_toolbelt/plugins/plugin.py @@ -76,18 +76,20 @@ def id_with_version(self) -> str: else: return f"{sluggy(self.name)}_{sluggy(self.version.replace('.', '-'))}" - def guess_download_url(self) -> str: + @property + def download_url(self) -> str: """Try to guess download URL if it's not set during the object init. Returns: str: download URL """ if self.url: + print("youhou") return self.url elif self.repository_url_xml and self.name and self.version: split_url = urlsplit(self.repository_url_xml) new_url = split_url._replace(path=split_url.path.replace("plugins.xml", "")) - return f"{urlunsplit(new_url)}/{self.name}.{self.version}.zip" + return f"{urlunsplit(new_url)}{self.name}.{self.version}.zip" else: return None @@ -111,8 +113,9 @@ def from_dict(cls, input_dict: dict) -> Self: # URL auto build if input_dict.get("official_repository") is True: input_dict["url"] = ( - f"{cls.OFFICIAL_REPOSITORY_URL_BASE}/" - f"plugins/{input_dict.get('name')}/{input_dict.get('version')}/download" + f"{cls.OFFICIAL_REPOSITORY_URL_BASE}" + f"plugins/{input_dict.get('name')}/" + f"version/{input_dict.get('version')}/download" ) input_dict["repository_url_xml"] = cls.OFFICIAL_REPOSITORY_XML input_dict["location"] = "remote" @@ -136,7 +139,8 @@ def from_dict(cls, input_dict: dict) -> Self: "type": "remote", } - plugin_obj_one = QgisPlugin.from_dict(sample_plugin_complete) + plugin_obj_one: QgisPlugin = QgisPlugin.from_dict(sample_plugin_complete) + assert plugin_obj_one.url == plugin_obj_one.download_url print(plugin_obj_one) sample_plugin_minimal = { From 3b186a25205ae476cdf607ac0602dac5ab1e9343 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 15:54:46 +0100 Subject: [PATCH 18/26] typo --- qgis_deployment_toolbelt/utils/file_downloader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qgis_deployment_toolbelt/utils/file_downloader.py b/qgis_deployment_toolbelt/utils/file_downloader.py index 78ba4b6e..da7978fc 100644 --- a/qgis_deployment_toolbelt/utils/file_downloader.py +++ b/qgis_deployment_toolbelt/utils/file_downloader.py @@ -55,7 +55,7 @@ def download_remote_file_to_local( local_file_path (Path): local path to the index file user_agent (str, optional): user agent to use to perform the request. Defaults \ to f"{__title_clean__}/{__version__}". - conent_type (str): HTTP content-type. + content_type (str): HTTP content-type. chunk_size (int): size of each chunk to read and write in bytes. Returns: @@ -69,7 +69,7 @@ def download_remote_file_to_local( # headers headers = {"User-Agent": user_agent} if content_type: - headers["Accept":content_type] + headers["Accept"] = content_type # download the remote file into local file custom_request = Request(url=remote_url_to_download, headers=headers) From f30b9c3844b36b3bfa810a454d06ca1a83bbd210 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 17:08:27 +0100 Subject: [PATCH 19/26] Add new options force and threads --- docs/jobs/plugins_manager.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/jobs/plugins_manager.md b/docs/jobs/plugins_manager.md index 6e28c30a..12beac17 100644 --- a/docs/jobs/plugins_manager.md +++ b/docs/jobs/plugins_manager.md @@ -21,13 +21,31 @@ Sample job configuration in your scenario file: ### action -Tell the job what to do with splash screens: +Tell the job what to do with plugins in **installed profiles**: Possible_values: -- `create`: add splash screen if not set -- `create_or_restore`: add splash screen if not set and replace eventual existing one -- `remove`: remove splash screen +- `create`: add plugins if they are not present +- `create_or_restore`: add plugins if not present and replace eventual existing one +- `remove`: remove plugins which are not listed + +### force + +Controls download mode. + +Possible_values: + +- `false` (_default_): download only plugins which are not present into the local QDT folder +- `true`: download every plugin referenced in profile.json files into the local QDT folder, even if the archive is already here + +### threads + +Number of threads to use for downloading. + +Possible_values: + +- `1`: do not use multi-thread but download plugins synchroneously +- `2`, `3`, `4` or `5` (_default_): number of threads to parallelize plugins download ---- From fe30026375ac71dd2c4ade76d0088c8debf613ad Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 17:08:43 +0100 Subject: [PATCH 20/26] Add downloading logic --- .../jobs/job_plugins_synchronizer.py | 189 ++++++++++-------- 1 file changed, 106 insertions(+), 83 deletions(-) diff --git a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py index 67e0bf78..de7b79e3 100644 --- a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py +++ b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py @@ -54,7 +54,21 @@ class JobPluginsManager: "default": "create_or_restore", "possible_values": ("create", "create_or_restore", "remove"), "condition": "in", - } + }, + "force": { + "type": bool, + "required": False, + "default": False, + "possible_values": None, + "condition": None, + }, + "threads": { + "type": int, + "required": False, + "default": 5, + "possible_values": (1, 2, 3, 4, 5), + "condition": "in", + }, } def __init__(self, options: dict) -> None: @@ -101,93 +115,102 @@ def run(self) -> None: return # filter plugins to download, filtering out those which are not already present locally - qdt_plugins_to_download = self.filter_list_downloadable_plugins( - input_list=qdt_referenced_plugins - ) - if not len(qdt_plugins_to_download): - logger.info( - f"All referenced plugins are already present in {self.qdt_plugins_folder}. " - "Skipping download step." + if self.options.get("force") is True: + qdt_plugins_to_download = qdt_referenced_plugins + else: + qdt_plugins_to_download = self.filter_list_downloadable_plugins( + input_list=qdt_referenced_plugins ) - return + if not len(qdt_plugins_to_download): + logger.info( + f"All referenced plugins are already present in {self.qdt_plugins_folder}. " + "Skipping download step." + ) + return - # download plugins into the QDT local folder - only those which are not already present - - # if self.options.get("action") in ("create", "create_or_restore"): - - # for plugin in qdt_referenced_plugins: - # # destination - - # with ThreadPoolExecutor( - # max_workers=5, thread_name_prefix=f"{__title_clean__}" - # ) as executor: - # for plugin in qdt_profile.plugins: - # # local path - # qdt_dest_plugin_path = Path( - # self.qdt_plugins_folder, - # sluggy(plugin.name), - # f"{sluggy(plugin.version)}.zip", - # ) - - # # submit download to pool - # executor.submit( - # # func to execute - # download_remote_file_to_local, - # # func parameters - # local_file_path=qdt_dest_plugin_path, - # remote_url_to_download=plugin.url, - # content_type="application/zip", - # ) - - # li_installed_profiles_path = [ - # d - # for d in self.qgis_profiles_path.iterdir() - # if d.is_dir() and not d.name.startswith(".") - # ] - - # if self.options.get("action") in ("create", "create_or_restore"): - # for profile_dir in li_installed_profiles_path: - - # # case where splash image is specified into the profile.json - # if Path(profile_dir / "profile.json").is_file(): - # qdt_profile = QdtProfile.from_json( - # profile_json_path=Path(profile_dir / "profile.json"), - # profile_folder=profile_dir.resolve(), - # ) - # print("hop") - # # plugins - # # profile_plugins_dir = profile_dir / "python/plugins" - - # with ThreadPoolExecutor( - # max_workers=5, thread_name_prefix=f"{__title_clean__}" - # ) as executor: - # for plugin in qdt_profile.plugins: - # # local path - # qdt_dest_plugin_path = Path( - # self.qdt_plugins_folder, - # sluggy(plugin.name), - # f"{sluggy(plugin.version)}.zip", - # ) - - # # submit download to pool - # executor.submit( - # # func to execute - # download_remote_file_to_local, - # # func parameters - # local_file_path=qdt_dest_plugin_path, - # remote_url_to_download=plugin.url, - # content_type="application/zip", - # ) - - # else: - # logger.debug(f"No profile.json found for profile '{profile_dir}") - # continue - - # else: - # raise NotImplementedError + # launch download + downloaded_plugins, failed_downloads = self.download_plugins( + plugins_to_download=qdt_plugins_to_download, + destination_parent_folder=self.qdt_plugins_folder, + threads=self.options.get("threads", 5), + ) logger.debug(f"Job {self.ID} ran successfully.") + def download_plugins( + self, + plugins_to_download: list[QgisPlugin], + destination_parent_folder: Path, + threads: int = 5, + ) -> tuple[list[Path], list[Path]]: + """Download listed plugins into the specified folder, using multithreads or not. + + Args: + plugins_to_download (list[QgisPlugin]): list of plugins to download + destination_parent_folder (Path): where to store downloaded plugins + threads (int, optional): number of threads to use. If 0, downloads will be \ + performed synchronously. Defaults to 5. + + Returns: + tuple[list[Path],list[Path]]: tuple of (downloaded plugins, failed downloads) + """ + downloaded_plugins: list[QgisPlugin] = [] + failed_plugins: list[QgisPlugin] = [] + + if threads < 2: + logger.debug(f"Downloading {len(plugins_to_download)} threads.") + for plugin in plugins_to_download: + # local path + plugin_download_path = Path( + destination_parent_folder, f"{plugin.id_with_version}.zip" + ) + try: + download_remote_file_to_local( + local_file_path=plugin_download_path, + remote_url_to_download=plugin.download_url, + content_type="application/zip", + ) + logger.info( + f"Plugin {plugin.name} from {plugin.guess_download_url} " + f"downloaded in {plugin_download_path}" + ) + downloaded_plugins.append(plugin) + except Exception as err: + logger.error( + f"Download of plugin {plugin.name} failed. Trace: {err}" + ) + failed_plugins.append(plugin) + continue + else: + logger.debug( + f"Downloading {len(plugins_to_download)} using {threads} simultaneously." + ) + with ThreadPoolExecutor( + max_workers=threads, thread_name_prefix=f"{__title_clean__}" + ) as executor: + for plugin in plugins_to_download: + # local path + plugin_download_path = Path( + destination_parent_folder, f"{plugin.id_with_version}.zip" + ) + + # submit download to pool + try: + executor.submit( + # func to execute + download_remote_file_to_local, + # func parameters + local_file_path=plugin_download_path, + remote_url_to_download=plugin.download_url, + content_type="application/zip", + ) + downloaded_plugins.append(plugin) + except Exception as err: + logger.error(err) + failed_plugins.append(plugin) + + return downloaded_plugins, failed_plugins + def list_referenced_plugins(self, parent_folder: Path) -> list[QgisPlugin]: """Return a list of plugins referenced in profile.json files found within a \ parent folder and sorted by unique id with version. From d5c14d7a543644b625cdd018dc6fa38eb6cf1ab2 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 17:12:10 +0100 Subject: [PATCH 21/26] Improve output URL building --- qgis_deployment_toolbelt/plugins/plugin.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/qgis_deployment_toolbelt/plugins/plugin.py b/qgis_deployment_toolbelt/plugins/plugin.py index 37c3f62a..70afafdb 100644 --- a/qgis_deployment_toolbelt/plugins/plugin.py +++ b/qgis_deployment_toolbelt/plugins/plugin.py @@ -16,7 +16,7 @@ from dataclasses import dataclass from enum import Enum from sys import version_info -from urllib.parse import urlsplit, urlunsplit +from urllib.parse import quote, urlsplit, urlunsplit from qgis_deployment_toolbelt.utils.slugger import sluggy @@ -84,8 +84,7 @@ def download_url(self) -> str: str: download URL """ if self.url: - print("youhou") - return self.url + return quote(self.url, safe="/:") elif self.repository_url_xml and self.name and self.version: split_url = urlsplit(self.repository_url_xml) new_url = split_url._replace(path=split_url.path.replace("plugins.xml", "")) @@ -107,15 +106,16 @@ def from_dict(cls, input_dict: dict) -> Self: cls.OFFICIAL_REPOSITORY_URL_BASE ): input_dict["official_repository"] = True + input_dict["repository_url_xml"] = cls.OFFICIAL_REPOSITORY_XML else: pass # URL auto build - if input_dict.get("official_repository") is True: + if input_dict.get("official_repository") is True and not input_dict.get("url"): input_dict["url"] = ( f"{cls.OFFICIAL_REPOSITORY_URL_BASE}" f"plugins/{input_dict.get('name')}/" - f"version/{input_dict.get('version')}/download" + f"version/{input_dict.get('version')}/download/" ) input_dict["repository_url_xml"] = cls.OFFICIAL_REPOSITORY_XML input_dict["location"] = "remote" @@ -163,3 +163,14 @@ def from_dict(cls, input_dict: dict) -> Self: plugin_obj_three = QgisPlugin.from_dict(sample_plugin_unofficial) print(plugin_obj_three) + + sample_plugin_different_name = { + "name": "Layers menu from project", + "version": "v2.0.6", + "url": "https://plugins.qgis.org/plugins/menu_from_project/version/v2.0.6/download/", + "type": "remote", + } + + plugin_obj_four: QgisPlugin = QgisPlugin.from_dict(sample_plugin_different_name) + print(plugin_obj_four.url) + assert plugin_obj_four.url == plugin_obj_four.download_url From b61f07baa8eee7a0581f2a763e71fc9aa53a8f2a Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 17:12:22 +0100 Subject: [PATCH 22/26] Improve error handling --- .../utils/file_downloader.py | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/qgis_deployment_toolbelt/utils/file_downloader.py b/qgis_deployment_toolbelt/utils/file_downloader.py index da7978fc..1ab5231f 100644 --- a/qgis_deployment_toolbelt/utils/file_downloader.py +++ b/qgis_deployment_toolbelt/utils/file_downloader.py @@ -84,17 +84,31 @@ def download_remote_file_to_local( break buffile.write(chunk) logger.info( - f"Téléchargement du fichier distant {remote_url_to_download} dans " - f"{local_file_path} a réussi." + f"Downloading {remote_url_to_download} to {local_file_path} succeeded." ) except HTTPError as error: - logger.error(error) - return error + logger.error( + f"Downloading {remote_url_to_download} to {local_file_path} failed. " + f"Cause: HTTPError. Trace: {error}" + ) + raise error except URLError as error: - logger.error(error) - return error + logger.error( + f"Downloading {remote_url_to_download} to {local_file_path} failed. " + f"Cause: URLError. Trace: {error}" + ) + raise error except TimeoutError as error: - logger.error(error) - return error + logger.error( + f"Downloading {remote_url_to_download} to {local_file_path} failed. " + f"Cause: TimeoutError. Trace: {error}" + ) + raise error + except Exception as error: + logger.error( + f"Downloading {remote_url_to_download} to {local_file_path} failed. " + f"Cause: Unknown error. Trace: {error}" + ) + raise error return local_file_path From 2bd4c39373734b504ecd8daf4108e36762b7ee70 Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 17:12:47 +0100 Subject: [PATCH 23/26] Add plugins manager --- scenario.qdt.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scenario.qdt.yml b/scenario.qdt.yml index a9e42651..ea10eba1 100644 --- a/scenario.qdt.yml +++ b/scenario.qdt.yml @@ -32,6 +32,13 @@ steps: branch: main local_destination: ~/.cache/qgis-deployment-toolbelt/Oslandia/ + - name: Synchronize plugins + uses: qplugins-manager + with: + action: create_or_restore + force: false + threads: 5 + - name: Create shortcuts for profiles uses: shortcuts-manager with: From 1c07121577ae66148bc822b969ef795d4d59679f Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 17:16:34 +0100 Subject: [PATCH 24/26] Fix typing --- .../jobs/job_plugins_synchronizer.py | 31 +++++++++---------- .../profiles/qdt_profile.py | 6 ++-- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py index de7b79e3..a447f018 100644 --- a/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py +++ b/qgis_deployment_toolbelt/jobs/job_plugins_synchronizer.py @@ -14,19 +14,16 @@ # Standard library import logging from concurrent.futures import ThreadPoolExecutor -from configparser import ConfigParser -from os import getenv from pathlib import Path from sys import platform as opersys +from typing import List, Tuple # package from qgis_deployment_toolbelt.__about__ import __title_clean__ from qgis_deployment_toolbelt.constants import OS_CONFIG, get_qdt_working_directory from qgis_deployment_toolbelt.plugins.plugin import QgisPlugin from qgis_deployment_toolbelt.profiles.qdt_profile import QdtProfile -from qgis_deployment_toolbelt.utils.check_path import check_path from qgis_deployment_toolbelt.utils.file_downloader import download_remote_file_to_local -from qgis_deployment_toolbelt.utils.slugger import sluggy # ############################################################################# # ########## Globals ############### @@ -139,23 +136,23 @@ def run(self) -> None: def download_plugins( self, - plugins_to_download: list[QgisPlugin], + plugins_to_download: List[QgisPlugin], destination_parent_folder: Path, threads: int = 5, - ) -> tuple[list[Path], list[Path]]: + ) -> Tuple[List[Path], List[Path]]: """Download listed plugins into the specified folder, using multithreads or not. Args: - plugins_to_download (list[QgisPlugin]): list of plugins to download + plugins_to_download (List[QgisPlugin]): list of plugins to download destination_parent_folder (Path): where to store downloaded plugins threads (int, optional): number of threads to use. If 0, downloads will be \ performed synchronously. Defaults to 5. Returns: - tuple[list[Path],list[Path]]: tuple of (downloaded plugins, failed downloads) + Tuple[List[Path],List[Path]]: tuple of (downloaded plugins, failed downloads) """ - downloaded_plugins: list[QgisPlugin] = [] - failed_plugins: list[QgisPlugin] = [] + downloaded_plugins: List[QgisPlugin] = [] + failed_plugins: List[QgisPlugin] = [] if threads < 2: logger.debug(f"Downloading {len(plugins_to_download)} threads.") @@ -211,7 +208,7 @@ def download_plugins( return downloaded_plugins, failed_plugins - def list_referenced_plugins(self, parent_folder: Path) -> list[QgisPlugin]: + def list_referenced_plugins(self, parent_folder: Path) -> List[QgisPlugin]: """Return a list of plugins referenced in profile.json files found within a \ parent folder and sorted by unique id with version. @@ -219,10 +216,10 @@ def list_referenced_plugins(self, parent_folder: Path) -> list[QgisPlugin]: parent_folder (Path): folder to start searching for profile.json files Returns: - list[QgisPlugin]: list of plugins referenced within profile.json files + List[QgisPlugin]: list of plugins referenced within profile.json files """ unique_profiles_identifiers: list = [] - all_profiles: list[QgisPlugin] = [] + all_profiles: List[QgisPlugin] = [] profile_json_counter: int = 0 for profile_json in parent_folder.glob("**/*/profile.json"): @@ -249,16 +246,16 @@ def list_referenced_plugins(self, parent_folder: Path) -> list[QgisPlugin]: return sorted(all_profiles, key=lambda x: x.id_with_version) def filter_list_downloadable_plugins( - self, input_list: list[QgisPlugin] - ) -> list[QgisPlugin]: + self, input_list: List[QgisPlugin] + ) -> List[QgisPlugin]: """Filter input list of plugins keeping only those which are not present within \ the local QDT plugins folder. Args: - input_list (list[QgisPlugin]): list of plugins to filter + input_list (List[QgisPlugin]): list of plugins to filter Returns: - list[QgisPlugin]: list of plugins to download + List[QgisPlugin]: list of plugins to download """ plugins_to_download = [] diff --git a/qgis_deployment_toolbelt/profiles/qdt_profile.py b/qgis_deployment_toolbelt/profiles/qdt_profile.py index b7df0976..ff2e4571 100644 --- a/qgis_deployment_toolbelt/profiles/qdt_profile.py +++ b/qgis_deployment_toolbelt/profiles/qdt_profile.py @@ -17,7 +17,7 @@ from pathlib import Path from sys import platform as opersys from sys import version_info -from typing import Union +from typing import List, Union # Imports depending on Python version if version_info[1] < 11: @@ -234,10 +234,10 @@ def name(self) -> Path: return self._name @property - def plugins(self) -> list[dict]: + def plugins(self) -> List[dict]: """Returns the plugins associated with the profile. - :return list[dict]: list of plugins + :return List[dict]: list of plugins """ return [QgisPlugin.from_dict(p) for p in self._plugins] From 2834e96b9c98aedb30efd5476ccf0ff36bef2a9e Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 18:54:16 +0100 Subject: [PATCH 25/26] Add Python 3.11 to integration tests --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 739611bc..335a568f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,7 +56,7 @@ jobs: strategy: matrix: os: [macos-latest, ubuntu-22.04, windows-latest] - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11"] runs-on: ${{ matrix.os }} steps: From dc2b91fc5e811155347e55bdc7bbe84cba5c519b Mon Sep 17 00:00:00 2001 From: Julien M Date: Tue, 17 Jan 2023 18:54:33 +0100 Subject: [PATCH 26/26] Fix class methods order --- qgis_deployment_toolbelt/plugins/plugin.py | 62 ++++++++++++---------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/qgis_deployment_toolbelt/plugins/plugin.py b/qgis_deployment_toolbelt/plugins/plugin.py index 70afafdb..8bd67687 100644 --- a/qgis_deployment_toolbelt/plugins/plugin.py +++ b/qgis_deployment_toolbelt/plugins/plugin.py @@ -48,7 +48,7 @@ class QgisPlugin: """Model describing a QGIS plugin.""" # optional mapping on attributes names. - # {attribute_name_in_output_object: attribute_name_from_input_file} + # Structure: {attribute_name_in_output_object: attribute_name_from_input_file} ATTR_MAP = { "location": "type", } @@ -64,36 +64,16 @@ class QgisPlugin: url: str = None version: str = "latest" - @property - def id_with_version(self) -> str: - """Unique identifier using plugin_id (if set) and name + version slugified. - - Returns: - str: plugin identifier meant to be unique per version - """ - if self.plugin_id: - return f"{self.plugin_id}_{sluggy(self.name)}_{sluggy(self.version.replace('.', '-'))}" - else: - return f"{sluggy(self.name)}_{sluggy(self.version.replace('.', '-'))}" + @classmethod + def from_dict(cls, input_dict: dict) -> Self: + """Create object from a JSON file. - @property - def download_url(self) -> str: - """Try to guess download URL if it's not set during the object init. + Args: + input_dict (dict): input dictionary Returns: - str: download URL + Self: instanciated object """ - if self.url: - return quote(self.url, safe="/:") - elif self.repository_url_xml and self.name and self.version: - split_url = urlsplit(self.repository_url_xml) - new_url = split_url._replace(path=split_url.path.replace("plugins.xml", "")) - return f"{urlunsplit(new_url)}{self.name}.{self.version}.zip" - else: - return None - - @classmethod - def from_dict(cls, input_dict: dict) -> Self: # map attributes names for k, v in cls.ATTR_MAP.items(): if v in input_dict.keys(): @@ -125,6 +105,34 @@ def from_dict(cls, input_dict: dict) -> Self: **input_dict, ) + @property + def download_url(self) -> str: + """Try to guess download URL if it's not set during the object init. + + Returns: + str: download URL + """ + if self.url: + return quote(self.url, safe="/:") + elif self.repository_url_xml and self.name and self.version: + split_url = urlsplit(self.repository_url_xml) + new_url = split_url._replace(path=split_url.path.replace("plugins.xml", "")) + return f"{urlunsplit(new_url)}{self.name}.{self.version}.zip" + else: + return None + + @property + def id_with_version(self) -> str: + """Unique identifier using plugin_id (if set) and name + version slugified. + + Returns: + str: plugin identifier meant to be unique per version + """ + if self.plugin_id: + return f"{self.plugin_id}_{sluggy(self.name)}_{sluggy(self.version.replace('.', '-'))}" + else: + return f"{sluggy(self.name)}_{sluggy(self.version.replace('.', '-'))}" + # ############################################################################# # ##### Stand alone program ########