diff --git a/docs/index.md b/docs/index.md index 753c2144..da590fc6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -106,6 +106,15 @@ guides/howto_windows_sign_executable guides/* ``` +```{toctree} +--- +caption: Reference +glob: +maxdepth: 1 +--- +reference/* +``` + ```{toctree} --- caption: Miscellaneous diff --git a/docs/reference/qdt_profile.md b/docs/reference/qdt_profile.md new file mode 100644 index 00000000..9db59f15 --- /dev/null +++ b/docs/reference/qdt_profile.md @@ -0,0 +1,63 @@ +# QDT Profile + +## Rules + +You can add rules to make the profile deployment conditional. In the following example, the profile will be deployed only on Linux: + +```json +{ + "$schema": "https://raw.githubusercontent.com/Guts/qgis-deployment-cli/main/docs/schemas/profile/qgis_profile.json", + "name": "only_linux", + "folder_name": "qdt_only_linux", + "description": "A QGIS profile for QDT with a conditional deployment rule.", + "author": "Julien Moura", + "email": "infos+qdt@oslandia.com", + "qgisMinimumVersion": "3.34.0", + "qgisMaximumVersion": "3.99.10", + "version": "1.7.0", + "rules": [ + { + "name": "Environment", + "description": "Profile is configured to run only on Linux.", + "conditions": { + "all": [ + { + "path": "$.environment.operating_system_code", + "value": "linux", + "operator": "equal" + } + ] + } + } + ] +} +``` + +## Model definition + +The project comes with a [JSON schema](https://raw.githubusercontent.com/Guts/qgis-deployment-cli/main/docs/schemas/profile/qgis_profile.json) describing the model of a profile: + +```{eval-rst} +.. literalinclude:: ../schemas/profile/qgis_profile.json + :language: json +``` + +With a submodel for plugin object: + +```{eval-rst} +.. literalinclude:: ../schemas/profile/qgis_plugin.json + :language: json +``` + +:::{tip} +To retrieve the ID of a plugin see [this page](../guides/howto_qgis_get_plugin_id.md). +::: + +---- + +## Sample profile.json + +```{eval-rst} +.. literalinclude:: ../../tests/fixtures/profiles/good_sample_profile.json + :language: json +``` diff --git a/docs/schemas/profile/qgis_profile.json b/docs/schemas/profile/qgis_profile.json index ed3d6a35..57b879f9 100644 --- a/docs/schemas/profile/qgis_profile.json +++ b/docs/schemas/profile/qgis_profile.json @@ -57,6 +57,14 @@ "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", "type": "string" }, + "rules": { + "description": "Logical rules based on contextual elements that condition profile deployment.", + "title": "QGIS Plugins", + "type": "array", + "items": { + "$ref": "rules.json" + } + }, "splash": { "description": "Relative path to the splash image.", "type": "string" diff --git a/docs/schemas/profile/rules.json b/docs/schemas/profile/rules.json new file mode 100644 index 00000000..06bb29a8 --- /dev/null +++ b/docs/schemas/profile/rules.json @@ -0,0 +1,203 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Guts/qgis-deployment-cli/main/docs/schemas/profile/rules.json", + "$comment": "A set of JSON rules usable for QDT profiles to make conditional deployment.", + "type": "object", + "description": "Rules contain a set of conditions. When the engine is run, each rule condition is evaluated. If the results are truthy, the profile's is installed.", + "required": [ + "conditions", + "name" + ], + "properties": { + "conditions": { + "$id": "#/properties/conditions", + "type": "object", + "title": "Conditions", + "description": "Rule conditions are a combination of facts, operators, and values that determine whether the rule is a success or a failure. The simplest form of a condition consists of a fact, an operator, and a value. When the engine runs, the operator is used to compare the fact against the value. Each rule's conditions must have either an all or an any operator at its root, containing an array of conditions. The all operator specifies that all conditions contained within must be truthy for the rule to be considered a success. The any operator only requires one condition to be truthy for the rule to succeed.", + "default": {}, + "examples": [ + { + "all": [ + { + "path": "$.environment.operating_system_code", + "operator": "equal", + "value": "linux" + } + ] + } + ], + "oneOf": [ + { + "required": [ + "any" + ] + }, + { + "required": [ + "all" + ] + } + ], + "properties": { + "any": { + "$ref": "#/definitions/conditionArray" + }, + "all": { + "$ref": "#/definitions/conditionArray" + } + } + }, + "name": { + "$id": "#/properties/name", + "default": {}, + "description": "A way of naming your rules, allowing them to be easily identifiable in Rule Results. This is usually of type String, but could also be Object, Array, or Number. Note that the name should be unique but it's not mandatory and that it has no impact on execution of the rule.", + "examples": [ + "My Rule Name" + ], + "title": "The Name Schema", + "type": "string" + } + }, + "definitions": { + "conditionArray": { + "type": "array", + "title": "Condition Array", + "description": "An array of conditions with a possible recursive inclusion of another condition array.", + "default": [], + "items": { + "anyOf": [ + { + "$ref": "#/definitions/conditions" + }, + { + "$ref": "#/definitions/condition" + } + ] + } + }, + "condition": { + "type": "object", + "title": "Condition", + "description": "Rule conditions are a combination of facts, operators, and values that determine whether the rule is a success or a failure. The simplest form of a condition consists of a fact, an operator, and a value. When the engine runs, the operator is used to compare the fact against the value. Sometimes facts require additional input to perform calculations. For this, the params property is passed as an argument to the fact handler. params essentially functions as fact arguments, enabling fact handlers to be more generic and reusable.", + "examples": [ + { + "fact": "gameDuration", + "operator": "equal", + "value": 40.0 + }, + { + "value": 5.0, + "fact": "personalFoulCount", + "operator": "greater_than_inclusive" + }, + { + "fact": "product-price", + "operator": "greater_than", + "path": "$.price", + "value": 100.0, + "params": { + "productId": "widget" + } + } + ], + "required": [ + "path", + "operator", + "value" + ], + "properties": { + "operator": { + "required": true, + "type": "string", + "anyOf": [ + { + "const": "equal", + "title": "fact must equal value" + }, + { + "const": "not_equal", + "title": "fact must not equal value" + }, + { + "const": "greater_than", + "title": "fact must be greater than value" + }, + { + "const": "greater_than_inclusive", + "title": "fact must be greater than or equal to value" + }, + { + "const": "less_than", + "title": "fact must be less than value" + }, + { + "const": "less_than_inclusive", + "title": "fact must be less than or equal to value" + }, + { + "const": "in", + "title": "fact must be included in value (an array)" + }, + { + "const": "not_in", + "title": "fact must not be included in value (an array)" + }, + { + "const": "contains", + "title": "fact (an array) must include value" + }, + { + "const": "not_contains", + "title": "fact (an array) must not include value" + } + ], + "title": "Operator", + "description": "The operator compares the value returned by the fact to what is stored in the value property. If the result is truthy, the condition passes.", + "default": "", + "examples": [ + "equal" + ] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object" + }, + { + "type": "array" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "title": "Value", + "description": "The value the fact should be compared to.", + "default": 0, + "examples": [ + 40 + ] + }, + "path": { + "type": "string", + "title": "Path", + "description": "For more complex data structures, writing a separate fact handler for each object property quickly becomes verbose and unwieldy. To address this, a path property may be provided to traverse fact data using json-path syntax. Json-path support is provided by jsonpath-ng.", + "default": "", + "examples": [ + "$.price" + ] + }, + "params": { + "type": "array", + "title": "Parameters", + "description": "A dict that can provide the operator more information about how to process the object." + } + } + } + } +} diff --git a/docs/usage/profile.md b/docs/usage/profile.md index 8f30d746..e10f2f3b 100644 --- a/docs/usage/profile.md +++ b/docs/usage/profile.md @@ -108,34 +108,3 @@ profiles/*/previewImages/ - [gitignore explained on GitHub official documentation](https://docs.github.com/get-started/getting-started-with-git/ignoring-files) - the [.gitignore file](https://github.com/Guts/qgis-deployment-cli/blob/main/examples/.gitignore) used in official examples from QDT repository - ----- - -## Model definition - -The project comes with a [JSON schema](https://raw.githubusercontent.com/Guts/qgis-deployment-cli/main/docs/schemas/profile/qgis_profile.json) describing the model of a profile: - -```{eval-rst} -.. literalinclude:: ../schemas/profile/qgis_profile.json - :language: json -``` - -With a submodel for plugin object: - -```{eval-rst} -.. literalinclude:: ../schemas/profile/qgis_plugin.json - :language: json -``` - -:::{tip} -To retrieve the ID of a plugin see [this page](../guides/howto_qgis_get_plugin_id.md). -::: - ----- - -## Sample profile.json - -```{eval-rst} -.. literalinclude:: ../../tests/fixtures/profiles/good_sample_profile.json - :language: json -``` diff --git a/examples/README.md b/examples/README.md index 61f62427..2abc2ec2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,3 +36,12 @@ As QDT developer, you might want to launch QGIS with this profile to edit or che ```sh qgis --profile "Viewer Mode" --profiles-path examples/ ``` + +---- + +## Only Linux + +Just an empty profile to demonstrate that you can condition the profile deployment to rules. So: + +- if you are running on Linux, you should have a profile called `QDT Only Linux` +- if you are running on Windows, you should not have it! diff --git a/examples/profiles/only_linux/.gitignore b/examples/profiles/only_linux/.gitignore new file mode 100644 index 00000000..d1d22c88 --- /dev/null +++ b/examples/profiles/only_linux/.gitignore @@ -0,0 +1,4 @@ +# QGIS Profiles +python/plugins/ +previewImages/ +*.db diff --git a/examples/profiles/only_linux/profile.json b/examples/profiles/only_linux/profile.json new file mode 100644 index 00000000..4a8c5957 --- /dev/null +++ b/examples/profiles/only_linux/profile.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/Guts/qgis-deployment-cli/main/docs/schemas/profile/qgis_profile.json", + "name": "QDT Only Linux", + "folder_name": "qdt_only_linux", + "description": "Demonstrating a QDT profile that's deployed only on Linux.", + "author": "Julien Moura", + "email": "infos+qdt@oslandia.com", + "qgisMinimumVersion": "3.34.0", + "qgisMaximumVersion": "3.99.10", + "version": "1.0.0", + "rules": [ + { + "name": "Environment", + "description": "Profile is configured to run only on Linux.", + "conditions": { + "all": [ + { + "path": "$.environment.operating_system_code", + "value": "linux", + "operator": "equal" + } + ] + } + } + ] +} diff --git a/qgis_deployment_toolbelt/jobs/job_profiles_synchronizer.py b/qgis_deployment_toolbelt/jobs/job_profiles_synchronizer.py index 856e69ca..6f340369 100644 --- a/qgis_deployment_toolbelt/jobs/job_profiles_synchronizer.py +++ b/qgis_deployment_toolbelt/jobs/job_profiles_synchronizer.py @@ -18,9 +18,13 @@ from pathlib import Path from shutil import copy2, copytree +# 3rd party +from python_rule_engine import RuleEngine + # package from qgis_deployment_toolbelt.jobs.generic_job import GenericJob from qgis_deployment_toolbelt.profiles.qdt_profile import QdtProfile +from qgis_deployment_toolbelt.utils.computer_environment import environment_dict # ############################################################################# # ########## Globals ############### @@ -88,13 +92,79 @@ def run(self) -> None: f"{', '.join(self.PROFILES_NAMES_DOWNLOADED)}" ) + # filter out profiles that do not match the rules + profiles_matched, profiles_unmatched = self.filter_profiles_on_rules( + li_downloaded_profiles=profiles_folders + ) + if not len(profiles_matched): + logger.warning( + "None of the downloaded profiles meet the deployment requirements." + ) + return + + logger.info( + f"Of the {len(profiles_folders)} profiles downloaded, " + f"{len(profiles_unmatched)} do not meet the conditions for deployment." + ) + # copy profiles to the QGIS 3 self.sync_installed_profiles_from_downloaded_profiles( - downloaded_profiles=profiles_folders + downloaded_profiles=profiles_matched ) logger.debug(f"Job {self.ID} ran successfully.") + def filter_profiles_on_rules( + self, li_downloaded_profiles: Iterable[QdtProfile] + ) -> tuple[list[QdtProfile], list[QdtProfile]]: + """Evaluate profile regarding to its deployment rules. + + Args: + li_downloaded_profiles (Iterable[QdtProfile]): input list of QDT profiles + + Returns: + tuple[list[QdtProfile], list[QdtProfile]]: tuple of profiles that matched + and those which did not match their deployment rules + """ + li_profiles_matched = [] + li_profiles_unmatched = [] + + context_object = {"environment": environment_dict()} + for profile in li_downloaded_profiles: + if profile.rules is None: + logger.debug(f"No rules to apply to {profile.name}") + li_profiles_matched.append(profile) + continue + + logger.debug( + f"Checking that profile '{profile.name}' matches deployment conditions." + f"{len(profile.rules)} rules found." + ) + try: + engine = RuleEngine(rules=profile.rules) + results = engine.evaluate(obj=context_object) + if len(results) == len(profile.rules): + logger.debug( + f"Profile '{profile.name}' matches {len(profile.rules)} " + "deployment rule(s)." + ) + li_profiles_matched.append(profile) + else: + logger.info( + f"Profile '{profile.name}' does not match the deployment " + f"conditions: {len(results)}/{len(profile.rules)} rule(s) " + "matched." + ) + li_profiles_unmatched.append(profile) + + except Exception as err: + logger.error( + f"Error occurred parsing rules of profile '{profile.name}'. " + f"Trace: {err}" + ) + + return li_profiles_matched, li_profiles_unmatched + def compare_downloaded_with_installed_profiles( self, li_downloaded_profiles: Iterable[QdtProfile] ) -> tuple[list[QdtProfile], list[QdtProfile], list[QdtProfile]]: @@ -158,7 +228,7 @@ def compare_downloaded_with_installed_profiles( return li_profiles_outdated, li_profiles_different, li_profiles_equal def sync_installed_profiles_from_downloaded_profiles( - self, downloaded_profiles: tuple[QdtProfile] + self, downloaded_profiles: Iterable[QdtProfile] ) -> None: """Copy downloaded profiles to QGIS profiles folder. If the QGIS profiles folder doesn't exist, it will be created and every downloaded profile will be diff --git a/qgis_deployment_toolbelt/profiles/qdt_profile.py b/qgis_deployment_toolbelt/profiles/qdt_profile.py index 48486dad..f0295508 100644 --- a/qgis_deployment_toolbelt/profiles/qdt_profile.py +++ b/qgis_deployment_toolbelt/profiles/qdt_profile.py @@ -69,6 +69,7 @@ def __init__( plugins: list | None = None, qgis_maximum_version: str | None = None, qgis_minimum_version: str | None = None, + rules: list[dict] | None = None, splash: str | None = None, version: str | None = None, **kwargs, @@ -99,6 +100,7 @@ def __init__( self._plugins = None self._qgis_maximum_version = None self._qgis_minimum_version = None + self._rules = None self._version = None # if values have been passed, so use them as objects attributes. @@ -125,6 +127,8 @@ def __init__( self._qgis_maximum_version = qgis_maximum_version if qgis_minimum_version: self._qgis_minimum_version = qgis_minimum_version + if rules: + self._rules = rules if splash: self._splash = splash if version: @@ -213,7 +217,7 @@ def is_loaded_from_json(self) -> bool: return self.loaded_from_json @property - def icon(self) -> str: + def icon(self) -> str | None: """Returns the icon as specified into the original profile.json. :return str: profile icon value @@ -239,7 +243,7 @@ def json_ref_path(self) -> Path: return self._json_ref_path.resolve() @property - def name(self) -> str: + def name(self) -> str | None: """Returns the profile's name. If not set, the folder name is used. Returns: @@ -275,7 +279,16 @@ def plugins(self) -> list[QgisPlugin]: return [] @property - def splash(self) -> str | Path: + def rules(self) -> list[dict] | None: + """Returns the rules associated with the profile. + + Returns: + list[rules]: list of rules + """ + return self._rules + + @property + def splash(self) -> str | Path | None: """Returns the profile splash image as path if can be resolved or as string. Returns: @@ -291,7 +304,7 @@ def splash(self) -> str | Path: return self._splash @property - def version(self) -> str: + def version(self) -> str | None: """Returns the profile version as string. Returns: @@ -299,7 +312,7 @@ def version(self) -> str: """ return self._version - def is_older_than(self, version_to_compare: str | QdtProfile) -> bool: + def is_older_than(self, version_to_compare: str | QdtProfile) -> bool | None: """Determine if the actual object version is older than the given version to compare. diff --git a/qgis_deployment_toolbelt/utils/computer_environment.py b/qgis_deployment_toolbelt/utils/computer_environment.py new file mode 100644 index 00000000..6f6e755b --- /dev/null +++ b/qgis_deployment_toolbelt/utils/computer_environment.py @@ -0,0 +1,58 @@ +#! python3 # noqa: E265 + +""" + Base of QDT jobs. + + Author: Julien Moura (https://github.com/guts) +""" + + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import logging +import platform +from sys import platform as opersys + +# ############################################################################# +# ########## Globals ############### +# ################################## + +# logs +logger = logging.getLogger(__name__) + + +# ############################################################################# +# ########## Functions ############# +# ################################## + + +def environment_dict() -> dict: + """Returns a dictionary containing some environment information (computer, network, + platform) that can be used in QDT various places: rules... + + Returns: + dict: dict with some environment metadata to use in rules. + """ + try: + linux_distribution_name = f"{platform.freedesktop_os_release().get('NAME')}" + linux_distribution_version = ( + f"{platform.freedesktop_os_release().get('VERSION_ID')}" + ) + except OSError as err: + logger.debug(f"Trace: {err}.") + linux_distribution_name = None + linux_distribution_version = None + + return { + "computer_network_name": platform.node(), + "operating_system_code": opersys, + "processor_architecture": platform.machine(), + # custom Linux + "linux_distribution_name": linux_distribution_name, + "linux_distribution_version": linux_distribution_version, + # custom Windows + "windows_edition": platform.win32_edition(), + } diff --git a/requirements/base.txt b/requirements/base.txt index 3c8cd431..6cfb476f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -3,6 +3,7 @@ dulwich>=0.21.7,<0.21.8 giturlparse>=0.12,<0.13 imagesize>=1.4,<1.5 packaging>=20,<24 +python-rule-engine>=0.5,<0.6 pyyaml>=5.4,<7 pywin32==306 ; sys_platform == 'win32' requests>=2.31,<3 diff --git a/tests/dev/dev_rules_engine.py b/tests/dev/dev_rules_engine.py new file mode 100644 index 00000000..d50a01a1 --- /dev/null +++ b/tests/dev/dev_rules_engine.py @@ -0,0 +1,89 @@ +import pprint +from sys import platform + +from python_rule_engine import RuleEngine + +# rule = { +# "name": "basic_rule", +# "conditions": { +# "all": [ +# { +# # JSONPath support +# "path": "$.person.name", +# "operator": "equal", +# "value": "Lionel", +# }, +# {"path": "$.person.last_name", "operator": "equal", "value": "Messi"}, +# { +# "path": "$.environment.name", +# "operator": "equal", +# "value": "linux", +# }, +# ] +# }, +# } + +# print(platform) +# obj = { +# "person": {"name": "Lionel", "last_name": "Messi"}, +# "environment": {"name": platform}, +# } + +# engine = RuleEngine([rule]) + +# results = engine.evaluate(obj) +# # print(all([r.conditions.match for r in results])) +# print(len(results)) +# for r in results: +# print(r) +# print(r.description) +# print(r.conditions.match) + +# from package tests +obj = { + "person": {"name": "Santissago", "last_name": "Alvarez"}, + "environment": {"operating_system": platform}, +} + +rules = [ + { + "name": "basic_rule", + "description": "Basic rule to test the engine", + "extra": {"some_field": "some_value"}, + "conditions": { + "all": [ + { + "condition": "pioupiou", + "path": "$.person.name", + "value": "Santiago", + "operator": "equal", + }, + {"path": "$.person.last_name", "value": "Alvarez", "operator": "equal"}, + ] + }, + }, + { + "name": "basic_rule", + "description": "Basic rule to test the engine", + "extra": {"some_field": "some_value"}, + "conditions": { + "all": [ + { + "path": "$.environment.operating_system", + "value": "linux", + "operator": "equal", + }, + ] + }, + }, +] + +engine = RuleEngine(rules) + +results = engine.evaluate(obj) +print(f"{len(rules)} rules") +print(len(results), [r.conditions.match for r in results]) +if len(results) == len(rules): + assert results[0].conditions.match is True +else: + print("FAIIILED")