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

@@ -1,8 +1,11 @@
1
+ import logging
2
+ import os
1
3
  from typing import Annotated, Optional
2
4
 
3
5
  import typer
4
- from fastapi import Body, FastAPI, Query
6
+ from fastapi import Body, Depends, FastAPI, HTTPException, Query, status
5
7
  from fastapi.responses import PlainTextResponse
8
+ from fastapi.security.api_key import APIKeyHeader
6
9
 
7
10
  from datacontract.data_contract import DataContract, ExportFormat
8
11
  from datacontract.model.run import Run
@@ -55,8 +58,8 @@ models:
55
58
 
56
59
  app = FastAPI(
57
60
  docs_url="/",
58
- title="Data Contract API",
59
- summary="API to execute Data Contract CLI operations.",
61
+ title="Data Contract CLI API",
62
+ summary="You can use the API to test, export, and lint your data contracts.",
60
63
  license_info={
61
64
  "name": "MIT License",
62
65
  "identifier": "MIT",
@@ -87,6 +90,32 @@ app = FastAPI(
87
90
  ],
88
91
  )
89
92
 
93
+ api_key_header = APIKeyHeader(
94
+ name="x-api-key",
95
+ auto_error=False, # this makes authentication optional
96
+ )
97
+
98
+
99
+ def check_api_key(api_key_header: str | None):
100
+ correct_api_key = os.getenv("DATACONTRACT_CLI_API_KEY")
101
+ if correct_api_key is None or correct_api_key == "":
102
+ logging.info("Environment variable DATACONTRACT_CLI_API_KEY is not set. Skip API key check.")
103
+ return
104
+ if api_key_header is None or api_key_header == "":
105
+ logging.info("The API key is missing.")
106
+ raise HTTPException(
107
+ status_code=status.HTTP_401_UNAUTHORIZED,
108
+ detail="Missing API key. Use Header 'x-api-key' to provide the API key.",
109
+ )
110
+ if api_key_header != correct_api_key:
111
+ logging.info("The provided API key is not correct.")
112
+ raise HTTPException(
113
+ status_code=status.HTTP_403_FORBIDDEN,
114
+ detail="The provided API key is not correct.",
115
+ )
116
+ logging.info("Request authenticated with API key.")
117
+ pass
118
+
90
119
 
91
120
  @app.post(
92
121
  "/test",
@@ -98,6 +127,25 @@ app = FastAPI(
98
127
  Credentials must be provided via environment variables when running the web server.
99
128
  POST the data contract YAML as payload.
100
129
  """,
130
+ responses={
131
+ 401: {
132
+ "description": "Unauthorized (when an environment variable DATACONTRACT_CLI_API_KEY is configured).",
133
+ "content": {
134
+ "application/json": {
135
+ "examples": {
136
+ "api_key_missing": {
137
+ "summary": "API key Missing",
138
+ "value": {"detail": "Missing API key. Use Header 'x-api-key' to provide the API key."},
139
+ },
140
+ "api_key_wrong": {
141
+ "summary": "API key Wrong",
142
+ "value": {"detail": "The provided API key is not correct."},
143
+ },
144
+ }
145
+ }
146
+ },
147
+ },
148
+ },
101
149
  response_model_exclude_none=True,
102
150
  response_model_exclude_unset=True,
103
151
  )
@@ -110,6 +158,7 @@ async def test(
110
158
  examples=[DATA_CONTRACT_EXAMPLE_PAYLOAD],
111
159
  ),
112
160
  ],
161
+ api_key: Annotated[str | None, Depends(api_key_header)] = None,
113
162
  server: Annotated[
114
163
  str | None,
115
164
  Query(
@@ -118,6 +167,9 @@ async def test(
118
167
  ),
119
168
  ] = None,
120
169
  ) -> Run:
170
+ check_api_key(api_key)
171
+ logging.info("Testing data contract...")
172
+ logging.info(body)
121
173
  return DataContract(data_contract_str=body, server=server).test()
122
174
 
123
175
 
datacontract/cli.py CHANGED
@@ -196,6 +196,11 @@ def export(
196
196
  Optional[str],
197
197
  typer.Option(help="[engine] The engine used for great expection run."),
198
198
  ] = None,
199
+ # TODO: this should be a subcommand
200
+ template: Annotated[
201
+ Optional[Path],
202
+ typer.Option(help="[custom] The file path of Jinja template."),
203
+ ] = None,
199
204
  ):
200
205
  """
201
206
  Convert data contract to a specific format. Saves to file specified by `output` option if present, otherwise prints to stdout.
@@ -208,6 +213,7 @@ def export(
208
213
  rdf_base=rdf_base,
209
214
  sql_server_type=sql_server_type,
210
215
  engine=engine,
216
+ template=template,
211
217
  )
212
218
  # Don't interpret console markup in output.
213
219
  if output is None:
@@ -344,7 +350,7 @@ def catalog(
344
350
  ] = None,
345
351
  ):
346
352
  """
347
- Create an html catalog of data contracts.
353
+ Create a html catalog of data contracts.
348
354
  """
349
355
  path = Path(output)
350
356
  path.mkdir(parents=True, exist_ok=True)
@@ -433,16 +439,32 @@ def diff(
433
439
 
434
440
 
435
441
  @app.command()
436
- def serve(
442
+ def api(
437
443
  port: Annotated[int, typer.Option(help="Bind socket to this port.")] = 4242,
438
- host: Annotated[str, typer.Option(help="Bind socket to this host.")] = "127.0.0.1",
444
+ host: Annotated[
445
+ str, typer.Option(help="Bind socket to this host. Hint: For running in docker, set it to 0.0.0.0")
446
+ ] = "127.0.0.1",
439
447
  ):
440
448
  """
441
- Start the datacontract web server.
449
+ Start the datacontract CLI as server application with REST API.
450
+
451
+ The OpenAPI documentation as Swagger UI is available on http://localhost:4242.
452
+ You can execute the commands directly from the Swagger UI.
453
+
454
+ To protect the API, you can set the environment variable DATACONTRACT_CLI_API_KEY to a secret API key.
455
+ To authenticate, requests must include the header 'x-api-key' with the correct API key.
456
+ This is highly recommended, as data contract tests may be subject to SQL injections or leak sensitive information.
457
+
458
+ To connect to servers (such as a Snowflake data source), set the credentials as environment variables as documented in
459
+ https://cli.datacontract.com/#test
442
460
  """
443
461
  import uvicorn
462
+ from uvicorn.config import LOGGING_CONFIG
463
+
464
+ log_config = LOGGING_CONFIG
465
+ log_config["root"] = {"level": "INFO"}
444
466
 
445
- uvicorn.run("datacontract.web:app", port=port, host=host, reload=True)
467
+ uvicorn.run(app="datacontract.api:app", port=port, host=host, reload=True, log_config=LOGGING_CONFIG)
446
468
 
447
469
 
448
470
  def _handle_result(run):
@@ -0,0 +1,40 @@
1
+ from pathlib import Path
2
+
3
+ from jinja2 import Environment, FileSystemLoader
4
+
5
+ from datacontract.export.exporter import Exporter
6
+ from datacontract.model.data_contract_specification import (
7
+ DataContractSpecification,
8
+ Model,
9
+ )
10
+
11
+
12
+ class CustomExporter(Exporter):
13
+ """Exporter implementation for converting data contracts to Markdown."""
14
+
15
+ def export(
16
+ self,
17
+ data_contract: DataContractSpecification,
18
+ model: Model,
19
+ server: str,
20
+ sql_server_type: str,
21
+ export_args: dict,
22
+ ) -> str:
23
+ """Exports a data contract to custom format with Jinja."""
24
+ template = export_args.get("template")
25
+ if template is None:
26
+ raise RuntimeError("Export to custom requires template argument.")
27
+
28
+ return to_custom(data_contract, template)
29
+
30
+
31
+ def to_custom(data_contract: DataContractSpecification, template_path: Path) -> str:
32
+ template = get_template(template_path)
33
+ rendered_sql = template.render(data_contract=data_contract)
34
+ return rendered_sql
35
+
36
+
37
+ def get_template(path: Path):
38
+ abosolute_path = Path(path).resolve()
39
+ env = Environment(loader=FileSystemLoader(str(abosolute_path.parent)))
40
+ return env.get_template(path.name)
@@ -45,6 +45,7 @@ class ExportFormat(str, Enum):
45
45
  dcs = "dcs"
46
46
  markdown = "markdown"
47
47
  iceberg = "iceberg"
48
+ custom = "custom"
48
49
 
49
50
  @classmethod
50
51
  def get_supported_formats(cls):
@@ -206,3 +206,7 @@ exporter_factory.register_lazy_exporter(
206
206
  exporter_factory.register_lazy_exporter(
207
207
  name=ExportFormat.iceberg, module_path="datacontract.export.iceberg_converter", class_name="IcebergExporter"
208
208
  )
209
+
210
+ exporter_factory.register_lazy_exporter(
211
+ name=ExportFormat.custom, module_path="datacontract.export.custom_converter", class_name="CustomExporter"
212
+ )
datacontract/lint/urls.py CHANGED
@@ -33,22 +33,22 @@ def _set_api_key(headers, url):
33
33
 
34
34
  if hostname == "datamesh-manager.com" or hostname.endswith(".datamesh-manager.com"):
35
35
  if datamesh_manager_api_key is None or datamesh_manager_api_key == "":
36
- print("Error: Data Mesh Manager API Key is not set. Set env variable DATAMESH_MANAGER_API_KEY.")
36
+ print("Error: Data Mesh Manager API key is not set. Set env variable DATAMESH_MANAGER_API_KEY.")
37
37
  raise DataContractException(
38
38
  type="lint",
39
39
  name=f"Reading data contract from {url}",
40
- reason="Error: Data Mesh Manager API Key is not set. Set env variable DATAMESH_MANAGER_API_KEY.",
40
+ reason="Error: Data Mesh Manager API key is not set. Set env variable DATAMESH_MANAGER_API_KEY.",
41
41
  engine="datacontract",
42
42
  result="error",
43
43
  )
44
44
  headers["x-api-key"] = datamesh_manager_api_key
45
45
  elif hostname == "datacontract-manager.com" or hostname.endswith(".datacontract-manager.com"):
46
46
  if datacontract_manager_api_key is None or datacontract_manager_api_key == "":
47
- print("Error: Data Contract Manager API Key is not set. Set env variable DATACONTRACT_MANAGER_API_KEY.")
47
+ print("Error: Data Contract Manager API key is not set. Set env variable DATACONTRACT_MANAGER_API_KEY.")
48
48
  raise DataContractException(
49
49
  type="lint",
50
50
  name=f"Reading data contract from {url}",
51
- reason="Error: Data Contract Manager API Key is not set. Set env variable DATACONTRACT_MANAGER_API_KEY.",
51
+ reason="Error: Data Contract Manager API key is not set. Set env variable DATACONTRACT_MANAGER_API_KEY.",
52
52
  engine="datacontract",
53
53
  result="error",
54
54
  )
@@ -1,5 +1,5 @@
1
1
  import os
2
- from typing import Any, Dict, List, Optional
2
+ from typing import Any, Dict, List
3
3
 
4
4
  import pydantic as pyd
5
5
  import yaml
@@ -32,9 +32,9 @@ DATACONTRACT_TYPES = [
32
32
 
33
33
 
34
34
  class Contact(pyd.BaseModel):
35
- name: str = None
36
- url: str = None
37
- email: str = None
35
+ name: str | None = None
36
+ url: str | None = None
37
+ email: str | None = None
38
38
 
39
39
  model_config = pyd.ConfigDict(
40
40
  extra="allow",
@@ -42,37 +42,37 @@ class Contact(pyd.BaseModel):
42
42
 
43
43
 
44
44
  class ServerRole(pyd.BaseModel):
45
- name: str = None
46
- description: str = None
45
+ name: str | None = None
46
+ description: str | None = None
47
47
  model_config = pyd.ConfigDict(
48
48
  extra="allow",
49
49
  )
50
50
 
51
51
 
52
52
  class Server(pyd.BaseModel):
53
- type: str = None
54
- description: str = None
55
- environment: str = None
56
- format: str = None
57
- project: str = None
58
- dataset: str = None
59
- path: str = None
60
- delimiter: str = None
61
- endpointUrl: str = None
62
- location: str = None
63
- account: str = None
64
- database: str = None
65
- schema_: str = pyd.Field(default=None, alias="schema")
66
- host: str = None
67
- port: int = None
68
- catalog: str = None
69
- topic: str = None
70
- http_path: str = None # Use ENV variable
71
- token: str = None # Use ENV variable
72
- dataProductId: str = None
73
- outputPortId: str = None
74
- driver: str = None
75
- storageAccount: str = None
53
+ type: str | None = None
54
+ description: str | None = None
55
+ environment: str | None = None
56
+ format: str | None = None
57
+ project: str | None = None
58
+ dataset: str | None = None
59
+ path: str | None = None
60
+ delimiter: str | None = None
61
+ endpointUrl: str | None = None
62
+ location: str | None = None
63
+ account: str | None = None
64
+ database: str | None = None
65
+ schema_: str | None = pyd.Field(default=None, alias="schema")
66
+ host: str | None = None
67
+ port: int | None = None
68
+ catalog: str | None = None
69
+ topic: str | None = None
70
+ http_path: str | None = None # Use ENV variable
71
+ token: str | None = None # Use ENV variable
72
+ dataProductId: str | None = None
73
+ outputPortId: str | None = None
74
+ driver: str | None = None
75
+ storageAccount: str | None = None
76
76
  roles: List[ServerRole] = None
77
77
 
78
78
  model_config = pyd.ConfigDict(
@@ -81,11 +81,11 @@ class Server(pyd.BaseModel):
81
81
 
82
82
 
83
83
  class Terms(pyd.BaseModel):
84
- usage: str = None
85
- limitations: str = None
86
- billing: str = None
87
- noticePeriod: str = None
88
- description: str = None
84
+ usage: str | None = None
85
+ limitations: str | None = None
86
+ billing: str | None = None
87
+ noticePeriod: str | None = None
88
+ description: str | None = None
89
89
 
90
90
  model_config = pyd.ConfigDict(
91
91
  extra="allow",
@@ -93,26 +93,27 @@ class Terms(pyd.BaseModel):
93
93
 
94
94
 
95
95
  class Definition(pyd.BaseModel):
96
- domain: str = None
97
- name: str = None
98
- title: str = None
99
- description: str = None
100
- type: str = None
96
+ domain: str | None = None
97
+ name: str | None = None
98
+ title: str | None = None
99
+ description: str | None = None
100
+ type: str | None = None
101
101
  enum: List[str] = []
102
- format: str = None
103
- minLength: int = None
104
- maxLength: int = None
105
- pattern: str = None
106
- minimum: int = None
107
- exclusiveMinimum: int = None
108
- maximum: int = None
109
- exclusiveMaximum: int = None
110
- pii: bool = None
111
- classification: str = None
102
+ format: str | None = None
103
+ minLength: int | None = None
104
+ maxLength: int | None = None
105
+ pattern: str | None = None
106
+ minimum: int | None = None
107
+ exclusiveMinimum: int | None = None
108
+ maximum: int | None = None
109
+ exclusiveMaximum: int | None = None
110
+ pii: bool | None = None
111
+ classification: str | None = None
112
112
  fields: Dict[str, "Field"] = {}
113
+ items: "Field" = None
113
114
  tags: List[str] = []
114
115
  links: Dict[str, str] = {}
115
- example: str = None
116
+ example: str | None = None
116
117
  examples: List[Any] | None = None
117
118
 
118
119
  model_config = pyd.ConfigDict(
@@ -121,20 +122,20 @@ class Definition(pyd.BaseModel):
121
122
 
122
123
 
123
124
  class Quality(pyd.BaseModel):
124
- type: str = None
125
- description: str = None
126
- query: str = None
127
- dialect: str = None
128
- mustBe: int = None
129
- mustNotBe: int = None
130
- mustBeGreaterThan: int = None
131
- mustBeGreaterThanOrEqualTo: int = None
132
- mustBeLessThan: int = None
133
- mustBeLessThanOrEqualTo: int = None
125
+ type: str | None = None
126
+ description: str | None = None
127
+ query: str | None = None
128
+ dialect: str | None = None
129
+ mustBe: int | None = None
130
+ mustNotBe: int | None = None
131
+ mustBeGreaterThan: int | None = None
132
+ mustBeGreaterThanOrEqualTo: int | None = None
133
+ mustBeLessThan: int | None = None
134
+ mustBeLessThanOrEqualTo: int | None = None
134
135
  mustBeBetween: List[int] = None
135
136
  mustNotBeBetween: List[int] = None
136
- engine: str = None
137
- implementation: str | Dict[str, Any] = None
137
+ engine: str | None = None
138
+ implementation: str | Dict[str, Any] | None = None
138
139
 
139
140
  model_config = pyd.ConfigDict(
140
141
  extra="allow",
@@ -144,26 +145,26 @@ class Quality(pyd.BaseModel):
144
145
  class Field(pyd.BaseModel):
145
146
  ref: str = pyd.Field(default=None, alias="$ref")
146
147
  title: str | None = None
147
- type: str = None
148
- format: str = None
149
- required: bool = None
148
+ type: str | None = None
149
+ format: str | None = None
150
+ required: bool | None = None
150
151
  primary: bool = pyd.Field(
151
152
  default=None,
152
153
  deprecated="Removed in Data Contract Specification v1.1.0. Use primaryKey instead.",
153
154
  )
154
155
  primaryKey: bool | None = None
155
156
  unique: bool | None = None
156
- references: str = None
157
+ references: str | None = None
157
158
  description: str | None = None
158
159
  pii: bool | None = None
159
160
  classification: str | None = None
160
- pattern: str = None
161
- minLength: int = None
162
- maxLength: int = None
163
- minimum: int = None
164
- exclusiveMinimum: int = None
165
- maximum: int = None
166
- exclusiveMaximum: int = None
161
+ pattern: str | None = None
162
+ minLength: int | None = None
163
+ maxLength: int | None = None
164
+ minimum: int | None = None
165
+ exclusiveMinimum: int | None = None
166
+ maximum: int | None = None
167
+ exclusiveMaximum: int | None = None
167
168
  enum: List[str] | None = []
168
169
  tags: List[str] | None = []
169
170
  links: Dict[str, str] = {}
@@ -171,11 +172,11 @@ class Field(pyd.BaseModel):
171
172
  items: "Field" = None
172
173
  keys: "Field" = None
173
174
  values: "Field" = None
174
- precision: int = None
175
- scale: int = None
176
- example: str = pyd.Field(
175
+ precision: int | None = None
176
+ scale: int | None = None
177
+ example: Any | None = pyd.Field(
177
178
  default=None,
178
- deprecated="Removed in Data Contract Specification v1.1.0. Use " "examples instead.",
179
+ deprecated="Removed in Data Contract Specification v1.1.0. Use examples instead.",
179
180
  )
180
181
  examples: List[Any] | None = None
181
182
  quality: List[Quality] | None = []
@@ -187,10 +188,10 @@ class Field(pyd.BaseModel):
187
188
 
188
189
 
189
190
  class Model(pyd.BaseModel):
190
- description: Optional[str] = None
191
- type: Optional[str] = None
192
- namespace: Optional[str] = None
193
- title: Optional[str] = None
191
+ description: str | None = None
192
+ type: str | None = None
193
+ namespace: str | None = None
194
+ title: str | None = None
194
195
  fields: Dict[str, Field] = {}
195
196
  quality: List[Quality] | None = []
196
197
  primaryKey: List[str] | None = []
@@ -204,12 +205,12 @@ class Model(pyd.BaseModel):
204
205
 
205
206
 
206
207
  class Info(pyd.BaseModel):
207
- title: str = None
208
- version: str = None
209
- status: str = None
210
- description: str = None
211
- owner: str = None
212
- contact: Contact = None
208
+ title: str | None = None
209
+ version: str | None = None
210
+ status: str | None = None
211
+ description: str | None = None
212
+ owner: str | None = None
213
+ contact: Contact | None = None
213
214
 
214
215
  model_config = pyd.ConfigDict(
215
216
  extra="allow",
@@ -217,91 +218,91 @@ class Info(pyd.BaseModel):
217
218
 
218
219
 
219
220
  class Example(pyd.BaseModel):
220
- type: str = None
221
- description: str = None
222
- model: str = None
221
+ type: str | None = None
222
+ description: str | None = None
223
+ model: str | None = None
223
224
  data: str | object = None
224
225
 
225
226
 
226
227
  # Deprecated Quality class
227
228
  class DeprecatedQuality(pyd.BaseModel):
228
- type: str = None
229
+ type: str | None = None
229
230
  specification: str | object = None
230
231
 
231
232
 
232
233
  class Availability(pyd.BaseModel):
233
- description: Optional[str] = None
234
- percentage: Optional[str] = None
234
+ description: str | None = None
235
+ percentage: str | None = None
235
236
 
236
237
 
237
238
  class Retention(pyd.BaseModel):
238
- description: Optional[str] = None
239
- period: Optional[str] = None
240
- unlimited: Optional[bool] = None
241
- timestampField: Optional[str] = None
239
+ description: str | None = None
240
+ period: str | None = None
241
+ unlimited: bool | None = None
242
+ timestampField: str | None = None
242
243
 
243
244
 
244
245
  class Latency(pyd.BaseModel):
245
- description: Optional[str] = None
246
- threshold: Optional[str] = None
247
- sourceTimestampField: Optional[str] = None
248
- processedTimestampField: Optional[str] = None
246
+ description: str | None = None
247
+ threshold: str | None = None
248
+ sourceTimestampField: str | None = None
249
+ processedTimestampField: str | None = None
249
250
 
250
251
 
251
252
  class Freshness(pyd.BaseModel):
252
- description: Optional[str] = None
253
- threshold: Optional[str] = None
254
- timestampField: Optional[str] = None
253
+ description: str | None = None
254
+ threshold: str | None = None
255
+ timestampField: str | None = None
255
256
 
256
257
 
257
258
  class Frequency(pyd.BaseModel):
258
- description: Optional[str] = None
259
- type: Optional[str] = None
260
- interval: Optional[str] = None
261
- cron: Optional[str] = None
259
+ description: str | None = None
260
+ type: str | None = None
261
+ interval: str | None = None
262
+ cron: str | None = None
262
263
 
263
264
 
264
265
  class Support(pyd.BaseModel):
265
- description: Optional[str] = None
266
- time: Optional[str] = None
267
- responseTime: Optional[str] = None
266
+ description: str | None = None
267
+ time: str | None = None
268
+ responseTime: str | None = None
268
269
 
269
270
 
270
271
  class Backup(pyd.BaseModel):
271
- description: Optional[str] = None
272
- interval: Optional[str] = None
273
- cron: Optional[str] = None
274
- recoveryTime: Optional[str] = None
275
- recoveryPoint: Optional[str] = None
272
+ description: str | None = None
273
+ interval: str | None = None
274
+ cron: str | None = None
275
+ recoveryTime: str | None = None
276
+ recoveryPoint: str | None = None
276
277
 
277
278
 
278
279
  class ServiceLevel(pyd.BaseModel):
279
- availability: Optional[Availability] = None
280
- retention: Optional[Retention] = None
281
- latency: Optional[Latency] = None
282
- freshness: Optional[Freshness] = None
283
- frequency: Optional[Frequency] = None
284
- support: Optional[Support] = None
285
- backup: Optional[Backup] = None
280
+ availability: Availability | None = None
281
+ retention: Retention | None = None
282
+ latency: Latency | None = None
283
+ freshness: Freshness | None = None
284
+ frequency: Frequency | None = None
285
+ support: Support | None = None
286
+ backup: Backup | None = None
286
287
 
287
288
 
288
289
  class DataContractSpecification(pyd.BaseModel):
289
- dataContractSpecification: str = None
290
- id: str = None
291
- info: Info = None
290
+ dataContractSpecification: str | None = None
291
+ id: str | None = None
292
+ info: Info | None = None
292
293
  servers: Dict[str, Server] = {}
293
- terms: Terms = None
294
+ terms: Terms | None = None
294
295
  models: Dict[str, Model] = {}
295
296
  definitions: Dict[str, Definition] = {}
296
297
  examples: List[Example] = pyd.Field(
297
298
  default_factory=list,
298
299
  deprecated="Removed in Data Contract Specification " "v1.1.0. Use models.examples instead.",
299
300
  )
300
- quality: DeprecatedQuality = pyd.Field(
301
+ quality: DeprecatedQuality | None = pyd.Field(
301
302
  default=None,
302
303
  deprecated="Removed in Data Contract Specification v1.1.0. Use " "model-level and field-level quality instead.",
303
304
  )
304
- servicelevels: Optional[ServiceLevel] = None
305
+ servicelevels: ServiceLevel | None = None
305
306
  links: Dict[str, str] = {}
306
307
  tags: List[str] = []
307
308