eodash_catalog 0.0.23__tar.gz → 0.0.25__tar.gz

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.

Potentially problematic release.


This version of eodash_catalog might be problematic. Click here for more details.

Files changed (33) hide show
  1. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/.bumpversion.cfg +1 -1
  2. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/.github/workflows/ci.yml +0 -1
  3. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/PKG-INFO +1 -1
  4. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/src/eodash_catalog/__about__.py +1 -1
  5. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/src/eodash_catalog/endpoints.py +7 -2
  6. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/src/eodash_catalog/generate_indicators.py +77 -66
  7. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/src/eodash_catalog/stac_handling.py +7 -5
  8. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/src/eodash_catalog/utils.py +43 -4
  9. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/.github/workflows/python-publish.yml +0 -0
  10. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/.gitignore +0 -0
  11. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/.vscode/extensions.json +0 -0
  12. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/.vscode/settings.json +0 -0
  13. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/LICENSE.txt +0 -0
  14. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/README.md +0 -0
  15. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/pyproject.toml +0 -0
  16. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/requirements.txt +0 -0
  17. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/ruff.toml +0 -0
  18. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/src/eodash_catalog/__init__.py +0 -0
  19. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/src/eodash_catalog/duration.py +0 -0
  20. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/src/eodash_catalog/sh_endpoint.py +0 -0
  21. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/src/eodash_catalog/thumbnails.py +0 -0
  22. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/__init__.py +0 -0
  23. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/test-data/regional_forecast.json +0 -0
  24. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/test_generate.py +0 -0
  25. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/testing-catalogs/testing.yaml +0 -0
  26. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/testing-collections/test_CROPOMAT1.yaml +0 -0
  27. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/testing-collections/test_see_solar_energy.yaml +0 -0
  28. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/testing-collections/test_tif_demo_1.yaml +0 -0
  29. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/testing-collections/test_tif_demo_2.yaml +0 -0
  30. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/testing-collections/test_wms_no_time.yaml +0 -0
  31. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/testing-indicators/test_indicator.yaml +0 -0
  32. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/testing-layers/baselayers.yaml +0 -0
  33. {eodash_catalog-0.0.23 → eodash_catalog-0.0.25}/tests/testing-layers/overlays.yaml +0 -0
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 0.0.23
2
+ current_version = 0.0.25
3
3
  commit = True
4
4
  tag = True
5
5
  parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(\-(?P<release>[a-z]+)\.(?P<build>\d+))?
@@ -17,7 +17,6 @@ jobs:
17
17
  with:
18
18
  python-version: ${{ matrix.python-version }}
19
19
  cache: 'pip'
20
- # You can test your matrix by printing the current Python version
21
20
  - name: Install dependencies
22
21
  run: python -m pip install -r requirements.txt
23
22
  - name: Lint
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: eodash_catalog
3
- Version: 0.0.23
3
+ Version: 0.0.25
4
4
  Summary: This package is intended to help create a compatible STAC catalog for the eodash dashboard client. It supports configuration of multiple endpoint types for information extraction.
5
5
  Project-URL: Documentation, https://github.com/eodash/eodash_catalog#readme
6
6
  Project-URL: Issues, https://github.com/eodash/eodash_catalog/issues
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2024-present Daniel Santillan <daniel.santillan@eox.at>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "0.0.23"
4
+ __version__ = "0.0.25"
@@ -12,6 +12,7 @@ import requests
12
12
  from dateutil import parser
13
13
  from pystac import Asset, Catalog, Collection, Item, Link, SpatialExtent, Summaries
14
14
  from pystac_client import Client
15
+ from structlog import get_logger
15
16
 
16
17
  from eodash_catalog.sh_endpoint import get_SH_token
