Repository: CS-SI/eodag
Pull request #669 (2023-03-08 08:27:57)

refactor: metadata parsing
Refactors metadata-mapping to perform `jsonpath` build/parse during search plugins init step.

Related to #628

Also changes environment variables mocking during tests.

github-actions[bot]: ## Unit Test Results
    2 files  ±0      2 suites  ±0   6m 4s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests") -45s
374 tests ±0  371 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") ±0  3 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") ±0  0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests") ±0 
748 runs  ±0  742 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") ±0  6 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") ±0  0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests") ±0 

Results for commit e38858c3. ± Comparison against base commit 93e33eee.

[test-results]:data:application/gzip;base64,H4sIAM5ICGQC/03MSw6DIBSF4a0Yxh0oD7l2Mw29QEKq0vAYme69YJU6/L+TnI1YN5tI7h29dSRml1roHFRyfi3JRl6gTKmOTLZ6xIy40/Cnl3tXamCVmwv0DUwIPhwS8lo/JYcjzkvJaZPf43j25XDv6x/6ZXGpBDEMQAAyDU8EoQbJ0WrDuNZKqYn2ANpYMQH5fAG4sibhBQEAAA==

github-actions[bot]: <strong> </strong>

| File | Coverage |   |
| - | :-: | :-: |
| **All files** | `88%` | :white_check_mark: |
| eodag/api/core.py | `92%` | :white_check_mark: |
| eodag/api/product/metadata\_mapping.py | `89%` | :white_check_mark: |
| eodag/plugins/apis/usgs.py | `80%` | :white_check_mark: |
| eodag/plugins/authentication/keycloak.py | `53%` | :x: |
| eodag/plugins/download/aws.py | `71%` | :white_check_mark: |
| eodag/plugins/download/http.py | `79%` | :white_check_mark: |
| eodag/plugins/search/base.py | `85%` | :white_check_mark: |
| eodag/plugins/search/qssearch.py | `87%` | :white_check_mark: |
| eodag/rest/utils.py | `83%` | :white_check_mark: |
| eodag/utils/\_\_init\_\_.py | `91%` | :white_check_mark: |
| tests/units/test\_core.py | `98%` | :white_check_mark: |
| tests/units/test\_search\_plugins.py | `98%` | :white_check_mark: |

<strong> </strong>

| File | Coverage |   |
| - | :-: | :-: |
| **All files** | `88%` | :white_check_mark: |
| eodag/api/core.py | `92%` | :white_check_mark: |
| eodag/api/product/metadata\_mapping.py | `89%` | :white_check_mark: |
| eodag/plugins/apis/usgs.py | `80%` | :white_check_mark: |
| eodag/plugins/authentication/keycloak.py | `53%` | :x: |
| eodag/plugins/download/aws.py | `71%` | :white_check_mark: |
| eodag/plugins/download/http.py | `79%` | :white_check_mark: |
| eodag/plugins/search/base.py | `85%` | :white_check_mark: |
| eodag/plugins/search/qssearch.py | `87%` | :white_check_mark: |
| eodag/rest/utils.py | `83%` | :white_check_mark: |
| eodag/utils/\_\_init\_\_.py | `91%` | :white_check_mark: |
| tests/units/test\_core.py | `98%` | :white_check_mark: |
| tests/units/test\_search\_plugins.py | `98%` | :white_check_mark: |
| eodag/api/core.py | `92%` | :white_check_mark: |
| eodag/api/product/metadata\_mapping.py | `89%` | :white_check_mark: |
| eodag/plugins/apis/usgs.py | `80%` | :white_check_mark: |
| eodag/plugins/authentication/keycloak.py | `53%` | :x: |
| eodag/plugins/download/aws.py | `71%` | :white_check_mark: |
| eodag/plugins/download/http.py | `79%` | :white_check_mark: |
| eodag/plugins/search/base.py | `85%` | :white_check_mark: |
| eodag/plugins/search/qssearch.py | `87%` | :white_check_mark: |
| eodag/rest/utils.py | `83%` | :white_check_mark: |
| eodag/utils/\_\_init\_\_.py | `91%` | :white_check_mark: |
| tests/units/test\_core.py | `98%` | :white_check_mark: |
| tests/units/test\_search\_plugins.py | `98%` | :white_check_mark: |

_Minimum allowed coverage is `70%`_

<p align="right">Generated by :monkey: cobertura-action against e38858c3d8bc85a174cfde34ddaaa92088def598 </p>

Patch:
diff --git a/eodag/api/core.py b/eodag/api/core.py
index 3c348c82..6d23d2c5 100644
--- a/eodag/api/core.py
+++ b/eodag/api/core.py
@@ -30,7 +30,7 @@ from whoosh.fields import Schema
 from whoosh.index import create_in, exists_in, open_dir
 from whoosh.qparser import QueryParser
 
