datacontract-cli 0.10.35__py3-none-any.whl → 0.10.36__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.

Potentially problematic release.


This version of datacontract-cli might be problematic. Click here for more details.

Files changed (34) hide show
  1. datacontract/api.py +1 -1
  2. datacontract/cli.py +1 -1
  3. datacontract/data_contract.py +18 -51
  4. datacontract/engines/data_contract_checks.py +280 -19
  5. datacontract/export/dbt_converter.py +30 -4
  6. datacontract/export/dqx_converter.py +12 -7
  7. datacontract/export/excel_exporter.py +3 -3
  8. datacontract/export/markdown_converter.py +35 -16
  9. datacontract/export/rdf_converter.py +2 -2
  10. datacontract/export/sql_type_converter.py +6 -4
  11. datacontract/imports/odcs_v3_importer.py +71 -18
  12. datacontract/imports/unity_importer.py +16 -11
  13. datacontract/init/init_template.py +1 -1
  14. datacontract/lint/resolve.py +1 -1
  15. datacontract/lint/schema.py +1 -1
  16. datacontract/schemas/datacontract-1.1.0.init.yaml +1 -1
  17. datacontract/schemas/datacontract-1.2.0.init.yaml +1 -1
  18. datacontract/schemas/datacontract-1.2.1.init.yaml +91 -0
  19. datacontract/schemas/datacontract-1.2.1.schema.json +2058 -0
  20. datacontract/schemas/odcs-3.0.2.schema.json +2382 -0
  21. datacontract/templates/datacontract_odcs.html +60 -41
  22. {datacontract_cli-0.10.35.dist-info → datacontract_cli-0.10.36.dist-info}/METADATA +27 -24
  23. {datacontract_cli-0.10.35.dist-info → datacontract_cli-0.10.36.dist-info}/RECORD +27 -31
  24. datacontract/lint/lint.py +0 -142
  25. datacontract/lint/linters/__init__.py +0 -0
  26. datacontract/lint/linters/description_linter.py +0 -33
  27. datacontract/lint/linters/field_pattern_linter.py +0 -34
  28. datacontract/lint/linters/field_reference_linter.py +0 -47
  29. datacontract/lint/linters/notice_period_linter.py +0 -55
  30. datacontract/lint/linters/valid_constraints_linter.py +0 -100
  31. {datacontract_cli-0.10.35.dist-info → datacontract_cli-0.10.36.dist-info}/WHEEL +0 -0
  32. {datacontract_cli-0.10.35.dist-info → datacontract_cli-0.10.36.dist-info}/entry_points.txt +0 -0
  33. {datacontract_cli-0.10.35.dist-info → datacontract_cli-0.10.36.dist-info}/licenses/LICENSE +0 -0
  34. {datacontract_cli-0.10.35.dist-info → datacontract_cli-0.10.36.dist-info}/top_level.txt +0 -0
@@ -61,13 +61,18 @@ def process_quality_rule(rule: Quality, column_name: str) -> Dict[str, Any]:
61
61
  specification = rule_data[DqxKeys.SPECIFICATION]
62
62
  check = specification[DqxKeys.CHECK]
63
63
 
64
- arguments = check.setdefault(DqxKeys.ARGUMENTS, {})
65
-
66
- if DqxKeys.COL_NAME not in arguments and DqxKeys.COL_NAMES not in arguments and DqxKeys.COLUMNS not in arguments:
67
- if check[DqxKeys.FUNCTION] not in ("is_unique", "foreign_key"):
68
- arguments[DqxKeys.COL_NAME] = column_name
69
- else:
70
- arguments[DqxKeys.COLUMNS] = [column_name]
64
+ if column_name:
65
+ arguments = check.setdefault(DqxKeys.ARGUMENTS, {})
66
+
67
+ if (
68
+ DqxKeys.COL_NAME not in arguments
69
+ and DqxKeys.COL_NAMES not in arguments
70
+ and DqxKeys.COLUMNS not in arguments
71
+ ):
72
+ if check[DqxKeys.FUNCTION] not in ("is_unique", "foreign_key"):
73
+ arguments[DqxKeys.COL_NAME] = column_name
74
+ else:
75
+ arguments[DqxKeys.COLUMNS] = [column_name]
71
76
 
72
77
  return specification
73
78
 