17
18
  from eodash_catalog.stac_handling import (
@@ -31,6 +32,8 @@ from eodash_catalog.utils import (
31
32
  retrieveExtentFromWMSWMTS,
32
33
  )
33
34
 
35
+ LOGGER = get_logger(__name__)
36
+
34
37
 
35
38
  def process_STAC_Datacube_Endpoint(
36
39
  catalog_config: dict, endpoint_config: dict, collection_config: dict, catalog: Catalog
@@ -164,6 +167,7 @@ def handle_STAC_based_endpoint(
164
167
  )
165
168
  # eodash v4 compatibility
166
169
  add_visualization_info(root_collection, collection_config, endpoint_config)
170
+ add_collection_information(catalog_config, root_collection, collection_config)
167
171
  add_example_info(root_collection, collection_config, endpoint_config, catalog_config)
168
172
  return root_collection
169
173
 
@@ -365,6 +369,7 @@ def handle_SH_WMS_endpoint(
365
369
  if isinstance(c_child, Collection):
366
370
  root_collection.extent.spatial.bboxes.append(c_child.extent.spatial.bboxes[0])
367
371
  # eodash v4 compatibility
372
+ add_collection_information(catalog_config, root_collection, collection_config)
368
373
  add_visualization_info(root_collection, collection_config, endpoint_config)
369
374
  return root_collection
370
375
 
@@ -772,7 +777,7 @@ def add_visualization_info(
772
777
  )
773
778
  )
774
779
  else:
775
- print("Visualization endpoint not supported")
780
+ LOGGER.info(f"Visualization endpoint not supported {endpoint_config['Name']}")
776
781
 
777
782
 
778
783
  def handle_custom_endpoint(
@@ -795,7 +800,7 @@ def handle_custom_endpoint(
795
800
  importlib.import_module(module_name), func_name
796
801
  )
797
802
  except ModuleNotFoundError as e:
798
- print(
803
+ LOGGER.warn(
799
804
  f"""function {func_name} from module {module_name} can not be imported.
800
805
  Check if you are specifying relative path inside the
801
806
  catalog repository or catalog generator repository."""
@@ -14,6 +14,7 @@ from dotenv import load_dotenv
14
14
  from pystac import Catalog, CatalogType, Collection, Link, Summaries
15
15
  from pystac.layout import TemplateLayoutStrategy
16
16
  from pystac.validation import validate_all
17
+ from structlog import get_logger
17
18
  from yaml.loader import SafeLoader
18
19
 
19
20
  from eodash_catalog.endpoints import (
@@ -40,14 +41,16 @@ from eodash_catalog.utils import (
40
41
  add_single_item_if_collection_empty,
41
42
  iter_len_at_least,
42
43
  recursive_save,
44
+ retry,
43
45
  )
44
46
 
45
47
  # make sure we are loading the env local definition
46
48
  load_dotenv()
49
+ LOGGER = get_logger(__name__)
47
50
 
48
51
 
49
52
  def process_catalog_file(file_path: str, options: Options):
50
- print("Processing catalog:", file_path)
53
+ LOGGER.info(f"Processing catalog: {file_path}")
51
54
  with open(file_path) as f:
52
55
  catalog_config: dict = yaml.load(f, Loader=SafeLoader)
53
56
 
@@ -62,7 +65,7 @@ def process_catalog_file(file_path: str, options: Options):
62
65
  # create full catalog
63
66
  process_collections = catalog_config["collections"]
64
67
  if len(process_collections) == 0:
65
- print("No applicable collections found for catalog, skipping creation")
68
+ LOGGER.info("No applicable collections found for catalog, skipping creation")
66
69
  return
67
70
  catalog = Catalog(
68
71
  id=catalog_config["id"],
@@ -87,7 +90,7 @@ def process_catalog_file(file_path: str, options: Options):
87
90
  options,
88
91
  )
89
92
  else:
90
- print(f"Warning: neither collection nor indicator found for {collection}")
93
+ LOGGER.info(f"Warning: neither collection nor indicator found for {collection}")
91
94
  if "MapProjection" in catalog_config:
92
95
  catalog.extra_fields["eodash:mapProjection"] = catalog_config["MapProjection"]
93
96
 
@@ -95,7 +98,7 @@ def process_catalog_file(file_path: str, options: Options):
95
98
  # expecting that the catalog will be hosted online, self url should correspond to that
96
99
  # default to a local folder + catalog id in case not set
97
100
 
98
- print("Started creation of collection files")
101
+ LOGGER.info("Started creation of collection files")
99
102
  start = time.time()
100
103
  if options.ni:
101
104
  catalog_self_href = f'{options.outputpath}/{catalog_config["id"]}'
@@ -109,15 +112,15 @@ def process_catalog_file(file_path: str, options: Options):
109
112
  catalog.normalize_hrefs(catalog_self_href, strategy=strategy)
110
113
  catalog.save(dest_href="{}/{}".format(options.outputpath, catalog_config["id"]))
111
114
  end = time.time()
112
- print(f"Catalog {catalog_config['id']}: Time consumed in saving: {end - start}")
115
+ LOGGER.info(f"Catalog {catalog_config['id']}: Time consumed in saving: {end - start}")
113
116
 
114
117
  if options.vd:
115
118
  # try to validate catalog if flag was set
116
- print(f"Running validation of catalog {file_path}")
119
+ LOGGER.info(f"Running validation of catalog {file_path}")
117
120
  try:
118
121
  validate_all(catalog.to_dict(), href=catalog_config["endpoint"])
119
122
  except Exception as e:
120
- print(f"Issue validation collection: {e}")
123
+ LOGGER.info(f"Issue validation collection: {e}")
121
124
 
122
125
 
123
126
  def extract_indicator_info(parent_collection: Collection):
@@ -161,7 +164,7 @@ def process_indicator_file(
161
164
  catalog_config: dict, file_path: str, catalog: Catalog, options: Options
162
165
  ):
163
166
  with open(file_path) as f:
164
- print("Processing indicator:", file_path)
167
+ LOGGER.info(f"Processing indicator: {file_path}")
165
168
  indicator_config: dict = yaml.load(f, Loader=SafeLoader)
166
169
  parent_indicator = get_or_create_collection(
167
170
  catalog, indicator_config["Name"], indicator_config, catalog_config, {}
@@ -191,71 +194,79 @@ def process_indicator_file(
191
194
  add_to_catalog(parent_indicator, catalog, {}, indicator_config)
192
195
 
193
196
 
197
+ @retry((Exception), tries=3, delay=5, backoff=2, logger=LOGGER)
194
198
  def process_collection_file(
195
199
  catalog_config: dict, file_path: str, catalog: Catalog | Collection, options: Options
196
200
  ):
197
- print("Processing collection:", file_path)
201
+ LOGGER.info(f"Processing collection: {file_path}")
198
202
  with open(file_path) as f:
199
203
  collection_config: dict = yaml.load(f, Loader=SafeLoader)
200
204
  if "Resources" in collection_config:
201
205
  for endpoint_config in collection_config["Resources"]:
202
- collection = None
203
- if endpoint_config["Name"] == "Sentinel Hub":
204
- collection = handle_SH_endpoint(
205
- catalog_config, endpoint_config, collection_config, catalog, options
206
- )
207
- elif endpoint_config["Name"] == "Sentinel Hub WMS":
208
- collection = handle_SH_WMS_endpoint(
209
- catalog_config, endpoint_config, collection_config, catalog
210
- )
211
- elif endpoint_config["Name"] == "GeoDB":
212
- collection = handle_GeoDB_endpoint(
213
- catalog_config, endpoint_config, collection_config, catalog
214
- )
215
- elif endpoint_config["Name"] == "VEDA":
216
- collection = handle_VEDA_endpoint(
217
- catalog_config, endpoint_config, collection_config, catalog, options
218
- )
219
- elif endpoint_config["Name"] == "marinedatastore":
220
- collection = handle_WMS_endpoint(
221
- catalog_config, endpoint_config, collection_config, catalog, wmts=True
222
- )
223
- elif endpoint_config["Name"] == "xcube":
224
- collection = handle_xcube_endpoint(
225
- catalog_config, endpoint_config, collection_config, catalog
226
- )
227
- elif endpoint_config["Name"] == "WMS":
228
- collection = handle_WMS_endpoint(
229
- catalog_config, endpoint_config, collection_config, catalog
230
- )
231
- elif endpoint_config["Name"] == "JAXA_WMTS_PALSAR":
232
- # somewhat one off creation of individual WMTS layers as individual items
233
- collection = handle_WMS_endpoint(
234
- catalog_config, endpoint_config, collection_config, catalog, wmts=True
235
- )
236
- elif endpoint_config["Name"] == "Collection-only":
237
- collection = handle_collection_only(
238
- catalog_config, endpoint_config, collection_config, catalog
239
- )
240
- elif endpoint_config["Name"] == "Custom-Endpoint":
241
- collection = handle_custom_endpoint(
242
- catalog_config,
243
- endpoint_config,
244
- collection_config,
245
- catalog,
246
- )
247
- elif endpoint_config["Name"] in ["COG source", "GeoJSON source"]:
248
- collection = handle_raw_source(
249
- catalog_config, endpoint_config, collection_config, catalog
250
- )
251
- else:
252
- raise ValueError("Type of Resource is not supported")
253
- if collection:
254
- add_single_item_if_collection_empty(collection)
255
- add_projection_info(endpoint_config, collection)
256
- add_to_catalog(collection, catalog, endpoint_config, collection_config)
257
- else:
258
- raise Exception(f"No collection was generated for resource {endpoint_config}")
206
+ try:
207
+ collection = None
208
+ if endpoint_config["Name"] == "Sentinel Hub":
209
+ collection = handle_SH_endpoint(
210
+ catalog_config, endpoint_config, collection_config, catalog, options
211
+ )
212
+ elif endpoint_config["Name"] == "Sentinel Hub WMS":
213
+ collection = handle_SH_WMS_endpoint(
214
+ catalog_config, endpoint_config, collection_config, catalog
215
+ )
216
+ elif endpoint_config["Name"] == "GeoDB":
217
+ collection = handle_GeoDB_endpoint(
218
+ catalog_config, endpoint_config, collection_config, catalog
219
+ )
220
+ elif endpoint_config["Name"] == "VEDA":
221
+ collection = handle_VEDA_endpoint(
222
+ catalog_config, endpoint_config, collection_config, catalog, options
223
+ )
224
+ elif endpoint_config["Name"] == "marinedatastore":
225
+ collection = handle_WMS_endpoint(
226
+ catalog_config, endpoint_config, collection_config, catalog, wmts=True
227
+ )
228
+ elif endpoint_config["Name"] == "xcube":
229
+ collection = handle_xcube_endpoint(
230
+ catalog_config, endpoint_config, collection_config, catalog
231
+ )
232
+ elif endpoint_config["Name"] == "WMS":
233
+ collection = handle_WMS_endpoint(
234
+ catalog_config, endpoint_config, collection_config, catalog
235
+ )
236
+ elif endpoint_config["Name"] == "JAXA_WMTS_PALSAR":
237
+ # somewhat one off creation of individual WMTS layers as individual items
238
+ collection = handle_WMS_endpoint(
239
+ catalog_config, endpoint_config, collection_config, catalog, wmts=True
240
+ )
241
+ elif endpoint_config["Name"] == "Collection-only":
242
+ collection = handle_collection_only(
243
+ catalog_config, endpoint_config, collection_config, catalog
244
+ )
245
+ elif endpoint_config["Name"] == "Custom-Endpoint":
246
+ collection = handle_custom_endpoint(
247
+ catalog_config,
248
+ endpoint_config,
249
+ collection_config,
250
+ catalog,
251
+ )
252
+ elif endpoint_config["Name"] in ["COG source", "GeoJSON source"]:
253
+ collection = handle_raw_source(
254
+ catalog_config, endpoint_config, collection_config, catalog
255
+ )
256
+ else:
257
+ raise ValueError("Type of Resource is not supported")
258
+ if collection:
259
+ add_single_item_if_collection_empty(collection)
260
+ add_projection_info(endpoint_config, collection)
261
+ add_to_catalog(collection, catalog, endpoint_config, collection_config)
262
+ else:
263
+ raise Exception(
264
+ f"No collection was generated for resource {endpoint_config}"
265
+ )
266
+ except Exception as e:
267
+ LOGGER.warn(f"""Exception: {e.args[0]} with config: {endpoint_config}""")
268
+ raise e
269
+
259
270
  elif "Subcollections" in collection_config:
260
271
  # if no endpoint is specified we check for definition of subcollections
261
272
  parent_collection = get_or_create_collection(
@@ -15,10 +15,13 @@ from pystac import (
15
15
  SpatialExtent,
16
16
  TemporalExtent,
17
17
  )
18
+ from structlog import get_logger
18
19
  from yaml.loader import SafeLoader
19
20
 
20
21
  from eodash_catalog.utils import generateDateIsostringsFromInterval
21
22
 
23
+ LOGGER = get_logger(__name__)
24
+
22
25
 
23
26
  def get_or_create_collection(
24
27
  catalog: Catalog,
@@ -65,7 +68,7 @@ def get_or_create_collection(
65
68
  if response.status_code == 200:
66
69
  description = response.text
67
70
  elif "Subtitle" in collection_config:
68
- print("WARNING: Markdown file could not be fetched")
71
+ LOGGER.warn("Markdown file could not be fetched")
69
72
  description = collection_config["Subtitle"]
70
73
  else:
71
74
  # relative path to assets was given
@@ -73,7 +76,7 @@ def get_or_create_collection(
73
76
  if response.status_code == 200:
74
77
  description = response.text
75
78
  elif "Subtitle" in collection_config:
76
- print("WARNING: Markdown file could not be fetched")
79
+ LOGGER.warn("Markdown file could not be fetched")
77
80
  description = collection_config["Subtitle"]
78
81
  elif "Subtitle" in collection_config:
79
82
  # Try to use at least subtitle to fill some information
@@ -242,10 +245,9 @@ def add_collection_information(
242
245
  )
243
246
  else:
244
247
  # fallback to proprietary
245
- print("WARNING: License could not be parsed, falling back to proprietary")
248
+ LOGGER.warn("License could not be parsed, falling back to proprietary")
246
249
  collection.license = "proprietary"
247
250
  else:
248
- # print("WARNING: No license was provided, falling back to proprietary")
249
251
  pass
250
252
 
251
253
  if "Provider" in collection_config:
@@ -258,7 +260,7 @@ def add_collection_information(
258
260
  for provider in collection_config["Provider"]
259
261
  ]
260
262
  except Exception:
261
- print(f"WARNING: Issue creating provider information for collection: {collection.id}")
263
+ LOGGER.warn(f"Issue creating provider information for collection: {collection.id}")
262
264
 
263
265
  if "Citation" in collection_config:
264
266
  if "DOI" in collection_config["Citation"]:
@@ -1,12 +1,13 @@
1
1
  import os
2
2
  import re
3
3
  import threading
4
+ import time
4
5
  import uuid
5
6
  from collections.abc import Iterator
6
7
  from dataclasses import dataclass
7
8
  from datetime import datetime, timedelta
8
9
  from decimal import Decimal
9
- from functools import reduce
10
+ from functools import reduce, wraps
10
11
  from typing import Any
11
12
 
12
13
  from dateutil import parser
@@ -14,6 +15,7 @@ from owslib.wms import WebMapService
14
15
  from owslib.wmts import WebMapTileService
15
16
  from pystac import Catalog, Collection, Item, RelType
16
17
  from six import string_types
18
+ from structlog import get_logger
17
19
 
18
20
  from eodash_catalog.duration import Duration
19
21
 
@@ -30,6 +32,8 @@ ISO8601_PERIOD_REGEX = re.compile(
30
32
  )
31
33
  # regular expression to parse ISO duartion strings.
32
34
 
35
+ LOGGER = get_logger(__name__)
36
+
33
37
 
34
38
  def create_geojson_point(lon: int | float, lat: int | float) -> dict[str, Any]:
35
39
  point = {"type": "Point", "coordinates": [lon, lat]}
@@ -84,10 +88,10 @@ def retrieveExtentFromWMSWMTS(
84
88
  # get unique times
85
89
  times = reduce(lambda re, x: [*re, x] if x not in re else re, times, [])
86
90
  except Exception as e:
87
- print("Issue extracting information from service capabilities")
91
+ LOGGER.warn("Issue extracting information from service capabilities")
88
92
  template = "An exception of type {0} occurred. Arguments:\n{1!r}"
89
93
  message = template.format(type(e).__name__, e.args)
90
- print(message)
94
+ LOGGER.warn(message)
91
95
 
92
96
  bbox = [-180.0, -90.0, 180.0, 90.0]
93
97
  if service and service[layer].boundingBoxWGS84:
@@ -116,7 +120,6 @@ def parse_duration(datestring):
116
120
  if key not in ("separator", "sign"):
117
121
  if val is None:
118
122
  groups[key] = "0n"
119
- # print groups[key]
120
123
  if key in ("years", "months"):
121
124
  groups[key] = Decimal(groups[key][:-1].replace(",", "."))
122
125
  else:
@@ -268,3 +271,39 @@ def replace_with_env_variables(s: str) -> str:
268
271
 
269
272
  # Use re.sub with the replacement function
270
273
  return re.sub(pattern, replacer, s)
274
+
275
+
276
+ def retry(exceptions, tries=3, delay=2, backoff=1, logger=None):
277
+ """
278
+ Retry decorator for retrying exceptions.
279
+
280
+ :param exceptions: Exception or tuple of exceptions to catch.
281
+ :param tries: Number of attempts. Default is 3.
282
+ :param delay: Initial delay between attempts in seconds. Default is 2.
283
+ :param backoff: Multiplier applied to delay between attempts. Default is 1 (no backoff).
284
+ :param logger: Logger to use. If None, print. Default is None.
285
+ """
286
+
287
+ def decorator(func):
288
+ @wraps(func)
289
+ def wrapper(*args, **kwargs):
290
+ _tries, _delay = tries, delay
291
+ while _tries > 0:
292
+ try:
293
+ return func(*args, **kwargs)
294
+ except exceptions as e:
295
+ _tries -= 1
296
+ if _tries == 0:
297
+ raise
298
+ else:
299
+ msg = f"{e}, Try: {tries-_tries+1}/{tries}, retry in {_delay} seconds..."
300
+ if logger:
301
+ logger.warning(msg)
302
+ else:
303
+ print(msg)
304
+ time.sleep(_delay)
305
+ _delay *= backoff
306
+
307
+ return wrapper
308
+
309
+ return decorator