eodag 3.8.1__py3-none-any.whl → 3.9.1__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.
Files changed (43) hide show
  1. eodag/api/core.py +1 -1
  2. eodag/api/product/drivers/generic.py +5 -1
  3. eodag/api/product/metadata_mapping.py +132 -35
  4. eodag/cli.py +36 -4
  5. eodag/config.py +5 -2
  6. eodag/plugins/apis/ecmwf.py +3 -1
  7. eodag/plugins/apis/usgs.py +2 -1
  8. eodag/plugins/authentication/aws_auth.py +235 -37
  9. eodag/plugins/authentication/base.py +12 -2
  10. eodag/plugins/authentication/oauth.py +5 -0
  11. eodag/plugins/base.py +3 -2
  12. eodag/plugins/download/aws.py +44 -285
  13. eodag/plugins/download/base.py +3 -2
  14. eodag/plugins/download/creodias_s3.py +1 -38
  15. eodag/plugins/download/http.py +111 -103
  16. eodag/plugins/download/s3rest.py +3 -1
  17. eodag/plugins/manager.py +2 -1
  18. eodag/plugins/search/__init__.py +2 -1
  19. eodag/plugins/search/base.py +2 -1
  20. eodag/plugins/search/build_search_result.py +2 -2
  21. eodag/plugins/search/creodias_s3.py +9 -1
  22. eodag/plugins/search/qssearch.py +3 -1
  23. eodag/resources/ext_product_types.json +1 -1
  24. eodag/resources/product_types.yml +220 -30
  25. eodag/resources/providers.yml +633 -88
  26. eodag/resources/stac_provider.yml +5 -2
  27. eodag/resources/user_conf_template.yml +0 -5
  28. eodag/rest/core.py +8 -0
  29. eodag/rest/errors.py +9 -0
  30. eodag/rest/server.py +8 -0
  31. eodag/rest/stac.py +8 -0
  32. eodag/rest/utils/__init__.py +2 -4
  33. eodag/rest/utils/rfc3339.py +1 -1
  34. eodag/utils/__init__.py +69 -54
  35. eodag/utils/dates.py +204 -0
  36. eodag/utils/s3.py +187 -168
  37. {eodag-3.8.1.dist-info → eodag-3.9.1.dist-info}/METADATA +4 -3
  38. {eodag-3.8.1.dist-info → eodag-3.9.1.dist-info}/RECORD +42 -42
  39. {eodag-3.8.1.dist-info → eodag-3.9.1.dist-info}/entry_points.txt +1 -1
  40. eodag/utils/rest.py +0 -100
  41. {eodag-3.8.1.dist-info → eodag-3.9.1.dist-info}/WHEEL +0 -0
  42. {eodag-3.8.1.dist-info → eodag-3.9.1.dist-info}/licenses/LICENSE +0 -0
  43. {eodag-3.8.1.dist-info → eodag-3.9.1.dist-info}/top_level.txt +0 -0
eodag/api/core.py CHANGED
@@ -74,6 +74,7 @@ from eodag.utils import (
74
74
  string_to_jsonpath,
75
75
  uri_to_path,
76
76
  )
77
+ from eodag.utils.dates import rfc3339_str_to_datetime
77
78
  from eodag.utils.env import is_env_var_true
