diff --git a/.vscode/settings.json b/.vscode/settings.json index 60d98022..7a9ce778 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,6 +31,7 @@ "rcfile", "recupere", "requêtage", + "requêter", "setuptools", "sharings", "sidc", @@ -62,18 +63,17 @@ "python.analysis.extraPaths": [ ".", ], - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, - "python.linting.mypyEnabled": true, - "python.linting.mypyArgs": [ + "pylint.args": ["--rcfile=.pylintrc"], + "mypy-type-checker.args": [ "--strict", "--check-untyped-defs", "--show-column-numbers", + "--config-file mypy.ini", ], "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true, }, - "python.formatting.provider": "black", // ASSOCIATIONS DE FICHIERS "files.associations": { // .pylintrc est au format .ini diff --git a/docs/comme-module.md b/docs/comme-module.md index c6c2de37..4eac7c09 100644 --- a/docs/comme-module.md +++ b/docs/comme-module.md @@ -1 +1,132 @@ # Utilisation comme module Python + +## Configuration + +Afin d'utiliser cette librairie comme module, vous devrez [écrire un fichier de configuration](configuration.md) comme pour les autres utilisations. + +Ce fichier devra être chargé au début de votre script grâce à la classe `Config` : + +```py +# Importation de la classe Config +from ignf_gpf_sdk.io.Config import Config + +# Chargement de mon fichier de config +Config().read("config.ini") +``` + +## Livraison de données + +### Avec la classe `UploadAction` + +Pour livrer des données, vous pouvez utiliser les [fichiers de descripteur de livraison](upload_descriptor.md) et appeler la classe `UploadAction`. +Cela sera plus simple d'un point de vue Python mais moins modulaire. + +Voici un exemple de code Python permettant de le faire (à lancer après le chargement de la config !) : + +```py +# Importation des classes DescriptorFileReader et UploadAction +from ignf_gpf_sdk.io.DescriptorFileReader import DescriptorFileReader +from ignf_gpf_sdk.workflow.action.UploadAction import UploadAction + +# Instanciation d'une DescriptorFileReader +descriptor_file_reader = DescriptorFileReader(p_descriptor) + +# Instanciation d'une UploadAction à partir du Reader +o_upload_action = UploadAction(o_dataset, behavior=s_behavior) +# On crée la livraison +o_upload = o_upload_action.run() +# On ferme la livraison et on monitore les exécutions de vérification +b_status = UploadAction.monitor_until_end(o_upload, Livraison.callback_check) +``` + +!!! note "Note" + + Vous pouvez préciser l'id d'un autre datastore s'il ne faut pas utiliser celui indiquer en configuration : + + ```py + # On crée la livraison en précisant un datastore spécifique + o_upload = o_upload_action.run(datastore='id-datastore-spécifique') + ``` + +### Sans la classe `UploadAction` + +Si vous souhaitez livrer les données de manière plus flexible, vous pouvez également utiliser directement la classe `Upload` pour créer, compléter et fermer votre livraison. + +Voici un exemple de code Python permettant de le faire (à lancer après le chargement de la config !) : + +```py +from pathlib import Path +# Importation de la classe Upload +from ignf_gpf_sdk.store.Upload import Upload + +# Attributs pour créer ma livraison (cf. la documentation) +# https://data.geopf.fr/api/swagger-ui/index.html#/Livraisons%20et%20vérifications/create +info = { + "name": "Nom de la livraison à créer", + "description": "Description de la livraison à créer", + "type": "VECTOR", + "srs": "EPSG:2154", +} + +# Création d'une livraison +upload = Upload.api_create(info) + +# Ajout des informations complémentaires (commentaires et étiquettes) +upload.api_add_comment({"text": "mon commentaire"}) +upload.api_add_tags({"tag1": "valeur1", "tag2": "valeur2"}) + +# Téléversement des fichiers +# Listes des fichiers : chemin local -> chemin distant +files = {Path('mon_fichier.zip') : 'chemin/api/'} +# Pour chaque fichier +for local_path, api_path in files.items(): + # On le téléverse en utilisant la méthode api_push_data_file + upload.api_push_data_file(local_path, api_path) + +# Téléversement des fichiers md5 +upload.api_push_md5_file(Path('checksum.md5')) + +# Fermeture de la livraison +upload.api_close() +``` + +!!! note "Note" + + Vous pouvez préciser l'id d'un autre datastore s'il ne faut pas utiliser celui indiquer en configuration : + + ```py + # Création d'une livraison en précisant un datastore spécifique + upload = Upload.api_create(info, datastore='id-datastore-spécifique') + ``` + +## Traitement et publications des données + +D'un point de vue API Entrepôt, pour traiter et publier des données, vous allez créer : + +* des exécutions de traitement (`processing execution`) ; +* des configurations (`configuration`) ; +* des offres (`offering`). + +Avec ce SDK, vous pouvez le faire en manipulant des workflows ou directement en manipulant les classes ProcessingExecution, Configuration et Offering. + +La première méthode est plus simple (et généreusement configurable !), la seconde méthode sera plus complexe mais très flexible. + +### En utilisant des workflows + +On part ici du principe que vous avez déjà écrit [votre workflow](workflow.md). + +```py +# Importation de la classe JsonHelper +from ignf_gpf_sdk.helper.JsonHelper import JsonHelper +# Importation des classes Workflow, GlobalResolver et StoreEntityResolver +from ignf_gpf_sdk.workflow.Workflow import Workflow +from ignf_gpf_sdk.workflow.resolver.GlobalResolver import GlobalResolver +from ignf_gpf_sdk.workflow.resolver.StoreEntityResolver import StoreEntityResolver +``` + +La première étape consiste à charger le fichier de workflow et à instancier la classe associée. Vous pouvez utiliser notre classe de lecture JSON qui gère les fichier `.jsonc` (c'est à dire avec des commentaires). + +```py +p_workflow = Path("mon_workflow.jsonc").absolute() +o_workflow = Workflow(p_workflow.stem, JsonHelper.load(p_workflow)) +``` diff --git a/docs/configuration.md b/docs/configuration.md index edf4acee..8c43d4fe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -125,12 +125,12 @@ Pour cela, il faut ajouter deux lignes dans le fichier de configuration. Voici u ```ini [store_authentification] # L'url de récupération du token d'authentification (cf. doc) -token_url=https://geoplateforme-gpf-iam.qua.gpf-tech.ign.fr/realms/geoplateforme/protocol/openid-connect/token +token_url= https://sso-qua.priv.geopf.fr/realms/geoplateforme/protocol/openid-connect/token # Autres paramètres à conserver (client_id, ...) [store_api] # L'url d'entrée de l'API (cf. doc) -root_url=https://geoplateforme-gpf-warehouse.qua.gpf-tech.ign.fr +root_url=https://data-qua.priv.geopf.fr/api # Autres paramètres à conserver (datastore, ...) ``` diff --git a/docs/configuration_details.md b/docs/configuration_details.md index 91975f80..85da19f4 100644 --- a/docs/configuration_details.md +++ b/docs/configuration_details.md @@ -22,7 +22,7 @@ Cette partie de la configuration permet au module de vous authentifier et de ré | Paramètre | Type | Défaut | Description | | ---------------------- | ---- | -------------- | --------------------------------------------------------------- | -| `token_url` | str | | URL du service d'authentification de la Géoplateforme. Elle n'est à priori pas à changer, sauf si vous [utilisez un environnement particulier](/configuration/#utiliser-un-environnement-particulier-qualification) (test, qualification, ...). | +| `token_url` | str | | URL du service d'authentification de la Géoplateforme. Elle n'est à priori pas à changer, sauf si vous [utilisez un environnement particulier](configuration.md#utiliser-un-environnement-particulier-qualification) (test, qualification, ...). | | `http_proxy` | str | `null` | Indiquez ici le proxy HTTP à utiliser si besoin. | | `https_proxy` | str | `null` | Indiquez ici le proxy HTTPS à utiliser si besoin. | | `grant_type` | str | `password` | Indiquez ici le type d'authentification à utiliser (`password` ou `client_credentials`). | diff --git a/ignf_gpf_sdk/__init__.py b/ignf_gpf_sdk/__init__.py index 929133c0..d4565d08 100644 --- a/ignf_gpf_sdk/__init__.py +++ b/ignf_gpf_sdk/__init__.py @@ -1,3 +1,3 @@ """Python API to simplify the use of the GPF HTTPS API.""" -__version__ = "0.1.15" +__version__ = "0.1.16" diff --git a/ignf_gpf_sdk/__main__.py b/ignf_gpf_sdk/__main__.py index b60a12a6..31f9cae3 100644 --- a/ignf_gpf_sdk/__main__.py +++ b/ignf_gpf_sdk/__main__.py @@ -4,7 +4,6 @@ import io import sys import argparse -import time import traceback from pathlib import Path from typing import Callable, List, Optional, Sequence @@ -23,6 +22,7 @@ from ignf_gpf_sdk.workflow.action.UploadAction import UploadAction from ignf_gpf_sdk.io.Config import Config from ignf_gpf_sdk.io.DescriptorFileReader import DescriptorFileReader +from ignf_gpf_sdk import store from ignf_gpf_sdk.store.Offering import Offering from ignf_gpf_sdk.store.Configuration import Configuration from ignf_gpf_sdk.store.StoredData import StoredData @@ -36,6 +36,8 @@ class Main: """Classe d'entrée pour utiliser la lib comme binaire.""" + DELETABLE_TYPES = [Upload.entity_name(), StoredData.entity_name(), Configuration.entity_name(), Offering.entity_name()] + def __init__(self) -> None: """Constructeur.""" # Résolution des paramètres utilisateurs @@ -99,7 +101,7 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace: o_sub_parser = o_sub_parsers.add_parser("config", help="Configuration") o_sub_parser.add_argument("--file", "-f", type=str, default=None, help="Chemin du fichier où sauvegarder la configuration (si null, la configuration est affichée)") o_sub_parser.add_argument("--section", "-s", type=str, default=None, help="Se limiter à une section") - o_sub_parser.add_argument("--option", "-o", type=str, default=None, help="Se limiter à une option (section doit être renseignée)") + o_sub_parser.add_argument("--option", "-o", type=str, default=None, help="Se limiter à une option (la section doit être renseignée)") # Parser pour upload s_epilog_upload = """Trois types de lancement : @@ -109,13 +111,13 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace: """ o_sub_parser = o_sub_parsers.add_parser("upload", help="Livraisons", epilog=s_epilog_upload, formatter_class=argparse.RawTextHelpFormatter) o_sub_parser.add_argument("--file", "-f", type=str, default=None, help="Chemin vers le fichier descriptor dont on veut effectuer la livraison)") - o_sub_parser.add_argument("--behavior", "-b", type=str, default=None, help="Action à effectuer si la livraison existe déjà (uniquement avec -f)") + o_sub_parser.add_argument("--behavior", "-b", choices=UploadAction.BEHAVIORS, default=None, help="Action à effectuer si la livraison existe déjà (uniquement avec -f)") o_sub_parser.add_argument("--id", type=str, default=None, help="Affiche la livraison demandée") o_exclusive = o_sub_parser.add_mutually_exclusive_group() o_exclusive.add_argument("--open", action="store_true", default=False, help="Rouvrir une livraison fermée (uniquement avec --id)") o_exclusive.add_argument("--close", action="store_true", default=False, help="Fermer une livraison ouverte (uniquement avec --id)") - o_sub_parser.add_argument("--infos", "-i", type=str, default=None, help="Filter les livraisons selon les infos") - o_sub_parser.add_argument("--tags", "-t", type=str, default=None, help="Filter les livraisons selon les tags") + o_sub_parser.add_argument("--infos", "-i", type=str, default=None, help="Filtrer les livraisons selon les infos") + o_sub_parser.add_argument("--tags", "-t", type=str, default=None, help="Filtrer les livraisons selon les tags") # Parser pour dataset o_sub_parser = o_sub_parsers.add_parser("dataset", help="Jeux de données") @@ -134,13 +136,23 @@ def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace: o_sub_parser.add_argument("--name", "-n", type=str, default=None, help="Nom du workflow à extraire") o_sub_parser.add_argument("--step", "-s", type=str, default=None, help="Étape du workflow à lancer") o_sub_parser.add_argument("--behavior", "-b", type=str, default=None, help="Action à effectuer si l'exécution de traitement existe déjà") + o_sub_parser.add_argument("--tag", "-t", type=str, nargs=2, action="append", metavar=("Clef", "Valeur"), default=[], help="Tag à ajouter aux actions (plusieurs tags possible)") + o_sub_parser.add_argument( + "--comment", + "-c", + type=str, + default=[], + action="append", + metavar='"Le commentaire"', + help="Commentaire à ajouter aux actions (plusieurs commentaires possible, mettre le commentaire entre guillemets)", + ) # Parser pour delete o_sub_parser = o_sub_parsers.add_parser("delete", help="Delete") - o_sub_parser.add_argument("--type", choices=["livraison", "stored_data", "configuration", "offre"], required=True, help="Type de l'entité à supprimé") - o_sub_parser.add_argument("--id", type=str, required=True, help="identifiant de l'entité à supprimé") + o_sub_parser.add_argument("--type", choices=Main.DELETABLE_TYPES, required=True, help="Type de l'entité à supprimer") + o_sub_parser.add_argument("--id", type=str, required=True, help="Identifiant de l'entité à supprimer") o_sub_parser.add_argument("--cascade", action="store_true", help="Action à effectuer si l'exécution de traitement existe déjà") - o_sub_parser.add_argument("--force", action="store_true", help="Mode forcée, les suppressions sont faites sans aucune interaction") + o_sub_parser.add_argument("--force", action="store_true", help="Mode forcé, les suppressions sont faites sans aucune interaction") return o_parser.parse_args(args) @@ -255,19 +267,19 @@ def config(self) -> None: print(o_string_io.read()[:-1]) @staticmethod - def __monitoring_upload(upload: Upload, message_ok: str, message_ko: str, callback: Optional[Callable[[str], None]] = None) -> bool: - """monitiring de l'upload et affichage état de sortie + def __monitoring_upload(upload: Upload, message_ok: str, message_ko: str, callback: Optional[Callable[[str], None]] = None, ctrl_c_action: Optional[Callable[[], bool]] = None) -> bool: + """Monitoring de l'upload et affichage état de sortie Args: upload (Upload): upload à monitorer message_ok (str): message si les vérifications sont ok message_ko (str): message si les vérifications sont en erreur callback (Optional[Callable[[str], None]], optional): fonction de callback à exécuter avec le message de suivi. - + ctrl_c_action (Optional[Callable[[], bool]], optional): gestion du ctrl-C Returns: bool: True si toutes les vérifications sont ok, sinon False """ - b_res = UploadAction.monitor_until_end(upload, callback) + b_res = UploadAction.monitor_until_end(upload, callback, ctrl_c_action) if b_res: Config().om.info(message_ok.format(upload=upload), green_colored=True) else: @@ -313,7 +325,7 @@ def upload(self) -> None: Config().om.info(f"La livraison {o_upload} est fermé, les tests sont en cours.") self.__monitoring_upload(o_upload, "Livraison {upload} fermée avec succès.", "Livraison {o_upload} fermée en erreur !", print) return - # si ferme OK ou KO : wwarning + # si ferme OK ou KO : warning if o_upload["status"] in [Upload.STATUS_CLOSED, Upload.STATUS_UNSTABLE]: Config().om.warning(f"La livraison {o_upload} est déjà fermée, status : {o_upload['status']}") return @@ -353,6 +365,37 @@ def dataset(self) -> None: l_children.append(p_child.name) print("Jeux de données disponibles :\n * {}".format("\n * ".join(l_children))) + @staticmethod + def ctrl_c_action() -> bool: + """fonction callback pour la gestion du ctrl-C + Renvoie un booléen d'arrêt de traitement. Si True, on doit arrêter le traitement. + """ + # issues/9 : + # sortie => sortie du monitoring, ne pas arrêter le traitement + # stopper l’exécution de traitement => stopper le traitement (et donc le monitoring) [par défaut] (raise une erreur d'interruption volontaire) + # ignorer / "erreur de manipulation" => reprendre le suivi + s_reponse = "rien" + while s_reponse not in ["a", "s", "c", ""]: + Config().om.info( + "Vous avez taper ctrl-C. Que souhaitez-vous faire ?\n\ + \t* 'a' : pour sortir et le traitement [par défaut]\n\ + \t* 's' : pour sortir le traitement\n\ + \t* 'c' : pour annuler et le traitement" + ) + s_reponse = input().lower() + + if s_reponse == "s": + Config().om.info("\t 's' : sortir le traitement") + sys.exit(0) + + if s_reponse == "c": + Config().om.info("\t 'c' : annuler et le traitement") + return False + + # on arrête le traitement + Config().om.info("\t 'a' : sortir et le traitement [par défaut]") + return True + def workflow(self) -> None: """Vérifie ou exécute un workflow.""" p_root = Config.data_dir_path / "workflows" @@ -415,7 +458,10 @@ def callback_run_step(processing_execution: ProcessingExecution) -> None: except Exception: PrintLogHelper.print("Logs indisponibles pour le moment...") - o_workflow.run_step(self.o_args.step, callback_run_step, behavior=s_behavior, datastore=self.datastore) + # on lance le monitoring de l'étape en précisant la gestion du ctrl-C + d_tags = {l_el[0]: l_el[1] for l_el in self.o_args.tag} + o_workflow.run_step(self.o_args.step, callback_run_step, self.ctrl_c_action, behavior=s_behavior, datastore=self.datastore, comments=self.o_args.comment, tags=d_tags) + else: l_children: List[str] = [] for p_child in p_root.iterdir(): @@ -425,47 +471,41 @@ def callback_run_step(processing_execution: ProcessingExecution) -> None: def delete(self) -> None: """suppression d'une entité par son type et son id""" - l_entities: List[StoreEntity] = [] - if self.o_args.type == "livraison": - l_entities.append(Upload.api_get(self.o_args.id)) - elif self.o_args.type == "stored_data": - o_stored_data = StoredData.api_get(self.o_args.id) - if self.o_args.cascade: - # liste des configurations - l_configuration = Configuration.api_list({"stored_data": self.o_args.id}) - for o_configuration in l_configuration: - # pour chaque configuration on récupère les offerings - l_offering = o_configuration.api_list_offerings() - l_entities += l_offering - l_entities.append(o_configuration) - l_entities.append(o_stored_data) - elif self.o_args.type == "configuration": - o_configuration = Configuration.api_get(self.o_args.id) - if self.o_args.cascade: - l_offering = o_configuration.api_list_offerings() - l_entities += l_offering - l_entities.append(o_configuration) - elif self.o_args.type == "offre": - l_entities.append(Offering.api_get(self.o_args.id)) - - # affichage élément supprimés - Config().om.info("Suppression de :") - for o_entity in l_entities: - Config().om.info(str(o_entity), green_colored=True) - - # demande validation si non forcée - if not self.o_args.force: + + def question_before_delete(l_delete: List[StoreEntity]) -> List[StoreEntity]: + Config().om.info("suppression de :") + for o_entity in l_delete: + Config().om.info(str(o_entity), green_colored=True) Config().om.info("Voulez-vous effectué la suppression ? (oui/NON)") s_rep = input() # si la réponse ne correspond pas à oui on sort if s_rep.lower() not in ["oui", "o", "yes", "y"]: Config().om.info("La suppression est annulée.") - return + return [] + return l_delete + + def print_before_delete(l_delete: List[StoreEntity]) -> List[StoreEntity]: + Config().om.info("suppression de :") + for o_entity in l_delete: + Config().om.info(str(o_entity), green_colored=True) + return l_delete + + if self.o_args.type in Main.DELETABLE_TYPES: + # récupération de l'entité de base + o_entity = store.TYPE__ENTITY[self.o_args.type].api_get(self.o_args.id) + else: + raise GpfSdkError(f"Type {self.o_args.type} non reconnu. Types valides : {', '.join(Main.DELETABLE_TYPES)}") + + # choix de la fonction exécuté avant la suppression + ## force : juste affichage + ## sinon : question d'acceptation de la suppression + f_delete = print_before_delete if self.o_args.force else question_before_delete + # suppression - for o_entity in l_entities: - o_entity.api_delete() - time.sleep(1) - Config().om.info("Suppression effectué.", green_colored=True) + if self.o_args.cascade: + o_entity.delete_cascade(f_delete) + else: + StoreEntity.delete_liste_entities([o_entity], f_delete) if __name__ == "__main__": diff --git a/ignf_gpf_sdk/_conf/default.ini b/ignf_gpf_sdk/_conf/default.ini index 7bc30e82..729e38e6 100644 --- a/ignf_gpf_sdk/_conf/default.ini +++ b/ignf_gpf_sdk/_conf/default.ini @@ -54,8 +54,6 @@ upload_get=${routing:upload_list}/{upload} upload_delete=${routing:upload_list}/{upload} upload_add_tags=${upload_get}/tags upload_delete_tags=${upload_get}/tags -upload_push_data=${upload_get}/data -upload_delete_data=${upload_push_data} upload_push_md5=${upload_get}/md5 upload_delete_md5=${upload_push_md5} upload_close=${upload_get}/close @@ -196,6 +194,10 @@ behavior_if_exists=STOP # d'une même ligne sont séparées par un point-virgule uniqueness_constraint_infos=name;layer_name uniqueness_constraint_tags= +behavior_if_exists=CONTINUE + +[offering] +behavior_if_exists=CONTINUE [static] create_file_key=file @@ -227,7 +229,7 @@ filter_infos = ((\s*)INFOS(\s*)\((?P.*?)\)(\s*))? filter_tags = ((\s*)TAGS(\s*)\((?P.*?)\)(\s*))? filter = ((\s*)\[${filter_infos},?${filter_tags}\]) store_entity_regex=(?P(upload|stored_data|processing_execution|offering|processing|configuration|endpoint|static|datastore))\.(?P(tags|infos))\.(?P\w*)${filter} -global_regex=(?P(\["|{"?)(?P[a-z_]+)(\.|":"|",")(?P[^"]*)("\]|"?})) +global_regex=(?P(\["|{"?)(?P[a-z_]+)(\.|": *"|", *")(?P({[^}]+})?[^"}]*?)("\]|"?})) file_regex=(?Pstr|list|dict)\((?P.*)\) diff --git a/ignf_gpf_sdk/_conf/json_schemas/workflow.json b/ignf_gpf_sdk/_conf/json_schemas/workflow.json index 95bbd2c6..90cf5ae8 100644 --- a/ignf_gpf_sdk/_conf/json_schemas/workflow.json +++ b/ignf_gpf_sdk/_conf/json_schemas/workflow.json @@ -68,6 +68,15 @@ "items": { "type": "string" } + }, + "comments": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "object" } } } @@ -76,6 +85,15 @@ }, "datastore": { "type" : "string" + }, + "comments": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "object" } }, "required": [ "workflow" ], diff --git a/ignf_gpf_sdk/helper/JsonHelper.py b/ignf_gpf_sdk/helper/JsonHelper.py index 162e2dfd..72bf5175 100644 --- a/ignf_gpf_sdk/helper/JsonHelper.py +++ b/ignf_gpf_sdk/helper/JsonHelper.py @@ -1,6 +1,6 @@ from pathlib import Path from typing import Any -import jsonschema # type: ignore +import jsonschema from jsonc_parser.parser import JsoncParser # type: ignore from jsonc_parser.errors import ParserError # type: ignore diff --git a/ignf_gpf_sdk/io/ApiRequester.py b/ignf_gpf_sdk/io/ApiRequester.py index 6130783f..b77fe031 100644 --- a/ignf_gpf_sdk/io/ApiRequester.py +++ b/ignf_gpf_sdk/io/ApiRequester.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals from io import BufferedReader +import json from pathlib import Path import re import time @@ -92,8 +93,14 @@ def route_request( # On formate l'URL s_url = s_route.format(**route_params) + # récupération du header additionnel + s_header = Config().get("routing", route_name + "_header", fallback=None) + d_header = {} + if s_header is not None: + d_header = json.loads(s_header) + # Exécution de la requête en boucle jusqu'au succès (ou erreur au bout d'un certains temps) - return self.url_request(s_url, method, params, data, files) + return self.url_request(s_url, method, params, data, files, d_header) def url_request( self, @@ -102,6 +109,7 @@ def url_request( params: Optional[Dict[str, Any]] = None, data: Optional[Union[Dict[str, Any], List[Any]]] = None, files: Optional[Dict[str, Tuple[str, BufferedReader]]] = None, + header: Dict[str, str] = {}, ) -> requests.Response: """Effectue une requête à l'API à partir d'une url. La requête est retentée plusieurs fois s'il y a un problème. @@ -111,6 +119,7 @@ def url_request( params (Optional[Dict[str, Any]], optional): paramètres de la requête (ajouté à l'url) data (Optional[Union[Dict[str, Any], List[Any]]], optional): contenue de la requête (ajouté au corp) files (Optional[Dict[str, Tuple[Any]]], optional): fichiers à envoyer + header (Dict[str, str], optional): Header additionnel pour la requête Returns: réponse si succès @@ -122,7 +131,7 @@ def url_request( i_nb_attempts += 1 try: # On fait la requête - return self.__url_request(url, method, params=params, data=data, files=files) + return self.__url_request(url, method, params=params, data=data, files=files, header=header) except NotFoundError as e_error: # Si l'entité n'est pas trouvée, on ne retente pas, on sort directement en erreur s_message = f"L'élément demandé n'existe pas ({e_error.message}). Contactez le support si vous n'êtes pas à l'origine de la demande. URL : {method} {e_error.url}." @@ -174,6 +183,7 @@ def __url_request( params: Optional[Dict[str, Any]] = None, data: Optional[Union[Dict[str, Any], List[Any]]] = None, files: Optional[Dict[str, Tuple[str, BufferedReader]]] = None, + header: Dict[str, str] = {}, ) -> requests.Response: """Effectue une requête à l'API à partir d'une url. Ne retente pas plusieurs fois si problème. @@ -183,6 +193,7 @@ def __url_request( params (Optional[Dict[str, Any]], optional): paramètres. data (Optional[Union[Dict[str, Any], List[Any]]], optional): données. files (Optional[Dict[str, Tuple[Any]]], optional): fichiers. + header (Dict[str, str], optional): Header additionnel pour la requête. Returns: réponse si succès @@ -191,6 +202,8 @@ def __url_request( # Définition du header d_headers = Authentifier().get_http_header(json_content_type=files is None) + d_headers.update(header) + # Création du MultipartEncoder (cf. https://github.com/requests/toolbelt#multipartform-data-encoder) d_requests: Dict[str, Any] = { "url": url, @@ -205,9 +218,13 @@ def __url_request( o_me = MultipartEncoder(fields=d_fields) d_headers["content-type"] = o_me.content_type # Execution de la requête - d_requests.update({"data": o_me}) + # TODO : contournement pour les uploads, supprimer `"verify": False` une fois le problème résolu + suppression proxy + d_requests.update({"data": o_me, "verify": False}) + del d_requests["proxies"] else: d_requests.update({"params": params, "json": data}) + + # exécution de la requête r = requests.request(**d_requests) # Vérification du résultat... diff --git a/ignf_gpf_sdk/io/OutputManager.py b/ignf_gpf_sdk/io/OutputManager.py index fe375d1a..e57f43d8 100644 --- a/ignf_gpf_sdk/io/OutputManager.py +++ b/ignf_gpf_sdk/io/OutputManager.py @@ -41,7 +41,7 @@ def debug(self, message: str) -> None: Args: message (str): message de type debug à journaliser """ - self.__logger.debug("DEBUG - %s", message) + self.__logger.debug("%sDEBUG - %s%s", Color.GREY, message, Color.END) def info(self, message: str, green_colored: bool = False) -> None: """Ajout d'un message de type info diff --git a/ignf_gpf_sdk/store/Configuration.py b/ignf_gpf_sdk/store/Configuration.py index f518cc42..434d2583 100644 --- a/ignf_gpf_sdk/store/Configuration.py +++ b/ignf_gpf_sdk/store/Configuration.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List +from typing import Any, Callable, Dict, List, Optional from ignf_gpf_sdk.store.Offering import Offering from ignf_gpf_sdk.store.StoreEntity import StoreEntity @@ -46,3 +46,17 @@ def api_add_offering(self, data_offering: Dict[str, Any]) -> Offering: Offering: représentation Python de l'Offering créée """ return Offering.api_create(data_offering, route_params={self._entity_name: self.id}) + + def delete_cascade(self, before_delete: Optional[Callable[[List["StoreEntity"]], List["StoreEntity"]]] = None) -> None: + """Fonction de suppression de la Configuration en supprimant en cascade les offres liées (et uniquement les offres, pas les données stockées). + + Args: + before_delete (Optional[Callable[[List[StoreEntity]], List[StoreEntity]]], optional): fonction à lancer avant la suppression (entrée : liste des entités à supprimer, + sortie : liste définitive des entités à supprimer). Defaults to None. + """ + # suppression d'une configuration : offres puis configuration + l_entities: List[StoreEntity] = [] + l_offering = self.api_list_offerings() + l_entities += l_offering + l_entities.append(self) + self.delete_liste_entities(l_entities, before_delete) diff --git a/ignf_gpf_sdk/store/StoreEntity.py b/ignf_gpf_sdk/store/StoreEntity.py index 709ce631..2b441210 100644 --- a/ignf_gpf_sdk/store/StoreEntity.py +++ b/ignf_gpf_sdk/store/StoreEntity.py @@ -1,6 +1,7 @@ import json from abc import ABC -from typing import Any, Dict, List, Optional, Type, TypeVar +import time +from typing import Any, Callable, Dict, List, Optional, Type, TypeVar from datetime import datetime from dateutil import parser @@ -242,6 +243,38 @@ def filter_dict_from_str(filters: Optional[str]) -> Dict[str, str]: raise StoreEntityError(s_error_message) return d_filter + def delete_cascade(self, before_delete: Optional[Callable[[List["StoreEntity"]], List["StoreEntity"]]] = None) -> None: + """Suppression en cascade d'une entité en supprimant les entités liées (qui vont dépendre du type de l'entité, donc cf. les classes filles). + + Args: + before_delete (Optional[Callable[[List[StoreEntity]], List[StoreEntity]]], optional): fonction à lancer avant la suppression (entrée : liste des entités à supprimer, + sortie : liste définitive des entités à supprimer). Defaults to None. + """ + # suppression d'une configuration : offres puis configuration + self.delete_liste_entities([self], before_delete) + + @staticmethod + def delete_liste_entities(l_entities: List["StoreEntity"], before_delete: Optional[Callable[[List["StoreEntity"]], List["StoreEntity"]]] = None) -> None: + """Suppression d'une liste d’entités. Exécution de `before_delete(l_entities)` avant la suppression, before_delete retourne la nouvelle liste des éléments à supprimer. + + Args: + l_entities (List[StoreEntity]]): liste des entités à supprimer + before_delete (Optional[Callable[[List[StoreEntity]], List[StoreEntity]]], optional): fonction à lancer avant la suppression (entrée : liste des entités à supprimer, + sortie : liste définitive des entités à supprimer). Defaults to None. + """ + if before_delete is not None: + # callback avant suppression + l_entities = before_delete(l_entities) + if not l_entities: + Config().om.info("Aucun élément supprimé.") + return + Config().om.info("Début de la suppression ...") + # suppression + for o_entity in l_entities: + o_entity.api_delete() + time.sleep(1) + Config().om.info("Suppression effectuée.", green_colored=True) + ############################################################## # Récupération du JSON ############################################################## diff --git a/ignf_gpf_sdk/store/StoredData.py b/ignf_gpf_sdk/store/StoredData.py index ee51b20b..09151181 100644 --- a/ignf_gpf_sdk/store/StoredData.py +++ b/ignf_gpf_sdk/store/StoredData.py @@ -1,3 +1,6 @@ +from typing import Optional, List, Callable + +from ignf_gpf_sdk.store.Configuration import Configuration from ignf_gpf_sdk.store.StoreEntity import StoreEntity from ignf_gpf_sdk.store.interface.TagInterface import TagInterface from ignf_gpf_sdk.store.interface.EventInterface import EventInterface @@ -18,3 +21,26 @@ class StoredData(TagInterface, CommentInterface, SharingInterface, EventInterfac STATUS_GENERATED = "GENERATED" STATUS_DELETED = "DELETED" STATUS_UNSTABLE = "UNSTABLE" + + def delete_cascade(self, before_delete: Optional[Callable[[List["StoreEntity"]], List["StoreEntity"]]] = None) -> None: + """Suppression de la donnée stockée avec suppression en cascade des configuration liées et des offres liées à chaque configuration. + + Args: + before_delete (Optional[Callable[[List[StoreEntity]], List[StoreEntity]]], optional): fonction à lancer avant la suppression (entrée : liste des entités à supprimer, + sortie : liste définitive des entités à supprimer). Defaults to None. + """ + # suppression d'une stored_data : offering et configuration liées puis la stored_data + l_entities: List[StoreEntity] = [] + + # liste des configurations + l_configuration = Configuration.api_list({"stored_data": self.id}) + for o_configuration in l_configuration: + # pour chaque configuration on récupère les offerings + l_offering = o_configuration.api_list_offerings() + l_entities += l_offering + l_entities.append(o_configuration) + # ajout de la stored_data + l_entities.append(self) + + # suppression + self.delete_liste_entities(l_entities, before_delete) diff --git a/ignf_gpf_sdk/store/__init__.py b/ignf_gpf_sdk/store/__init__.py index 7ad0129b..c5520101 100644 --- a/ignf_gpf_sdk/store/__init__.py +++ b/ignf_gpf_sdk/store/__init__.py @@ -1 +1,34 @@ """Classes représentant les entités de l'Entrepôt.""" + +from ignf_gpf_sdk.store.Annexe import Annexe +from ignf_gpf_sdk.store.Check import Check +from ignf_gpf_sdk.store.CheckExecution import CheckExecution +from ignf_gpf_sdk.store.Configuration import Configuration +from ignf_gpf_sdk.store.Datastore import Datastore +from ignf_gpf_sdk.store.Endpoint import Endpoint +from ignf_gpf_sdk.store.Offering import Offering +from ignf_gpf_sdk.store.Processing import Processing +from ignf_gpf_sdk.store.ProcessingExecution import ProcessingExecution +from ignf_gpf_sdk.store.Static import Static +from ignf_gpf_sdk.store.StoredData import StoredData +from ignf_gpf_sdk.store.Tms import Tms +from ignf_gpf_sdk.store.Upload import Upload +from ignf_gpf_sdk.store.User import User + +# lien entre le nom/type texte et la classe +TYPE__ENTITY = { + Annexe.entity_name(): Annexe, + Check.entity_name(): Check, + CheckExecution.entity_name(): CheckExecution, + Configuration.entity_name(): Configuration, + Datastore.entity_name(): Datastore, + Endpoint.entity_name(): Endpoint, + Offering.entity_name(): Offering, + Processing.entity_name(): Processing, + ProcessingExecution.entity_name(): ProcessingExecution, + Static.entity_name(): Static, + StoredData.entity_name(): StoredData, + Tms.entity_name(): Tms, + Upload.entity_name(): Upload, + User.entity_name(): User, +} diff --git a/ignf_gpf_sdk/workflow/Workflow.py b/ignf_gpf_sdk/workflow/Workflow.py index 1aa7cc04..b7bb1422 100644 --- a/ignf_gpf_sdk/workflow/Workflow.py +++ b/ignf_gpf_sdk/workflow/Workflow.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional -import jsonschema # type: ignore +import jsonschema from ignf_gpf_sdk.Errors import GpfSdkError from ignf_gpf_sdk.helper.JsonHelper import JsonHelper @@ -50,26 +50,38 @@ def get_raw_dict(self) -> Dict[str, Any]: """ return self.__raw_definition_dict - def run_step(self, step_name: str, callback: Optional[Callable[[ProcessingExecution], None]] = None, behavior: Optional[str] = None, datastore: Optional[str] = None) -> List[StoreEntity]: - """Lance une étape du workflow à partir de son nom. Liste les entités créées par chaque action et le retourne. + def run_step( + self, + step_name: str, + callback: Optional[Callable[[ProcessingExecution], None]] = None, + ctrl_c_action: Optional[Callable[[], bool]] = None, + behavior: Optional[str] = None, + datastore: Optional[str] = None, + comments: List[str] = [], + tags: Dict[str, str] = {}, + ) -> List[StoreEntity]: + """Lance une étape du workflow à partir de son nom. Liste les entités créées par chaque action et retourne la liste. Args: step_name (str): nom de l'étape callback (Optional[Callable[[ProcessingExecution], None]], optional): callback de suivi si création d'une exécution de traitement. + ctrl_c_action (Optional[Callable[[], bool]], optional): gestion du ctrl-C lors d'une exécution de traitement. behavior (Optional[str]): comportement à adopter si une entité existe déjà sur l'entrepôt. datastore (Optional[str]): id du datastore à utiliser. Si None, le datastore sera le premier trouvé dans l'action puis dans workflow puis dans configuration. + comments (Optional[List[str]]): liste des commentaire à rajouté à toute les actions de l'étape (les cas de doublons sont géré). + tags (Optional[Dict[str, str]]): dictionnaire des tag à rajouté pour toutes les action de l'étape. Écrasé par ceux du workflow, de l'étape et de l'action si les clef sont les même. Raises: WorkflowError: levée si un problème apparaît pendant l'exécution du workflow Returns: - liste des entités créées + List[StoreEntity]: liste des entités créées """ Config().om.info(f"Lancement de l'étape {step_name}...") # Création d'une liste pour stocker les entités créées l_store_entity: List[StoreEntity] = [] # Récupération de l'étape dans la définition de workflow - d_step_definition = self.__get_step_definition(step_name) + d_step_definition = self.__get_step_definition(step_name, comments, tags) # initialisation des actions parentes o_parent_action: Optional[ActionAbstract] = None # Pour chaque action définie dans le workflow, instanciation de l'objet Action puis création sur l'entrepôt @@ -93,12 +105,13 @@ def run_step(self, step_name: str, callback: Optional[Callable[[ProcessingExecut o_action.run(s_use_datastore) # on attend la fin de l'exécution si besoin if isinstance(o_action, ProcessingExecutionAction): - s_status = o_action.monitoring_until_end(callback=callback) + s_status = o_action.monitoring_until_end(callback=callback, ctrl_c_action=ctrl_c_action) if s_status != ProcessingExecution.STATUS_SUCCESS: s_error_message = f"L'exécution de traitement {o_action} ne s'est pas bien passée. Sortie {s_status}." Config().om.error(s_error_message) raise WorkflowError(s_error_message) - # On récupère l'entité + + # On récupère l'entité créée par l'Action if isinstance(o_action, ProcessingExecutionAction): # Ajout de upload et/ou stored_data if o_action.upload is not None: @@ -111,6 +124,7 @@ def run_step(self, step_name: str, callback: Optional[Callable[[ProcessingExecut elif isinstance(o_action, OfferingAction): if o_action.offering is not None: l_store_entity.append(o_action.offering) + # Message de fin Config().om.info(f"Exécution de l'action '{o_action.workflow_context}-{o_action.index}' : terminée") # cette action sera la parente de la suivante @@ -118,19 +132,50 @@ def run_step(self, step_name: str, callback: Optional[Callable[[ProcessingExecut # Retour de la liste return l_store_entity - def __get_step_definition(self, step_name: str) -> Dict[str, Any]: + def __get_step_definition(self, step_name: str, comments: List[str] = [], tags: Dict[str, str] = {}) -> Dict[str, Any]: """Renvoie le dictionnaire correspondant à une étape du workflow à partir de son nom. Lève une WorkflowError avec un message clair si l'étape n'est pas trouvée. Args: - step_name (string): nom de l'étape + step_name (str): nom de l'étape + comments (Optional[List[str]]): liste des commentaire à rajouté à toute les actions de l'étape (les cas de doublons sont géré). + tags (Optional[Dict[str, str]]): dictionnaire des tag à rajouté pour toutes les action de l'étape. Écrasé par ceux du workflow, de l'étape et de l'action si les clef sont les même. Raises: WorkflowExecutionError: est levée si l'étape n'existe pas dans le workflow + + Returns: + Dict[str, Any]: dictionnaire de l'étape """ # Recherche de l'étape correspondante if step_name in self.__raw_definition_dict["workflow"]["steps"]: - return dict(self.__raw_definition_dict["workflow"]["steps"][step_name]) + # récupération e l'étape : + d_step = dict(self.__raw_definition_dict["workflow"]["steps"][step_name]) + + # on récupère les commentaires commun au workflow et à l'étape + if "comments" in self.__raw_definition_dict: + comments.extend(self.__raw_definition_dict["comments"]) + if "comments" in d_step: + comments.extend(d_step["comments"]) + + # on récupère les tags commun au workflow et à l'étape + if "tags" in self.__raw_definition_dict: + tags.update(self.__raw_definition_dict["tags"]) + if "tags" in d_step: + tags.update(d_step["tags"]) + + # Ajout des commentaire et des tags à chaque actions + for d_action in d_step["actions"]: + if "comments" in d_action: + d_action["comments"] = [*comments, *d_action["comments"]] + else: + d_action["comments"] = comments + if "tags" in d_action: + d_action["tags"] = {**tags, **d_action["tags"]} + else: + d_action["tags"] = tags + + return d_step # Si on passe le if, c'est que l'étape n'existe pas dans la définition du workflow s_error_message = f"L'étape {step_name} n'est pas définie dans le workflow {self.__name}" @@ -264,25 +309,18 @@ def validate(self) -> List[str]: # Maintenant que l'on a fait ça, on peut faire des vérifications pratiques - # 1. Est-ce que les parents de chaque étape existent ? # Pour chaque étape for s_step_name in self.steps: + # 1. Est-ce que les parents de chaque étape existent ? # Pour chaque parent de l'étape for s_parent_name in self.__get_step_definition(s_step_name)["parents"]: # S'il n'est pas dans la liste if not s_parent_name in self.steps: l_errors.append(f"Le parent « {s_parent_name} » de l'étape « {s_step_name} » n'est pas défini dans le workflow.") - - # 2. Est-ce que chaque action a au moins une étape ? - # Pour chaque étape - for s_step_name in self.steps: - # est-ce qu'il y a au moins une action ? + # 2. Est-ce que chaque action a au moins une étape ? if not self.__get_step_definition(s_step_name)["actions"]: l_errors.append(f"L'étape « {s_step_name} » n'a aucune action de défini.") - - # 3. Est-ce que chaque action de chaque étape est instantiable ? - # Pour chaque étape - for s_step_name in self.steps: + # 3. Est-ce que chaque action de chaque étape est instantiable ? # Pour chaque action de l'étape for i, d_action in enumerate(self.__get_step_definition(s_step_name)["actions"], 1): # On tente de l'instancier diff --git a/ignf_gpf_sdk/workflow/action/ActionAbstract.py b/ignf_gpf_sdk/workflow/action/ActionAbstract.py index 70e604c2..3317e67d 100644 --- a/ignf_gpf_sdk/workflow/action/ActionAbstract.py +++ b/ignf_gpf_sdk/workflow/action/ActionAbstract.py @@ -22,6 +22,11 @@ class ActionAbstract(ABC): __parent_action (Optional["Action"]): action parente """ + # comportements possibles (que peut écrire l'utilisateur) + BEHAVIOR_STOP = "STOP" + BEHAVIOR_DELETE = "DELETE" + BEHAVIOR_CONTINUE = "CONTINUE" + def __init__(self, workflow_context: str, definition_dict: Dict[str, Any], parent_action: Optional["ActionAbstract"] = None) -> None: super().__init__() self.__workflow_context: str = workflow_context diff --git a/ignf_gpf_sdk/workflow/action/ConfigurationAction.py b/ignf_gpf_sdk/workflow/action/ConfigurationAction.py index 50409a0a..ff71a081 100644 --- a/ignf_gpf_sdk/workflow/action/ConfigurationAction.py +++ b/ignf_gpf_sdk/workflow/action/ConfigurationAction.py @@ -1,5 +1,7 @@ from typing import Any, Dict, Optional +from ignf_gpf_sdk.Errors import GpfSdkError from ignf_gpf_sdk.store.Configuration import Configuration +from ignf_gpf_sdk.workflow.Errors import StepActionError from ignf_gpf_sdk.workflow.action.ActionAbstract import ActionAbstract from ignf_gpf_sdk.io.Config import Config from ignf_gpf_sdk.io.Errors import ConflictError @@ -15,10 +17,12 @@ class ConfigurationAction(ActionAbstract): __configuration (Optional[Configuration]): représentation Python de la configuration créée """ - def __init__(self, workflow_context: str, definition_dict: Dict[str, Any], parent_action: Optional["ActionAbstract"] = None) -> None: + def __init__(self, workflow_context: str, definition_dict: Dict[str, Any], parent_action: Optional["ActionAbstract"] = None, behavior: Optional[str] = None) -> None: super().__init__(workflow_context, definition_dict, parent_action) # Autres attributs self.__configuration: Optional[Configuration] = None + # comportement (écrit dans la ligne de commande par l'utilisateur), sinon celui par défaut (dans la config) qui vaut CONTINUE + self.__behavior: str = behavior if behavior is not None else Config().get_str("configuration", "behavior_if_exists") def run(self, datastore: Optional[str] = None) -> None: Config().om.info("Création et complétion d'une configuration...") @@ -41,16 +45,30 @@ def __create_configuration(self, datastore: Optional[str]) -> None: # On regarde si on trouve quelque chose avec la fonction find o_configuration = self.find_configuration(datastore) if o_configuration is not None: - self.__configuration = o_configuration - Config().om.info(f"Configuration {self.__configuration['name']} déjà existante, complétion uniquement.") - else: - # Création en gérant une erreur de type ConflictError (si la Configuration existe déjà selon les critères de l'API) - try: - Config().om.info("Création de la configuration...") - self.__configuration = Configuration.api_create(self.definition_dict["body_parameters"], route_params={"datastore": datastore}) - Config().om.info(f"Configuration {self.__configuration['name']} créée avec succès.") - except ConflictError: - Config().om.warning("La configuration que vous tentez de créer existe déjà !") + if self.__behavior == self.BEHAVIOR_STOP: + raise GpfSdkError(f"Impossible de créer la configuration, une configuration équivalente {o_configuration} existe déjà.") + if self.__behavior == self.BEHAVIOR_DELETE: + Config().om.warning(f"Une donnée configuration équivalente à {o_configuration} va être supprimée puis recréée.") + # Suppression de la donnée stockée + o_configuration.api_delete() + # on force à None pour que la création soit faite + self.__configuration = None + # Comportement "on continue l'exécution" + elif self.__behavior == self.BEHAVIOR_CONTINUE: + Config().om.info(f"Configuration {o_configuration} déjà existante, complétion uniquement.") + self.__configuration = o_configuration + return + else: + raise GpfSdkError( + f"Le comportement {self.__behavior} n'est pas reconnu ({self.BEHAVIOR_STOP}|{self.BEHAVIOR_DELETE}|{self.BEHAVIOR_CONTINUE}), l'exécution de traitement n'est pas possible." + ) + # Création en gérant une erreur de type ConflictError (si la Configuration existe déjà selon les critères de l'API) + try: + Config().om.info("Création de la configuration...") + self.__configuration = Configuration.api_create(self.definition_dict["body_parameters"], route_params={"datastore": datastore}) + Config().om.info(f"Configuration {self.__configuration['name']} créée avec succès.") + except ConflictError as e: + raise StepActionError(f"Impossible de créer la configuration il y a un conflict : \n{e.message}") from e def __add_tags(self) -> None: """Ajout des tags sur la Configuration.""" diff --git a/ignf_gpf_sdk/workflow/action/OfferingAction.py b/ignf_gpf_sdk/workflow/action/OfferingAction.py index ad72dfb9..05133774 100644 --- a/ignf_gpf_sdk/workflow/action/OfferingAction.py +++ b/ignf_gpf_sdk/workflow/action/OfferingAction.py @@ -1,5 +1,6 @@ import time from typing import Any, Dict, Optional +from ignf_gpf_sdk.Errors import GpfSdkError from ignf_gpf_sdk.store.Offering import Offering from ignf_gpf_sdk.store.Configuration import Configuration @@ -19,10 +20,12 @@ class OfferingAction(ActionAbstract): __offering (Optional[Offering]): représentation Python de la Offering créée """ - def __init__(self, workflow_context: str, definition_dict: Dict[str, Any], parent_action: Optional["ActionAbstract"] = None) -> None: + def __init__(self, workflow_context: str, definition_dict: Dict[str, Any], parent_action: Optional["ActionAbstract"] = None, behavior: Optional[str] = None) -> None: super().__init__(workflow_context, definition_dict, parent_action) # Autres attributs self.__offering: Optional[Offering] = None + # comportement (écrit dans la ligne de commande par l'utilisateur), sinon celui par défaut (dans la config) qui vaut CONTINUE + self.__behavior: str = behavior if behavior is not None else Config().get_str("offering", "behavior_if_exists") def run(self, datastore: Optional[str] = None) -> None: Config().om.info("Création d'une offre...") @@ -65,14 +68,28 @@ def __create_offering(self, datastore: Optional[str]) -> None: """ o_offering = self.find_offering(datastore) if o_offering is not None: - self.__offering = o_offering - Config().om.info(f"Offre {self.__offering['layer_name']} déjà existante, complétion uniquement.") - else: - # Création en gérant une erreur de type ConflictError (si la Configuration existe déjà selon les critères de l'API) - try: - self.__offering = Offering.api_create(self.definition_dict["body_parameters"], route_params=self.definition_dict["url_parameters"]) - except ConflictError as e: - raise StepActionError(f"Impossible de créer l'offre il y a un conflict : \n{e.message}") from e + if self.__behavior == self.BEHAVIOR_STOP: + raise GpfSdkError(f"Impossible de créer l'offre, une offre équivalente {o_offering} existe déjà.") + if self.__behavior == self.BEHAVIOR_DELETE: + Config().om.warning(f"Une donnée offre équivalente à {o_offering} va être supprimée puis recréée.") + # Suppression de la donnée stockée + o_offering.api_delete() + # on force à None pour que la création soit faite + self.__offering = None + # Comportement "on continue l'exécution" + elif self.__behavior == self.BEHAVIOR_CONTINUE: + Config().om.info(f"Offre {o_offering} déjà existante, complétion uniquement.") + self.__offering = o_offering + return + else: + raise GpfSdkError( + f"Le comportement {self.__behavior} n'est pas reconnu ({self.BEHAVIOR_STOP}|{self.BEHAVIOR_DELETE}|{self.BEHAVIOR_CONTINUE}), l'exécution de traitement n'est pas possible." + ) + # Création en gérant une erreur de type ConflictError (si la Configuration existe déjà selon les critères de l'API) + try: + self.__offering = Offering.api_create(self.definition_dict["body_parameters"], route_params=self.definition_dict["url_parameters"]) + except ConflictError as e: + raise StepActionError(f"Impossible de créer l'offre il y a un conflict : \n{e.message}") from e def find_configuration(self, datastore: Optional[str] = None) -> Optional[Configuration]: """Fonction permettant de récupérer la Configuration associée à l'Offering qui doit être crée par cette Action. diff --git a/ignf_gpf_sdk/workflow/action/ProcessingExecutionAction.py b/ignf_gpf_sdk/workflow/action/ProcessingExecutionAction.py index d571a21d..995b952d 100644 --- a/ignf_gpf_sdk/workflow/action/ProcessingExecutionAction.py +++ b/ignf_gpf_sdk/workflow/action/ProcessingExecutionAction.py @@ -1,5 +1,5 @@ import time -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Optional, Union from ignf_gpf_sdk.Errors import GpfSdkError from ignf_gpf_sdk.io.Config import Config @@ -18,15 +18,10 @@ class ProcessingExecutionAction(ActionAbstract): __definition_dict (Dict[str, Any]): définition de l'action __parent_action (Optional["Action"]): action parente __processing_execution (Optional[ProcessingExecution]): représentation Python de l'exécution de traitement créée - __Upload (Optional[Upload]): représentation Python de la livraison en sortie (null si données stockée en sortie) - __StoredData (Optional[StoredData]): représentation Python de la données stockée en sortie (null si livraison en sortie) + __Upload (Optional[Upload]): représentation Python de la livraison en sortie (null si donnée stockée en sortie) + __StoredData (Optional[StoredData]): représentation Python de la donnée stockée en sortie (null si livraison en sortie) """ - # comportements possibles (que peut écrire l'utilisateur) - BEHAVIOR_STOP = "STOP" - BEHAVIOR_DELETE = "DELETE" - BEHAVIOR_CONTINUE = "CONTINUE" - # status possibles d'une ProcessingExecution (status délivrés par l'api) # STATUS_CREATED # STATUS_ABORTED STATUS_SUCCESS STATUS_FAILURE @@ -87,6 +82,7 @@ def __create_processing_execution(self, datastore: Optional[str] = None) -> None self.__processing_execution = None # Comportement "on continue l'exécution" elif self.__behavior == self.BEHAVIOR_CONTINUE: + o_stored_data.api_update() # on regarde si le résultat du traitement précédent est en échec if o_stored_data["status"] == StoredData.STATUS_UNSTABLE: raise GpfSdkError(f"Le traitement précédent a échoué sur la donnée stockée en sortie {o_stored_data}. Impossible de lancer le traitement demandé.") @@ -101,6 +97,7 @@ def __create_processing_execution(self, datastore: Optional[str] = None) -> None raise GpfSdkError(f"Impossible de trouver l'exécution de traitement liée à la donnée stockée {o_stored_data}") # arbitrairement, on prend le premier de la liste self.__processing_execution = l_proc_exec[0] + Config().om.info(f"La donnée stocké en sortie {o_stored_data} déjà existante, on reprend le traitement associé : {self.__processing_execution}.") return # Comportement non reconnu else: @@ -145,7 +142,7 @@ def __add_tags(self) -> None: Config().om.info(f"Donnée stockée {self.stored_data['name']} : les {len(self.definition_dict['tags'])} tags ont été ajoutés avec succès.") else: # on a pas de stored_data ni de upload - raise StepActionError("aucune upload ou stored-data trouvé. Impossible d'ajouter les tags") + raise StepActionError("ni upload ni stored-data trouvé. Impossible d'ajouter les tags") def __add_comments(self) -> None: """Ajout des commentaires sur l'Upload ou la StoredData en sortie du ProcessingExecution.""" @@ -153,19 +150,24 @@ def __add_comments(self) -> None: # cas on a pas de commentaires : on ne fait rien return # on ajoute les commentaires + i_nb_ajout = 0 if self.upload is not None: - Config().om.info(f"Livraison {self.upload['name']} : ajout des {len(self.definition_dict['comments'])} commentaires...") - for s_comment in self.definition_dict["comments"]: - self.upload.api_add_comment({"text": s_comment}) - Config().om.info(f"Livraison {self.upload['name']} : les {len(self.definition_dict['comments'])} commentaires ont été ajoutés avec succès.") + o_data: Union[StoredData, Upload] = self.upload + s_type = "Livraison" elif self.stored_data is not None: - Config().om.info(f"Donnée stockée {self.stored_data['name']} : ajout des {len(self.definition_dict['comments'])} commentaires...") - for s_comment in self.definition_dict["comments"]: - self.stored_data.api_add_comment({"text": s_comment}) - Config().om.info(f"Donnée stockée {self.stored_data['name']} : les {len(self.definition_dict['comments'])} commentaires ont été ajoutés avec succès.") + o_data = self.stored_data + s_type = "Donnée stockée" else: # on a pas de stored_data ni de upload - raise StepActionError("aucune upload ou stored-data trouvé. Impossible d'ajouter les commentaires") + raise StepActionError("ni upload ni stored-data trouvé. Impossible d'ajouter les commentaires") + + Config().om.info(f"{s_type} {o_data['name']} : ajout des {len(self.definition_dict['comments'])} commentaires...") + l_actual_comments = [d_comment["text"] for d_comment in o_data.api_list_comments() if d_comment] + for s_comment in self.definition_dict["comments"]: + if s_comment not in l_actual_comments: + o_data.api_add_comment({"text": s_comment}) + i_nb_ajout += 1 + Config().om.info(f"{s_type} {o_data['name']} : {i_nb_ajout} commentaires ont été ajoutés.") def __launch(self) -> None: """Lancement de la ProcessingExecution.""" @@ -187,7 +189,7 @@ def find_stored_data(self, datastore: Optional[str] = None) -> Optional[StoredDa l'exécution de traitement en fonction des filtres définis dans la Config. Returns: - données stockées retrouvée + donnée stockée retrouvée """ # Récupération des critères de filtre d_infos, d_tags = ActionAbstract.get_filters("processing_execution", self.definition_dict["body_parameters"]["output"]["stored_data"], self.definition_dict.get("tags", {})) @@ -199,75 +201,100 @@ def find_stored_data(self, datastore: Optional[str] = None) -> Optional[StoredDa # sinon on retourne None return None - def monitoring_until_end(self, callback: Optional[Callable[[ProcessingExecution], None]] = None) -> str: + def monitoring_until_end(self, callback: Optional[Callable[[ProcessingExecution], None]] = None, ctrl_c_action: Optional[Callable[[], bool]] = None) -> str: """Attend que la ProcessingExecution soit terminée (statut `SUCCESS`, `FAILURE` ou `ABORTED`) avant de rendre la main. - La fonction callback indiquée est exécutée après **chaque vérification** en lui passant en paramètre - le log du traitement et le status du traitement (callback(logs, status)). + La fonction callback indiquée est exécutée après **chaque vérification du statut** en lui passant en paramètre + la processing execution (callback(self.processing_execution)). - Si l'utilisateur stoppe le programme, la ProcessingExecution est arrêtée avant de quitter. + Si l'utilisateur stoppe le programme (par ctrl-C), le devenir de la ProcessingExecutionAction sera géré par la callback ctrl_c_action(). Args: - callback (Optional[Callable[[ProcessingExecution], None]], optional): fonction de callback à exécuter prend en argument le traitement (callback(processing-execution)). + callback (Optional[Callable[[ProcessingExecution], None]], optional): fonction de callback à exécuter. Prend en argument le traitement (callback(processing-execution)). + ctrl_c_action (Optional[Callable[[], bool]], optional): fonction de gestion du ctrl-C. Renvoie True si on doit stopper le traitement. Returns: - True si SUCCESS, False si FAILURE, None si ABORTED + str: statut final de l'exécution du traitement """ + + def callback_not_null(o_pe: ProcessingExecution) -> None: + """fonction pour éviter des if à chaque appel + + Args: + o_pe (ProcessingExecution): traitement en cours + """ + if callback is not None: + callback(o_pe) + # NOTE : Ne pas utiliser self.__processing_execution mais self.processing_execution pour faciliter les tests i_nb_sec_between_check = Config().get_int("processing_execution", "nb_sec_between_check_updates") Config().om.info(f"Monitoring du traitement toutes les {i_nb_sec_between_check} secondes...") if self.processing_execution is None: - raise StepActionError("Aucune procession-execution de trouvé. Impossible de suivre le déroulement du traitement") - try: - s_status = self.processing_execution.get_store_properties()["status"] - while s_status not in [ProcessingExecution.STATUS_ABORTED, ProcessingExecution.STATUS_SUCCESS, ProcessingExecution.STATUS_FAILURE]: + raise StepActionError("Aucune processing-execution trouvée. Impossible de suivre le déroulement du traitement") + + self.processing_execution.api_update() + s_status = self.processing_execution.get_store_properties()["status"] + while s_status not in [ProcessingExecution.STATUS_ABORTED, ProcessingExecution.STATUS_SUCCESS, ProcessingExecution.STATUS_FAILURE]: + try: # appel de la fonction affichant les logs - if callback is not None: - callback(self.processing_execution) + callback_not_null(self.processing_execution) + # On attend le temps demandé time.sleep(i_nb_sec_between_check) + # On met à jour __processing_execution + valeur status self.processing_execution.api_update() s_status = self.processing_execution.get_store_properties()["status"] - # Si on est sorti c'est que c'est fini - ## dernier affichage - if callback is not None: - callback(self.processing_execution) - ## on return le status de fin - return str(s_status) - except KeyboardInterrupt as e: - # TODO - # si le traitement est déjà dans un statu fini on ne fait rien => transmission de l'interruption - self.processing_execution.api_update() - s_status = self.processing_execution.get_store_properties()["status"] - if s_status in [ProcessingExecution.STATUS_ABORTED, ProcessingExecution.STATUS_SUCCESS, ProcessingExecution.STATUS_FAILURE]: - Config().om.warning("traitement déjà fini") - raise - # arrêt du traitement - Config().om.warning("Ctrl+C : traitement en cours d’interruption, veuillez attendre...") - self.processing_execution.api_abort() - # attente que le traitement passe dans un statu fini - self.processing_execution.api_update() - s_status = self.processing_execution.get_store_properties()["status"] - while s_status not in [ProcessingExecution.STATUS_ABORTED, ProcessingExecution.STATUS_SUCCESS, ProcessingExecution.STATUS_FAILURE]: - # On attend 2s - time.sleep(2) - # On met à jour __processing_execution + valeur status - self.processing_execution.api_update() - s_status = self.processing_execution.get_store_properties()["status"] - ## dernier affichage - if callback is not None: - callback(self.processing_execution) - if s_status == ProcessingExecution.STATUS_ABORTED and self.output_new_entity: - # suppression de l'upload ou la stored data en sortie - if self.upload is not None: - Config().om.warning("Suppression de l'upload en cours de remplissage suite à l’interruption du programme") - self.upload.api_delete() - elif self.stored_data is not None: - Config().om.warning("Suppression de la stored-data en cours de remplissage suite à l'interruption du programme") - self.stored_data.api_delete() - # transmission de l'interruption - raise KeyboardInterrupt() from e + + except KeyboardInterrupt: + # on appelle la callback de gestion du ctrl-C + if ctrl_c_action is None or ctrl_c_action(): + # on doit arrêter le traitement (maj + action spécifique selon le statut) + + # mise à jour du traitement + self.processing_execution.api_update() + + # si le traitement est déjà dans un statut terminé, on ne fait rien => transmission de l'interruption + s_status = self.processing_execution.get_store_properties()["status"] + + # si le traitement est terminé, on fait un dernier affichage : + if s_status in [ProcessingExecution.STATUS_ABORTED, ProcessingExecution.STATUS_SUCCESS, ProcessingExecution.STATUS_FAILURE]: + callback_not_null(self.processing_execution) + Config().om.warning("traitement déjà terminé.") + raise + + # arrêt du traitement + Config().om.warning("Ctrl+C : traitement en cours d’interruption, veuillez attendre...") + self.processing_execution.api_abort() + # attente que le traitement passe dans un statut terminé + self.processing_execution.api_update() + s_status = self.processing_execution.get_store_properties()["status"] + while s_status not in [ProcessingExecution.STATUS_ABORTED, ProcessingExecution.STATUS_SUCCESS, ProcessingExecution.STATUS_FAILURE]: + # On attend 2s + time.sleep(2) + # On met à jour __processing_execution + valeur status + self.processing_execution.api_update() + s_status = self.processing_execution.get_store_properties()["status"] + # traitement terminé. On fait un dernier affichage : + callback_not_null(self.processing_execution) + + # si statut Aborted : + # suppression de l'upload ou de la stored data en sortie + if s_status == ProcessingExecution.STATUS_ABORTED and self.output_new_entity: + if self.upload is not None: + Config().om.warning("Suppression de l'upload en cours de remplissage suite à l’interruption du programme") + self.upload.api_delete() + elif self.stored_data is not None: + Config().om.warning("Suppression de la stored-data en cours de remplissage suite à l'interruption du programme") + self.stored_data.api_delete() + # enfin, transmission de l'interruption + raise + + # Si on est sorti du while c'est que la processing execution est terminée + ## dernier affichage + callback_not_null(self.processing_execution) + ## on return le status de fin + return str(s_status) @property def processing_execution(self) -> Optional[ProcessingExecution]: diff --git a/ignf_gpf_sdk/workflow/action/UploadAction.py b/ignf_gpf_sdk/workflow/action/UploadAction.py index 36186fa6..a43542d0 100644 --- a/ignf_gpf_sdk/workflow/action/UploadAction.py +++ b/ignf_gpf_sdk/workflow/action/UploadAction.py @@ -21,6 +21,7 @@ class UploadAction: BEHAVIOR_STOP = "STOP" BEHAVIOR_DELETE = "DELETE" BEHAVIOR_CONTINUE = "CONTINUE" + BEHAVIORS = [BEHAVIOR_STOP, BEHAVIOR_CONTINUE, BEHAVIOR_DELETE] def __init__(self, dataset: Dataset, behavior: Optional[str] = None) -> None: self.__dataset: Dataset = dataset @@ -212,7 +213,7 @@ def upload(self) -> Optional[Upload]: return self.__upload @staticmethod - def monitor_until_end(upload: Upload, callback: Optional[Callable[[str], None]] = None) -> bool: + def monitor_until_end(upload: Upload, callback: Optional[Callable[[str], None]] = None, ctrl_c_action: Optional[Callable[[], bool]] = None) -> bool: """Attend que toute les vérifications liées à la Livraison indiquée soient terminées (en erreur ou en succès) avant de rendre la main. @@ -222,6 +223,7 @@ def monitor_until_end(upload: Upload, callback: Optional[Callable[[str], None]] Args: upload (Upload): Livraison à monitorer callback (Optional[Callable[[str], None]]): fonction de callback à exécuter avec le message de suivi. + ctrl_c_action (Optional[Callable[[], bool]], optional): gestion du ctrl-C. Si None ou si la fonction renvoie True, il faut arrêter les vérifications. Returns: True si toutes les vérifications sont ok, sinon False @@ -231,24 +233,42 @@ def monitor_until_end(upload: Upload, callback: Optional[Callable[[str], None]] b_success: Optional[bool] = None Config().om.info(f"Monitoring des vérifications toutes les {i_nb_sec_between_check} secondes...") while b_success is None: - # On récupère les vérifications - d_checks = upload.api_list_checks() - # On peut déterminer b_success s'il n'y en a plus en attente et en cours - if len(d_checks["asked"]) == len(d_checks["in_progress"]) == 0: - b_success = len(d_checks["failed"]) == 0 - # On affiche un rapport via la fonction de callback précisée - s_message = s_check_message_pattern.format( - nb_asked=len(d_checks["asked"]), - nb_in_progress=len(d_checks["in_progress"]), - nb_passed=len(d_checks["passed"]), - nb_failed=len(d_checks["failed"]), - ) - if callback is not None: - callback(s_message) - # Si l'état est toujours indéterminé - if b_success is None: - # On attend le temps demandé - time.sleep(i_nb_sec_between_check) + try: + # On récupère les vérifications + d_checks = upload.api_list_checks() + # On peut déterminer b_success s'il n'y en a plus en attente et en cours + if 0 == len(d_checks["asked"]) == len(d_checks["in_progress"]): + b_success = len(d_checks["failed"]) == 0 + # On affiche un rapport via la fonction de callback précisée + s_message = s_check_message_pattern.format( + nb_asked=len(d_checks["asked"]), + nb_in_progress=len(d_checks["in_progress"]), + nb_passed=len(d_checks["passed"]), + nb_failed=len(d_checks["failed"]), + ) + if callback is not None: + callback(s_message) + # Si l'état est toujours indéterminé + if b_success is None: + # On attend le temps demandé + time.sleep(i_nb_sec_between_check) + + except KeyboardInterrupt: + # on appelle la callback de gestion du ctrl-C + if ctrl_c_action is None or ctrl_c_action(): + # on doit arrêter les vérifications : + # si les vérifications sont déjà terminées, on ne fait rien => transmission de l'interruption + if b_success is not None: + Config().om.warning("vérifications déjà terminées.") + raise + + # arrêt des vérifications + Config().om.warning("Ctrl+C : vérifications en cours d’interruption, veuillez attendre...") + upload.api_close() + # enfin, transmission de l'interruption + raise + + # Si on est sorti du while c'est que les vérifications sont terminées # On log le dernier rapport selon l'état et on sort if b_success: Config().om.info(s_message) diff --git a/ignf_gpf_sdk/workflow/resolver/Errors.py b/ignf_gpf_sdk/workflow/resolver/Errors.py index 06abcd56..9f35bc4c 100644 --- a/ignf_gpf_sdk/workflow/resolver/Errors.py +++ b/ignf_gpf_sdk/workflow/resolver/Errors.py @@ -1,3 +1,5 @@ +from pathlib import Path + from ignf_gpf_sdk.Errors import GpfSdkError @@ -67,16 +69,18 @@ class ResolveFileNotFoundError(GpfSdkError): __message (str): message décrivant le problème __resolver_name (str): nom du résolveur __to_solve (str): chaîne à résoudre + __absolute_path (Path): chemin vers le fichier non trouvé """ - def __init__(self, resolver_name: str, to_solve: str) -> None: - s_message = f"Erreur de traitement d'un fichier (résolveur '{resolver_name}') avec la chaîne '{to_solve}': fichier non existant." + def __init__(self, resolver_name: str, to_solve: str, path: Path) -> None: + s_message = f"Erreur de traitement d'un fichier (résolveur '{resolver_name}') avec la chaîne '{to_solve}': fichier ({path.absolute()}) non existant." super().__init__(s_message) self.__resolver_name = resolver_name self.__to_solve = to_solve + self.__absolute_path = path.absolute() def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.__resolver_name}, {self.__to_solve})" + return f"{self.__class__.__name__}({self.__resolver_name}, {self.__to_solve}, {self.__absolute_path})" class ResolveFileInvalidError(GpfSdkError): diff --git a/ignf_gpf_sdk/workflow/resolver/FileResolver.py b/ignf_gpf_sdk/workflow/resolver/FileResolver.py index a913d987..e8744f17 100644 --- a/ignf_gpf_sdk/workflow/resolver/FileResolver.py +++ b/ignf_gpf_sdk/workflow/resolver/FileResolver.py @@ -8,50 +8,50 @@ class FileResolver(AbstractResolver): - """Classe permettant de résoudre des paramètres faisant référence à des fichiers. - - Ce résolveur permet d'insérer le contenu d'un fichier au moment de la résolution. + """Classe permettant de résoudre des paramètres faisant référence à des fichiers : ce résolveur + permet d'insérer le contenu d'un fichier au moment de la résolution. Ce fichier peut être un fichier texte basique, une liste au format JSON ou un dictionnaire au format JSON. - Fichier texte : + **Fichier texte :** (dans cet exemple, `file` est le nom du résolveur) + + Contenu du fichier `exemple.txt` : - Contenu du fichier `exemple.txt` : + ```txt + coucou + ``` - ```txt - coucou - ``` + Chaîne à remplacer : `Je veux dire : {file.str(exemple.txt)}` - Chaîne à remplacer : `Je veux dire : {file.str(exemple.txt)}` + Résultat : `Je veux dire : coucou` - Résultat : `Je veux dire : coucou` + **Fichier de liste :** (dans cet exemple, `file` est le nom du résolveur) - Fichier de liste : + Contenu du fichier `list.json` : - Contenu du fichier `list.json` : + ```json + ["valeur 1", "valeur 2"] + ``` - ```json - ["valeur 1", "valeur 2"] - ``` + Chaîne à remplacer : `{"values": ["file","list(list.json)"]}` - Chaîne à remplacer : `{"values": "{file.list(list.json)"]}` + Résultat : `{"values": ["valeur 1", "valeur 2"]}` - Résultat : `{"values": ["valeur 1", "valeur 2"]}` + **Fichier de clé-valeur :** (dans cet exemple, `file` est le nom du résolveur) - Fichier de clé-valeur : + Contenu du fichier `dict.json` : - Contenu du fichier `dict.json` : + ```json + {"k1":"v1", "k2":"v2"} + ``` - ```json - {"k1":"v1", "k2":"v2"} - ``` + Chaîne à remplacer : `{"parameters": {"file":"dict(dict.json)"}}` - Chaîne à remplacer : `{"parameters": {"{file.dict(dict.json)}":"value"}}` + Résultat : `{"parameters": {"k1":"v1", "k2":"v2"}}` - Résultat : `{"parameters": {"k1":"v1", "k2":"v2"}}` Attributes: __name (str): nom de code du resolver @@ -59,9 +59,20 @@ class FileResolver(AbstractResolver): _file_regex = re.compile(Config().get_str("workflow_resolution_regex", "file_regex")) + def __init__(self, name: str, root_path: Path) -> None: + """À l'instanciation, il faut indiquer au résolveur le chemin racine d'où chercher les fichiers. + + Args: + name (str): nom du résolveur + root_path (Path): chemin racine + """ + super().__init__(name) + self.__root_path = root_path.absolute() + def __resolve_str(self, string_to_solve: str, s_path: str) -> str: - """fonction privé qui se charge d'extraire une string d'un fichier texte + """fonction privée qui se charge d'extraire une string d'un fichier texte on valide que le contenu est bien un texte + Args: string_to_solve (str): chaîne à résoudre s_path (str): string du path du fichier à ouvrir @@ -69,20 +80,21 @@ def __resolve_str(self, string_to_solve: str, s_path: str) -> str: Returns: texte contenu dans le fichier """ - p_path_text = Path(s_path) + p_path_text = self.__root_path / s_path if p_path_text.exists(): s_result = str(p_path_text.read_text(encoding="UTF-8").rstrip("\n")) else: - raise ResolveFileNotFoundError(self.name, string_to_solve) + raise ResolveFileNotFoundError(self.name, string_to_solve, p_path_text) return s_result def __resolve_list(self, string_to_solve: str, s_path: str) -> str: - """fonction privé qui se charge d'extraire une string d'un fichier contenant une liste + """fonction privée qui se charge d'extraire une string d'un fichier contenant une liste on valide que le contenu est bien une liste Args: string_to_solve (str): chaîne à résoudre s_path (str): string du path du fichier à ouvrir + Returns: liste contenue dans le fichier """ @@ -99,7 +111,7 @@ def __resolve_list(self, string_to_solve: str, s_path: str) -> str: return s_data def __resolve_dict(self, string_to_solve: str, s_path: str) -> str: - """fonction privé qui se charge d'extraire une string d'un fichier contenant un dictionnaire + """fonction privée qui se charge d'extraire une string d'un fichier contenant un dictionnaire on valide que le contenu est bien un dictionnaire Args: @@ -122,7 +134,7 @@ def __resolve_dict(self, string_to_solve: str, s_path: str) -> str: return s_data def resolve(self, string_to_solve: str) -> str: - """Fonction permettant de renvoyer sous forme de string la resolution + """Fonction permettant de renvoyer sous forme de string la résolution des paramètres de fichier passés en entrée. Args: diff --git a/ignf_gpf_sdk/workflow/resolver/GlobalResolver.py b/ignf_gpf_sdk/workflow/resolver/GlobalResolver.py index c6a57e74..165d83dd 100644 --- a/ignf_gpf_sdk/workflow/resolver/GlobalResolver.py +++ b/ignf_gpf_sdk/workflow/resolver/GlobalResolver.py @@ -14,12 +14,12 @@ class GlobalResolver(metaclass=Singleton): __resolvers (Dict[str, AbstractResolver]): association nom du résolveur / résolveur. """ - _regex: Pattern[str] = re.compile(Config().get_str("workflow_resolution_regex", "global_regex")) _solved_strings: Dict[str, str] = {} def __init__(self) -> None: """Constructeur.""" self.__resolvers: Dict[str, AbstractResolver] = {} + self.__regex: Pattern[str] = re.compile(Config().get_str("workflow_resolution_regex", "global_regex")) def add_resolver(self, resolver: AbstractResolver) -> None: """Ajoute un résolveur à la liste.""" @@ -39,12 +39,16 @@ def resolve(self, string_to_solve_global: str) -> str: Returns: chaîne résolue """ - return GlobalResolver._regex.sub(GlobalResolver.resolve_group, string_to_solve_global) + return self.__regex.sub(GlobalResolver.resolve_group, string_to_solve_global) @property def resolvers(self) -> Dict[str, AbstractResolver]: return self.__resolvers + @property + def regex(self) -> Pattern[str]: + return self.__regex + @staticmethod def resolve_group(match: Match[str]) -> str: """Résout la chaîne trouvé par la regex et permet de la remplacer. diff --git a/mypy.ini b/mypy.ini index b456bbb4..b973b1e5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,3 +1,5 @@ [mypy] [mypy-requests_toolbelt.*] ignore_missing_imports = True +[mypy-jsonschema.*] +ignore_missing_imports = True diff --git a/tests/io/ApiRequesterTestCase.py b/tests/io/ApiRequesterTestCase.py index 9d39eeba..49a3abd2 100644 --- a/tests/io/ApiRequesterTestCase.py +++ b/tests/io/ApiRequesterTestCase.py @@ -77,7 +77,7 @@ def test_route_request_ok_datastore_config(self) -> None: ) # Vérification sur o_mock_request s_url = "https://api.test.io/api/v1/datastores/TEST_DATASTORE/create/42" - o_mock_request.assert_called_once_with(s_url, ApiRequester.POST, self.param, self.data, self.files) + o_mock_request.assert_called_once_with(s_url, ApiRequester.POST, self.param, self.data, self.files, {}) # Vérification sur la réponse renvoyée par la fonction : ça doit être celle renvoyée par url_request self.assertEqual(o_fct_response, o_api_response) @@ -98,7 +98,7 @@ def test_route_request_ok_datastore_params(self) -> None: ) # Vérification sur o_mock_request s_url = "https://api.test.io/api/v1/datastores/OTHER_DATASTORE/create/42" - o_mock_request.assert_called_once_with(s_url, ApiRequester.POST, self.param, self.data, self.files) + o_mock_request.assert_called_once_with(s_url, ApiRequester.POST, self.param, self.data, self.files, {}) # Vérification sur la réponse renvoyée par la fonction : ça doit être celle renvoyée par url_request self.assertEqual(o_fct_response, o_api_response) @@ -308,11 +308,4 @@ def test_route_upload_file(self) -> None: with patch.object(ApiRequester, "route_request", return_value=None) as o_mock_request: ApiRequester().route_upload_file(s_route_name, p_file, s_path_api, d_route_params, s_method, d_params, d_data) o_mock_open.assert_called_once_with("rb") - o_mock_request.assert_called_once_with( - s_route_name, - route_params=d_route_params, - method=s_method, - params=d_params, - data=d_data, - files=o_dict_files, - ) + o_mock_request.assert_called_once_with(s_route_name, route_params=d_route_params, method=s_method, params=d_params, data=d_data, files=o_dict_files) diff --git a/tests/store/ConfigurationTestCase.py b/tests/store/ConfigurationTestCase.py index 831dbee3..b969b579 100644 --- a/tests/store/ConfigurationTestCase.py +++ b/tests/store/ConfigurationTestCase.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from ignf_gpf_sdk.io.ApiRequester import ApiRequester from ignf_gpf_sdk.store.Offering import Offering @@ -63,3 +63,37 @@ def test_add_offering(self) -> None: self.assertIsInstance(o_offering, Offering) self.assertEqual(o_offering.id, "11111111") self.assertDictEqual(o_offering.get_store_properties(), d_data_offering) + + def test_delete_cascade(self) -> None: + """test de delete_cascade""" + o_configuration = Configuration({"_id": "2222222"}) + + # Mock offerings + o_mock_offering_1 = MagicMock() + o_mock_offering_2 = MagicMock() + + # mock pour la fonction before_delete + o_mock = MagicMock() + o_mock.before_delete_function.return_value = [o_configuration] + + for f_before_delete in [None, o_mock.before_delete_function]: + # Configuration sans offre + with patch.object(Configuration, "api_list_offerings", return_value=[]) as o_mock_list: + with patch.object(Configuration, "delete_liste_entities", return_value=None) as o_mock_delete: + o_configuration.delete_cascade(f_before_delete) + o_mock_delete.assert_called_once_with([o_configuration], f_before_delete) + o_mock_list.assert_called_once_with() + + # Configuration avec offres + with patch.object(Configuration, "api_list_offerings", return_value=[o_mock_offering_1, o_mock_offering_2]) as o_mock_list: + with patch.object(Configuration, "delete_liste_entities", return_value=None) as o_mock_delete: + o_configuration.delete_cascade(f_before_delete) + o_mock_delete.assert_called_once_with( + [ + o_mock_offering_1, + o_mock_offering_2, + o_configuration, + ], + f_before_delete, + ) + o_mock_list.assert_called_once_with() diff --git a/tests/store/StoreEntityTestCase.py b/tests/store/StoreEntityTestCase.py index d8cb48a8..aa6b9ca2 100644 --- a/tests/store/StoreEntityTestCase.py +++ b/tests/store/StoreEntityTestCase.py @@ -1,7 +1,9 @@ import json -from unittest.mock import call, patch -from ignf_gpf_sdk.store.Errors import StoreEntityError +import time +from typing import List +from unittest.mock import MagicMock, Mock, call, patch +from ignf_gpf_sdk.store.Errors import StoreEntityError from ignf_gpf_sdk.store.StoreEntity import StoreEntity from ignf_gpf_sdk.io.ApiRequester import ApiRequester from tests.GpfTestCase import GpfTestCase @@ -384,3 +386,81 @@ def test_get_datetime(self) -> None: # Vérifications self.assertIsNotNone(o_datetime) o_mock_update.assert_not_called() + + def test_delete_cascade(self) -> None: + """test de delete_cascade""" + o_store_entity = StoreEntity({"_id": "1", "datetime": "2022-09-20T10:45:04.396Z"}) + + # test sans before_delete + with patch.object(StoreEntity, "delete_liste_entities", return_value=None) as o_mock_delete: + o_store_entity.delete_cascade() + o_mock_delete.assert_called_once_with([o_store_entity], None) + + # test avec before_delete + with patch.object(StoreEntity, "delete_liste_entities", return_value=None) as o_mock_delete: + o_mock = MagicMock() + o_mock.before_delete_function.return_value = [o_store_entity] + o_store_entity.delete_cascade(o_mock.before_delete_function) + o_mock_delete.assert_called_once_with([o_store_entity], o_mock.before_delete_function) + + @patch.object(time, "sleep", return_value=None) + def test_delete_liste_entities(self, o_mock_sleep: Mock) -> None: + """test de delete_liste_entities""" + + o_mock_1 = MagicMock() + o_mock_2 = MagicMock() + o_mock_3 = MagicMock() + + def reset_mock() -> None: + """reset des mock de la fonction""" + o_mock_1.reset_mock() + o_mock_2.reset_mock() + o_mock_3.reset_mock() + o_mock_sleep.reset_mock() + + # suppression d'un élément sans before_delete + StoreEntity.delete_liste_entities([o_mock_1]) + o_mock_1.api_delete.assert_called_once_with() + self.assertEqual(1, o_mock_sleep.call_count) + reset_mock() + + # suppression de plusieurs éléments sans before_delete + l_entity: List[StoreEntity] = [o_mock_1, o_mock_2] + StoreEntity.delete_liste_entities(l_entity) + o_mock_1.api_delete.assert_called_once_with() + o_mock_2.api_delete.assert_called_once_with() + self.assertEqual(2, o_mock_sleep.call_count) + reset_mock() + + # suppression avec before_delete, sans modification + o_mock_function = MagicMock() + o_mock_function.before_delete_function.return_value = l_entity + StoreEntity.delete_liste_entities(l_entity, o_mock_function.before_delete_function) + o_mock_function.before_delete_function.assert_called_once_with(l_entity) + o_mock_1.api_delete.assert_called_once_with() + o_mock_2.api_delete.assert_called_once_with() + self.assertEqual(2, o_mock_sleep.call_count) + reset_mock() + + # suppression avec before_delete, avec modification + o_mock_function = MagicMock() + o_mock_function.before_delete_function.return_value = [o_mock_1, o_mock_3] + StoreEntity.delete_liste_entities(l_entity, o_mock_function.before_delete_function) + o_mock_function.before_delete_function.assert_called_once_with(l_entity) + o_mock_1.api_delete.assert_called_once_with() + o_mock_2.api_delete.assert_not_called() + o_mock_3.api_delete.assert_called_once_with() + self.assertEqual(2, o_mock_sleep.call_count) + reset_mock() + + # suppression avec before_delete, avec annulation liste vide ou None + for o_return in [[], None]: # type:ignore + o_mock_function = MagicMock() + o_mock_function.before_delete_function.return_value = o_return + StoreEntity.delete_liste_entities(l_entity, o_mock_function.before_delete_function) + o_mock_function.before_delete_function.assert_called_once_with(l_entity) + o_mock_1.api_delete.assert_not_called() + o_mock_2.api_delete.assert_not_called() + o_mock_3.api_delete.assert_not_called() + self.assertEqual(0, o_mock_sleep.call_count) + reset_mock() diff --git a/tests/store/StoredDataTestCase.py b/tests/store/StoredDataTestCase.py new file mode 100644 index 00000000..62297722 --- /dev/null +++ b/tests/store/StoredDataTestCase.py @@ -0,0 +1,66 @@ +from unittest.mock import MagicMock, patch +from ignf_gpf_sdk.store.Configuration import Configuration + +from ignf_gpf_sdk.store.StoredData import StoredData +from tests.GpfTestCase import GpfTestCase + + +class StoredDataTestCase(GpfTestCase): + """Tests StoredData class. + + cmd : python3 -m unittest -b tests.store.StoredDataTestCase + """ + + def test_delete_cascade(self) -> None: + """test de delete_cascade""" + o_store_entity = StoredData({"_id": "1", "datetime": "2022-09-20T10:45:04.396Z"}) + + # Mock offerings + o_mock_offering_1 = MagicMock() + o_mock_offering_2 = MagicMock() + o_mock_offering_3 = MagicMock() + # mock config 1 => 2 offering + o_mock_config_1 = MagicMock() + o_mock_config_1.api_list_offerings.return_value = [o_mock_offering_1, o_mock_offering_2] + # mock config 2 => 0 offering + o_mock_config_2 = MagicMock() + o_mock_config_2.api_list_offerings.return_value = [] + # mock config 3 => 1 offering + o_mock_config_3 = MagicMock() + o_mock_config_3.api_list_offerings.return_value = [o_mock_offering_3] + + # mock pour la fonction before_delete + o_mock = MagicMock() + o_mock.before_delete_function.return_value = [o_store_entity] + + for f_before_delete in [None, o_mock.before_delete_function]: + # StoredData sans configuration + with patch.object(Configuration, "api_list", return_value=[]) as o_mock_list: + with patch.object(StoredData, "delete_liste_entities", return_value=None) as o_mock_delete: + o_store_entity.delete_cascade(f_before_delete) + o_mock_delete.assert_called_once_with([o_store_entity], f_before_delete) + o_mock_list.assert_called_once_with({"stored_data": "1"}) + + # StoredData avec configuration et offres + with patch.object(Configuration, "api_list", return_value=[o_mock_config_1, o_mock_config_2, o_mock_config_3]) as o_mock_list: + with patch.object(StoredData, "delete_liste_entities", return_value=None) as o_mock_delete: + o_store_entity.delete_cascade(f_before_delete) + o_mock_delete.assert_called_once_with( + [ + o_mock_offering_1, + o_mock_offering_2, + o_mock_config_1, + o_mock_config_2, + o_mock_offering_3, + o_mock_config_3, + o_store_entity, + ], + f_before_delete, + ) + o_mock_list.assert_called_once_with({"stored_data": "1"}) + o_mock_config_1.api_list_offerings.assert_called_once_with() + o_mock_config_2.api_list_offerings.assert_called_once_with() + o_mock_config_3.api_list_offerings.assert_called_once_with() + o_mock_config_1.reset_mock() + o_mock_config_2.reset_mock() + o_mock_config_3.reset_mock() diff --git a/tests/workflow/WorkflowTestCase.py b/tests/workflow/WorkflowTestCase.py index 1ab2a442..4ac4d5e2 100644 --- a/tests/workflow/WorkflowTestCase.py +++ b/tests/workflow/WorkflowTestCase.py @@ -1,7 +1,9 @@ from pathlib import Path -from typing import Any, Dict, Optional, Type, List, Callable +from typing import Any, Dict, Optional, Type, List from unittest.mock import PropertyMock, patch, MagicMock +import jsonschema + from ignf_gpf_sdk.Errors import GpfSdkError from ignf_gpf_sdk.helper.JsonHelper import JsonHelper from ignf_gpf_sdk.io.Config import Config @@ -33,57 +35,99 @@ def test_get_raw_dict(self) -> None: o_workflow = Workflow("nom", d_workflow) self.assertDictEqual(d_workflow, o_workflow.get_raw_dict()) + def __list_action_run_step(self, s_etape: str, d_args_run_step: Dict[str, Any], d_workflow: Dict[str, Any]) -> List[Dict[str, Any]]: + """création de la liste des actions pour run_run_step() + + Args: + s_etape (str): non de l'étape + d_args_run_step (Dict[str, Any]): dictionnaire donné à run step + d_workflow (Dict[str, Any]): dictionnaire du workflow + + Returns: + List[Dict[str, Any]]: liste des actions avec les commentaire et les tags de gérer + """ + if s_etape not in d_workflow["workflow"]["steps"]: + return [] + l_comments: List[str] = d_args_run_step["comments"] + d_tags: Dict[str, str] = d_args_run_step["tags"] + l_actions = [] + + # général workflow + if "comments" in d_workflow: + l_comments = [*l_comments, *d_workflow["comments"]] + if "tags" in d_workflow: + d_tags = {**d_tags, **d_workflow["tags"]} + # dans l'étape + if "comments" in d_workflow["workflow"]["steps"][s_etape]: + l_comments = [*l_comments, *d_workflow["workflow"]["steps"][s_etape]["comments"]] + if "tags" in d_workflow["workflow"]["steps"][s_etape]: + d_tags = {**d_tags, **d_workflow["workflow"]["steps"][s_etape]["tags"]} + for d_action in d_workflow["workflow"]["steps"][s_etape]["actions"]: + d_action = d_action.copy() + if "comments" in d_action: + d_action["comments"] = [*l_comments, *d_action["comments"]] + else: + d_action["comments"] = l_comments + if "tags" in d_action: + d_action["tags"] = {**d_tags, **d_action["tags"]} + else: + d_action["tags"] = d_tags + l_actions.append(d_action) + return l_actions + def run_run_step( self, - s_etape: str, - s_datastore: Optional[str], + d_args_run_step: Dict[str, Any], d_workflow: Dict[str, Any], l_run_args: List[Any], - callback: Optional[Callable[[ProcessingExecution], None]] = None, - behavior: Optional[str] = None, monitoring_until_end: Optional[List[str]] = None, error_message: Optional[str] = None, + output_type: str = "configuration", ) -> None: """Fonction de lancement des tests pour run_step() Args: - s_etape (str): nom de l'étape du workflow à lancé - s_datastore (Optional[str]): nom du datastore à utiliser, si None datastore trouvé dans l'étape, le workflow ou None. + d_args_run_step Dict[str, Any]: dictionnaire donné à run step d_workflow (Dict[str, Any]): dictionnaire du workflow l_run_args (List[Any]): liste des argument passé en appel de action.run(). Un élément = un appel - callback (Optional[Callable[[ProcessingExecution], None]], optional): possible callback utilisé. Defaults to None. - behavior (Optional[str], optional): Action en cas de doublon, None action par défaut. Defaults to None. monitoring_until_end (Optional[List[str]], optional): si None, action sans monitoring, si défini : définition du side effect du mock de action.monitoring_until_end(). Defaults to None. error_message (Optional[str], optional): Message d'erreur compléter avec "action", si None : pas d'erreur attendu. Defaults to None. + output_type (str): type de l'entité de sortie : stored_data, configuration, offering ou upload """ + s_etape = str(d_args_run_step["step_name"]) + # récupération de la liste d'action - l_actions = [] - if s_etape in d_workflow["workflow"]["steps"]: - l_actions = d_workflow["workflow"]["steps"][s_etape]["actions"] + l_actions = self.__list_action_run_step(s_etape, d_args_run_step, d_workflow) # création du o_mock_action - if monitoring_until_end: - # cas avec monitoring + if output_type == "upload": o_mock_action = MagicMock(spec=ProcessingExecutionAction) - o_mock_action.monitoring_until_end.side_effect = monitoring_until_end - o_mock_action.stored_data = "entite" + o_mock_action.stored_data = None + o_mock_action.upload = f"Entity_{output_type}" + elif output_type == "stored_data": + o_mock_action = MagicMock(spec=ProcessingExecutionAction) + o_mock_action.stored_data = f"Entity_{output_type}" o_mock_action.upload = None - + elif output_type == "offering": + o_mock_action = MagicMock(spec=OfferingAction) + o_mock_action.offering = f"Entity_{output_type}" else: - # cas sans monitoring o_mock_action = MagicMock(spec=ConfigurationAction) - o_mock_action.configuration = "entite" + o_mock_action.configuration = f"Entity_{output_type}" + + if monitoring_until_end: + # cas avec monitoring + o_mock_action.monitoring_until_end.side_effect = monitoring_until_end + ## config mock générale o_mock_action.resolve.return_value = None o_mock_action.run.return_value = None + ## mock de la property definition_dict - if len(l_actions) == 1: - type(o_mock_action).definition_dict = PropertyMock(return_value=l_actions[0]) - else: - d_effect = [] - for d_el in l_actions: - d_effect += [d_el] * 2 - type(o_mock_action).definition_dict = PropertyMock(side_effect=d_effect) + d_effect = [] + for d_el in l_actions: + d_effect += [d_el] * 2 + type(o_mock_action).definition_dict = PropertyMock(side_effect=d_effect) # initialisation de Workflow o_workflow = Workflow("nom", d_workflow) @@ -93,18 +137,18 @@ def run_run_step( if error_message is not None: # si on attend une erreur with self.assertRaises(WorkflowError) as o_arc: - o_workflow.run_step(s_etape, callback, behavior, s_datastore) + o_workflow.run_step(**d_args_run_step) self.assertEqual(o_arc.exception.message, error_message.format(action=o_mock_action)) else: # pas d'erreur attendu - l_entities = o_workflow.run_step(s_etape, callback, behavior, s_datastore) - self.assertListEqual(l_entities, ["entite"] * len(l_run_args)) + l_entities = o_workflow.run_step(**d_args_run_step) + self.assertListEqual(l_entities, [f"Entity_{output_type}"] * len(l_run_args)) # vérification des appels à generate self.assertEqual(o_mock_action_generate.call_count, len(l_run_args)) o_parent = None - for i in range(len(l_run_args)): - o_mock_action_generate.assert_any_call(s_etape, l_actions[i], o_parent, behavior) + for d_action in l_actions: + o_mock_action_generate.assert_any_call(s_etape, d_action, o_parent, d_args_run_step["behavior"]) o_parent = o_mock_action # vérification des appels à résolve @@ -118,7 +162,7 @@ def run_run_step( # si monitoring : vérification des appels à monitoring if monitoring_until_end: self.assertEqual(o_mock_action.resolve.call_count, len(l_run_args)) - o_mock_action.monitoring_until_end.assert_any_call(callback=callback) + o_mock_action.monitoring_until_end.assert_any_call(callback=d_args_run_step["callback"], ctrl_c_action=None) def test_run_step(self) -> None: """test de run_step""" @@ -152,48 +196,93 @@ def callback(o_pe: ProcessingExecution) -> None: } } } - d_workflow_2: Dict[str, Any] = {"datastore": "datastore_workflow", **d_workflow} + # ajout datastore + d_workflow_2: Dict[str, Any] = {"datastore": "datastore_workflow", **d_workflow.copy()} + # tag + commentaire au niveau workflow + étape + d_workflow_3: Dict[str, Any] = { + "workflow": { + "steps": { + "mise-en-base": { + "actions": [{"type": "action1"}], + "comments": ["commentaire step 1", "commentaire step 2"], + "tags": {"key_step": "val"}, + } + }, + }, + "comments": ["commentaire workflow 1", "commentaire workflow 2"], + "tags": {"key_workflow": "val"}, + } s_datastore = "datastore_force" + d_args: Dict[str, Any] = { + "callback": None, + "behavior": None, + "datastore": None, + "comments": [], + "tags": {}, + } # test simple sans s_datastore - self.run_run_step("mise-en-base", None, d_workflow, [None]) - self.run_run_step("mise-en-base2", None, d_workflow, [None, None]) + self.run_run_step({**d_args, "step_name": "mise-en-base"}, d_workflow, [None]) + self.run_run_step({**d_args, "step_name": "mise-en-base2"}, d_workflow, [None, None]) # datastore au niveau des étapes - self.run_run_step("mise-en-base3", None, d_workflow, ["datastore_3"]) - self.run_run_step("mise-en-base4", None, d_workflow, ["datastore_4-1", None]) + self.run_run_step({**d_args, "step_name": "mise-en-base3"}, d_workflow, ["datastore_3"]) + self.run_run_step({**d_args, "step_name": "mise-en-base4"}, d_workflow, ["datastore_4-1", None]) # datastore au niveau du workflow + étapes - self.run_run_step("mise-en-base", None, d_workflow_2, ["datastore_workflow"]) - self.run_run_step("mise-en-base3", None, d_workflow_2, ["datastore_3"]) - self.run_run_step("mise-en-base4", None, d_workflow_2, ["datastore_4-1", "datastore_workflow"]) + self.run_run_step({**d_args, "step_name": "mise-en-base"}, d_workflow_2, ["datastore_workflow"]) + self.run_run_step({**d_args, "step_name": "mise-en-base3"}, d_workflow_2, ["datastore_3"]) + self.run_run_step({**d_args, "step_name": "mise-en-base4"}, d_workflow_2, ["datastore_4-1", "datastore_workflow"]) # datastore au niveau du workflow + étape + forcé dans l'appel - self.run_run_step("mise-en-base", s_datastore, d_workflow, [s_datastore]) - self.run_run_step("mise-en-base", s_datastore, d_workflow_2, [s_datastore]) - self.run_run_step("mise-en-base3", s_datastore, d_workflow_2, [s_datastore]) - self.run_run_step("mise-en-base4", s_datastore, d_workflow_2, [s_datastore, s_datastore]) + self.run_run_step({**d_args, "step_name": "mise-en-base", "datastore": s_datastore}, d_workflow, [s_datastore]) + self.run_run_step({**d_args, "step_name": "mise-en-base", "datastore": s_datastore}, d_workflow_2, [s_datastore]) + self.run_run_step({**d_args, "step_name": "mise-en-base3", "datastore": s_datastore}, d_workflow_2, [s_datastore]) + self.run_run_step({**d_args, "step_name": "mise-en-base4", "datastore": s_datastore}, d_workflow_2, [s_datastore, s_datastore]) + self.run_run_step({**d_args, "step_name": "mise-en-base4", "datastore": s_datastore}, d_workflow_2, [s_datastore, s_datastore], output_type="offering") + + # test pour les commentaires + tags + self.run_run_step({**d_args, "step_name": "mise-en-base", "datastore": s_datastore}, d_workflow_3, [s_datastore]) + self.run_run_step({**d_args, "step_name": "mise-en-base", "datastore": s_datastore, "comments": ["commentaire 1", "commentaire 2"], "tags": {"workflow": "val"}}, d_workflow_3, [s_datastore]) # étape qui n'existe pas - self.run_run_step("existe_pas", None, d_workflow, [], error_message="L'étape existe_pas n'est pas définie dans le workflow nom") + self.run_run_step({**d_args, "step_name": "existe_pas"}, d_workflow, [], error_message="L'étape existe_pas n'est pas définie dans le workflow nom") + # test avec monitoring - self.run_run_step("mise-en-base", None, d_workflow, [None], monitoring_until_end=["SUCCESS"]) - self.run_run_step("mise-en-base", None, d_workflow, [None], monitoring_until_end=["FAILURE"], error_message="L'exécution de traitement {action} ne s'est pas bien passée. Sortie FAILURE.") - self.run_run_step("mise-en-base", None, d_workflow, [None], monitoring_until_end=["ABORTED"], error_message="L'exécution de traitement {action} ne s'est pas bien passée. Sortie ABORTED.") + self.run_run_step({**d_args, "step_name": "mise-en-base"}, d_workflow, [None], monitoring_until_end=["SUCCESS"], output_type="upload") + self.run_run_step({**d_args, "step_name": "mise-en-base"}, d_workflow, [None], monitoring_until_end=["SUCCESS"], output_type="stored_data") + self.run_run_step({**d_args, "step_name": "mise-en-base4"}, d_workflow_2, ["datastore_4-1", "datastore_workflow"], monitoring_until_end=["SUCCESS", "SUCCESS"], output_type="stored_data") + self.run_run_step( + {**d_args, "step_name": "mise-en-base"}, + d_workflow, + [None], + monitoring_until_end=["FAILURE"], + error_message="L'exécution de traitement {action} ne s'est pas bien passée. Sortie FAILURE.", + output_type="stored_data", + ) self.run_run_step( - "mise-en-base4", - None, + {**d_args, "step_name": "mise-en-base"}, + d_workflow, + [None], + monitoring_until_end=["ABORTED"], + error_message="L'exécution de traitement {action} ne s'est pas bien passée. Sortie ABORTED.", + output_type="stored_data", + ) + self.run_run_step( + {**d_args, "step_name": "mise-en-base4"}, d_workflow_2, ["datastore_4-1", "datastore_workflow"], monitoring_until_end=["SUCCESS", "ABORTED"], error_message="L'exécution de traitement {action} ne s'est pas bien passée. Sortie ABORTED.", + output_type="stored_data", ) - self.run_run_step("mise-en-base4", None, d_workflow_2, ["datastore_4-1", "datastore_workflow"], monitoring_until_end=["SUCCESS", "SUCCESS"]) # callbable - self.run_run_step("mise-en-base", None, d_workflow, [None], callback, None, ["SUCCESS"]) + self.run_run_step({**d_args, "step_name": "mise-en-base", "callback": callback}, d_workflow, [None], monitoring_until_end=["SUCCESS", "SUCCESS"], output_type="stored_data") # behavior - self.run_run_step("mise-en-base", None, d_workflow, [None], None, "DELETE") - self.run_run_step("mise-en-base", None, d_workflow, [None], callback, "DELETE", ["SUCCESS"]) + self.run_run_step({**d_args, "step_name": "mise-en-base", "behavior": "DELETE"}, d_workflow, [None]) + self.run_run_step( + {**d_args, "step_name": "mise-en-base", "callback": callback, "behavior": "DELETE"}, d_workflow, [None], monitoring_until_end=["SUCCESS", "SUCCESS"], output_type="stored_data" + ) def run_generation(self, expected_type: Type[ActionAbstract], name: str, dico_def: Dict[str, Any], parent: Optional[ActionAbstract] = None, behavior: Optional[str] = None) -> None: """lancement de la commande de génération @@ -265,12 +354,15 @@ def test_open_workflow(self) -> None: def test_validate(self) -> None: """Test de la fonction validate.""" p_workflows = Config.data_dir_path / "workflows" + # On valide le workflow generic_archive.jsonc o_workflow_1 = Workflow.open_workflow(p_workflows / "generic_archive.jsonc") self.assertFalse(o_workflow_1.validate()) + # On valide le workflow generic_vecteur.jsonc o_workflow_2 = Workflow.open_workflow(p_workflows / "generic_vecteur.jsonc") self.assertFalse(o_workflow_2.validate()) + # On ne valide pas le workflow bad-workflow.jsonc p_workflow = GpfTestCase.data_dir_path / "workflows" / "bad-workflow.jsonc" o_workflow_2 = Workflow(p_workflow.stem, JsonHelper.load(p_workflow)) @@ -281,6 +373,21 @@ def test_validate(self) -> None: self.assertEqual(l_errors[2], "L'étape « no-parent-no-action » n'a aucune action de défini.") self.assertEqual(l_errors[3], "L'action n°1 de l'étape « configuration-wfs » n'est pas instantiable (Aucune correspondance pour ce type d'action : type-not-found).") self.assertEqual(l_errors[4], "L'action n°2 de l'étape « configuration-wfs » n'a pas la clef obligatoire ('type').") + ## cas erreur non valide + with patch.object(Workflow, "generate", side_effect=Exception("error")) as o_mock_jsonschema: + l_errors = o_workflow_1.validate() + self.assertTrue(l_errors) + self.assertEqual(l_errors[0], "L'action n°1 de l'étape « intégration-archive-livrée » lève une erreur inattendue (error).") + self.assertEqual(l_errors[1], "L'action n°1 de l'étape « configuration-archive-livrée » lève une erreur inattendue (error).") + self.assertEqual(l_errors[2], "L'action n°1 de l'étape « publication-archive-livrée » lève une erreur inattendue (error).") + + # problème avec le schema du fichier workflow + p_schema = Config.conf_dir_path / "json_schemas" / "workflow.json" + with patch.object(jsonschema, "validate", side_effect=jsonschema.exceptions.SchemaError("error")) as o_mock_jsonschema: + with self.assertRaises(GpfSdkError) as o_arc: + o_workflow_2.validate() + self.assertEqual(o_arc.exception.message, f"Le schéma décrivant la structure d'un workflow {p_schema} est invalide. Contactez le support.") + o_mock_jsonschema.assert_called_once() def test_get_actions(self) -> None: """Test de get_actions.""" @@ -329,3 +436,24 @@ def test_get_action(self) -> None: # Vérifications self.assertEqual(o_action, o_action_get) o_mock_get_actions.assert_called_once_with("stem_name") + + def test_get_all_steps(self) -> None: + """test de get_all_steps""" + o_workflow = Workflow( + "workflow_name", + { + "workflow": { + "steps": { + "etape1": {"parents": [], "actions": []}, + "etape2A": {"parents": ["etape1"], "actions": []}, + "etape2B": {"parents": ["etape1"], "actions": []}, + "etape3": {"parents": ["etape2A", "etape2B"], "actions": []}, + }, + }, + }, + ) + l_steps = o_workflow.get_all_steps() + self.assertEqual(l_steps[0], "Etape « etape1 » [parent(s) : ]") + self.assertEqual(l_steps[1], "Etape « etape2A » [parent(s) : etape1]") + self.assertEqual(l_steps[2], "Etape « etape2B » [parent(s) : etape1]") + self.assertEqual(l_steps[3], "Etape « etape3 » [parent(s) : etape2A, etape2B]") diff --git a/tests/workflow/action/ConfigurationActionTestCase.py b/tests/workflow/action/ConfigurationActionTestCase.py index 790add29..e010b0bf 100644 --- a/tests/workflow/action/ConfigurationActionTestCase.py +++ b/tests/workflow/action/ConfigurationActionTestCase.py @@ -1,8 +1,11 @@ from typing import Any, Dict, List, Optional from unittest.mock import patch, MagicMock +from ignf_gpf_sdk.Errors import GpfSdkError +from ignf_gpf_sdk.io.Errors import ConflictError from ignf_gpf_sdk.store.Configuration import Configuration +from ignf_gpf_sdk.workflow.Errors import StepActionError from ignf_gpf_sdk.workflow.action.ActionAbstract import ActionAbstract from ignf_gpf_sdk.workflow.action.ConfigurationAction import ConfigurationAction from tests.GpfTestCase import GpfTestCase @@ -37,24 +40,28 @@ def test_find_configuration(self) -> None: "tag": "val", }, } - # exécution de ConfigurationAction - o_ca = ConfigurationAction("contexte", d_action) - # Mock de ActionAbstract.get_filters et Configuration.api_list - with patch.object(ActionAbstract, "get_filters", return_value=({"info": "val"}, {"tag": "val"})) as o_mock_get_filters: - with patch.object(Configuration, "api_list", return_value=[o_c1, o_c2]) as o_mock_api_list: - # Appel de la fonction find_configuration - o_stored_data = o_ca.find_configuration("datastore_id") - # Vérifications - o_mock_get_filters.assert_called_once_with("configuration", d_action["body_parameters"], d_action["tags"]) - o_mock_api_list.assert_called_once_with(infos_filter={"info": "val"}, tags_filter={"tag": "val"}, datastore="datastore_id") - self.assertEqual(o_stored_data, o_c1) - - def run_args( + for o_api_list_return in [[o_c1, o_c2], [], None]: + # exécution de ConfigurationAction + o_ca = ConfigurationAction("contexte", d_action) + # Mock de ActionAbstract.get_filters et Configuration.api_list + with patch.object(ActionAbstract, "get_filters", return_value=({"info": "val"}, {"tag": "val"})) as o_mock_get_filters: + with patch.object(Configuration, "api_list", return_value=o_api_list_return) as o_mock_api_list: + # Appel de la fonction find_configuration + o_stored_data = o_ca.find_configuration("datastore_id") + # Vérifications + o_mock_get_filters.assert_called_once_with("configuration", d_action["body_parameters"], d_action["tags"]) + o_mock_api_list.assert_called_once_with(infos_filter={"info": "val"}, tags_filter={"tag": "val"}, datastore="datastore_id") + self.assertEqual(o_stored_data, o_c1 if o_api_list_return else None) + + def run_args( # pylint:disable=too-many-statements self, tags: Optional[Dict[str, Any]], comments: Optional[List[str]], config_already_exists: bool, comment_exist: bool = False, + behavior: Optional[str] = None, + datastore: Optional[str] = None, + conflict_creation: bool = False, ) -> None: """lancement +test de ConfigurationAction.run selon param Args: @@ -62,6 +69,9 @@ def run_args( comments (Optional[List[str]]): liste des comments ou None config_already_exists (bool): configuration déjà existante comment_exist (bool): si on a un commentaire qui existe déjà + behavior (Optional[str]): si on a un commentaire qui existe déjà + datastore (Optional[str]): id du datastore à utiliser. + conflict_creation (bool): si il y a un conflict lors de la creation """ # creation du dictionnaire qui reprend les paramètres du workflow pour créer une configuration d_action: Dict[str, Any] = {"type": "configuration", "body_parameters": {"param": "valeur"}} @@ -78,28 +88,67 @@ def run_args( o_mock_configuration.api_add_comment.return_value = None o_mock_configuration.api_list_comments.return_value = [{"text": "commentaire existe"}] if comment_exist else [] - # Liste des configurations déjà existantes - if config_already_exists: - l_configs = [o_mock_configuration] + # paramétrage du mock de Configuration.api_create + if conflict_creation: + d_api_create: Dict[str, Any] = {"side_effect": ConflictError("url", "POST", None, None, '{"error_description":["message_erreur"]}')} else: - l_configs = [] + d_api_create = {"return_value": o_mock_configuration} - # suppression de la mise en page forcé pour le with - with patch.object(Configuration, "api_create", return_value=o_mock_configuration) as o_mock_configuration_api_create: - with patch.object(Configuration, "api_list", return_value=l_configs) as o_mock_configuration_api_list: - # initialisation de Configuration - o_conf = ConfigurationAction("contexte", d_action) - # on lance l'exécution de run - o_conf.run() + # Liste des configurations déjà existantes + o_configs = o_mock_configuration if config_already_exists else None - # test de l'appel à Configuration.api_create - o_mock_configuration_api_list.assert_called_once() + # suppression de la mise en page forcé pour le with + with patch.object(Configuration, "api_create", **d_api_create) as o_mock_configuration_api_create: + with patch.object(ConfigurationAction, "find_configuration", return_value=o_configs) as o_mock_find_configuration: - # test de l'appel à Configuration.api_create + # initialisation de Configuration + o_conf = ConfigurationAction("contexte", d_action, behavior=behavior) if config_already_exists: - o_mock_configuration_api_create.assert_not_called() + if behavior == "STOP": + # on attend une erreur + with self.assertRaises(GpfSdkError) as o_err: + o_conf.run(datastore) + self.assertEqual(o_err.exception.message, f"Impossible de créer la configuration, une configuration équivalente {o_mock_configuration} existe déjà.") + o_mock_find_configuration.assert_called_once_with(datastore) + return + + if behavior == "DELETE": + if conflict_creation: + with self.assertRaises(StepActionError) as o_err_2: + o_conf.run(datastore) + print(o_err_2.exception.message) + self.assertEqual(o_err_2.exception.message, "Impossible de créer la configuration il y a un conflict : \nmessage_erreur") + return + # suppression de l'existant puis normal + o_conf.run(datastore) + # un appel à find_stored_data + o_mock_configuration.api_delete.assert_called_once_with() + o_mock_configuration_api_create.assert_called_once_with(d_action["body_parameters"], route_params={"datastore": datastore}) + elif not behavior or behavior == "CONTINUE": + o_conf.run(datastore) + # on n'a pas d'api create + self.assertEqual(o_mock_configuration, o_conf.configuration) + o_mock_configuration_api_create.assert_not_called() + else: + # behavior non valide + with self.assertRaises(GpfSdkError) as o_err: + o_conf.run(datastore) + self.assertEqual(o_err.exception.message, f"Le comportement {behavior} n'est pas reconnu (STOP|DELETE|CONTINUE), l'exécution de traitement n'est pas possible.") + return + + elif conflict_creation: + # pas de conflict trouvé avant création mais erreur conflict lors de la creation + with self.assertRaises(StepActionError) as o_err_2: + o_conf.run(datastore) + self.assertEqual(o_err_2.exception.message, "Impossible de créer la configuration il y a un conflict : \nmessage_erreur") + return else: - o_mock_configuration_api_create.assert_called_once_with(d_action["body_parameters"], route_params={"datastore": None}) + # on lance l'exécution de run + o_conf.run(datastore) + o_mock_configuration_api_create.assert_called_once_with(d_action["body_parameters"], route_params={"datastore": datastore}) + + # test de l'appel à Configuration.api_create + o_mock_find_configuration.assert_called_once() # test api_add_tags if "tags" in d_action and d_action["tags"]: @@ -130,3 +179,8 @@ def test_run(self) -> None: self.run_args({"tag1": "val1", "tag2": "val2"}, ["comm1", "comm2", "comm3", "comm4"], b_config_already_exists, False) ## 2 tag + 4 commentaire + 1 commentaire qui existe déjà self.run_args({"tag1": "val1", "tag2": "val2"}, ["comm1", "comm2", "comm3", "comm4"], b_config_already_exists, True) + self.run_args({"tag1": "val1", "tag2": "val2"}, ["comm1", "comm2", "comm3", "comm4"], b_config_already_exists, True, "STOP", "toto") + self.run_args({"tag1": "val1", "tag2": "val2"}, ["comm1", "comm2", "comm3", "comm4"], b_config_already_exists, True, "CONTINUE", "toto") + self.run_args({"tag1": "val1", "tag2": "val2"}, ["comm1", "comm2", "comm3", "comm4"], b_config_already_exists, True, "DELETE", "toto") + self.run_args({"tag1": "val1", "tag2": "val2"}, ["comm1", "comm2", "comm3", "comm4"], b_config_already_exists, True, "non", "toto") + self.run_args({"tag1": "val1", "tag2": "val2"}, ["comm1", "comm2", "comm3", "comm4"], b_config_already_exists, True, "DELETE", "toto", True) diff --git a/tests/workflow/action/OfferingActionTestCase.py b/tests/workflow/action/OfferingActionTestCase.py index b4efc007..374dac23 100644 --- a/tests/workflow/action/OfferingActionTestCase.py +++ b/tests/workflow/action/OfferingActionTestCase.py @@ -1,6 +1,7 @@ import time from unittest.mock import patch, MagicMock -from typing import Any +from typing import Any, Optional +from ignf_gpf_sdk.Errors import GpfSdkError from ignf_gpf_sdk.io.Errors import ConflictError from ignf_gpf_sdk.store.Offering import Offering @@ -20,9 +21,9 @@ class OfferingActionTestCase(GpfTestCase): # creation du dictionnaire qui reprend les paramètres du workflow pour créer une offre d_action = {"type": "offering", "body_parameters": {"endpoint": "id_endpoint"}, "url_parameters": {"configuration": "id_configuration"}} - def __get_offering_action(self) -> OfferingAction: + def __get_offering_action(self, behavior: Optional[str] = None) -> OfferingAction: # Instanciation de OfferingAction - o_offering_action = OfferingAction("contexte", self.d_action) + o_offering_action = OfferingAction("contexte", self.d_action, behavior=behavior) # Retour return o_offering_action @@ -116,10 +117,8 @@ def offering_getitem(arg: str) -> Any: o_mock_time.assert_any_call(1) # On mock find_offering et api_create - def test_run_existing(self) -> None: - """test de run quand l'offre à créer existe""" - # Instanciation de OfferingAction - o_offering_action = self.__get_offering_action() + def test_run_existing_behavior_continue(self) -> None: + """test de run quand l'offre à créer existe behavior continue""" # mock de offering o_mock_offering = MagicMock() @@ -154,19 +153,86 @@ def side_effect_text(arg: str) -> Any: return "PUBLISHED" return "getitem" - for f_effect in [side_effect_dict, side_effect_text]: - o_mock_offering.__getitem__.side_effect = f_effect + # Instanciation de OfferingAction + for o_offering_action in [self.__get_offering_action(), self.__get_offering_action("CONTINUE")]: + for f_effect in [side_effect_dict, side_effect_text]: + o_mock_offering.__getitem__.side_effect = f_effect - with patch.object(o_offering_action, "find_offering", return_value=o_mock_offering) as o_mock_offering_action_list_offering: - with patch.object(Offering, "api_create", return_value=None) as o_mock_offering_api_create: - # on lance l'exécution de run - o_offering_action.run() + with patch.object(o_offering_action, "find_offering", return_value=o_mock_offering) as o_mock_offering_action_list_offering: + with patch.object(Offering, "api_create", return_value=None) as o_mock_offering_api_create: + # on lance l'exécution de run + o_offering_action.run() - # test de l'appel à OfferingAction.find_offering - o_mock_offering_action_list_offering.assert_called_once() + # test de l'appel à OfferingAction.find_offering + o_mock_offering_action_list_offering.assert_called_once() + + # test de l'appel à Offering.api_create + o_mock_offering_api_create.assert_not_called() + + # On mock find_offering et api_create + def test_run_existing_behavior_stop(self) -> None: + """test de run quand l'offre à créer existe behavior stop""" + # Instanciation de OfferingAction + o_offering_action = self.__get_offering_action("STOP") + + # mock de offering + o_mock_offering = MagicMock() + with patch.object(o_offering_action, "find_offering", return_value=o_mock_offering): + with self.assertRaises(GpfSdkError) as o_err: + o_offering_action.run() + self.assertEqual(o_err.exception.message, f"Impossible de créer l'offre, une offre équivalente {o_mock_offering} existe déjà.") + + # On mock find_offering et api_create + def test_run_existing_behavior_delete(self) -> None: + """test de run quand l'offre à créer existe behavior delete""" + + def side_effect_dict(arg: str) -> Any: + """side_effect pour récupération des élément de offering, gestion du cas des url qui sont dans un dictionnaire + + Args: + arg (str): clef à affiché + + Returns: + Any: valeur de retour + """ + if arg == "urls": + return [{"url": "http://1"}, {"url": "http://2"}] + if arg == "status": + return "PUBLISHED" + return "getitem" + + # Instanciation de OfferingAction + o_offering_action = self.__get_offering_action("DELETE") + + # mock de offering + o_mock_offering = MagicMock() + o_mock_offering.__getitem__.side_effect = side_effect_dict + with patch.object(o_offering_action, "find_offering", return_value=o_mock_offering) as o_mock_offering_action_list_offering: + with patch.object(Offering, "api_create", return_value=o_mock_offering) as o_mock_offering_api_create: + # on lance l'exécution de run + o_offering_action.run() + + # test de l'appel à OfferingAction.find_offering + o_mock_offering_action_list_offering.assert_called_once() + + # test de l'appel à Offering.api_create + o_mock_offering_api_create.assert_called_once() + + # test appel de o_offering.api_delete + o_mock_offering.api_delete.assert_called_once() - # test de l'appel à Offering.api_create - o_mock_offering_api_create.assert_not_called() + # On mock find_offering et api_create + def test_run_existing_behavior_faux(self) -> None: + """test de run quand l'offre à créer existe behavior non compatible""" + # Instanciation de OfferingAction + o_offering_action = self.__get_offering_action("non") + + # mock de offering + o_mock_offering = MagicMock() + with patch.object(o_offering_action, "find_offering", return_value=o_mock_offering): + with self.assertRaises(GpfSdkError) as o_err: + o_offering_action.run() + self.assertEqual(o_err.exception.message, "Le comportement non n'est pas reconnu (STOP|DELETE|CONTINUE), l'exécution de traitement n'est pas possible.") def test_find_offering_exists_and_ok(self) -> None: """Test de find_offering quand une offering est trouvée et que le endpoint correspond. diff --git a/tests/workflow/action/ProcessingExecutionActionTestCase.py b/tests/workflow/action/ProcessingExecutionActionTestCase.py index 2487f3f2..a03944ad 100644 --- a/tests/workflow/action/ProcessingExecutionActionTestCase.py +++ b/tests/workflow/action/ProcessingExecutionActionTestCase.py @@ -1,3 +1,4 @@ +import time from typing import Any, Dict, List, Optional from unittest.mock import PropertyMock, call, patch, MagicMock @@ -6,6 +7,7 @@ from ignf_gpf_sdk.store.ProcessingExecution import ProcessingExecution from ignf_gpf_sdk.store.StoredData import StoredData from ignf_gpf_sdk.store.Upload import Upload +from ignf_gpf_sdk.workflow.Errors import StepActionError from ignf_gpf_sdk.workflow.action.ActionAbstract import ActionAbstract from ignf_gpf_sdk.workflow.action.ProcessingExecutionAction import ProcessingExecutionAction from ignf_gpf_sdk.Errors import GpfSdkError @@ -247,40 +249,37 @@ def monitoring_until_end_args(self, s_status_end: str, b_waits: bool, b_callback b_callback (bool): si on a une fonction callback """ - if b_waits: - l_status = [ProcessingExecution.STATUS_CREATED,ProcessingExecution.STATUS_WAITING, ProcessingExecution.STATUS_PROGRESS] - else: - l_status = [] - if b_callback: - f_callback = MagicMock() - else: - f_callback = None + l_status = [] if not b_waits else [ProcessingExecution.STATUS_CREATED,ProcessingExecution.STATUS_WAITING, ProcessingExecution.STATUS_PROGRESS] + f_callback = MagicMock() if b_callback else None + f_ctrl_c = MagicMock(return_value=True) # mock de o_mock_processing_execution o_mock_processing_execution = MagicMock(name="test") o_mock_processing_execution.get_store_properties.side_effect = [{"status": el} for el in l_status] + [{"status": s_status_end}]*3 o_mock_processing_execution.api_update.return_value = None - with patch.object(ProcessingExecutionAction, "processing_execution", new_callable=PropertyMock) as o_mock_pe, \ + with patch.object(ProcessingExecutionAction, "processing_execution", new_callable=PropertyMock, return_value = o_mock_processing_execution), \ + patch.object(time, "sleep", return_value=None), \ patch.object(Config, "get_int", return_value=0) : - o_mock_pe.return_value = o_mock_processing_execution # initialisation de ProcessingExecutionAction o_pea = ProcessingExecutionAction("contexte", {}) - s_return = o_pea.monitoring_until_end(f_callback) + s_return = o_pea.monitoring_until_end(f_callback, f_ctrl_c) # vérification valeur de sortie self.assertEqual(s_return, s_status_end) # vérification de l'attente ## update - self.assertEqual(o_mock_processing_execution.api_update.call_count, len(l_status)) + self.assertEqual(o_mock_processing_execution.api_update.call_count, len(l_status)+1) ##log + callback if f_callback is not None: self.assertEqual(f_callback.call_count, len(l_status)+1) self.assertEqual(f_callback.mock_calls, [call(o_mock_processing_execution)] * (len(l_status)+1)) - def interrupt_monitoring_until_end_args(self, s_status_end: str, b_waits: bool, b_callback: bool, b_upload: bool, b_stored_data: bool, b_new_output: bool) -> None: + f_ctrl_c.assert_not_called() + + def interrupt_monitoring_until_end_args(self, s_status_end: str, b_waits: bool, b_callback: bool, s_ctrl_c: str, b_upload: bool, b_stored_data: bool, b_new_output: bool) -> None: # cas interruption par l'utilisateur. """lancement + test de ProcessingExecutionAction.monitoring_until_end() + simulation ctrl+C pendant monitoring_until_end @@ -288,21 +287,37 @@ def interrupt_monitoring_until_end_args(self, s_status_end: str, b_waits: bool, s_status_end (str): status de fin b_waits (bool): si on a des status intermédiaire b_callback (bool): si on a une fonction callback + s_ctrl_c (str): si on a une fonction callback pour gérer les ctrl_c et action du ctrl + C. option : "non", "pass", "delete" b_upload (bool): si sortie du traitement en upload b_stored_data (bool): si sortie du traitement en stored-data b_new_output (bool): si on a une nouvelle sortie (création) un ancienne (modification) """ + # print( + # "s_status_end", s_status_end, + # "b_waits", b_waits, + # "b_callback", b_callback, + # "s_ctrl_c", s_ctrl_c, + # "b_upload", b_upload, + # "b_stored_data", b_stored_data, + # "b_new_output", b_new_output, + # ) if b_waits: + i_nb_call_back=4 l_status = [{"status": ProcessingExecution.STATUS_CREATED}, {"status": ProcessingExecution.STATUS_WAITING}, {"status": ProcessingExecution.STATUS_PROGRESS}, \ - {"raise": "KeyboardInterrupt"}, {"status": ProcessingExecution.STATUS_PROGRESS}, {"status": s_status_end}] + {"raise": "KeyboardInterrupt"}, {"status": ProcessingExecution.STATUS_PROGRESS}, {"status": ProcessingExecution.STATUS_PROGRESS}, {"status": s_status_end}] else: - l_status = [{"raise": "KeyboardInterrupt"}, {"status": s_status_end}] - if b_callback: - f_callback = MagicMock() + i_nb_call_back =2 + l_status = [{"status": ProcessingExecution.STATUS_PROGRESS}, {"raise": "KeyboardInterrupt"}, {"status": s_status_end}] + + f_callback = MagicMock() if b_callback else None + if s_ctrl_c == "delete": + f_ctrl_c = MagicMock(return_value=True) + elif s_ctrl_c == "pass": + f_ctrl_c = MagicMock(return_value=False) else: - f_callback = None + f_ctrl_c = None d_definition_dict: Dict[str, Any] = {"body_parameters":{"output":{}}} d_output = {"name": "new"} if b_new_output else {"_id": "ancien"} @@ -332,6 +347,7 @@ def status() -> Dict[str, Any]: """ nonlocal i_iter s_el = l_status[i_iter] + i_iter+=1 if "raise" in s_el: raise KeyboardInterrupt() @@ -347,6 +363,7 @@ def status() -> Dict[str, Any]: with patch.object(ProcessingExecutionAction, "processing_execution", new_callable=PropertyMock) as o_mock_pe, \ patch.object(ProcessingExecutionAction, "upload", new_callable=PropertyMock) as o_mock_u, \ patch.object(ProcessingExecutionAction, "stored_data", new_callable=PropertyMock) as o_mock_sd, \ + patch.object(time, "sleep", return_value=None), \ patch.object(Config, "get_int", return_value=0) : o_mock_pe.return_value = o_mock_processing_execution @@ -356,9 +373,28 @@ def status() -> Dict[str, Any]: # initialisation de ProcessingExecutionAction o_pea = ProcessingExecutionAction("contexte", d_definition_dict) + # ctrl+C mais continue + if s_ctrl_c == "pass": + s_return = o_pea.monitoring_until_end(f_callback, f_ctrl_c) + + # vérification valeur de sortie + self.assertEqual(s_return, s_status_end) + + # vérification de l'attente + ## update + self.assertEqual(o_mock_processing_execution.api_update.call_count, len(l_status)) + ##log + callback + if f_callback is not None: + self.assertEqual(f_callback.call_count, len(l_status)) + self.assertEqual(f_callback.mock_calls, [call(o_mock_processing_execution)] * (len(l_status))) + + if f_ctrl_c: + f_ctrl_c.assert_called_once_with() + return + # vérification sortie en erreur de monitoring_until_end with self.assertRaises(KeyboardInterrupt): - o_pea.monitoring_until_end(f_callback) + o_pea.monitoring_until_end(f_callback, f_ctrl_c) # exécution de abort if not b_waits: @@ -368,11 +404,11 @@ def status() -> Dict[str, Any]: # vérification de l'attente ## update - self.assertEqual(o_mock_processing_execution.api_update.call_count, len(l_status)-1) + self.assertEqual(o_mock_processing_execution.api_update.call_count, len(l_status)) ##log + callback if f_callback is not None: - self.assertEqual(f_callback.call_count, len(l_status)-2) - self.assertEqual(f_callback.mock_calls, [call(o_mock_processing_execution)] * (len(l_status)-2)) + self.assertEqual(f_callback.call_count, i_nb_call_back) + self.assertEqual(f_callback.mock_calls, [call(o_mock_processing_execution)] * i_nb_call_back) # vérification suppression el de sortie si nouveau if b_waits and s_status_end == ProcessingExecution.STATUS_ABORTED: @@ -388,15 +424,25 @@ def status() -> Dict[str, Any]: o_mock_stored_data.api_delete.assert_not_called() def test_monitoring_until_end(self)-> None: - """test de test_monitoring_until_end""" + """test de monitoring_until_end""" for s_status_end in [ProcessingExecution.STATUS_ABORTED, ProcessingExecution.STATUS_SUCCESS, ProcessingExecution.STATUS_FAILURE]: for b_waits in [False, True]: for b_callback in [False, True]: self.monitoring_until_end_args(s_status_end, b_waits, b_callback) - for b_new_output in [False, True]: - self.interrupt_monitoring_until_end_args(s_status_end, b_waits, b_callback, True, False, b_new_output) - self.interrupt_monitoring_until_end_args(s_status_end, b_waits, b_callback, False, True, b_new_output) - self.interrupt_monitoring_until_end_args(s_status_end, b_waits, b_callback, False, False, b_new_output) + for s_ctrl_c in ["non", "pass", "delete"]: + for b_new_output in [False, True]: + self.interrupt_monitoring_until_end_args(s_status_end, b_waits, b_callback, s_ctrl_c, True, False, b_new_output) + self.interrupt_monitoring_until_end_args(s_status_end, b_waits, b_callback, s_ctrl_c, False, True, b_new_output) + self.interrupt_monitoring_until_end_args(s_status_end, b_waits, b_callback, s_ctrl_c, False, False, b_new_output) + # cas sans processing execution => impossible + with patch.object(ProcessingExecutionAction, "processing_execution", new_callable=PropertyMock, return_value = None): + o_pea = ProcessingExecutionAction("contexte", {}) + # on attend une erreur + with self.assertRaises(StepActionError) as o_err: + o_pea.monitoring_until_end() + self.assertEqual(o_err.exception.message, "Aucune processing-execution trouvée. Impossible de suivre le déroulement du traitement") + return + def test_output_new_entity(self)-> None: """test de output_new_entity""" diff --git a/tests/workflow/action/UploadActionTestCase.py b/tests/workflow/action/UploadActionTestCase.py index 4e08c19d..0ea0a991 100644 --- a/tests/workflow/action/UploadActionTestCase.py +++ b/tests/workflow/action/UploadActionTestCase.py @@ -322,7 +322,7 @@ def test_run(self) -> None: def test_monitor_until_end_ok(self) -> None: """Vérifie le bon fonctionnement de monitor_until_end si à la fin c'est ok.""" - # 2 réponses possibles pour api_list_checks : il faut attendre ou c'est tout ok + # 3 réponses possibles pour api_list_checks : il faut attendre sur les 2 premières; tout est ok sur la troisième. d_list_checks_wait_1 = {"asked": [{}, {}],"in_progress": [],"passed": [],"failed": []} d_list_checks_wait_2 = {"asked": [{}],"in_progress": [{}],"passed": [],"failed": []} d_list_checks_ok = {"asked": [],"in_progress": [],"passed": [{},{}],"failed": []} @@ -332,22 +332,25 @@ def test_monitor_until_end_ok(self) -> None: with patch.object(Upload, "api_list_checks", side_effect=l_returns) as o_mock_list_checks: # On instancie un Upload o_upload = Upload({"_id": "id_upload_monitor"}) - # On instancie un faut callback + # On instancie un faux callback f_callback = MagicMock() + f_ctrl_c = MagicMock(return_value=False) # On effectue le monitoring - b_result = UploadAction.monitor_until_end(o_upload, f_callback) + b_result = UploadAction.monitor_until_end(o_upload, f_callback, f_ctrl_c) # Vérification sur o_mock_list_checks et f_callback: ont dû être appelés 3 fois self.assertEqual(o_mock_list_checks.call_count, 3) self.assertEqual(f_callback.call_count, 3) f_callback.assert_any_call("Vérifications : 2 en attente, 0 en cours, 0 en échec, 0 en succès") f_callback.assert_any_call("Vérifications : 1 en attente, 1 en cours, 0 en échec, 0 en succès") f_callback.assert_any_call("Vérifications : 0 en attente, 0 en cours, 0 en échec, 2 en succès") + # Vérification sur f_ctrl_c : n'a pas dû être appelée + f_ctrl_c.assert_not_called() # Vérifications sur b_result : doit être finalement ok self.assertTrue(b_result) def test_monitor_until_end_ko(self) -> None: """Vérifie le bon fonctionnement de monitor_until_end si à la fin c'est ko.""" - # 3 réponses possibles pour api_list_checks : 2 il faut attendre, 1 il y a un pb + # 3 réponses possibles pour api_list_checks : il faut attendre sur les 2 premières; il y a un pb sur la troisième. d_list_checks_wait_1 = {"asked": [{}, {}],"in_progress": [],"passed": [],"failed": []} d_list_checks_wait_2 = {"asked": [{}],"in_progress": [{}],"passed": [],"failed": []} d_list_checks_ko = {"asked": [],"in_progress": [],"passed": [{}],"failed": [{}]} @@ -357,19 +360,53 @@ def test_monitor_until_end_ko(self) -> None: with patch.object(Upload, "api_list_checks", side_effect=l_returns) as o_mock_list_checks: # On instancie un Upload o_upload = Upload({"_id": "id_upload_monitor"}) - # On instancie un faut callback + # On instancie un faux callback f_callback = MagicMock() + f_ctrl_c = MagicMock(return_value=False) # On effectue le monitoring - b_result = UploadAction.monitor_until_end(o_upload, f_callback) + b_result = UploadAction.monitor_until_end(o_upload, f_callback, f_ctrl_c) # Vérification sur o_mock_list_checks et f_callback: ont dû être appelés 3 fois self.assertEqual(o_mock_list_checks.call_count, 3) self.assertEqual(f_callback.call_count, 3) f_callback.assert_any_call("Vérifications : 2 en attente, 0 en cours, 0 en échec, 0 en succès") f_callback.assert_any_call("Vérifications : 1 en attente, 1 en cours, 0 en échec, 0 en succès") f_callback.assert_any_call("Vérifications : 0 en attente, 0 en cours, 1 en échec, 1 en succès") + # Vérification sur f_ctrl_c : n'a pas dû être appelée + f_ctrl_c.assert_not_called() # Vérifications sur b_result : doit être finalement ko self.assertFalse(b_result) + def test_interrupt_monitor_until_end(self) -> None: + """Vérifie le bon fonctionnement de monitor_until_end si il y a interruption en cours de route.""" + + # On patch la fonction api_list_checks de l'upload + def checks() -> Dict[str, List[Dict[str, Any]]]: + """fonction pour mock de api_list_checks (=> checks) + + Raises: + KeyboardInterrupt: simulation du Ctrl+C + + Returns: + Dict[str, List[Dict[str, Any]]]: dict contenant les checks + """ + raise KeyboardInterrupt() + + with patch.object(Upload, "api_list_checks", side_effect=checks) as o_mock_list_checks: + with patch.object(Upload, "api_close") as o_mock_api_close: + # On instancie un Upload + o_upload = Upload({"_id": "id_upload_monitor"}) + # On instancie un faux callback + f_callback = MagicMock() + f_ctrl_c = MagicMock(return_value=True) + # On effectue le monitoring + with self.assertRaises(KeyboardInterrupt): + UploadAction.monitor_until_end(o_upload, f_callback, f_ctrl_c) + + # Vérification sur les appels de fonction + o_mock_list_checks.assert_called_once_with() + f_ctrl_c.assert_called_once_with() + o_mock_api_close.assert_called_once_with() + def test_api_tree_not_empty(self) -> None: """Vérifie le bon fonctionnement de api_tree si ce n'est pas vide.""" # Arborescence en entrée diff --git a/tests/workflow/resolver/FileResolverTestCase.py b/tests/workflow/resolver/FileResolverTestCase.py index e51372ca..7127be46 100644 --- a/tests/workflow/resolver/FileResolverTestCase.py +++ b/tests/workflow/resolver/FileResolverTestCase.py @@ -1,5 +1,6 @@ from ignf_gpf_sdk.workflow.resolver.Errors import ResolveFileInvalidError, ResolveFileNotFoundError, ResolverError from ignf_gpf_sdk.workflow.resolver.FileResolver import FileResolver +from ignf_gpf_sdk.workflow.resolver.GlobalResolver import GlobalResolver from tests.GpfTestCase import GpfTestCase @@ -10,7 +11,7 @@ class FileResolverTestCase(GpfTestCase): cmd : python3 -m unittest -b tests.workflow.resolver.FileResolverTestCase """ - file_path = GpfTestCase.test_dir_path / "workflow" / "resolver" / "FileResolver" + root_path = GpfTestCase.test_dir_path / "workflow" / "resolver" / "FileResolver" s_str_value: str = "contenu du fichier de type str" s_list_value: str = str('["info_1", "info_2"]') @@ -18,7 +19,7 @@ class FileResolverTestCase(GpfTestCase): def test_resolve_other(self) -> None: """Vérifie le bon fonctionnement de la fonction resolve pour un str.""" - o_file_resolver = FileResolver("file") + o_file_resolver = FileResolver("file", self.root_path) # Si mot clé incorrect erreur levée with self.assertRaises(ResolverError) as o_arc_1: o_file_resolver.resolve("other(text.txt)") @@ -26,49 +27,71 @@ def test_resolve_other(self) -> None: def test_resolve_str(self) -> None: """Vérifie le bon fonctionnement de la fonction resolve pour un str.""" - o_file_resolver = FileResolver("file") + o_file_resolver = FileResolver("file", self.root_path) # Si ok - s_test_str: str = o_file_resolver.resolve(f"str({self.file_path}/text.txt)") + s_test_str: str = o_file_resolver.resolve("str(text.txt)") self.assertEqual(self.s_str_value, s_test_str) # Si non existant erreur levée with self.assertRaises(ResolveFileNotFoundError) as o_arc_1: o_file_resolver.resolve("str(not-existing.txt)") - self.assertEqual(o_arc_1.exception.message, "Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'str(not-existing.txt)': fichier non existant.") + self.assertEqual( + o_arc_1.exception.message, f"Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'str(not-existing.txt)': fichier ({self.root_path}/not-existing.txt) non existant." + ) def test_resolve_list(self) -> None: """Vérifie le bon fonctionnement de la fonction resolve pour un list.""" - o_file_resolver = FileResolver("file") + o_file_resolver = FileResolver("file", self.root_path) # Si ok - s_test_list: str = o_file_resolver.resolve(f"list({self.file_path}/list.json)") + s_test_list: str = o_file_resolver.resolve("list(list.json)") self.assertEqual(self.s_list_value, s_test_list) # Si non existant erreur levée with self.assertRaises(ResolveFileNotFoundError) as o_arc_1: o_file_resolver.resolve("list(not-existing.json)") - self.assertEqual(o_arc_1.exception.message, "Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'list(not-existing.json)': fichier non existant.") + self.assertEqual( + o_arc_1.exception.message, f"Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'list(not-existing.json)': fichier ({self.root_path}/not-existing.json) non existant." + ) # Si pas liste erreur levée with self.assertRaises(ResolveFileInvalidError) as o_arc_2: - o_file_resolver.resolve(f"list({self.file_path}/dict.json)") - self.assertEqual(o_arc_2.exception.message, f"Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'list({self.file_path}/dict.json)'.") + o_file_resolver.resolve("list(dict.json)") + self.assertEqual(o_arc_2.exception.message, "Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'list(dict.json)'.") # Si pas valide erreur levée with self.assertRaises(ResolveFileInvalidError) as o_arc_3: - o_file_resolver.resolve(f"list({self.file_path}/not-valid.json)") - self.assertEqual(o_arc_3.exception.message, f"Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'list({self.file_path}/not-valid.json)'.") + o_file_resolver.resolve("list(not-valid.json)") + self.assertEqual(o_arc_3.exception.message, "Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'list(not-valid.json)'.") def test_resolve_dict(self) -> None: """Vérifie le bon fonctionnement de la fonction resolve pour un dict.""" - o_file_resolver = FileResolver("file") + o_file_resolver = FileResolver("file", self.root_path) # Si ok - s_test_dict: str = o_file_resolver.resolve(f"dict({self.file_path}/dict.json)") + s_test_dict: str = o_file_resolver.resolve("dict(dict.json)") self.assertEqual(self.s_dict_value, s_test_dict) # Si non existant erreur levée with self.assertRaises(ResolveFileNotFoundError) as o_arc_1: o_file_resolver.resolve("dict(not-existing.json)") - self.assertEqual(o_arc_1.exception.message, "Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'dict(not-existing.json)': fichier non existant.") + self.assertEqual( + o_arc_1.exception.message, f"Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'dict(not-existing.json)': fichier ({self.root_path}/not-existing.json) non existant." + ) # Si pas liste erreur levée with self.assertRaises(ResolveFileInvalidError) as o_arc_2: - o_file_resolver.resolve(f"dict({self.file_path}/list.json)") - self.assertEqual(o_arc_2.exception.message, f"Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'dict({self.file_path}/list.json)'.") + o_file_resolver.resolve("dict(list.json)") + self.assertEqual(o_arc_2.exception.message, "Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'dict(list.json)'.") # Si pas valide erreur levée with self.assertRaises(ResolveFileInvalidError) as o_arc_3: - o_file_resolver.resolve(f"dict({self.file_path}/not-valid.json)") - self.assertEqual(o_arc_3.exception.message, f"Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'dict({self.file_path}/not-valid.json)'.") + o_file_resolver.resolve("dict(not-valid.json)") + self.assertEqual(o_arc_3.exception.message, "Erreur de traitement d'un fichier (résolveur 'file') avec la chaîne 'dict(not-valid.json)'.") + + def test_global_resolve(self) -> None: + """Vérifie le bon fonctionnement en pratique.""" + GlobalResolver().add_resolver(FileResolver("file", self.root_path)) + # Test 1 : texte + s_text_1 = "Test sur du text : {file.str(text.txt)}" + s_result_1 = GlobalResolver().resolve(s_text_1) + self.assertEqual(s_result_1, "Test sur du text : contenu du fichier de type str") + # Test 2 : liste + s_text_2 = '{"ma liste" : ["file","list(list.json)"]}' + s_result_2 = GlobalResolver().resolve(s_text_2) + self.assertEqual(s_result_2, '{"ma liste" : ["info_1", "info_2"]}') + # Test 3 : dict + s_text_3 = '{"mon dict" : {"file":"dict(dict.json)"} }' + s_result_3 = GlobalResolver().resolve(s_text_3) + self.assertEqual(s_result_3, '{"mon dict" : {"k1":"v1", "k2":"v2"} }') diff --git a/tests/workflow/resolver/GlobalResolverTestCase.py b/tests/workflow/resolver/GlobalResolverTestCase.py index ca2fa824..516b203f 100644 --- a/tests/workflow/resolver/GlobalResolverTestCase.py +++ b/tests/workflow/resolver/GlobalResolverTestCase.py @@ -30,6 +30,7 @@ class GlobalResolverTestCase(GpfTestCase): def setUpClass(cls) -> None: """fonction lancée une fois avant tous les tests de la classe""" super().setUpClass() + GlobalResolver._instance = None # pylint:disable=protected-access GlobalResolver().add_resolver(DictResolver("localization", GlobalResolverTestCase.localization)) GlobalResolver().add_resolver(DictResolver("profession", GlobalResolverTestCase.profession)) @@ -41,18 +42,69 @@ def test_add_resolver(self) -> None: self.assertTrue("localization" in GlobalResolver().resolvers) self.assertTrue("profession" in GlobalResolver().resolvers) + def test_regex(self) -> None: + """Vérifie que la regex fonctionne bien sur les cas particuliers.""" + o_regex = GlobalResolver().regex + + d_tests = { + "{localization.Jacques_country}_{profession.sailor}": [ + { + "param": "{localization.Jacques_country}", + "resolver_name": "localization", + "to_solve": "Jacques_country", + }, + { + "param": "{profession.sailor}", + "resolver_name": "profession", + "to_solve": "sailor", + }, + ], + "{localization.Jacques_country} {profession.sailor}": [ + { + "param": "{localization.Jacques_country}", + "resolver_name": "localization", + "to_solve": "Jacques_country", + }, + { + "param": "{profession.sailor}", + "resolver_name": "profession", + "to_solve": "sailor", + }, + ], + "{localization.{profession.chef}_city}": [ + { + "param": "{localization.{profession.chef}_city}", + "resolver_name": "localization", + "to_solve": "{profession.chef}_city", + }, + ], + } + for s_string, l_results in d_tests.items(): + self.assertListEqual(l_results, [i.groupdict() for i in o_regex.finditer(s_string)]) + def test_resolve(self) -> None: """Vérifie le bon fonctionnement de la fonction resolve.""" # Cas simples : une seule résolution + # Comme string (pour insérer une ou plusieurs string) self.assertEqual(GlobalResolver().resolve("{localization.Jacques_country}"), "France") - self.assertEqual(GlobalResolver().resolve('{"localization":"Jacques_country"}'), "France") + self.assertEqual(GlobalResolver().resolve("{profession.sailor}"), "John") + self.assertEqual(GlobalResolver().resolve("{localization.Jacques_country}_{profession.sailor}"), "France_John") + # Comme liste (pour insérer une string) self.assertEqual(GlobalResolver().resolve('["localization","Jacques_country"]'), "France") - self.assertEqual(GlobalResolver().resolve('{"localization":"store"}'), "{'Paris': 'Champs-Elysée', 'London': 'rue_Londres'}") + # Comme dict (pour insérer une string) + self.assertEqual(GlobalResolver().resolve('{"localization":"Jacques_country"}'), "France") + # Comme liste (pour insérer une liste) self.assertEqual(GlobalResolver().resolve('["localization","city"]'), "['Paris', 'London']") - self.assertEqual(GlobalResolver().resolve("{profession.sailor}"), "John") + # Comme dict (pour insérer un dict) + self.assertEqual(GlobalResolver().resolve('{"localization":"store"}'), "{'Paris': 'Champs-Elysée', 'London': 'rue_Londres'}") # Cas avancés : deux résolutions l'une dans l'autre + # Comme string self.assertEqual(GlobalResolver().resolve("{localization.{profession.sailor}_country}"), "England") self.assertEqual(GlobalResolver().resolve("{localization.{profession.chef}_city}"), "Paris") + # Comme liste (pour insérer une string) + self.assertEqual(GlobalResolver().resolve('["localization","{profession.sailor}_country"]'), "England") + # Comme dict (pour insérer une string) + self.assertEqual(GlobalResolver().resolve('{"localization":"{profession.sailor}_country"}'), "England") # Cas erreur : with self.assertRaises(ResolverNotFoundError) as o_arc: GlobalResolver().resolve("{resolver_not_found.foo}")