-from eodag.api.product.metadata_mapping import mtd_cfg_as_jsonpath
+from eodag.api.product.metadata_mapping import mtd_cfg_as_conversion_and_querypath
 from eodag.api.search_result import SearchResult
 from eodag.config import (
     SimpleYamlProxyConfig,
@@ -53,6 +53,7 @@ from eodag.utils import (
     get_geometry_from_various,
     makedirs,
     obj_md5sum,
+    string_to_jsonpath,
     uri_to_path,
 )
 from eodag.utils.exceptions import (
@@ -322,7 +323,7 @@ class EODataAccessGateway(object):
                 or isinstance(some_configured_value, tuple)
             ):
                 # also build as jsonpath the incoming conf
-                mtd_cfg_as_jsonpath(
+                mtd_cfg_as_conversion_and_querypath(
                     deepcopy(
                         conf_update[provider][search_plugin_key]["metadata_mapping"]
                     ),
@@ -518,9 +519,10 @@ class EODataAccessGateway(object):
 
         if not already_fetched:
             # get ext_product_types conf
-            if "EODAG_EXT_PRODUCT_TYPES_CFG_FILE" in os.environ:
+            ext_product_types_cfg_file = os.getenv("EODAG_EXT_PRODUCT_TYPES_CFG_FILE")
+            if ext_product_types_cfg_file is not None:
                 ext_product_types_conf = get_ext_product_types_conf(
-                    os.environ["EODAG_EXT_PRODUCT_TYPES_CFG_FILE"]
+                    ext_product_types_cfg_file
                 )
             else:
                 ext_product_types_conf = get_ext_product_types_conf()
@@ -557,7 +559,44 @@ class EODataAccessGateway(object):
                     default_provider_search_config, "discover_product_types", {}
                 )
                 # compare confs
-                if default_discovery_conf == user_discovery_conf and (
+                if default_discovery_conf["result_type"] == "json" and isinstance(
+                    default_discovery_conf["results_entry"], str
+                ):
+                    default_discovery_conf_parsed = dict(
+                        default_discovery_conf,
+                        **{
+                            "results_entry": string_to_jsonpath(
+                                default_discovery_conf["results_entry"], force=True
+                            )
+                        },
+                        **mtd_cfg_as_conversion_and_querypath(
+                            dict(
+                                generic_product_type_id=default_discovery_conf[
+                                    "generic_product_type_id"
+                                ]
+                            )
+                        ),
+                        **dict(
+                            generic_product_type_parsable_properties=mtd_cfg_as_conversion_and_querypath(
+                                default_discovery_conf[
+                                    "generic_product_type_parsable_properties"
+                                ]
+                            )
+                        ),
+                        **dict(
+                            generic_product_type_parsable_metadata=mtd_cfg_as_conversion_and_querypath(
+                                default_discovery_conf[
+                                    "generic_product_type_parsable_metadata"
+                                ]
+                            )
+                        ),
+                    )
+                else:
+                    default_discovery_conf_parsed = default_discovery_conf
+                if (
+                    user_discovery_conf == default_discovery_conf
+                    or user_discovery_conf == default_discovery_conf_parsed
+                ) and (
                     not default_discovery_conf.get("fetch_url", None)
                     or "ext_product_types_conf" not in locals()
                     or "ext_product_types_conf" in locals()
diff --git a/eodag/api/product/metadata_mapping.py b/eodag/api/product/metadata_mapping.py
index 67dea0b7..a2525a65 100644
--- a/eodag/api/product/metadata_mapping.py
+++ b/eodag/api/product/metadata_mapping.py
@@ -25,7 +25,7 @@ import geojson
 import pyproj
 from dateutil.parser import isoparse
 from dateutil.tz import UTC, tzutc
-from jsonpath_ng.jsonpath import Child, Fields, Index
+from jsonpath_ng.jsonpath import Child
 from lxml import etree
 from lxml.etree import XPathEvalError
 from shapely import wkt
@@ -34,11 +34,11 @@ from shapely.ops import transform
 
 from eodag.utils import (
     DEFAULT_PROJ,
-    cached_parse,
     deepcopy,
     get_timestamp,
     items_recursive_apply,
     nested_pairs2dict,
+    string_to_jsonpath,
 )
 
 logger = logging.getLogger("eodag.api.product.metadata_mapping")
@@ -589,18 +589,20 @@ def properties_from_json(json, mapping, discovery_config=None):
     discovery_pattern = discovery_config.get("metadata_pattern", None)
     discovery_path = discovery_config.get("metadata_path", None)
     if discovery_pattern and discovery_path:
-        discovered_properties = cached_parse(discovery_path).find(json)
+        discovered_properties = string_to_jsonpath(discovery_path).find(json)
         for found_jsonpath in discovered_properties:
             if "metadata_path_id" in discovery_config.keys():
-                found_key_paths = cached_parse(
-                    discovery_config["metadata_path_id"]
+                found_key_paths = string_to_jsonpath(
+                    discovery_config["metadata_path_id"], force=True
                 ).find(found_jsonpath.value)
                 if not found_key_paths:
                     continue
                 found_key = found_key_paths[0].value
                 used_jsonpath = Child(
                     found_jsonpath.full_path,
-                    cached_parse(discovery_config["metadata_path_value"]),
+                    string_to_jsonpath(
+                        discovery_config["metadata_path_value"], force=True
+                    ),
                 )
             else:
                 # default key got from metadata_path
@@ -612,8 +614,8 @@ def properties_from_json(json, mapping, discovery_config=None):
                 and used_jsonpath not in used_jsonpaths
             ):
                 if "metadata_path_value" in discovery_config.keys():
-                    found_value_path = cached_parse(
-                        discovery_config["metadata_path_value"]
+                    found_value_path = string_to_jsonpath(
+                        discovery_config["metadata_path_value"], force=True
                     ).find(found_jsonpath.value)
                     properties[found_key] = (
                         found_value_path[0].value if found_value_path else NOT_AVAILABLE
@@ -782,31 +784,18 @@ def properties_from_xml(
     return properties
 
 
-def mtd_cfg_as_jsonpath(
-    src_dict, dest_dict={}, common_jsonpath=None, keep_conversion=True
-):
-    """Metadata configuration dictionary to jsonpath objects dictionnay
-    Transform every src_dict value from jsonpath str to jsonpath object
+def mtd_cfg_as_conversion_and_querypath(src_dict, dest_dict={}, result_type="json"):
+    """Metadata configuration dictionary to querypath with conversion dictionnary
+    Transform every src_dict value from jsonpath_str to tuple `(conversion, jsonpath_object)`
+    or from xpath_str to tuple `(conversion, xpath_str)`
 
     :param src_dict: Input dict containing jsonpath str as values
     :type src_dict: dict
     :param dest_dict: (optional) Output dict containing jsonpath objects as values
     :type dest_dict: dict
-    :param common_jsonpath: (optional) common jsonpath used optimize jsonpath build process
-    :type common_jsonpath: str
-    :param keep_conversion: (optional) whether to keep conversion on parse error or not
-    :type keep_conversion: bool
     :returns: dest_dict
     :rtype: dict
     """
-    if common_jsonpath:
-        common_jsonpath_parsed = cached_parse(common_jsonpath)
-        common_jsonpath_match = re.compile(
-            rf"^{re.escape(common_jsonpath)}\.[a-zA-Z0-9-_:\.\[\]\"]+$"
-        )
-        array_field_match = re.compile(r"^[a-zA-Z0-9-_:]+\[[0-9]+\]$")
-    else:
-        common_jsonpath_match = None
     if not dest_dict:
         dest_dict = deepcopy(src_dict)
     for metadata in src_dict:
@@ -814,44 +803,19 @@ def mtd_cfg_as_jsonpath(
             dest_dict[metadata] = (None, NOT_MAPPED)
         else:
             conversion, path = get_metadata_path(dest_dict[metadata])
-            try:
-                # combine with common jsonpath if possible
-                if common_jsonpath_match and common_jsonpath_match.match(path):
-                    path_suffix = path[len(common_jsonpath) + 1 :]
-                    path_splits = path_suffix.split(".")
-                    parsed_path = common_jsonpath_parsed
-                    for path_split in path_splits:
-                        path_split = path_split.strip("'").strip('"')
-                        if "[" in path_split and array_field_match.match(path_split):
-                            # simple array field
-                            indexed_path, index = path_split[:-1].split("[")
-                            index = int(index)
-                            parsed_path = Child(
-                                Child(parsed_path, Fields(indexed_path)),
-                                Index(index=index),
-                            )
-                            continue
-                        elif "[" in path_split:
-                            # nested array field
-                            parsed_path = cached_parse(path)
-                            break
-                        else:
-                            parsed_path = Child(parsed_path, Fields(path_split))
-                else:
-                    parsed_path = cached_parse(path)
-                # If the metadata is queryable (i.e a list of 2 elements), replace the value of the last item
-                if len(dest_dict[metadata]) == 2:
-                    dest_dict[metadata][1] = (conversion, parsed_path)
-                else:
-                    dest_dict[metadata] = (conversion, parsed_path)
-            except Exception:  # jsonpath_ng does not provide a proper exception
-                # Keep path as this and its associated conversion (or None if not keep_conversion)
-                if not keep_conversion:
+            if result_type == "json":
+                parsed_path = string_to_jsonpath(path)
+                if isinstance(parsed_path, str):
+                    # not a jsonpath: assume the mapping is to be passed as is. Ignore any transformation specified.
+                    # If a value is to be passed as is, we don't want to transform it further
                     conversion = None
-                if len(dest_dict[metadata]) == 2:
-                    dest_dict[metadata][1] = (conversion, path)
-                else:
-                    dest_dict[metadata] = (conversion, path)
+            else:
+                parsed_path = path
+
+            if len(dest_dict[metadata]) == 2:
+                dest_dict[metadata][1] = (conversion, parsed_path)
+            else:
+                dest_dict[metadata] = (conversion, parsed_path)
 
             # Put the updated mapping at the end
             dest_dict[metadata] = dest_dict.pop(metadata)
diff --git a/eodag/plugins/apis/usgs.py b/eodag/plugins/apis/usgs.py
index 26fb40a7..2e352227 100644
--- a/eodag/plugins/apis/usgs.py
+++ b/eodag/plugins/apis/usgs.py
@@ -28,7 +28,7 @@ from usgs import USGSAuthExpiredError, USGSError, api
 from eodag.api.product import EOProduct
 from eodag.api.product.metadata_mapping import (
     DEFAULT_METADATA_MAPPING,
-    mtd_cfg_as_jsonpath,
+    mtd_cfg_as_conversion_and_querypath,
     properties_from_json,
 )
 from eodag.plugins.apis.base import Api
@@ -41,7 +41,6 @@ from eodag.utils import (
     GENERIC_PRODUCT_TYPE,
     USER_AGENT,
     ProgressCallback,
-    deepcopy,
     format_dict_items,
     path_to_uri,
 )
@@ -53,6 +52,22 @@ logger = logging.getLogger("eodag.plugins.apis.usgs")
 class UsgsApi(Download, Api):
     """A plugin that enables to query and download data on the USGS catalogues"""
 
+    def __init__(self, provider, config):
+        super(UsgsApi, self).__init__(provider, config)
+
+        # Same method as in base.py, Search.__init__()
+        # Prepare the metadata mapping
+        # Do a shallow copy, the structure is flat enough for this to be sufficient
+        metas = DEFAULT_METADATA_MAPPING.copy()
+        # Update the defaults with the mapping value. This will add any new key
+        # added by the provider mapping that is not in the default metadata.
+        metas.update(self.config.metadata_mapping)
+        self.config.metadata_mapping = mtd_cfg_as_conversion_and_querypath(
+            metas,
+            self.config.metadata_mapping,
+            result_type=getattr(self.config, "result_type", "json"),
+        )
+
     def authenticate(self):
         """Login to usgs api
 
@@ -159,28 +174,13 @@ class UsgsApi(Download, Api):
                         )
             results["data"]["results"] = list(results_by_entity_id.values())
 
-            # Same method as in base.py, Search.__init__()
-            # Prepare the metadata mapping
-            # Do a shallow copy, the structure is flat enough for this to be sufficient
-            metas = DEFAULT_METADATA_MAPPING.copy()
-            # Update the defaults with the mapping value. This will add any new key
-            # added by the provider mapping that is not in the default metadata.
-            # A deepcopy is done to prevent self.config.metadata_mapping from being modified when metas[metadata]
-            # is a list and is modified
-            metas.update(deepcopy(self.config.metadata_mapping))
-            # common_jsonpath usage to optimize jsonpath build process
-            mtd_cfg_as_jsonpath_options = {}
-            if hasattr(self.config, "common_metadata_mapping_path"):
-                mtd_cfg_as_jsonpath_options[
-                    "common_jsonpath"
-                ] = self.config.common_metadata_mapping_path
-            metas = mtd_cfg_as_jsonpath(metas, **mtd_cfg_as_jsonpath_options)
-
             for result in results["data"]["results"]:
 
                 result["productType"] = usgs_dataset
 
-                product_properties = properties_from_json(result, metas)
+                product_properties = properties_from_json(
+                    result, self.config.metadata_mapping
+                )
 
                 final.append(
                     EOProduct(
diff --git a/eodag/plugins/authentication/keycloak.py b/eodag/plugins/authentication/keycloak.py
index 245c2c7b..26b4d2e7 100644
--- a/eodag/plugins/authentication/keycloak.py
+++ b/eodag/plugins/authentication/keycloak.py
@@ -62,7 +62,10 @@ class KeycloakOIDCPasswordAuth(Authentication):
             auth_errors = getattr(self.config, "auth_error_code", [None])
             if not isinstance(auth_errors, list):
                 auth_errors = [auth_errors]
-            if e.response.status_code in auth_errors:
+            if (
+                hasattr(e.response, "status_code")
+                and e.response.status_code in auth_errors
+            ):
                 raise AuthenticationError(
                     "HTTP Error %s returned, %s\nPlease check your credentials for %s"
                     % (e.response.status_code, e.response.text.strip(), self.provider)
diff --git a/eodag/plugins/download/aws.py b/eodag/plugins/download/aws.py
index 383ddbbc..3e440a02 100644
--- a/eodag/plugins/download/aws.py
+++ b/eodag/plugins/download/aws.py
@@ -28,7 +28,7 @@ from botocore.handlers import disable_signing
 from lxml import etree
 
 from eodag.api.product.metadata_mapping import (
-    mtd_cfg_as_jsonpath,
+    mtd_cfg_as_conversion_and_querypath,
     properties_from_json,
     properties_from_xml,
 )
@@ -256,7 +256,7 @@ class AwsDownload(Download):
             )
             logger.info("Fetching extra metadata from %s" % fetch_url)
             resp = requests.get(fetch_url, headers=USER_AGENT, timeout=HTTP_REQ_TIMEOUT)
-            update_metadata = mtd_cfg_as_jsonpath(update_metadata)
+            update_metadata = mtd_cfg_as_conversion_and_querypath(update_metadata)
             if fetch_format == "json":
                 json_resp = resp.json()
                 update_metadata = properties_from_json(json_resp, update_metadata)
diff --git a/eodag/plugins/download/http.py b/eodag/plugins/download/http.py
index ba7fa25c..ff52491e 100644
--- a/eodag/plugins/download/http.py
+++ b/eodag/plugins/download/http.py
@@ -30,7 +30,7 @@ from requests import HTTPError, RequestException
 from eodag.api.product.metadata_mapping import (
     OFFLINE_STATUS,
     ONLINE_STATUS,
-    mtd_cfg_as_jsonpath,
+    mtd_cfg_as_conversion_and_querypath,
     properties_from_json,
     properties_from_xml,
 )
@@ -139,7 +139,7 @@ class HTTPDownload(Download):
         if order_metadata_mapping:
             logger.debug("Parsing order response to update product metada-mapping")
             order_metadata_mapping_jsonpath = {}
-            order_metadata_mapping_jsonpath = mtd_cfg_as_jsonpath(
+            order_metadata_mapping_jsonpath = mtd_cfg_as_conversion_and_querypath(
                 order_metadata_mapping, order_metadata_mapping_jsonpath
             )
             properties_update = properties_from_json(
@@ -276,9 +276,11 @@ class HTTPDownload(Download):
                                 self.config.order_status_on_success["metadata_mapping"]
                             )
                             order_metadata_mapping_jsonpath = {}
-                            order_metadata_mapping_jsonpath = mtd_cfg_as_jsonpath(
-                                new_search_metadata_mapping,
-                                order_metadata_mapping_jsonpath,
+                            order_metadata_mapping_jsonpath = (
+                                mtd_cfg_as_conversion_and_querypath(
+                                    new_search_metadata_mapping,
+                                    order_metadata_mapping_jsonpath,
+                                )
                             )
                             properties_update = properties_from_xml(
                                 result,
diff --git a/eodag/plugins/search/base.py b/eodag/plugins/search/base.py
index afde7654..067a2527 100644
--- a/eodag/plugins/search/base.py
+++ b/eodag/plugins/search/base.py
@@ -18,7 +18,7 @@
 
 from eodag.api.product.metadata_mapping import (
     DEFAULT_METADATA_MAPPING,
-    mtd_cfg_as_jsonpath,
+    mtd_cfg_as_conversion_and_querypath,
 )
 from eodag.plugins.base import PluginTopic
 
@@ -40,14 +40,10 @@ class Search(PluginTopic):
         # Update the defaults with the mapping value. This will add any new key
         # added by the provider mapping that is not in the default metadata
         metas.update(self.config.metadata_mapping)
-        # common_jsonpath usage to optimize jsonpath build process
-        mtd_cfg_as_jsonpath_options = {}
-        if hasattr(self.config, "common_metadata_mapping_path"):
-            mtd_cfg_as_jsonpath_options[
-                "common_jsonpath"
-            ] = self.config.common_metadata_mapping_path
-        self.config.metadata_mapping = mtd_cfg_as_jsonpath(
-            metas, self.config.metadata_mapping, **mtd_cfg_as_jsonpath_options
+        self.config.metadata_mapping = mtd_cfg_as_conversion_and_querypath(
+            metas,
+            self.config.metadata_mapping,
+            result_type=getattr(self.config, "result_type", "json"),
         )
 
     def clear(self):
diff --git a/eodag/plugins/search/qssearch.py b/eodag/plugins/search/qssearch.py
index ae345c81..cec3aaed 100644
--- a/eodag/plugins/search/qssearch.py
+++ b/eodag/plugins/search/qssearch.py
@@ -30,9 +30,8 @@ from eodag.api.product.metadata_mapping import (
     NOT_AVAILABLE,
     NOT_MAPPED,
     format_metadata,
-    get_metadata_path_value,
     get_search_param,
-    mtd_cfg_as_jsonpath,
+    mtd_cfg_as_conversion_and_querypath,
     properties_from_json,
     properties_from_xml,
 )
@@ -40,10 +39,11 @@ from eodag.plugins.search.base import Search
 from eodag.utils import (
     GENERIC_PRODUCT_TYPE,
     USER_AGENT,
-    cached_parse,
+    _deprecated,
     dict_items_recursive_apply,
     format_dict_items,
     quote,
+    string_to_jsonpath,
     update_nested_dict,
     urlencode,
 )
@@ -166,6 +166,79 @@ class QueryStringSearch(Search):
         self.next_page_url = None
         self.next_page_query_obj = None
         self.next_page_merge = None
+        # parse jsonpath on init: pagination
+        if (
+            self.config.result_type == "json"
+            and "total_items_nb_key_path" in self.config.pagination
+        ):
+            self.config.pagination["total_items_nb_key_path"] = string_to_jsonpath(
+                self.config.pagination["total_items_nb_key_path"]
+            )
+        if (
+            self.config.result_type == "json"
+            and "next_page_url_key_path" in self.config.pagination
+        ):
+            self.config.pagination["next_page_url_key_path"] = string_to_jsonpath(
+                self.config.pagination.get("next_page_url_key_path", None)
+            )
+        if (
+            self.config.result_type == "json"
+            and "next_page_query_obj_key_path" in self.config.pagination
+        ):
+            self.config.pagination["next_page_query_obj_key_path"] = string_to_jsonpath(
+                self.config.pagination.get("next_page_query_obj_key_path", None)
+            )
+        if (
+            self.config.result_type == "json"
+            and "next_page_merge_key_path" in self.config.pagination
+        ):
+            self.config.pagination["next_page_merge_key_path"] = string_to_jsonpath(
+                self.config.pagination.get("next_page_merge_key_path", None)
+            )
+
+        # parse jsonpath on init: product types discovery
+        if (
+            getattr(self.config, "discover_product_types", {}).get(
+                "results_entry", None
+            )
+            and getattr(self.config, "discover_product_types", {}).get(
+                "result_type", None
+            )
+            == "json"
+        ):
+            self.config.discover_product_types["results_entry"] = string_to_jsonpath(
+                self.config.discover_product_types["results_entry"], force=True
+            )
+            self.config.discover_product_types[
+                "generic_product_type_id"
+            ] = mtd_cfg_as_conversion_and_querypath(
+                {"foo": self.config.discover_product_types["generic_product_type_id"]}
+            )[
+                "foo"
+            ]
+            self.config.discover_product_types[
+                "generic_product_type_parsable_properties"
+            ] = mtd_cfg_as_conversion_and_querypath(
+                self.config.discover_product_types[
+                    "generic_product_type_parsable_properties"
+                ]
+            )
+            self.config.discover_product_types[
+                "generic_product_type_parsable_metadata"
+            ] = mtd_cfg_as_conversion_and_querypath(
+                self.config.discover_product_types[
+                    "generic_product_type_parsable_metadata"
+                ]
+            )
+
+        # parse jsonpath on init: product type specific metadata-mapping
+        for product_type in self.config.products.keys():
+            if "metadata_mapping" in self.config.products[product_type].keys():
+                self.config.products[product_type][
+                    "metadata_mapping"
+                ] = mtd_cfg_as_conversion_and_querypath(
+                    self.config.products[product_type]["metadata_mapping"]
+                )
 
     def clear(self):
         """Clear search context"""
@@ -203,9 +276,9 @@ class QueryStringSearch(Search):
                     # extract results from response json
                     result = [
                         match.value
-                        for match in cached_parse(
-                            self.config.discover_product_types["results_entry"]
-                        ).find(resp_as_json)
+                        for match in self.config.discover_product_types[
+                            "results_entry"
+                        ].find(resp_as_json)
                     ]
 
                     conf_update_dict = {
@@ -215,8 +288,8 @@ class QueryStringSearch(Search):
 
                     for product_type_result in result:
                         # providers_config extraction
-                        mapping_config = {}
-                        mapping_config = mtd_cfg_as_jsonpath(
+                        extracted_mapping = properties_from_json(
+                            product_type_result,
                             dict(
                                 self.config.discover_product_types[
                                     "generic_product_type_parsable_properties"
@@ -227,11 +300,6 @@ class QueryStringSearch(Search):
                                     ]
                                 },
                             ),
-                            mapping_config,
-                        )
-
-                        extracted_mapping = properties_from_json(
-                            product_type_result, mapping_config
                         )
                         generic_product_type_id = extracted_mapping.pop(
                             "generic_product_type_id"
@@ -245,16 +313,14 @@ class QueryStringSearch(Search):
                             ),
                         )
                         # product_types_config extraction
-                        mapping_config = {}
-                        mapping_config = mtd_cfg_as_jsonpath(
+                        conf_update_dict["product_types_config"][
+                            generic_product_type_id
+                        ] = properties_from_json(
+                            product_type_result,
                             self.config.discover_product_types[
                                 "generic_product_type_parsable_metadata"
                             ],
-                            mapping_config,
                         )
-                        conf_update_dict["product_types_config"][
-                            generic_product_type_id
-                        ] = properties_from_json(product_type_result, mapping_config)
 
                         # update keywords
                         keywords_fields = [
@@ -351,18 +417,14 @@ class QueryStringSearch(Search):
             other_product_type_def_params = self.get_product_type_def_params(
                 other_product_for_mapping, **kwargs
             )
-            self.update_metadata_mapping(
+            self.config.metadata_mapping.update(
                 other_product_type_def_params.get("metadata_mapping", {})
             )
         # from current product
-        self.update_metadata_mapping(
+        self.config.metadata_mapping.update(
             self.product_type_def_params.get("metadata_mapping", {})
         )
 
-        # Add to the query, the queryable parameters set in the provider product type definition
-        self.product_type_def_params = self.get_product_type_def_params(
-            product_type, **kwargs
-        )
         # if product_type_def_params is set, remove product_type as it may conflict with this conf
         if self.product_type_def_params:
             keywords.pop("productType", None)
@@ -395,27 +457,13 @@ class QueryStringSearch(Search):
         total_items = len(eo_products) if total_items == 0 else total_items
         return eo_products, total_items
 
+    @_deprecated(
+        reason="Simply run `self.config.metadata_mapping.update(metadata_mapping)` instead",
+        version="2.10.0",
+    )
     def update_metadata_mapping(self, metadata_mapping):
         """Update plugin metadata_mapping with input metadata_mapping configuration"""
         self.config.metadata_mapping.update(metadata_mapping)
-        unparsed_metadata = {
-            k: v
-            for k, v in metadata_mapping.items()
-            if isinstance(get_metadata_path_value(self.config.metadata_mapping[k]), str)
-        }
-        # common_jsonpath usage to optimize jsonpath build process
-        # On error, assume the mapping is to be passed as is. Ignore any transformation specified.
-        #   If a value is to be passed as is, we don't want to transform it further
-        mtd_cfg_as_jsonpath_options = {"keep_conversion": False}
-        if hasattr(self.config, "common_metadata_mapping_path"):
-            mtd_cfg_as_jsonpath_options[
-                "common_jsonpath"
-            ] = self.config.common_metadata_mapping_path
-        self.config.metadata_mapping = mtd_cfg_as_jsonpath(
-            unparsed_metadata,
-            self.config.metadata_mapping,
-            **mtd_cfg_as_jsonpath_options,
-        )
 
     def build_query_string(self, product_type, **kwargs):
         """Build The query string using the search parameters"""
@@ -630,9 +678,9 @@ class QueryStringSearch(Search):
             # extract total_items_nb from search results
             total_items_nb = 0
             if self.config.result_type == "json":
-                total_items_nb_key_path_parsed = cached_parse(
-                    self.config.pagination["total_items_nb_key_path"]
-                )
+                total_items_nb_key_path_parsed = self.config.pagination[
+                    "total_items_nb_key_path"
+                ]
 
         results = []
         for search_url in self.search_urls:
@@ -687,7 +735,7 @@ class QueryStringSearch(Search):
             else:
                 resp_as_json = response.json()
                 if next_page_url_key_path:
-                    path_parsed = cached_parse(next_page_url_key_path)
+                    path_parsed = next_page_url_key_path
                     try:
                         self.next_page_url = path_parsed.find(resp_as_json)[0].value
                         logger.debug(
@@ -696,7 +744,7 @@ class QueryStringSearch(Search):
                     except IndexError:
                         logger.debug("Next page URL could not be collected")
                 if next_page_query_obj_key_path:
-                    path_parsed = cached_parse(next_page_query_obj_key_path)
+                    path_parsed = next_page_query_obj_key_path
                     try:
                         self.next_page_query_obj = path_parsed.find(resp_as_json)[
                             0
@@ -707,7 +755,7 @@ class QueryStringSearch(Search):
                     except IndexError:
                         logger.debug("Next page Query-object could not be collected")
                 if next_page_merge_key_path:
-                    path_parsed = cached_parse(next_page_merge_key_path)
+                    path_parsed = next_page_merge_key_path
                     try:
                         self.next_page_merge = path_parsed.find(resp_as_json)[0].value
                         logger.debug(
@@ -792,9 +840,7 @@ class QueryStringSearch(Search):
         else:
             count_results = response.json()
             if isinstance(count_results, dict):
-                path_parsed = cached_parse(
-                    self.config.pagination["total_items_nb_key_path"]
-                )
+                path_parsed = self.config.pagination["total_items_nb_key_path"]
                 total_results = path_parsed.find(count_results)[0].value
             else:  # interpret the result as a raw int
                 total_results = int(count_results)
@@ -855,7 +901,7 @@ class QueryStringSearch(Search):
             return self.config.products[product_type]
         elif GENERIC_PRODUCT_TYPE in self.config.products.keys():
             logger.debug(
-                "Getting genric provider product type definition parameters for %s",
+                "Getting generic provider product type definition parameters for %s",
                 product_type,
             )
             return {
@@ -962,6 +1008,19 @@ class ODataV4Search(QueryStringSearch):
     """A specialisation of a QueryStringSearch that does a two step search to retrieve
     all products metadata"""
 
+    def __init__(self, provider, config):
+        super(ODataV4Search, self).__init__(provider, config)
+
+        # parse jsonpath on init
+        metadata_pre_mapping = getattr(self.config, "metadata_pre_mapping", {})
+        metadata_path = metadata_pre_mapping.get("metadata_path", None)
+        metadata_path_id = metadata_pre_mapping.get("metadata_path_id", None)
+        metadata_path_value = metadata_pre_mapping.get("metadata_path_value", None)
+        if metadata_path and metadata_path_id and metadata_path_value:
+            self.config.metadata_pre_mapping["metadata_path"] = string_to_jsonpath(
+                metadata_path
+            )
+
     def do_search(self, *args, **kwargs):
         """A two step search can be performed if the metadata are not given into the search result"""
 
@@ -1009,7 +1068,8 @@ class ODataV4Search(QueryStringSearch):
         metadata_path_value = metadata_pre_mapping.get("metadata_path_value", None)
 
         if metadata_path and metadata_path_id and metadata_path_value:
-            parsed_metadata_path = cached_parse(metadata_path)
+            # metadata_path already parsed on init
+            parsed_metadata_path = metadata_path
             for result in results:
                 found_metadata = parsed_metadata_path.find(result)
                 if found_metadata:
@@ -1056,11 +1116,11 @@ class PostJsonSearch(QueryStringSearch):
             other_product_type_def_params = self.get_product_type_def_params(
                 other_product_for_mapping, **kwargs
             )
-            self.update_metadata_mapping(
+            self.config.metadata_mapping.update(
                 other_product_type_def_params.get("metadata_mapping", {})
             )
         # from current product
-        self.update_metadata_mapping(
+        self.config.metadata_mapping.update(
             self.product_type_def_params.get("metadata_mapping", {})
         )
 
diff --git a/eodag/resources/providers.yml b/eodag/resources/providers.yml
index 8f22bb88..53c0b3ff 100644
--- a/eodag/resources/providers.yml
+++ b/eodag/resources/providers.yml
@@ -30,7 +30,6 @@
     pagination:
       max_items_per_page: 5000
       total_items_nb_key_path: '$.totalHits'
-    common_metadata_mapping_path: '$'
     metadata_mapping:
       id: '$.displayId'
       geometry: '$.spatialBounds'
@@ -110,7 +109,6 @@
       metadata_pattern: '^[a-zA-Z0-9_]+$'
       search_param: '{{{{"search":{{{{"{metadata}":"{{{metadata}}}" }}}} }}}}'
       metadata_path: '$.*'
-    common_metadata_mapping_path: '$'
     metadata_mapping:
       # landsat8_downloadLink : 's3://landsat-pds/c{storedInCollection}/L8/{path}/{row}/{productID}'
       geometry:
@@ -560,7 +558,6 @@
       metadata_pattern: '^(?!collection)[a-zA-Z0-9_]+$'
       search_param: '{metadata}={{{metadata}}}'
       metadata_path: '$.properties.*'
-    common_metadata_mapping_path: '$'
     metadata_mapping:
       # Opensearch resource identifier within the search engine context (in our case
       # within the context of the data provider)
@@ -736,7 +733,6 @@
       metadata_pattern: '^(?!collection)[a-zA-Z0-9_]+$'
       search_param: '{metadata}={{{metadata}}}'
       metadata_path: '$.properties.*'
-    common_metadata_mapping_path: '$'
     metadata_mapping:
       # Opensearch resource identifier within the search engine context (in our case
       # within the context of the data provider)
@@ -922,7 +918,6 @@
               and:
                 -  "Attributes/OData.CSC.StringAttribute/any(att:att/Name eq '{metadata}' and att/OData.CSC.StringAttribute/Value eq '{{{metadata}}}')"
       metadata_path: '$.Attributes.*'
-    common_metadata_mapping_path: '$'
     discover_product_types:
       fetch_url: 'https://datahub.creodias.eu/stac/collections'
       result_type: json
@@ -1625,7 +1620,6 @@
       metadata_path: '$.Metadata'
       metadata_path_id: 'id'
       metadata_path_value: 'value'
-    common_metadata_mapping_path: '$'
     metadata_mapping:
       # Opensearch resource identifier within the search engine context (in our case
       # within the context of the data provider)
@@ -2177,7 +2171,6 @@
     type: EcmwfApi
     api_endpoint: https://api.ecmwf.int/v1
     extract: false
-    common_metadata_mapping_path: '$'
     metadata_mapping:
       productType: '$.productType'
       title: '$.id'
@@ -2400,7 +2393,6 @@
     type: CdsApi
     api_endpoint: https://ads.atmosphere.copernicus.eu/api/v2
     extract: false
-    common_metadata_mapping_path: '$'
     metadata_mapping:
       productType: '$.productType'
       title: '$.id'
@@ -2718,7 +2710,6 @@
     type: CdsApi
     api_endpoint: https://cds.climate.copernicus.eu/api/v2
     extract: false
-    common_metadata_mapping_path: '$'
     metadata_mapping:
       productType: '$.productType'
       title: '$.id'
@@ -2970,7 +2961,6 @@
       metadata_pattern: '^(?!collection)[a-zA-Z0-9_]+$'
       search_param: '{metadata}={{{metadata}}}'
       metadata_path: '$.properties.*'
-    common_metadata_mapping_path: '$'
     metadata_mapping:
       # Opensearch resource identifier within the search engine context (in our case
       # within the context of the data provider)
@@ -3234,7 +3224,6 @@
       metadata_pattern: '^[a-zA-Z0-9_]+$'
       search_param: '{{{{"{metadata}":{{{metadata}#to_geojson}} }}}}'
       metadata_path: '$.*'
-    common_metadata_mapping_path: '$'
     metadata_mapping:
       startTimeFromAscendingNode: '{$.startTimeFromAscendingNode#to_iso_utc_datetime}'
       completionTimeFromAscendingNode:
@@ -3355,7 +3344,6 @@
               and:
                 -  "Attributes/OData.CSC.StringAttribute/any(att:att/Name eq '{metadata}' and att/OData.CSC.StringAttribute/Value eq '{{{metadata}}}')"
       metadata_path: '$.Attributes.*'
-    common_metadata_mapping_path: '$'
     per_product_metadata_query: false
     metadata_pre_mapping:
       metadata_path: '$.Attributes'
diff --git a/eodag/rest/utils.py b/eodag/rest/utils.py
index a56a9ba8..d8205356 100644
--- a/eodag/rest/utils.py
+++ b/eodag/rest/utils.py
@@ -21,7 +21,7 @@ from eodag.plugins.crunch.filter_latest_intersect import FilterLatestIntersect
 from eodag.plugins.crunch.filter_latest_tpl_name import FilterLatestByName
 from eodag.plugins.crunch.filter_overlap import FilterOverlap
 from eodag.rest.stac import StacCatalog, StacCollection, StacCommon, StacItem
-from eodag.utils import _deprecated, cached_parse, dict_items_recursive_apply
+from eodag.utils import _deprecated, dict_items_recursive_apply, string_to_jsonpath
 from eodag.utils.exceptions import (
     MisconfiguredError,
     NoMatchingProductType,
@@ -317,9 +317,9 @@ def get_metadata_query_paths(metadata_mapping):
                 # We check if our query path pattern matches one or more of the dict path
                 matches = [
                     (str(match.full_path))
-                    for match in cached_parse(STAC_QUERY_PATTERN).find(
-                        metadata_query_dict
-                    )
+                    for match in string_to_jsonpath(
+                        STAC_QUERY_PATTERN, force=True
+                    ).find(metadata_query_dict)
                 ]
                 if matches:
                     metadata_query_path = matches[0]
@@ -343,7 +343,7 @@ def get_arguments_query_paths(arguments):
     """
     return dict(
         (str(match.full_path), match.value)
-        for match in cached_parse(STAC_QUERY_PATTERN).find(arguments)
+        for match in string_to_jsonpath(STAC_QUERY_PATTERN, force=True).find(arguments)
     )
 
 
diff --git a/eodag/utils/__init__.py b/eodag/utils/__init__.py
index 720c8ed2..76d720a1 100644
--- a/eodag/utils/__init__.py
+++ b/eodag/utils/__init__.py
@@ -63,6 +63,7 @@ from dateutil.parser import isoparse
 from dateutil.tz import UTC
 from jsonpath_ng import jsonpath
 from jsonpath_ng.ext import parse
+from jsonpath_ng.jsonpath import Child, Fields, Index, Root
 from requests.auth import AuthBase
 from shapely.geometry import Polygon, shape
 from shapely.geometry.base import BaseGeometry
@@ -85,6 +86,10 @@ GENERIC_PRODUCT_TYPE = "GENERIC_PRODUCT_TYPE"
 eodag_version = metadata("eodag")["Version"]
 USER_AGENT = {"User-Agent": f"eodag/{eodag_version}"}
 
+JSONPATH_MATCH = re.compile(r"^[\{\(]*\$(\..*)*$")
+WORKABLE_JSONPATH_MATCH = re.compile(r"^\$(\.[a-zA-Z0-9-_:\.\[\]\"\(\)=\?]+)*$")
+ARRAY_FIELD_MATCH = re.compile(r"^[a-zA-Z0-9-_:]+\[[0-9]+\]$")
+
 
 def _deprecated(reason="", version=None):
     """Simple decorator to mark functions/methods/classes as deprecated.
@@ -883,27 +888,62 @@ def list_items_recursive_sort(config_list):
     return result_list
 
 
-def string_to_jsonpath(key, str_value):
+def string_to_jsonpath(*args, force=False):
     """Get jsonpath for "$.foo.bar" like string
 
     >>> string_to_jsonpath(None, "$.foo.bar")
     Child(Child(Root(), Fields('foo')), Fields('bar'))
+    >>> string_to_jsonpath("$.foo.bar")
+    Child(Child(Root(), Fields('foo')), Fields('bar'))
+    >>> string_to_jsonpath("foo")
+    'foo'
+    >>> string_to_jsonpath("foo", force=True)
+    Fields('foo')
 
-    :param key: Input item key
-    :type key: str
-    :param str_value: Input item value, to be converted
-    :type str_value: str
+    :param args: Last arg as input string value, to be converted
+    :type args: str
+    :param force: force conversion even if input string is not detected as a jsonpath
+    :type force: bool
     :returns: Parsed value
     :rtype: str
     """
-    if "$." in str(str_value):
+    path_str = args[-1]
+    if JSONPATH_MATCH.match(str(path_str)) or force:
         try:
-            return cached_parse(str_value)
+            common_jsonpath = "$"
+            common_jsonpath_parsed = Root()
+
+            # combine with common jsonpath if possible
+            if WORKABLE_JSONPATH_MATCH.match(path_str):
+                path_suffix = path_str[len(common_jsonpath) + 1 :]
+                path_splits = path_suffix.split(".") if path_suffix else []
+                parsed_path = common_jsonpath_parsed
+                for path_split in path_splits:
+                    path_split = path_split.strip("'").strip('"')
+                    if "[" in path_split and ARRAY_FIELD_MATCH.match(path_split):
+                        # simple array field
+                        indexed_path, index = path_split[:-1].split("[")
+                        index = int(index)
+                        parsed_path = Child(
+                            Child(parsed_path, Fields(indexed_path)),
+                            Index(index=index),
+                        )
+                        continue
+                    elif "[" in path_split:
+                        # nested array field
+                        parsed_path = cached_parse(path_str)
+                        break
+                    else:
+                        parsed_path = Child(parsed_path, Fields(path_split))
+                return parsed_path
+            else:
+                return cached_parse(path_str)
+
         except Exception:  # jsonpath_ng does not provide a proper exception
             # If str_value does not contain a jsonpath, return it as is
-            return str_value
+            return path_str
     else:
-        return str_value
+        return path_str
 
 
 def format_string(key, str_to_format, **format_variables):


Test patch:
diff --git a/tests/context.py b/tests/context.py
index 63f4f7f7..763a4e35 100644
--- a/tests/context.py
+++ b/tests/context.py
@@ -79,6 +79,7 @@ from eodag.utils import (
     GENERIC_PRODUCT_TYPE,
     flatten_top_directories,
     deepcopy,
+    cached_parse,
 )
 from eodag.utils.exceptions import (
     AddressNotFound,
diff --git a/tests/test_config.py b/tests/test_config.py
index 363483a4..740a76f1 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -17,7 +17,6 @@
 # limitations under the License.
 
 import os
-import re
 import tempfile
 import unittest
 from io import StringIO
@@ -245,20 +244,15 @@ class TestConfigFunctions(unittest.TestCase):
     @classmethod
     def setUpClass(cls):
         super(TestConfigFunctions, cls).setUpClass()
-        # backup os.environ as it will be modified by tests
-        cls.eodag_env_pattern = re.compile(r"EODAG_\w+")
-        cls.eodag_env_backup = {
-            k: v for k, v in os.environ.items() if cls.eodag_env_pattern.match(k)
-        }
+        # mock os.environ to empty env
+        cls.mock_os_environ = mock.patch.dict(os.environ, {}, clear=True)
+        cls.mock_os_environ.start()
 
     @classmethod
     def tearDownClass(cls):
         super(TestConfigFunctions, cls).tearDownClass()
-        # restore os.environ
-        for k, v in os.environ.items():
-            if cls.eodag_env_pattern.match(k):
-                os.environ.pop(k)
-        os.environ.update(cls.eodag_env_backup)
+        # stop os.environ
+        cls.mock_os_environ.stop()
 
     def test_load_default_config(self):
         """Default config must be successfully loaded"""
diff --git a/tests/units/test_core.py b/tests/units/test_core.py
index 16c349f1..3278ed95 100644
--- a/tests/units/test_core.py
+++ b/tests/units/test_core.py
@@ -20,7 +20,6 @@ import copy
 import glob
 import json
 import os
-import re
 import shutil
 import unittest
 import uuid
@@ -259,19 +258,14 @@ class TestCore(TestCoreBase):
         super(TestCore, self).setUp()
         self.dag = EODataAccessGateway()
         self.conf_dir = os.path.join(os.path.expanduser("~"), ".config", "eodag")
-        # backup os.environ as it will be modified by tests
-        self.eodag_env_pattern = re.compile(r"EODAG_\w+")
-        self.eodag_env_backup = {
-            k: v for k, v in os.environ.items() if self.eodag_env_pattern.match(k)
-        }
+        # mock os.environ to empty env
+        self.mock_os_environ = mock.patch.dict(os.environ, {}, clear=True)
+        self.mock_os_environ.start()
 
     def tearDown(self):
         super(TestCore, self).tearDown()
-        # restore os.environ
-        for k, v in os.environ.items():
-            if self.eodag_env_pattern.match(k):
-                os.environ.pop(k)
-        os.environ.update(self.eodag_env_backup)
+        # stop os.environ
+        self.mock_os_environ.stop()
 
     def test_supported_providers_in_unit_test(self):
         """Every provider must be referenced in the core unittest SUPPORTED_PROVIDERS class attribute"""
@@ -877,20 +871,15 @@ class TestCoreConfWithEnvVar(TestCoreBase):
     def setUpClass(cls):
         super(TestCoreConfWithEnvVar, cls).setUpClass()
         cls.dag = EODataAccessGateway()
-        # backup os.environ as it will be modified by tests
-        cls.eodag_env_pattern = re.compile(r"EODAG_\w+")
-        cls.eodag_env_backup = {
-            k: v for k, v in os.environ.items() if cls.eodag_env_pattern.match(k)
-        }
+        # mock os.environ to empty env
+        cls.mock_os_environ = mock.patch.dict(os.environ, {}, clear=True)
+        cls.mock_os_environ.start()
 
     @classmethod
     def tearDownClass(cls):
         super(TestCoreConfWithEnvVar, cls).tearDownClass()
-        # restore os.environ
-        for k, v in os.environ.items():
-            if cls.eodag_env_pattern.match(k):
-                os.environ.pop(k)
-        os.environ.update(cls.eodag_env_backup)
+        # stop os.environ
+        cls.mock_os_environ.stop()
 
     def test_core_object_prioritize_locations_file_in_envvar(self):
         """The core object must use the locations file pointed to by the EODAG_LOCS_CFG_FILE env var"""
diff --git a/tests/units/test_http_server.py b/tests/units/test_http_server.py
index 370ae67a..77fa4782 100644
--- a/tests/units/test_http_server.py
+++ b/tests/units/test_http_server.py
@@ -19,7 +19,6 @@
 import importlib
 import json
 import os
-import re
 import unittest
 from tempfile import TemporaryDirectory
 
@@ -59,11 +58,9 @@ class RequestTestCase(unittest.TestCase):
             merge=True,
         )
 
-        # backup os.environ as it will be modified by tests
-        cls.eodag_env_pattern = re.compile(r"EODAG_\w+")
-        cls.eodag_env_backup = {
-            k: v for k, v in os.environ.items() if cls.eodag_env_pattern.match(k)
-        }
+        # mock os.environ to empty env
+        cls.mock_os_environ = mock.patch.dict(os.environ, {}, clear=True)
+        cls.mock_os_environ.start()
 
         # disable product types fetch
         os.environ["EODAG_EXT_PRODUCT_TYPES_CFG_FILE"] = ""
@@ -71,11 +68,8 @@ class RequestTestCase(unittest.TestCase):
     @classmethod
     def tearDownClass(cls):
         super(RequestTestCase, cls).tearDownClass()
-        # restore os.environ
-        for k, v in os.environ.items():
-            if cls.eodag_env_pattern.match(k):
-                os.environ.pop(k)
-        os.environ.update(cls.eodag_env_backup)
+        # stop os.environ
+        cls.mock_os_environ.stop()
 
         # stop Mock and remove tmp config dir
         cls.expanduser_mock.stop()
diff --git a/tests/units/test_search_plugins.py b/tests/units/test_search_plugins.py
index b56950ca..3984404f 100644
--- a/tests/units/test_search_plugins.py
+++ b/tests/units/test_search_plugins.py
@@ -33,6 +33,7 @@ from tests.context import (
     EOProduct,
     PluginManager,
     RequestError,
+    cached_parse,
     get_geometry_from_various,
     load_default_config,
 )
@@ -278,9 +279,9 @@ class TestSearchPluginQueryStringSearch(BaseSearchPluginTest):
 
         # change onfiguration for this test to filter out some collections
         results_entry = search_plugin.config.discover_product_types["results_entry"]
-        search_plugin.config.discover_product_types[
-            "results_entry"
-        ] = 'collections[?billing=="free"]'
+        search_plugin.config.discover_product_types["results_entry"] = cached_parse(
+            'collections[?billing=="free"]'
+        )
 
         mock__request.return_value = mock.Mock()
         mock__request.return_value.json.return_value = {
@@ -535,7 +536,7 @@ class TestSearchPluginODataV4Search(BaseSearchPluginTest):
         self.assertDictEqual(
             self.onda_search_plugin.config.metadata_pre_mapping,
             {
-                "metadata_path": "$.Metadata",
+                "metadata_path": cached_parse("$.Metadata"),
                 "metadata_path_id": "id",
                 "metadata_path_value": "value",
             },
diff --git a/tests/units/test_stac_utils.py b/tests/units/test_stac_utils.py
index dded67fb..456062b1 100644
--- a/tests/units/test_stac_utils.py
+++ b/tests/units/test_stac_utils.py
@@ -19,7 +19,6 @@
 import importlib
 import json
 import os
-import re
 import unittest
 from tempfile import TemporaryDirectory
 
@@ -70,11 +69,9 @@ class TestStacUtils(unittest.TestCase):
         cls.empty_arguments = {}
         cls.empty_criterias = {}
 
-        # backup os.environ as it will be modified by tests
-        cls.eodag_env_pattern = re.compile(r"EODAG_\w+")
-        cls.eodag_env_backup = {
-            k: v for k, v in os.environ.items() if cls.eodag_env_pattern.match(k)
-        }
+        # mock os.environ to empty env
+        cls.mock_os_environ = mock.patch.dict(os.environ, {}, clear=True)
+        cls.mock_os_environ.start()
 
         # disable product types fetch
         os.environ["EODAG_EXT_PRODUCT_TYPES_CFG_FILE"] = ""
@@ -82,15 +79,11 @@ class TestStacUtils(unittest.TestCase):
     @classmethod
     def tearDownClass(cls):
         super(TestStacUtils, cls).tearDownClass()
-        # restore os.environ
-        for k, v in os.environ.items():
-            if cls.eodag_env_pattern.match(k):
-                os.environ.pop(k)
-        os.environ.update(cls.eodag_env_backup)
-
         # stop Mock and remove tmp config dir
         cls.expanduser_mock.stop()
         cls.tmp_home_dir.cleanup()
+        # stop os.environ
+        cls.mock_os_environ.stop()
 
     def test_download_stac_item_by_id(self):
         pass  # TODO