eodag 4.0.0a2__py3-none-any.whl → 4.0.0a4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eodag/api/provider.py ADDED
@@ -0,0 +1,990 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2025, CS GROUP - France, https://www.csgroup.eu/
3
+ #
4
+ # This file is part of EODAG project
5
+ # https://www.github.com/CS-SI/EODAG
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import tempfile
23
+ import traceback
24
+ from collections import UserDict
25
+ from inspect import isclass
26
+ from textwrap import shorten
27
+ from typing import (
28
+ TYPE_CHECKING,
29
+ Any,
30
+ Iterator,
31
+ Mapping,
32
+ Optional,
33
+ Union,
34
+ get_type_hints,
35
+ )
36
+
37
+ import yaml
38
+
39
+ from eodag.api.collection import Collection
40
+ from eodag.api.product.metadata_mapping import (
41
+ NOT_AVAILABLE,
42
+ mtd_cfg_as_conversion_and_querypath,
43
+ )
44
+ from eodag.config import PluginConfig, credentials_in_auth, load_stac_provider_config
45
+ from eodag.utils import (
46
+ GENERIC_COLLECTION,
47
+ STAC_SEARCH_PLUGINS,
48
+ cast_scalar_value,
49
+ deepcopy,
50
+ merge_mappings,
51
+ slugify,
52
+ update_nested_dict,
53
+ )
54
+ from eodag.utils.exceptions import (
55
+ UnsupportedCollection,
56
+ UnsupportedProvider,
57
+ ValidationError,
58
+ )
59
+ from eodag.utils.free_text_search import compile_free_text_query
60
+ from eodag.utils.repr import dict_to_html_table, str_as_href
61
+
62
+ if TYPE_CHECKING:
63
+ from typing_extensions import Self
64
+
65
+ from eodag.api.core import EODataAccessGateway
66
+
67
+ logger = logging.getLogger("eodag.provider")
68
+
69
+ AUTH_TOPIC_KEYS = ("auth", "search_auth", "download_auth")
70
+ PLUGINS_TOPICS_KEYS = ("api", "search", "download") + AUTH_TOPIC_KEYS
71
+
72
+
73
+ class ProviderConfig(yaml.YAMLObject):
74
+ """EODAG configuration for a provider.
75
+
76
+ :param name: The name of the provider
77
+ :param priority: (optional) The priority of the provider while searching a product.
78
+ Lower value means lower priority. (Default: 0)
79
+ :param roles: The roles of the provider (e.g. "host", "producer", "licensor", "processor")
80
+ :param description: (optional) A short description of the provider
81
+ :param url: URL to the webpage representing the provider
82
+ :param api: (optional) The configuration of a plugin of type Api
83
+ :param search: (optional) The configuration of a plugin of type Search
84
+ :param products: (optional) The collections supported by the provider
85
+ :param download: (optional) The configuration of a plugin of type Download
86
+ :param auth: (optional) The configuration of a plugin of type Authentication
87
+ :param search_auth: (optional) The configuration of a plugin of type Authentication for search
88
+ :param download_auth: (optional) The configuration of a plugin of type Authentication for download
89
+ :param kwargs: Additional configuration variables for this provider
90
+ """
91
+
92
+ name: str
93
+ group: str
94
+ priority: int = 0
95
+ roles: list[str]
96
+ description: str
97
+ url: str
98
+ api: PluginConfig
99
+ search: PluginConfig
100
+ products: dict[str, Any]
101
+ download: PluginConfig
102
+ auth: PluginConfig
103
+ search_auth: PluginConfig
104
+ download_auth: PluginConfig
105
+
106
+ yaml_loader = yaml.Loader
107
+ yaml_dumper = yaml.SafeDumper
108
+ yaml_tag = "!provider"
109
+
110
+ def __setstate__(self, state: dict[str, Any]) -> None:
111
+ """Apply defaults when building from yaml."""
112
+ self.__dict__.update(state)
113
+ self._apply_defaults()
114
+
115
+ def __contains__(self, key):
116
+ """Check if a key is in the ProviderConfig."""
117
+ return key in self.__dict__
118
+
119
+ @classmethod
120
+ def from_yaml(cls, loader: yaml.Loader, node: Any) -> Iterator[Self]:
121
+ """Build a :class:`~eodag.api.provider.ProviderConfig` from Yaml"""
122
+ cls.validate(tuple(node_key.value for node_key, _ in node.value))
123
+ for node_key, node_value in node.value:
124
+ if node_key.value == "name":
125
+ node_value.value = slugify(node_value.value).replace("-", "_")
126
+ break
127
+ return loader.construct_yaml_object(node, cls)
128
+
129
+ @classmethod
130
+ def from_mapping(cls, mapping: dict[str, Any]) -> Self:
131
+ """Build a :class:`~eodag.api.provider.ProviderConfig` from a mapping"""
132
+ cls.validate(mapping)
133
+ # Create a deep copy to avoid modifying the input dict or its nested structures
134
+ mapping_copy = deepcopy(mapping)
135
+ for key in PLUGINS_TOPICS_KEYS:
136
+ if not (_mapping := mapping_copy.get(key)):
137
+ continue
138
+
139
+ if not isinstance(_mapping, dict):
140
+ _mapping = _mapping.__dict__
141
+
142
+ mapping_copy[key] = PluginConfig.from_mapping(_mapping)
143
+ c = cls()
144
+ c.__dict__.update(mapping_copy)
145
+ c._apply_defaults()
146
+ return c
147
+
148
+ @staticmethod
149
+ def validate(config_keys: Union[tuple[str, ...], dict[str, Any]]) -> None:
150
+ """Validate a :class:`~eodag.api.provider.ProviderConfig`
151
+
152
+ :param config_keys: The configurations keys to validate
153
+ """
154
+ if "name" not in config_keys:
155
+ raise ValidationError("Provider config must have name key")
156
+ if not any(k in config_keys for k in PLUGINS_TOPICS_KEYS):
157
+ raise ValidationError("A provider must implement at least one plugin")
158
+ non_api_keys = [k for k in PLUGINS_TOPICS_KEYS if k != "api"]
159
+ if "api" in config_keys and any(k in config_keys for k in non_api_keys):
160
+ raise ValidationError(
161
+ "A provider implementing an Api plugin must not implement any other "
162
+ "type of plugin"
163
+ )
164
+
165
+ def update(self, config: Union[Self, dict[str, Any]]) -> None:
166
+ """Update the configuration parameters with values from `mapping`
167
+
168
+ :param config: The config from which to override configuration parameters
169
+ """
170
+ source = config if isinstance(config, dict) else config.__dict__
171
+
172
+ merge_mappings(
173
+ self.__dict__,
174
+ {
175
+ key: value
176
+ for key, value in source.items()
177
+ if key not in PLUGINS_TOPICS_KEYS and value is not None
178
+ },
179
+ )
180
+ for key in PLUGINS_TOPICS_KEYS:
181
+ current_value: Optional[PluginConfig] = getattr(self, key, None)
182
+ config_value = source.get(key, {})
183
+ if current_value is not None:
184
+ current_value |= config_value
185
+ elif isinstance(config_value, PluginConfig):
186
+ setattr(self, key, config_value)
187
+ elif config_value:
188
+ try:
189
+ setattr(self, key, PluginConfig.from_mapping(config_value))
190
+ except ValidationError as e:
191
+ logger.warning(
192
+ (
193
+ "Could not add %s Plugin config to %s configuration: %s. "
194
+ "Try updating existing %s Plugin configs instead."
195
+ ),
196
+ key,
197
+ self.name,
198
+ str(e),
199
+ ", ".join([k for k in PLUGINS_TOPICS_KEYS if hasattr(self, k)]),
200
+ )
201
+ self._apply_defaults()
202
+
203
+ def with_name(self, new_name: str) -> Self:
204
+ """Create a copy of this :class:`~eodag.api.provider.ProviderConfig` with a different name.
205
+
206
+ :param new_name: The new name for the provider config.
207
+ :return: A new ProviderConfig instance with the updated name.
208
+ """
209
+ config_dict = self.__dict__.copy()
210
+ config_dict["name"] = new_name
211
+
212
+ for key in PLUGINS_TOPICS_KEYS:
213
+ if key in config_dict and isinstance(config_dict[key], PluginConfig):
214
+ config_dict[key] = config_dict[key].__dict__
215
+
216
+ return self.__class__.from_mapping(config_dict)
217
+
218
+ def _apply_defaults(self: Self) -> None:
219
+ """Applies some default values to provider config."""
220
+ stac_search_default_conf = load_stac_provider_config()
221
+
222
+ # For the provider, set the default output_dir of its download plugin
223
+ # as tempdir in a portable way
224
+ for download_topic_key in ("download", "api"):
225
+ if download_topic_key in vars(self):
226
+ download_conf = getattr(self, download_topic_key)
227
+ if not getattr(download_conf, "output_dir", None):
228
+ download_conf.output_dir = tempfile.gettempdir()
229
+ if not getattr(download_conf, "delete_archive", None):
230
+ download_conf.delete_archive = True
231
+
232
+ try:
233
+ if (
234
+ stac_search_default_conf is not None
235
+ and self.search
236
+ and self.search.type in STAC_SEARCH_PLUGINS
237
+ ):
238
+ # search config set to stac defaults overriden with provider config
239
+ per_provider_stac_provider_config = deepcopy(stac_search_default_conf)
240
+ self.search.__dict__ = update_nested_dict(
241
+ per_provider_stac_provider_config["search"],
242
+ self.search.__dict__,
243
+ allow_empty_values=True,
244
+ )
245
+ except AttributeError:
246
+ pass
247
+
248
+
249
+ class Provider:
250
+ """
251
+ Represents a data provider with its configuration and utility methods.
252
+
253
+ :param config: Provider configuration as :meth:`~eodag.api.provider.ProviderConfig` instance or :class:`dict`
254
+ :param collections_fetched: Flag indicating whether collections have been fetched
255
+
256
+ Example
257
+ -------
258
+
259
+ >>> from eodag.api.provider import Provider
260
+ >>> config = {
261
+ ... 'name': 'example_provider',
262
+ ... 'description': 'Example provider for testing',
263
+ ... 'search': {'type': 'StacSearch'},
264
+ ... 'products': {'S2_MSI_L1C': {'_collection': 'S2_MSI_L1C'}}
265
+ ... }
266
+ >>> provider = Provider(config)
267
+ >>> provider.name
268
+ 'example_provider'
269
+ >>> 'S2_MSI_L1C' in provider.collections_config
270
+ True
271
+ >>> provider.priority # Default priority
272
+ 0
273
+ """
274
+
275
+ _name: str
276
+ _config: ProviderConfig
277
+ collections_fetched: bool
278
+
279
+ def __init__(self, config: Union[ProviderConfig, dict[str, Any]]):
280
+ """Initialize provider with configuration."""
281
+ if isinstance(config, dict):
282
+ self._config = ProviderConfig.from_mapping(config)
283
+ elif isinstance(config, ProviderConfig):
284
+ self._config = config
285
+ else:
286
+ msg = f"Unsupported config type: {type(config)}. Expected ProviderConfig or dict."
287
+ raise ValidationError(msg)
288
+
289
+ self._name = self._config.name
290
+ self.collections_fetched = False
291
+
292
+ def __str__(self) -> str:
293
+ """Return the provider's name as string."""
294
+ return self.name
295
+
296
+ def __repr__(self) -> str:
297
+ """Return a string representation of the Provider."""
298
+ return f"Provider('{self.name}')"
299
+
300
+ def __eq__(self, other: object):
301
+ """Compare providers by name or with a string."""
302
+ if isinstance(other, Provider):
303
+ return self.name == other.name
304
+
305
+ elif isinstance(other, str):
306
+ return self.name == other
307
+
308
+ return False
309
+
310
+ def __hash__(self):
311
+ """Hash based on provider name, for use in sets/dicts."""
312
+ return hash(self.name)
313
+
314
+ def _repr_html_(self, embedded: bool = False) -> str:
315
+ """HTML representation for Jupyter/IPython display."""
316
+ group_display = f" ({self.group})" if self.group else ""
317
+ thead = (
318
+ f"""<thead><tr><td style='text-align: left; color: grey;'>
319
+ {type(self).__name__}("<span style='color: black'>{self.name}{group_display}</span>")</td></tr></thead>
320
+ """
321
+ if not embedded
322
+ else ""
323
+ )
324
+ tr_style = "style='background-color: transparent;'" if embedded else ""
325
+
326
+ summaries = {
327
+ "name": self.name,
328
+ "title": self.config.description or "",
329
+ "url": self.config.url or "",
330
+ "priority": self.priority,
331
+ }
332
+ if self.group:
333
+ summaries["group"] = self.group
334
+
335
+ col_html_table = dict_to_html_table(summaries, depth=1, brackets=False)
336
+
337
+ return (
338
+ f"<table>{thead}<tbody>"
339
+ f"<tr {tr_style}><td style='text-align: left;'>"
340
+ f"{col_html_table}</td></tr>"
341
+ "</tbody></table>"
342
+ )
343
+
344
+ @property
345
+ def config(self) -> ProviderConfig:
346
+ """
347
+ Provider configuration (read-only assignment).
348
+
349
+ To update configuration safely, use :meth:`~eodag.api.provider.Provider.update_from_config`
350
+ which handles metadata mapping and other provider-specific logic.
351
+
352
+ Note: Direct config modification (``config.update()``, ``config.name = ...``)
353
+ bypasses important provider validation.
354
+ """
355
+ return self._config
356
+
357
+ @property
358
+ def name(self) -> str:
359
+ """The name of the provider."""
360
+ return self._name
361
+
362
+ @property
363
+ def title(self) -> Optional[str]:
364
+ """The title of the provider."""
365
+ return getattr(self.config, "description", None)
366
+
367
+ @property
368
+ def url(self) -> Optional[str]:
369
+ """The url of the provider."""
370
+ return getattr(self.config, "url", None)
371
+
372
+ @property
373
+ def collections_config(self) -> dict[str, Any]:
374
+ """Return the collections configuration dictionary for this provider."""
375
+ return getattr(self.config, "products", {})
376
+
377
+ @property
378
+ def priority(self) -> int:
379
+ """Return the provider's priority (default: 0)."""
380
+ return self.config.priority
381
+
382
+ @property
383
+ def group(self) -> Optional[str]:
384
+ """Return the provider's group, if any."""
385
+ return getattr(self.config, "group", None)
386
+
387
+ @property
388
+ def search_config(self) -> Optional[PluginConfig]:
389
+ """Return the search plugin config, if any."""
390
+ return getattr(self.config, "search", None) or getattr(self.config, "api", None)
391
+
392
+ @property
393
+ def fetchable(self) -> bool:
394
+ """Return True if the provider can fetch collections."""
395
+ return bool(
396
+ getattr(self.search_config, "discover_collections", {}).get("fetch_url")
397
+ )
398
+
399
+ @property
400
+ def unparsable_properties(self) -> set[str]:
401
+ """Return set of unparsable properties from
402
+ :attr:`~eodag.config.PluginConfig.DiscoverCollections.generic_collection_unparsable_properties`, if any.
403
+ """
404
+ if not self.fetchable or self.search_config is None:
405
+ return set()
406
+
407
+ props = getattr(
408
+ getattr(self.search_config, "discover_collections", None),
409
+ "generic_collection_unparsable_properties",
410
+ {},
411
+ )
412
+ return set(props.keys()) if isinstance(props, dict) else set()
413
+
414
+ @property
415
+ def api_config(self) -> Optional[PluginConfig]:
416
+ """Return the api plugin config, if any."""
417
+ return getattr(self.config, "api", None)
418
+
419
+ @property
420
+ def download_config(self) -> Optional[PluginConfig]:
421
+ """Return the download plugin config, if any."""
422
+ return getattr(self.config, "download", None)
423
+
424
+ def _get_auth_confs_with_credentials(self) -> list[PluginConfig]:
425
+ """
426
+ Collect all auth configs from the provider that have credentials.
427
+
428
+ :return: List of auth plugin configs with credentials.
429
+ """
430
+ return [
431
+ getattr(self.config, auth_key)
432
+ for auth_key in AUTH_TOPIC_KEYS
433
+ if hasattr(self.config, auth_key)
434
+ and credentials_in_auth(getattr(self.config, auth_key))
435
+ ]
436
+
437
+ def _copy_matching_credentials(
438
+ self,
439
+ auth_confs_with_creds: list[PluginConfig],
440
+ ) -> None:
441
+ """
442
+ Copy credentials from matching auth configs to the target auth config.
443
+
444
+ :param auth_confs_with_creds: Auth configs with credentials.
445
+ """
446
+ for key in AUTH_TOPIC_KEYS:
447
+ provider_auth_config = getattr(self.config, key, None)
448
+ if provider_auth_config and not credentials_in_auth(provider_auth_config):
449
+ for conf_with_creds in auth_confs_with_creds:
450
+ if conf_with_creds.matches_target_auth(provider_auth_config):
451
+ getattr(
452
+ self.config, key
453
+ ).credentials = conf_with_creds.credentials
454
+
455
+ def delete_collection(self, name: str) -> None:
456
+ """Remove a collection from this provider.
457
+
458
+ :param name: The collection name.
459
+
460
+ :raises UnsupportedCollection: If the collection is not found.
461
+ """
462
+ try:
463
+ del self.collections_config[name]
464
+ except KeyError:
465
+ msg = f"Collection '{name}' not found in provider '{self.name}'."
466
+ raise UnsupportedCollection(msg)
467
+
468
+ def sync_collections(
469
+ self,
470
+ dag: EODataAccessGateway,
471
+ strict_mode: bool,
472
+ ) -> None:
473
+ """
474
+ Synchronize collections for a provider based on strict or permissive mode.
475
+
476
+ In strict mode, removes collections not in :attr:`~eodag.api.core.EODataAccessGateway.collections_config`.
477
+ In permissive mode, adds empty collection to config for missing types.
478
+
479
+ :param dag: The gateway instance to use to list existing collections and to create new collection instances.
480
+ :param strict_mode: If ``True``, remove unknown collections; if ``False``, add empty configs for them.
481
+ """
482
+ products_to_remove: list[str] = []
483
+ products_to_add: list[str] = []
484
+
485
+ for product_id in self.collections_config:
486
+ if product_id == GENERIC_COLLECTION:
487
+ continue
488
+
489
+ if product_id not in dag.collections_config:
490
+ if strict_mode:
491
+ products_to_remove.append(product_id)
492
+ continue
493
+
494
+ empty_product = Collection.create_with_dag(
495
+ dag, id=product_id, title=product_id, description=NOT_AVAILABLE
496
+ )
497
+ dag.collections_config[product_id] = empty_product
498
+ products_to_add.append(product_id)
499
+
500
+ if products_to_add:
501
+ logger.debug(
502
+ "Collections permissive mode, %s added (provider %s)",
503
+ ", ".join(products_to_add),
504
+ self,
505
+ )
506
+
507
+ if products_to_remove:
508
+ logger.debug(
509
+ "Collections strict mode, ignoring %s (provider %s)",
510
+ ", ".join(products_to_remove),
511
+ self,
512
+ )
513
+ for id in products_to_remove:
514
+ self.delete_collection(id)
515
+
516
+ def _mm_already_built(self) -> bool:
517
+ """Check if metadata mapping is already built (converted to querypaths/conversion)."""
518
+ mm = getattr(self.search_config, "metadata_mapping", None)
519
+ if not mm:
520
+ return False
521
+
522
+ try:
523
+ first = next(iter(mm.values()))
524
+ except StopIteration:
525
+ return False
526
+
527
+ # Consider it built if it's a tuple, or a list with second element as tuple
528
+ if isinstance(first, tuple):
529
+ return True
530
+ if isinstance(first, list) and len(first) > 1 and isinstance(first[1], tuple):
531
+ return True
532
+
533
+ return False
534
+
535
+ def update_from_config(self, config: Union[ProviderConfig, dict[str, Any]]) -> None:
536
+ """Update the provider's configuration from a given config.
537
+
538
+ :param config: The new configuration to update from.
539
+ :raises ValidationError: If the config attempts to change the provider name.
540
+ """
541
+ # Prevent name changes to maintain provider identity
542
+ source = config if isinstance(config, dict) else config.__dict__
543
+ if (new_name := source.get("name")) and new_name != self._name:
544
+ raise ValidationError(
545
+ f"Cannot change provider name from '{self._name}' to '{new_name}'. "
546
+ "Provider names are immutable after creation."
547
+ )
548
+
549
+ # check if metadata mapping is already built for that provider
550
+ # this happens when the provider search plugin has already been used
551
+ search_key = "search" if "search" in self.config else "api"
552
+ new_conf_search = source.get(search_key, {}) or {}
553
+
554
+ if "metadata_mapping" in new_conf_search and self._mm_already_built():
555
+ mtd_cfg_as_conversion_and_querypath(
556
+ deepcopy(new_conf_search["metadata_mapping"]),
557
+ new_conf_search["metadata_mapping"],
558
+ )
559
+
560
+ self.config.update(config)
561
+
562
+
563
+ class ProvidersDict(UserDict[str, Provider]):
564
+ """
565
+ A dictionary-like collection of :class:`~eodag.api.provider.Provider` objects, keyed by provider name.
566
+
567
+ :param providers: Initial providers to populate the dictionary.
568
+ """
569
+
570
+ def __contains__(self, item: object) -> bool:
571
+ """
572
+ Check if a provider is in the dictionary by name or :class:`~eodag.api.provider.Provider` instance.
573
+
574
+ :param item: Provider name or Provider instance to check.
575
+ :return: True if the provider is in the dictionary, False otherwise.
576
+ """
577
+ if isinstance(item, Provider):
578
+ return item.name in self.data
579
+ return item in self.data
580
+
581
+ def __setitem__(self, key: str, value: Provider) -> None:
582
+ """
583
+ Add a :class:`~eodag.api.provider.Provider` to the dictionary.
584
+
585
+ :param key: The name of the provider.
586
+ :param value: The Provider instance to add.
587
+ :raises ValueError: If the provider key already exists.
588
+ """
589
+ if key in self.data:
590
+ msg = f"Provider '{key}' already exists."
591
+ raise ValueError(msg)
592
+ super().__setitem__(key, value)
593
+
594
+ def __delitem__(self, key: str) -> None:
595
+ """
596
+ Delete a provider by name.
597
+
598
+ :param key: The name of the provider to delete.
599
+ :raises UnsupportedProvider: If the provider key is not found.
600
+ """
601
+ if key not in self.data:
602
+ msg = f"Provider '{key}' not found."
603
+ raise UnsupportedProvider(msg)
604
+ super().__delitem__(key)
605
+
606
+ def __repr__(self) -> str:
607
+ """
608
+ String representation of :class:`~eodag.api.provider.ProvidersDict`.
609
+
610
+ :return: String listing provider names.
611
+ """
612
+ return f"ProvidersDict({list(self.data.keys())})"
613
+
614
+ def _repr_html_(self, embeded=False) -> str:
615
+ """
616
+ HTML representation for Jupyter/IPython display.
617
+
618
+ :return: HTML string representation of the :class:`~eodag.api.provider.ProvidersDict`.
619
+ """
620
+ longest_name = max([len(k) for k in self.keys()])
621
+ thead = (
622
+ f"""<thead><tr><td style='text-align: left; color: grey;'>
623
+ {type(self).__name__}&ensp;({len(self)})
624
+ </td></tr></thead>
625
+ """
626
+ if not embeded
627
+ else ""
628
+ )
629
+ tr_style = "style='background-color: transparent;'" if embeded else ""
630
+ return (
631
+ f"<table>{thead}"
632
+ + "".join(
633
+ [
634
+ f"""<tr {tr_style}><td style='text-align: left;'>
635
+ <details><summary style='color: grey;'>
636
+ <span style='color: black; font-family: monospace;'>{k}:{'&nbsp;' * (longest_name - len(k))}</span>
637
+ Provider(
638
+ {"'priority': '<span style='color: black'>" + str(v.priority) + "</span>',&ensp;"
639
+ if v.priority is not None else ""}
640
+ {"'title': '<span style='color: black'>"
641
+ + shorten(v.title, width=70, placeholder="[...]") + "</span>',&ensp;"
642
+ if v.title else ""}
643
+ {"'url': '" + str_as_href(v.url) + "'" if v.url else ""}
644
+ )
645
+ </summary>
646
+ {v._repr_html_(embedded=True)}
647
+ </details>
648
+ </td></tr>
649
+ """
650
+ for k, v in self.items()
651
+ ]
652
+ )
653
+ + "</table>"
654
+ )
655
+
656
+ @property
657
+ def names(self) -> list[str]:
658
+ """
659
+ List of provider names.
660
+
661
+ :return: List of provider names.
662
+ """
663
+ return [provider.name for provider in self.data.values()]
664
+
665
+ @property
666
+ def groups(self) -> list[str]:
667
+ """
668
+ List of provider groups if exist or names.
669
+
670
+ :return: List of provider groups if exist or names.
671
+ """
672
+ return list(
673
+ set(provider.group or provider.name for provider in self.data.values())
674
+ )
675
+
676
+ @property
677
+ def configs(self) -> dict[str, ProviderConfig]:
678
+ """
679
+ Dictionary of provider configs keyed by provider name.
680
+
681
+ :return: Dictionary mapping provider name to :class:`~eodag.api.provider.ProviderConfig`.
682
+ """
683
+ return {provider.name: provider.config for provider in self.data.values()}
684
+
685
+ @property
686
+ def priorities(self) -> dict[str, int]:
687
+ """
688
+ Dictionary of provider priorities keyed by provider name.
689
+
690
+ :return: Dictionary mapping provider name to priority integer.
691
+ """
692
+ return {
693
+ provider.name: provider.config.priority for provider in self.data.values()
694
+ }
695
+
696
+ def get_config(self, provider: str) -> Optional[ProviderConfig]:
697
+ """
698
+ Get a :class:`~eodag.api.provider.ProviderConfig` from provider name.
699
+
700
+ :param provider: The provider name.
701
+ :return: The :class:`~eodag.api.provider.ProviderConfig` if found, otherwise None.
702
+ """
703
+ prov = self.get(provider)
704
+ return prov.config if prov else None
705
+
706
+ def filter(self, q: Optional[str] = None) -> ProvidersDict:
707
+ """
708
+ Return providers whose name, group, description, URL or collection matches the free-text query.
709
+
710
+ Supports logical operators with parenthesis (``AND``/``OR``/``NOT``), quoted phrases (``"exact phrase"``),
711
+ ``*`` and ``?`` wildcards.
712
+
713
+ If no query is provided, returns all providers.
714
+
715
+ :param q: Free-text parameter to filter providers. If None, returns all providers.
716
+ :return: matching Provider objects in a :class:`~eodag.api.provider.ProvidersDict`.
717
+
718
+ Example
719
+ -------
720
+
721
+ >>> from eodag.api.provider import ProvidersDict, Provider
722
+ >>> providers = ProvidersDict()
723
+ >>> providers['test1'] = Provider({
724
+ ... 'name': 'test1',
725
+ ... 'description': 'Satellite data',
726
+ ... 'search': {'type': 'StacSearch'}
727
+ ... })
728
+ >>> providers['test2'] = Provider({
729
+ ... 'name': 'test2',
730
+ ... 'description': 'Weather data',
731
+ ... 'search': {'type': 'StacSearch'}
732
+ ... })
733
+ >>> # Filter by description content
734
+ >>> providers.filter('Satellite')
735
+ ProvidersDict(['test1'])
736
+ >>> # Filter with logical operators
737
+ >>> providers['test3'] = Provider({
738
+ ... 'name': 'test3',
739
+ ... 'description': 'Satellite weather data',
740
+ ... 'search': {'type': 'StacSearch'}
741
+ ... })
742
+ >>> providers.filter('Satellite AND weather')
743
+ ProvidersDict(['test3'])
744
+ >>> # Get all providers when no filter
745
+ >>> len(providers.filter())
746
+ 3
747
+ """
748
+ if not q:
749
+ # yield from self.data.values()
750
+ return self
751
+
752
+ free_text_query = compile_free_text_query(q)
753
+ searchable_attributes = {"name", "group", "description", "products"}
754
+
755
+ filtered = ProvidersDict()
756
+ for p in self.data.values():
757
+ searchables = {
758
+ k: v for k, v in p.config.__dict__.items() if k in searchable_attributes
759
+ }
760
+ if free_text_query(searchables):
761
+ # yield p
762
+ filtered[p.name] = p
763
+ return filtered
764
+
765
+ def filter_by_name_or_group(
766
+ self, name_or_group: Optional[str] = None
767
+ ) -> Iterator[Provider]:
768
+ """
769
+ Yield providers whose name or group matches the given ``name_or_group``.
770
+
771
+ If ``name_or_group`` is ``None``, yields all providers.
772
+
773
+ :param name_or_group: The provider name or group to filter by. If None, yields all providers.
774
+ :return: Iterator of matching :class:`~eodag.api.provider.Provider` objects.
775
+
776
+ Example
777
+ -------
778
+
779
+ >>> from eodag.api.provider import ProvidersDict, Provider
780
+ >>> providers = ProvidersDict()
781
+ >>> providers['sentinel'] = Provider({'name': 'sentinel', 'group': 'esa', 'search': {'type': 'StacSearch'}})
782
+ >>> providers['landsat'] = Provider({'name': 'landsat', 'group': 'usgs', 'search': {'type': 'StacSearch'}})
783
+ >>> providers['modis'] = Provider({'name': 'modis', 'group': 'nasa', 'search': {'type': 'StacSearch'}})
784
+ >>>
785
+ >>> # Filter by exact provider name
786
+ >>> list(p.name for p in providers.filter_by_name_or_group('sentinel'))
787
+ ['sentinel']
788
+ >>>
789
+ >>> # Filter by group (case-insensitive)
790
+ >>> list(p.name for p in providers.filter_by_name_or_group('ESA'))
791
+ ['sentinel']
792
+ >>>
793
+ >>> # Get all providers when no filter
794
+ >>> len(list(providers.filter_by_name_or_group()))
795
+ 3
796
+ """
797
+ if name_or_group is None:
798
+ yield from self.data.values()
799
+ return
800
+
801
+ name_or_group_lower = name_or_group.lower()
802
+ for provider in self.data.values():
803
+ if provider.name.lower() == name_or_group_lower or (
804
+ provider.group and provider.group.lower() == name_or_group_lower
805
+ ):
806
+ yield provider
807
+
808
+ def delete_collection(self, provider: str, collection: str) -> None:
809
+ """
810
+ Delete a collection from a provider.
811
+
812
+ :param provider: The provider's name.
813
+ :param product_ID: The collection to delete.
814
+ :raises UnsupportedProvider: If the provider or product is not found.
815
+ """
816
+ if provider_obj := self.get(provider):
817
+ if collection in provider_obj.collections_config:
818
+ provider_obj.delete_collection(collection)
819
+ else:
820
+ msg = f"Collection '{collection}' not found for provider '{provider}'."
821
+ raise UnsupportedCollection(msg)
822
+ else:
823
+ msg = f"Provider '{provider}' not found."
824
+ raise UnsupportedProvider(msg)
825
+
826
+ def _share_credentials(self) -> None:
827
+ """
828
+ Share credentials between plugins with matching criteria
829
+ across all providers in this dictionary.
830
+ """
831
+ auth_confs_with_creds: list[PluginConfig] = []
832
+ for provider in self.values():
833
+ auth_confs_with_creds.extend(provider._get_auth_confs_with_credentials())
834
+
835
+ if not auth_confs_with_creds:
836
+ return
837
+
838
+ for provider in self.values():
839
+ provider._copy_matching_credentials(auth_confs_with_creds)
840
+
841
+ @staticmethod
842
+ def _get_whitelisted_configs(
843
+ configs: Mapping[str, Union[ProviderConfig, dict[str, Any]]],
844
+ ) -> Mapping[str, Union[ProviderConfig, dict[str, Any]]]:
845
+ """
846
+ Filter configs according to the EODAG_PROVIDERS_WHITELIST environment variable, if set.
847
+
848
+ :param configs: The dictionary of provider configurations.
849
+ :return: Filtered configurations.
850
+ """
851
+ whitelist = set(os.getenv("EODAG_PROVIDERS_WHITELIST", "").split(","))
852
+ if not whitelist or whitelist == {""}:
853
+ return configs
854
+ return {name: conf for name, conf in configs.items() if name in whitelist}
855
+
856
+ def update_from_configs(
857
+ self,
858
+ configs: Mapping[str, Union[ProviderConfig, dict[str, Any]]],
859
+ ) -> None:
860
+ """
861
+ Update providers from a dictionary of configurations.
862
+
863
+ :param configs: A dictionary mapping provider names to configurations.
864
+ """
865
+ configs = self._get_whitelisted_configs(configs)
866
+ for name, conf in configs.items():
867
+ if isinstance(conf, dict) and conf.get("name") != name:
868
+ if "name" in conf:
869
+ logger.debug(
870
+ "%s: config name '%s' overridden by dict key",
871
+ name,
872
+ conf["name"],
873
+ )
874
+ conf = {**conf, "name": name}
875
+ elif isinstance(conf, ProviderConfig) and conf.name != name:
876
+ raise ValidationError(
877
+ f"ProviderConfig name '{conf.name}' must match dict key '{name}'"
878
+ )
879
+
880
+ try:
881
+ if name in self.data:
882
+ self.data[name].update_from_config(conf)
883
+ else:
884
+ self.data[name] = Provider(conf)
885
+
886
+ self.data[name].collections_fetched = False
887
+
888
+ except Exception:
889
+ operation = "updating" if name in self.data else "creating"
890
+ logger.warning("%s: skipped %s due to invalid config", name, operation)
891
+ logger.debug("Traceback:\n%s", traceback.format_exc())
892
+
893
+ self._share_credentials()
894
+
895
+ def update_from_config_file(self, file_path: str) -> None:
896
+ """
897
+ Override provider configurations with values loaded from a YAML file.
898
+
899
+ :param file_path: The path to the configuration file.
900
+ :raises yaml.parser.ParserError: If the YAML file cannot be parsed.
901
+ """
902
+ logger.info("Loading user configuration from: %s", os.path.abspath(file_path))
903
+ with open(os.path.abspath(os.path.realpath(file_path)), "r") as fh:
904
+ try:
905
+ config_in_file = yaml.safe_load(fh)
906
+ if config_in_file is None:
907
+ return
908
+ except yaml.parser.ParserError as e:
909
+ logger.error("Unable to load configuration file %s", file_path)
910
+ raise e
911
+
912
+ self.update_from_configs(config_in_file)
913
+
914
+ def update_from_env(self) -> None:
915
+ """
916
+ Override provider configurations with environment variables values.
917
+
918
+ Environment variables must start with ``EODAG__`` and follow a nested key
919
+ pattern separated by double underscores ``__``.
920
+ """
921
+
922
+ def build_mapping_from_env(
923
+ env_var: str, env_value: str, mapping: dict[str, Any]
924
+ ) -> None:
925
+ """
926
+ Recursively build a dictionary from an environment variable.
927
+
928
+ The environment variable must respect the pattern: ``KEY1__KEY2__[...]__KEYN``.
929
+ It will be transformed into a nested dictionary.
930
+
931
+ :param env_var: The environment variable key (nested keys separated by ``__``).
932
+ :param env_value: The value from environment variable.
933
+ :param mapping: The dictionary where the nested mapping is built.
934
+ """
935
+ parts = env_var.split("__")
936
+ iter_parts = iter(parts)
937
+ env_type = get_type_hints(PluginConfig).get(next(iter_parts, ""), str)
938
+ child_env_type = (
939
+ get_type_hints(env_type).get(next(iter_parts, ""))
940
+ if isclass(env_type)
941
+ else None
942
+ )
943
+ if len(parts) == 2 and child_env_type:
944
+ try:
945
+ env_value = cast_scalar_value(env_value, child_env_type)
946
+ except TypeError:
947
+ logger.warning(
948
+ f"Could not convert {parts} value {env_value} to {child_env_type}"
949
+ )
950
+ mapping.setdefault(parts[0], {})
951
+ mapping[parts[0]][parts[1]] = env_value
952
+ elif len(parts) == 1:
953
+ try:
954
+ env_value = cast_scalar_value(env_value, env_type)
955
+ except TypeError:
956
+ logger.warning(
957
+ f"Could not convert {parts[0]} value {env_value} to {env_type}"
958
+ )
959
+ mapping[parts[0]] = env_value
960
+ else:
961
+ new_map = mapping.setdefault(parts[0], {})
962
+ build_mapping_from_env("__".join(parts[1:]), env_value, new_map)
963
+
964
+ logger.debug("Loading configuration from environment variables")
965
+
966
+ mapping_from_env: dict[str, dict[str, Any]] = {}
967
+ for env_var in os.environ:
968
+ if env_var.startswith("EODAG__"):
969
+ build_mapping_from_env(
970
+ env_var[len("EODAG__") :].lower(), # noqa
971
+ os.environ[env_var],
972
+ mapping_from_env,
973
+ )
974
+
975
+ self.update_from_configs(mapping_from_env)
976
+
977
+ @classmethod
978
+ def from_configs(
979
+ cls, configs: Mapping[str, Union[ProviderConfig, dict[str, Any]]]
980
+ ) -> Self:
981
+ """
982
+ Build a ProvidersDict from a configuration mapping.
983
+
984
+ :param configs: A dictionary mapping provider names to configuration dicts or
985
+ :class:`~eodag.api.provider.ProviderConfig` instances.
986
+ :return: An instance of :class:`~eodag.api.provider.ProvidersDict` populated with the given configurations.
987
+ """
988
+ providers = cls()
989
+ providers.update_from_configs(configs)
990
+ return providers