@@ -283,7 +283,7 @@ def fill_single_property_template(
283
283
  sheet: Worksheet, row_index: int, prefix: str, property: SchemaProperty, header_map: dict
284
284
  ) -> int:
285
285
  """Fill a single property row using the template's column structure"""
286
- property_name = f"{prefix}.{property.name}" if prefix else property.name
286
+ property_name = f"{prefix}{'.' + property.name if property.name else ''}" if prefix else property.name
287
287
 
288
288
  # Helper function to set cell value by header name
289
289
  def set_by_header(header_name: str, value: Any):
@@ -307,7 +307,7 @@ def fill_single_property_template(
307
307
  set_by_header("Classification", property.classification)
308
308
  set_by_header("Tags", ",".join(property.tags) if property.tags else "")
309
309
  set_by_header(
310
- "Example(s)", ",".join(property.examples) if property.examples else ""
310
+ "Example(s)", ",".join(map(str, property.examples)) if property.examples else ""
311
311
  ) # Note: using "Example(s)" as in template
312
312
  set_by_header("Encrypted Name", property.encryptedName)
313
313
  set_by_header(
@@ -404,7 +404,7 @@ def fill_properties_quality(
404
404
  if not property.name:
405
405
  continue
406
406
 
407
- full_property_name = f"{prefix}.{property.name}" if prefix else property.name
407
+ full_property_name = f"{prefix}{'.' + property.name if property.name else ''}" if prefix else property.name
408
408
 
409
409
  # Add quality attributes for this property
410
410
  if property.quality:
@@ -82,7 +82,7 @@ def obj_attributes_to_markdown(obj: BaseModel, excluded_fields: set = set(), is_
82
82
  if value
83
83
  ]
84
84
  description = f"*{description_to_markdown(description_value)}*"
85
- extra = [extra_to_markdown(obj)] if obj.model_extra else []
85
+ extra = [extra_to_markdown(obj, is_in_table_cell)] if obj.model_extra else []
86
86
  return newline_char.join([description] + attributes + extra)
87
87
 
88
88
 
@@ -293,26 +293,45 @@ def dict_to_markdown(dictionary: Dict[str, str]) -> str:
293
293
  return "\n".join(markdown_parts) + "\n"
294
294
 
295
295
 
296
- def extra_to_markdown(obj: BaseModel) -> str:
296
+ def extra_to_markdown(obj: BaseModel, is_in_table_cell: bool = False) -> str:
297
297
  """
298
298
  Convert the extra attributes of a data contract to Markdown format.
299
299
  Args:
300
300
  obj (BaseModel): The data contract object containing extra attributes.
301
+ is_in_table_cell (bool): Whether the extra attributes are in a table cell.
301
302
  Returns:
302
303
  str: A Markdown formatted string representing the extra attributes of the data contract.
303
304
  """
304
- markdown_part = ""
305
305
  extra = obj.model_extra
306
- if extra:
307
- for key_extra, value_extra in extra.items():
308
- markdown_part += f"\n### {key_extra.capitalize()}\n"
309
- if isinstance(value_extra, list) and len(value_extra):
310
- if isinstance(value_extra[0], dict):
311
- markdown_part += array_of_dict_to_markdown(value_extra)
312
- elif isinstance(value_extra[0], str):
313
- markdown_part += array_to_markdown(value_extra)
314
- elif isinstance(value_extra, dict):
315
- markdown_part += dict_to_markdown(value_extra)
316
- else:
317
- markdown_part += f"{str(value_extra)}\n"
318
- return markdown_part
306
+
307
+ if not extra:
308
+ return ""
309
+
310
+ bullet_char = "•"
311
+ value_line_ending = "" if is_in_table_cell else "\n"
312
+ row_suffix = "<br>" if is_in_table_cell else ""
313
+
314
+ def render_header(key: str) -> str:
315
+ return f"{bullet_char} **{key}:** " if is_in_table_cell else f"\n### {key.capitalize()}\n"
316
+
317
+ parts: list[str] = []
318
+ for key_extra, value_extra in extra.items():
319
+ if not value_extra:
320
+ continue
321
+
322
+ parts.append(render_header(key_extra))
323
+
324
+ if isinstance(value_extra, list) and len(value_extra):
325
+ if isinstance(value_extra[0], dict):
326
+ parts.append(array_of_dict_to_markdown(value_extra))
327
+ elif isinstance(value_extra[0], str):
328
+ parts.append(array_to_markdown(value_extra))
329
+ elif isinstance(value_extra, dict):
330
+ parts.append(dict_to_markdown(value_extra))
331
+ else:
332
+ parts.append(f"{str(value_extra)}{value_line_ending}")
333
+
334
+ if row_suffix:
335
+ parts.append(row_suffix)
336
+
337
+ return "".join(parts)
@@ -57,8 +57,8 @@ def to_rdf(data_contract_spec: DataContractSpecification, base) -> Graph:
57
57
  else:
58
58
  g = Graph(base=Namespace(""))
59
59
 
60
- dc = Namespace("https://datacontract.com/DataContractSpecification/1.2.0/")
61
- dcx = Namespace("https://datacontract.com/DataContractSpecification/1.2.0/Extension/")
60
+ dc = Namespace("https://datacontract.com/DataContractSpecification/1.2.1/")
61
+ dcx = Namespace("https://datacontract.com/DataContractSpecification/1.2.1/Extension/")
62
62
 
63
63
  g.bind("dc", dc)
64
64
  g.bind("dcx", dcx)
@@ -133,8 +133,9 @@ def convert_to_dataframe(field: Field) -> None | str:
133
133
  if type.lower() in ["time"]:
134
134
  return "STRING"
135
135
  if type.lower() in ["number", "decimal", "numeric"]:
136
- # precision and scale not supported by data contract
137
- return "DECIMAL"
136
+ precision = field.precision if field.precision is not None else 38
137
+ scale = field.scale if field.scale is not None else 0
138
+ return f"DECIMAL({precision},{scale})"
138
139
  if type.lower() in ["float"]:
139
140
  return "FLOAT"
140
141
  if type.lower() in ["double"]:
@@ -182,8 +183,9 @@ def convert_to_databricks(field: Field) -> None | str:
182
183
  if type.lower() in ["time"]:
183
184
  return "STRING"
184
185
  if type.lower() in ["number", "decimal", "numeric"]:
185
- # precision and scale not supported by data contract
186
- return "DECIMAL"
186
+ precision = field.precision if field.precision is not None else 38
187
+ scale = field.scale if field.scale is not None else 0
188
+ return f"DECIMAL({precision},{scale})"
187
189
  if type.lower() in ["float"]:
188
190
  return "FLOAT"
189
191
  if type.lower() in ["double"]:
@@ -207,7 +207,11 @@ def import_models(odcs: Any) -> Dict[str, Model]:
207
207
  schema_physical_name = odcs_schema.physicalName
208
208
  schema_description = odcs_schema.description if odcs_schema.description is not None else ""
209
209
  model_name = schema_physical_name if schema_physical_name is not None else schema_name
210
- model = Model(description=" ".join(schema_description.splitlines()) if schema_description else "", type="table")
210
+ model = Model(
211
+ description=" ".join(schema_description.splitlines()) if schema_description else "",
212
+ type="table",
213
+ tags=odcs_schema.tags if odcs_schema.tags is not None else None,
214
+ )
211
215
  model.fields = import_fields(odcs_schema.properties, custom_type_mappings, server_type=get_server_type(odcs))
212
216
  if odcs_schema.quality is not None:
213
217
  model.quality = convert_quality_list(odcs_schema.quality)
@@ -231,6 +235,8 @@ def convert_quality_list(odcs_quality_list):
231
235
  quality.description = odcs_quality.description
232
236
  if odcs_quality.query is not None:
233
237
  quality.query = odcs_quality.query
238
+ if odcs_quality.rule is not None:
239
+ quality.metric = odcs_quality.rule
234
240
  if odcs_quality.mustBe is not None:
235
241
  quality.mustBe = odcs_quality.mustBe
236
242
  if odcs_quality.mustNotBe is not None:
@@ -238,11 +244,11 @@ def convert_quality_list(odcs_quality_list):
238
244
  if odcs_quality.mustBeGreaterThan is not None:
239
245
  quality.mustBeGreaterThan = odcs_quality.mustBeGreaterThan
240
246
  if odcs_quality.mustBeGreaterOrEqualTo is not None:
241
- quality.mustBeGreaterThanOrEqualTo = odcs_quality.mustBeGreaterOrEqualTo
247
+ quality.mustBeGreaterOrEqualTo = odcs_quality.mustBeGreaterOrEqualTo
242
248
  if odcs_quality.mustBeLessThan is not None:
243
249
  quality.mustBeLessThan = odcs_quality.mustBeLessThan
244
250
  if odcs_quality.mustBeLessOrEqualTo is not None:
245
- quality.mustBeLessThanOrEqualTo = odcs_quality.mustBeLessOrEqualTo
251
+ quality.mustBeLessOrEqualTo = odcs_quality.mustBeLessOrEqualTo
246
252
  if odcs_quality.mustBeBetween is not None:
247
253
  quality.mustBeBetween = odcs_quality.mustBeBetween
248
254
  if odcs_quality.mustNotBeBetween is not None:
@@ -255,8 +261,6 @@ def convert_quality_list(odcs_quality_list):
255
261
  quality.model_extra["businessImpact"] = odcs_quality.businessImpact
256
262
  if odcs_quality.dimension is not None:
257
263
  quality.model_extra["dimension"] = odcs_quality.dimension
258
- if odcs_quality.rule is not None:
259
- quality.model_extra["rule"] = odcs_quality.rule
260
264
  if odcs_quality.schedule is not None:
261
265
  quality.model_extra["schedule"] = odcs_quality.schedule
262
266
  if odcs_quality.scheduler is not None:
@@ -330,7 +334,7 @@ def import_fields(
330
334
  return result
331
335
 
332
336
  for odcs_property in odcs_properties:
333
- mapped_type = map_type(odcs_property.logicalType, custom_type_mappings)
337
+ mapped_type = map_type(odcs_property.logicalType, custom_type_mappings, odcs_property.physicalType)
334
338
  if mapped_type is not None:
335
339
  property_name = odcs_property.name
336
340
  description = odcs_property.description if odcs_property.description is not None else None
@@ -373,23 +377,72 @@ def import_fields(
373
377
 
374
378
  result[property_name] = field
375
379
  else:
376
- logger.info(
377
- f"Can't map {odcs_property.name} to the Datacontract Mapping types, as there is no equivalent or special mapping. Consider introducing a customProperty 'dc_mapping_{odcs_property.logicalType}' that defines your expected type as the 'value'"
380
+ type_info = f"logicalType={odcs_property.logicalType}, physicalType={odcs_property.physicalType}"
381
+ logger.warning(
382
+ f"Can't map field '{odcs_property.name}' ({type_info}) to the Datacontract Mapping types. "
383
+ f"Both logicalType and physicalType are missing or unmappable. "
384
+ f"Consider introducing a customProperty 'dc_mapping_<type>' that defines your expected type as the 'value'"
378
385
  )
379
386
 
380
387
  return result
381
388
 
382
389
 
383
- def map_type(odcs_type: str, custom_mappings: Dict[str, str]) -> str | None:
384
- if odcs_type is None:
385
- return None
386
- t = odcs_type.lower()
387
- if t in DATACONTRACT_TYPES:
388
- return t
389
- elif custom_mappings.get(t) is not None:
390
- return custom_mappings.get(t)
391
- else:
392
- return None
390
+ def map_type(odcs_logical_type: str, custom_mappings: Dict[str, str], physical_type: str = None) -> str | None:
391
+ # Try to map logicalType first
392
+ if odcs_logical_type is not None:
393
+ t = odcs_logical_type.lower()
394
+ if t in DATACONTRACT_TYPES:
395
+ return t
396
+ elif custom_mappings.get(t) is not None:
397
+ return custom_mappings.get(t)
398
+
399
+ # Fallback to physicalType if logicalType is not mapped
400
+ if physical_type is not None:
401
+ pt = physical_type.lower()
402
+ # Remove parameters from physical type (e.g., VARCHAR(50) -> varchar, DECIMAL(10,2) -> decimal)
403
+ pt_base = pt.split('(')[0].strip()
404
+
405
+ # Try direct mapping of physical type
406
+ if pt in DATACONTRACT_TYPES:
407
+ return pt
408
+ elif pt_base in DATACONTRACT_TYPES:
409
+ return pt_base
410
+ elif custom_mappings.get(pt) is not None:
411
+ return custom_mappings.get(pt)
412
+ elif custom_mappings.get(pt_base) is not None:
413
+ return custom_mappings.get(pt_base)
414
+ # Common physical type mappings
415
+ elif pt_base in ["varchar", "char", "nvarchar", "nchar", "text", "ntext", "string", "character varying"]:
416
+ return "string"
417
+ elif pt_base in ["int", "integer", "smallint", "tinyint", "mediumint", "int2", "int4", "int8"]:
418
+ return "int"
419
+ elif pt_base in ["bigint", "long", "int64"]:
420
+ return "long"
421
+ elif pt_base in ["float", "real", "float4", "float8"]:
422
+ return "float"
423
+ elif pt_base in ["double", "double precision"]:
424
+ return "double"
425
+ elif pt_base in ["decimal", "numeric", "number"]:
426
+ return "decimal"
427
+ elif pt_base in ["boolean", "bool", "bit"]:
428
+ return "boolean"
429
+ elif pt_base in ["timestamp", "datetime", "datetime2", "timestamptz", "timestamp with time zone"]:
430
+ return "timestamp"
431
+ elif pt_base in ["date"]:
432
+ return "date"
433
+ elif pt_base in ["time"]:
434
+ return "time"
435
+ elif pt_base in ["json", "jsonb"]:
436
+ return "json"
437
+ elif pt_base in ["array"]:
438
+ return "array"
439
+ elif pt_base in ["object", "struct", "record"]:
440
+ return "object"
441
+ elif pt_base in ["bytes", "binary", "varbinary", "blob", "bytea"]:
442
+ return "bytes"
443
+ else:
444
+ return None
445
+ return None
393
446
 
394
447
 
395
448
  def get_custom_type_mappings(odcs_custom_properties: List[CustomProperty]) -> Dict[str, str]:
@@ -88,23 +88,28 @@ def import_unity_from_api(
88
88
  """
89
89
  try:
90
90
  # print(f"Retrieving Unity Catalog schema for table: {unity_table_full_name}")
91
+ profile = os.getenv("DATACONTRACT_DATABRICKS_PROFILE")
91
92
  host, token = os.getenv("DATACONTRACT_DATABRICKS_SERVER_HOSTNAME"), os.getenv("DATACONTRACT_DATABRICKS_TOKEN")
92
93
  # print(f"Databricks host: {host}, token: {'***' if token else 'not set'}")
93
- if not host:
94
- raise DataContractException(
95
- type="configuration",
96
- name="Databricks configuration",
97
- reason="DATACONTRACT_DATABRICKS_SERVER_HOSTNAME environment variable is not set",
98
- engine="datacontract",
99
- )
100
- if not token:
101
- raise DataContractException(
94
+ exception = DataContractException(
102
95
  type="configuration",
103
96
  name="Databricks configuration",
104
- reason="DATACONTRACT_DATABRICKS_TOKEN environment variable is not set",
97
+ reason="",
105
98
  engine="datacontract",
106
99
  )
107
- workspace_client = WorkspaceClient(host=host, token=token)
100
+ if not profile and not host and not token:
101
+ reason = "Either DATACONTRACT_DATABRICKS_PROFILE or both DATACONTRACT_DATABRICKS_SERVER_HOSTNAME and DATACONTRACT_DATABRICKS_TOKEN environment variables must be set"
102
+ exception.reason = reason
103
+ raise exception
104
+ if token and not host:
105
+ reason = "DATACONTRACT_DATABRICKS_SERVER_HOSTNAME environment variable is not set"
106
+ exception.reason = reason
107
+ raise exception
108
+ if host and not token:
109
+ reason = "DATACONTRACT_DATABRICKS_TOKEN environment variable is not set"
110
+ exception.reason = reason
111
+ raise exception
112
+ workspace_client = WorkspaceClient(profile=profile) if profile else WorkspaceClient(host=host, token=token)
108
113
  except Exception as e:
109
114
  raise DataContractException(
110
115
  type="schema",
@@ -3,7 +3,7 @@ import logging
3
3
 
4
4
  import requests
5
5
 
6
- DEFAULT_DATA_CONTRACT_INIT_TEMPLATE = "datacontract-1.2.0.init.yaml"
6
+ DEFAULT_DATA_CONTRACT_INIT_TEMPLATE = "datacontract-1.2.1.init.yaml"
7
7
 
8
8
 
9
9
  def get_init_template(location: str = None) -> str:
@@ -303,7 +303,7 @@ def _resolve_data_contract_from_str(
303
303
  # if ODCS, then validate the ODCS schema and import to DataContractSpecification directly
304
304
  odcs = parse_odcs_v3_from_str(data_contract_str)
305
305
 
306
- data_contract_specification = DataContractSpecification(dataContractSpecification="1.2.0")
306
+ data_contract_specification = DataContractSpecification(dataContractSpecification="1.2.1")
307
307
  return import_from_odcs(data_contract_specification, odcs)
308
308
 
309
309
  logging.info("Importing DCS")
@@ -8,7 +8,7 @@ import requests
8
8
 
9
9
  from datacontract.model.exceptions import DataContractException
10
10
 
11
- DEFAULT_DATA_CONTRACT_SCHEMA = "datacontract-1.2.0.schema.json"
11
+ DEFAULT_DATA_CONTRACT_SCHEMA = "datacontract-1.2.1.schema.json"
12
12
 
13
13
 
14
14
  def fetch_schema(location: str = None) -> Dict[str, Any]:
@@ -1,4 +1,4 @@
1
- dataContractSpecification: 1.2.0
1
+ dataContractSpecification: 1.2.1
2
2
  id: my-data-contract-id
3
3
  info:
4
4
  title: My Data Contract
@@ -1,4 +1,4 @@
1
- dataContractSpecification: 1.2.0
1
+ dataContractSpecification: 1.2.1
2
2
  id: my-data-contract-id
3
3
  info:
4
4
  title: My Data Contract
@@ -0,0 +1,91 @@
1
+ dataContractSpecification: 1.2.1
2
+ id: my-data-contract-id
3
+ info:
4
+ title: My Data Contract
5
+ version: 0.0.1
6
+ # description:
7
+ # owner:
8
+ # contact:
9
+ # name:
10
+ # url:
11
+ # email:
12
+
13
+
14
+ ### servers
15
+
16
+ #servers:
17
+ # production:
18
+ # type: s3
19
+ # location: s3://
20
+ # format: parquet
21
+ # delimiter: new_line
22
+
23
+ ### terms
24
+
25
+ #terms:
26
+ # usage:
27
+ # limitations:
28
+ # billing:
29
+ # noticePeriod:
30
+
31
+
32
+ ### models
33
+
34
+ # models:
35
+ # my_model:
36
+ # description:
37
+ # type:
38
+ # fields:
39
+ # my_field:
40
+ # type:
41
+ # description:
42
+
43
+
44
+ ### definitions
45
+
46
+ # definitions:
47
+ # my_field:
48
+ # domain:
49
+ # name:
50
+ # title:
51
+ # type:
52
+ # description:
53
+ # example:
54
+ # pii:
55
+ # classification:
56
+
57
+
58
+ ### servicelevels
59
+
60
+ #servicelevels:
61
+ # availability:
62
+ # description: The server is available during support hours
63
+ # percentage: 99.9%
64
+ # retention:
65
+ # description: Data is retained for one year because!
66
+ # period: P1Y
67
+ # unlimited: false
68
+ # latency:
69
+ # description: Data is available within 25 hours after the order was placed
70
+ # threshold: 25h
71
+ # sourceTimestampField: orders.order_timestamp
72
+ # processedTimestampField: orders.processed_timestamp
73
+ # freshness:
74
+ # description: The age of the youngest row in a table.
75
+ # threshold: 25h
76
+ # timestampField: orders.order_timestamp
77
+ # frequency:
78
+ # description: Data is delivered once a day
79
+ # type: batch # or streaming
80
+ # interval: daily # for batch, either or cron
81
+ # cron: 0 0 * * * # for batch, either or interval
82
+ # support:
83
+ # description: The data is available during typical business hours at headquarters
84
+ # time: 9am to 5pm in EST on business days
85
+ # responseTime: 1h
86
+ # backup:
87
+ # description: Data is backed up once a week, every Sunday at 0:00 UTC.
88
+ # interval: weekly
89
+ # cron: 0 0 * * 0
90
+ # recoveryTime: 24 hours
91
+ # recoveryPoint: 1 week