78
79
  from eodag.utils.exceptions import (
79
80
  AuthenticationError,
@@ -84,7 +85,6 @@ from eodag.utils.exceptions import (
84
85
  UnsupportedProvider,
85
86
  )
86
87
  from eodag.utils.free_text_search import compile_free_text_query
87
- from eodag.utils.rest import rfc3339_str_to_datetime
88
88
  from eodag.utils.stac_reader import fetch_stac_items
89
89
 
90
90
  if TYPE_CHECKING:
@@ -33,7 +33,11 @@ class GenericDriver(DatasetDriver):
33
33
  # data
34
34
  {
35
35
  "pattern": re.compile(
36
- r"^(?:.*[/\\])?([^/\\]+)(\.jp2|\.tiff?|\.dat|\.nc|\.grib2?)$",
36
+ (
37
+ r"^(?:.*[/\\])?([^/\\]+)"
38
+ r"(\.jp2|\.tiff?|\.dat|\.nc|\.grib2?|"
39
+ r"\.zarr|\.nat|\.covjson|\.parquet|\.zip|\.tar|\.gz)$"
40
+ ),
37
41
  re.IGNORECASE,
38
42
  ),
39
43
  "roles": ["data"],
@@ -47,7 +47,6 @@ from eodag.utils import (
47
47
  dict_items_recursive_apply,
48
48
  format_string,
49
49
  get_geometry_from_various,
50
- get_timestamp,
51
50
  items_recursive_apply,
52
51
  nested_pairs2dict,
53
52
  remove_str_array_quotes,
@@ -55,6 +54,7 @@ from eodag.utils import (
55
54
  string_to_jsonpath,
56
55
  update_nested_dict,
57
56
  )
57
+ from eodag.utils.dates import get_timestamp
58
58
  from eodag.utils.exceptions import ValidationError
59
59
 
60
60
  if TYPE_CHECKING:
@@ -149,39 +149,38 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
149
149
  """Format a string of form ``{<field_name>#<conversion_function>}``
150
150
 
151
151
  The currently understood converters are:
152
- - ``datetime_to_timestamp_milliseconds``: converts a utc date string to a timestamp in
153
- milliseconds
154
- - ``to_rounded_wkt``: simplify the WKT of a geometry
155
- - ``to_bounds_lists``: convert to list(s) of bounds
156
- - ``to_nwse_bounds``: convert to North,West,South,East bounds
157
- - ``to_nwse_bounds_str``: convert to North,West,South,East bounds string with given separator
158
- - ``to_geojson``: convert to a GeoJSON (via __geo_interface__ if exists)
152
+ - ``ceda_collection_name``: generate a CEDA collection name from a string
153
+ - ``csv_list``: convert to a comma separated list
154
+ - ``datetime_to_timestamp_milliseconds``: converts a utc date string to a timestamp in milliseconds
155
+ - ``dict_filter_and_sub``: filter dict items using jsonpath and then apply recursive_sub_str
156
+ - ``fake_l2a_title_from_l1c``: used to generate SAFE format metadata for data from AWS
157
+ - ``from_alternate``: update assets using given alternate
159
158
  - ``from_ewkt``: convert EWKT to shapely geometry / WKT in DEFAULT_PROJ
160
- - ``to_ewkt``: convert to EWKT (Extended Well-Known text)
161
159
  - ``from_georss``: convert GeoRSS to shapely geometry / WKT in DEFAULT_PROJ
162
- - ``csv_list``: convert to a comma separated list
163
- - ``to_iso_utc_datetime_from_milliseconds``: convert a utc timestamp in given
164
- milliseconds to a utc iso datetime
165
- - ``to_iso_utc_datetime``: convert a UTC datetime string to ISO UTC datetime
166
- string
167
- - ``to_iso_date``: remove the time part of a iso datetime string
168
- - ``remove_extension``: on a string that contains dots, only take the first
169
- part of the list obtained by splitting the string on dots
160
+ - ``get_ecmwf_time``: get the time of a datetime string in the ECMWF format
170
161
  - ``get_group_name``: get the matching regex group name
162
+ - ``recursive_sub_str``: recursively substitue in the structure (e.g. dict) values matching a regex
163
+ - ``remove_extension``: on a string that contains dots, only take the first part of the list obtained by
164
+ splitting the string on dots
171
165
  - ``replace_str``: execute "string".replace(old, new)
172
- - ``recursive_sub_str``: recursively substitue in the structure (e.g. dict)
173
- values matching a regex
174
- - ``slice_str``: slice a string (equivalent to s[start, end, step])
175
- - ``to_lower``: Convert a string to lowercase
176
- - ``to_upper``: Convert a string to uppercase
177
- - ``fake_l2a_title_from_l1c``: used to generate SAFE format metadata for data from AWS
178
166
  - ``s2msil2a_title_to_aws_productinfo``: used to generate SAFE format metadata for data from AWS
167
+ - ``sanitize``: sanitize string
168
+ - ``slice_str``: slice a string (equivalent to s[start, end, step])
179
169
  - ``split_cop_dem_id``: get the bbox by splitting the product id
180
170
  - ``split_corine_id``: get the product type by splitting the product id
171
+ - ``to_bounds_lists``: convert to list(s) of bounds
181
172
  - ``to_datetime_dict``: convert a datetime string to a dictionary where values are either a string or a list
182
- - ``get_ecmwf_time``: get the time of a datetime string in the ECMWF format
183
- - ``sanitize``: sanitize string
184
- - ``ceda_collection_name``: generate a CEDA collection name from a string
173
+ - ``to_ewkt``: convert to EWKT (Extended Well-Known text)
174
+ - ``to_geojson``: convert to a GeoJSON (via __geo_interface__ if exists)
175
+ - ``to_iso_date``: remove the time part of a iso datetime string
176
+ - ``to_iso_utc_datetime_from_milliseconds``: convert a utc timestamp in given milliseconds to a utc iso datetime
177
+ - ``to_iso_utc_datetime``: convert a UTC datetime string to ISO UTC datetime string
178
+ - ``to_lower``: Convert a string to lowercase
179
+ - ``to_nwse_bounds_str``: convert to North,West,South,East bounds string with given separator
180
+ - ``to_nwse_bounds``: convert to North,West,South,East bounds
181
+ - ``to_rounded_wkt``: simplify the WKT of a geometry
182
+ - ``to_title``: Convert a string to title case
183
+ - ``to_upper``: Convert a string to uppercase
185
184
 
186
185
  :param search_param: The string to be formatted
187
186
  :param args: (optional) Additional arguments to use in the formatting process
@@ -487,9 +486,9 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
487
486
  return {"lon": lon, "lat": lat}
488
487
 
489
488
  @staticmethod
490
- def convert_csv_list(values_list: Any) -> Any:
489
+ def convert_csv_list(values_list: Any, separator=",") -> Any:
491
490
  if isinstance(values_list, list):
492
- return ",".join([str(x) for x in values_list])
491
+ return separator.join([str(x) for x in values_list])
493
492
  else:
494
493
  return values_list
495
494
 
@@ -529,6 +528,35 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
529
528
  old, new = ast.literal_eval(args)
530
529
  return re.sub(old, new, value)
531
530
 
531
+ @staticmethod
532
+ def convert_replace_str_tuple(value: Any, args: str) -> str:
533
+ """
534
+ Apply multiple replacements on a string.
535
+ args should be a string representing a list/tuple of (old, new) pairs.
536
+ Example: '(("old1", "new1"), ("old2", "new2"))'
537
+ """
538
+ if isinstance(value, dict):
539
+ value = MetadataFormatter.convert_to_geojson(value)
540
+ elif not isinstance(value, str):
541
+ raise TypeError(
542
+ f"convert_replace_str_tuple expects a string or a dict (apply to_geojson). "
543
+ f"Got {type(value)}: {value}"
544
+ )
545
+
546
+ # args sera une chaîne représentant une liste/tuple de tuples
547
+ replacements = ast.literal_eval(args)
548
+
549
+ if not isinstance(replacements, (list, tuple)):
550
+ raise TypeError(
551
+ f"convert_replace_str_tuple expects a list/tuple of (old,new) pairs. "
552
+ f"Got {type(replacements)}: {replacements}"
553
+ )
554
+
555
+ for old, new in replacements:
556
+ value = re.sub(old, new, value)
557
+
558
+ return value
559
+
532
560
  @staticmethod
533
561
  def convert_ceda_collection_name(value: str) -> str:
534
562
  data_regex = re.compile(r"/data/(?P<name>.+?)/?$")
@@ -580,6 +608,45 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
580
608
  result[key] = match.value
581
609
  return result
582
610
 
611
+ @staticmethod
612
+ def convert_dict_filter_and_sub(
613
+ input_dict: dict[Any, Any], args: str
614
+ ) -> Union[dict[Any, Any], list[Any]]:
615
+ """Fitlers dict items using jsonpath and then apply recursive_sub_str"""
616
+ jsonpath_filter_str, old, new = ast.literal_eval(args)
617
+ filtered = MetadataFormatter.convert_dict_filter(
618
+ input_dict, jsonpath_filter_str
619
+ )
620
+ args_str = f"('{old}', '{new}')"
621
+ return MetadataFormatter.convert_recursive_sub_str(filtered, args_str)
622
+
623
+ @staticmethod
624
+ def convert_from_alternate(
625
+ input_obj: dict[str, Any], value: str
626
+ ) -> dict[str, Any]:
627
+ """
628
+ Update assets using given alternate.
629
+ """
630
+ result: dict[str, Any] = {}
631
+ for k, v in input_obj.items():
632
+ if not isinstance(v, dict):
633
+ continue
634
+
635
+ alt_dict = deepcopy(v).get("alternate")
636
+ if not isinstance(alt_dict, dict):
637
+ continue
638
+
639
+ value_entry = alt_dict.pop(value, None)
640
+ if not isinstance(value_entry, dict):
641
+ continue
642
+
643
+ result[k] = v | value_entry | {"alternate": alt_dict}
644
+
645
+ if len(result[k]["alternate"]) == 0:
646
+ del result[k]["alternate"]
647
+
648
+ return result
649
+
583
650
  @staticmethod
584
651
  def convert_slice_str(string: str, args: str) -> str:
585
652
  cmin, cmax, cstep = [
@@ -591,6 +658,8 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
591
658
  @staticmethod
592
659
  def convert_to_lower(string: str) -> str:
593
660
  """Convert a string to lowercase."""
661
+ if string == NOT_AVAILABLE:
662
+ return string
594
663
  return string.lower()
595
664
 
596
665
  @staticmethod
@@ -598,6 +667,13 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
598
667
  """Convert a string to uppercase."""
599
668
  return string.upper()
600
669
 
670
+ @staticmethod
671
+ def convert_to_title(string: str) -> str:
672
+ """Convert a string to title case."""
673
+ if string == NOT_AVAILABLE:
674
+ return string
675
+ return string.title()
676
+
601
677
  @staticmethod
602
678
  def convert_fake_l2a_title_from_l1c(string: str) -> str:
603
679
  id_regex = re.compile(
@@ -1362,17 +1438,30 @@ def format_query_params(
1362
1438
  error_context,
1363
1439
  )
1364
1440
 
1365
- for eodag_search_key, provider_search_key in queryables.items():
1441
+ for eodag_search_key, provider_search_param in queryables.items():
1366
1442
  user_input = query_dict[eodag_search_key]
1367
1443
 
1368
- if COMPLEX_QS_REGEX.match(provider_search_key):
1369
- parts = provider_search_key.split("=")
1444
+ if provider_search_param == user_input:
1445
+ # means the mapping is to be passed as is, in which case we
1446
+ # readily register it
1447
+ if (
1448
+ eodag_search_key in query_params
1449
+ and isinstance(query_params[eodag_search_key], dict)
1450
+ and isinstance(user_input, dict)
1451
+ ):
1452
+ query_params[eodag_search_key].update(user_input)
1453
+ else:
1454
+ query_params[eodag_search_key] = user_input
1455
+ continue
1456
+
1457
+ if COMPLEX_QS_REGEX.match(provider_search_param):
1458
+ parts = provider_search_param.split("=")
1370
1459
  if len(parts) == 1:
1371
1460
  formatted_query_param = format_metadata(
1372
- provider_search_key, product_type, **query_dict
1461
+ provider_search_param, product_type, **query_dict
1373
1462
  )
1374
1463
  formatted_query_param = formatted_query_param.replace("'", '"')
1375
- if "{{" in provider_search_key:
1464
+ if "{{" in provider_search_param:
1376
1465
  # retrieve values from hashes where keys are given in the param
1377
1466
  if "}[" in formatted_query_param:
1378
1467
  formatted_query_param = _resolve_hashes(formatted_query_param)
@@ -1396,7 +1485,7 @@ def format_query_params(
1396
1485
  provider_value, product_type, **query_dict
1397
1486
  )
1398
1487
  else:
1399
- query_params[provider_search_key] = user_input
1488
+ query_params[provider_search_param] = user_input
1400
1489
  # Now get all the literal search params (i.e params to be passed "as is"
1401
1490
  # in the search request)
1402
1491
  # ignore additional_params if it isn't a dictionary
@@ -1527,7 +1616,15 @@ def _get_queryables(
1527
1616
  config.discover_metadata.get("metadata_pattern", "")
1528
1617
  )
1529
1618
  search_param_cfg = config.discover_metadata.get("search_param", "")
1530
- if pattern.match(eodag_search_key) and isinstance(
1619
+ search_param_unparsed_cfg = config.discover_metadata.get(
1620
+ "search_param_unparsed", []
1621
+ )
1622
+ if (
1623
+ search_param_unparsed_cfg
1624
+ and eodag_search_key in search_param_unparsed_cfg
1625
+ ):
1626
+ queryables[eodag_search_key] = user_input
1627
+ elif pattern.match(eodag_search_key) and isinstance(
1531
1628
  search_param_cfg, str
1532
1629
  ):
1533
1630
  search_param = search_param_cfg.format(metadata=eodag_search_key)
eodag/cli.py CHANGED
@@ -42,13 +42,14 @@ Commands:
42
42
 
43
43
  from __future__ import annotations
44
44
 
45
+ import functools
45
46
  import json
46
47
  import os
47
48
  import shutil
48
49
  import sys
49
50
  import textwrap
50
51
  from importlib.metadata import metadata
51
- from typing import TYPE_CHECKING, Any, Mapping
52
+ from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional
52
53
  from urllib.parse import parse_qs
53
54
 
54
55
  import click
@@ -118,6 +119,22 @@ class MutuallyExclusiveOption(click.Option):
118
119
  return super(MutuallyExclusiveOption, self).handle_parse_result(ctx, opts, args)
119
120
 
120
121
 
122
+ def _deprecated_cli(message: str, version: Optional[str] = None) -> Callable[..., Any]:
123
+ """Decorator to mark a CLI command as deprecated and print a bold yellow warning."""
124
+ version_msg = f" -- Deprecated since v{version}" if version else ""
125
+
126
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
127
+ @functools.wraps(func)
128
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
129
+ full_message = f"DEPRECATED: {message}{version_msg}"
130
+ click.echo(click.style(full_message, fg="yellow", bold=True), err=True)
131
+ return func(*args, **kwargs)
132
+
133
+ return wrapper
134
+
135
+ return decorator
136
+
137
+
121
138
  @click.group(chain=True)
122
139
  @click.option(
123
140
  "-v",
@@ -631,9 +648,17 @@ def download(ctx: Context, **kwargs: Any) -> None:
631
648
 
632
649
 
633
650
  @eodag.command(
634
- help="Start eodag HTTP server\n\n"
635
- "Set EODAG_CORS_ALLOWED_ORIGINS environment variable to configure Cross-Origin Resource Sharing allowed origins as "
636
- "comma-separated URLs (e.g. 'http://somewhere,htttp://somewhere.else')."
651
+ help="(deprecated) Start eodag HTTP server\n\n"
652
+ + (
653
+ click.style(
654
+ "Running a web server from the CLI is deprecated and will be removed in a future version.\n"
655
+ "This feature has been moved to its own repository: https://github.com/CS-SI/stac-fastapi-eodag\n\n",
656
+ fg="yellow",
657
+ bold=True,
658
+ )
659
+ + "Set EODAG_CORS_ALLOWED_ORIGINS environment variable to configure Cross-Origin Resource Sharing allowed "
660
+ "origins as comma-separated URLs (e.g. 'http://somewhere,http://somewhere.else')."
661
+ )
637
662
  )
638
663
  @click.option(
639
664
  "-f",
@@ -676,6 +701,13 @@ def download(ctx: Context, **kwargs: Any) -> None:
676
701
  help="Run in debug mode (for development purpose)",
677
702
  )
678
703
  @click.pass_context
704
+ @_deprecated_cli(
705
+ message=(
706
+ "Running a web server from the CLI is deprecated and will be removed in a future version. "
707
+ "This feature has been moved to its own repository: https://github.com/CS-SI/stac-fastapi-eodag"
708
+ ),
709
+ version="3.9.0",
710
+ )
679
711
  def serve_rest(
680
712
  ctx: Context,
681
713
  daemon: bool,
eodag/config.py CHANGED
@@ -272,6 +272,8 @@ class PluginConfig(yaml.YAMLObject):
272
272
  search_param: str | dict[str, Any]
273
273
  #: Path to the metadata in search result
274
274
  metadata_path: str
275
+ #: list search parameters to send as is to the provider
276
+ search_param_unparsed: list[str]
275
277
  #: Whether an error must be raised when using a search parameter which is not queryable or not
276
278
  raise_mtd_discovery_error: bool
277
279
 
@@ -543,8 +545,6 @@ class PluginConfig(yaml.YAMLObject):
543
545
  #: :class:`~eodag.plugins.download.s3rest.S3RestDownload`
544
546
  #: At which level of the path part of the url the bucket can be found
545
547
  bucket_path_level: int
546
- #: :class:`~eodag.plugins.download.aws.AwsDownload` Whether download is done from a requester-pays bucket or not
547
- requester_pays: bool
548
548
  #: :class:`~eodag.plugins.download.aws.AwsDownload` S3 endpoint
549
549
  s3_endpoint: str
550
550
 
@@ -571,6 +571,9 @@ class PluginConfig(yaml.YAMLObject):
571
571
  #: :class:`~eodag.plugins.authentication.base.Authentication` Part of the search or download plugin configuration
572
572
  #: that needs authentication
573
573
  matching_conf: dict[str, Any]
574
+ #: :class:`~eodag.plugins.authentication.aws_auth.AwsAuth`
575
+ #: Whether download is done from a requester-pays bucket or not
576
+ requester_pays: bool
574
577
  #: :class:`~eodag.plugins.authentication.openid_connect.OIDCRefreshTokenBase`
575
578
  #: How the token should be used in the request
576
579
  token_provision: str
@@ -46,6 +46,7 @@ from eodag.utils.logging import get_logging_verbose
46
46
  if TYPE_CHECKING:
47
47
  from typing import Any, Optional, Union
48
48
 
49
+ from mypy_boto3_s3 import S3ServiceResource
49
50
  from requests.auth import AuthBase
50
51
 
51
52
  from eodag.api.product import EOProduct
@@ -55,6 +56,7 @@ if TYPE_CHECKING:
55
56
  from eodag.types.download_args import DownloadConf
56
57
  from eodag.utils import DownloadedCallback, ProgressCallback, Unpack
57
58
 
59
+
58
60
  logger = logging.getLogger("eodag.apis.ecmwf")
59
61
 
60
62
  ECMWF_MARS_KNOWN_FORMATS = {"grib": "grib", "netcdf": "nc"}
@@ -171,7 +173,7 @@ class EcmwfApi(Api, ECMWFSearch):
171
173
  def download(
172
174
  self,
173
175
  product: EOProduct,
174
- auth: Optional[Union[AuthBase, S3SessionKwargs]] = None,
176
+ auth: Optional[Union[AuthBase, S3SessionKwargs, S3ServiceResource]] = None,
175
177
  progress_callback: Optional[ProgressCallback] = None,
176
178
  wait: float = DEFAULT_DOWNLOAD_WAIT,
177
179
  timeout: float = DEFAULT_DOWNLOAD_TIMEOUT,
@@ -57,6 +57,7 @@ from eodag.utils.exceptions import (
57
57
  )
58
58
 
59
59
  if TYPE_CHECKING:
60
+ from mypy_boto3_s3 import S3ServiceResource
60
61
  from requests.auth import AuthBase
61
62
 
62
63
  from eodag.api.search_result import SearchResult
@@ -296,7 +297,7 @@ class UsgsApi(Api):
296
297
  def download(
297
298
  self,
298
299
  product: EOProduct,
299
- auth: Optional[Union[AuthBase, S3SessionKwargs]] = None,
300
+ auth: Optional[Union[AuthBase, S3SessionKwargs, S3ServiceResource]] = None,
300
301
  progress_callback: Optional[ProgressCallback] = None,
301
302
  wait: float = DEFAULT_DOWNLOAD_WAIT,
302
303
  timeout: float = DEFAULT_DOWNLOAD_TIMEOUT,