datacontract-cli 0.10.0__py3-none-any.whl → 0.10.37__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.
- datacontract/__init__.py +13 -0
- datacontract/api.py +260 -0
- datacontract/breaking/breaking.py +242 -12
- datacontract/breaking/breaking_rules.py +37 -1
- datacontract/catalog/catalog.py +80 -0
- datacontract/cli.py +387 -117
- datacontract/data_contract.py +216 -353
- datacontract/engines/data_contract_checks.py +1041 -0
- datacontract/engines/data_contract_test.py +113 -0
- datacontract/engines/datacontract/check_that_datacontract_contains_valid_servers_configuration.py +2 -3
- datacontract/engines/datacontract/check_that_datacontract_file_exists.py +1 -1
- datacontract/engines/fastjsonschema/check_jsonschema.py +176 -42
- datacontract/engines/fastjsonschema/s3/s3_read_files.py +16 -1
- datacontract/engines/soda/check_soda_execute.py +100 -56
- datacontract/engines/soda/connections/athena.py +79 -0
- datacontract/engines/soda/connections/bigquery.py +8 -1
- datacontract/engines/soda/connections/databricks.py +12 -3
- datacontract/engines/soda/connections/duckdb_connection.py +241 -0
- datacontract/engines/soda/connections/kafka.py +206 -113
- datacontract/engines/soda/connections/snowflake.py +8 -5
- datacontract/engines/soda/connections/sqlserver.py +43 -0
- datacontract/engines/soda/connections/trino.py +26 -0
- datacontract/export/avro_converter.py +72 -8
- datacontract/export/avro_idl_converter.py +31 -25
- datacontract/export/bigquery_converter.py +130 -0
- datacontract/export/custom_converter.py +40 -0
- datacontract/export/data_caterer_converter.py +161 -0
- datacontract/export/dbml_converter.py +148 -0
- datacontract/export/dbt_converter.py +141 -54
- datacontract/export/dcs_exporter.py +6 -0
- datacontract/export/dqx_converter.py +126 -0
- datacontract/export/duckdb_type_converter.py +57 -0
- datacontract/export/excel_exporter.py +923 -0
- datacontract/export/exporter.py +100 -0
- datacontract/export/exporter_factory.py +216 -0
- datacontract/export/go_converter.py +105 -0
- datacontract/export/great_expectations_converter.py +257 -36
- datacontract/export/html_exporter.py +86 -0
- datacontract/export/iceberg_converter.py +188 -0
- datacontract/export/jsonschema_converter.py +71 -16
- datacontract/export/markdown_converter.py +337 -0
- datacontract/export/mermaid_exporter.py +110 -0
- datacontract/export/odcs_v3_exporter.py +375 -0
- datacontract/export/pandas_type_converter.py +40 -0
- datacontract/export/protobuf_converter.py +168 -68
- datacontract/export/pydantic_converter.py +6 -0
- datacontract/export/rdf_converter.py +13 -6
- datacontract/export/sodacl_converter.py +36 -188
- datacontract/export/spark_converter.py +245 -0
- datacontract/export/sql_converter.py +37 -3
- datacontract/export/sql_type_converter.py +269 -8
- datacontract/export/sqlalchemy_converter.py +170 -0
- datacontract/export/terraform_converter.py +7 -2
- datacontract/imports/avro_importer.py +246 -26
- datacontract/imports/bigquery_importer.py +221 -0
- datacontract/imports/csv_importer.py +143 -0
- datacontract/imports/dbml_importer.py +112 -0
- datacontract/imports/dbt_importer.py +240 -0
- datacontract/imports/excel_importer.py +1111 -0
- datacontract/imports/glue_importer.py +288 -0
- datacontract/imports/iceberg_importer.py +172 -0
- datacontract/imports/importer.py +51 -0
- datacontract/imports/importer_factory.py +128 -0
- datacontract/imports/json_importer.py +325 -0
- datacontract/imports/jsonschema_importer.py +146 -0
- datacontract/imports/odcs_importer.py +60 -0
- datacontract/imports/odcs_v3_importer.py +516 -0
- datacontract/imports/parquet_importer.py +81 -0
- datacontract/imports/protobuf_importer.py +264 -0
- datacontract/imports/spark_importer.py +262 -0
- datacontract/imports/sql_importer.py +274 -35
- datacontract/imports/unity_importer.py +219 -0
- datacontract/init/init_template.py +20 -0
- datacontract/integration/datamesh_manager.py +86 -0
- datacontract/lint/resolve.py +271 -49
- datacontract/lint/resources.py +21 -0
- datacontract/lint/schema.py +53 -17
- datacontract/lint/urls.py +32 -12
- datacontract/model/data_contract_specification/__init__.py +1 -0
- datacontract/model/exceptions.py +4 -1
- datacontract/model/odcs.py +24 -0
- datacontract/model/run.py +49 -29
- datacontract/output/__init__.py +0 -0
- datacontract/output/junit_test_results.py +135 -0
- datacontract/output/output_format.py +10 -0
- datacontract/output/test_results_writer.py +79 -0
- datacontract/py.typed +0 -0
- datacontract/schemas/datacontract-1.1.0.init.yaml +91 -0
- datacontract/schemas/datacontract-1.1.0.schema.json +1975 -0
- datacontract/schemas/datacontract-1.2.0.init.yaml +91 -0
- datacontract/schemas/datacontract-1.2.0.schema.json +2029 -0
- datacontract/schemas/datacontract-1.2.1.init.yaml +91 -0
- datacontract/schemas/datacontract-1.2.1.schema.json +2058 -0
- datacontract/schemas/odcs-3.0.1.schema.json +2634 -0
- datacontract/schemas/odcs-3.0.2.schema.json +2382 -0
- datacontract/templates/datacontract.html +139 -294
- datacontract/templates/datacontract_odcs.html +685 -0
- datacontract/templates/index.html +236 -0
- datacontract/templates/partials/datacontract_information.html +86 -0
- datacontract/templates/partials/datacontract_servicelevels.html +253 -0
- datacontract/templates/partials/datacontract_terms.html +51 -0
- datacontract/templates/partials/definition.html +25 -0
- datacontract/templates/partials/example.html +27 -0
- datacontract/templates/partials/model_field.html +144 -0
- datacontract/templates/partials/quality.html +49 -0
- datacontract/templates/partials/server.html +211 -0
- datacontract/templates/style/output.css +491 -72
- datacontract_cli-0.10.37.dist-info/METADATA +2235 -0
- datacontract_cli-0.10.37.dist-info/RECORD +119 -0
- {datacontract_cli-0.10.0.dist-info → datacontract_cli-0.10.37.dist-info}/WHEEL +1 -1
- {datacontract_cli-0.10.0.dist-info → datacontract_cli-0.10.37.dist-info/licenses}/LICENSE +1 -1
- datacontract/engines/datacontract/check_that_datacontract_str_is_valid.py +0 -48
- datacontract/engines/soda/connections/dask.py +0 -28
- datacontract/engines/soda/connections/duckdb.py +0 -76
- datacontract/export/csv_type_converter.py +0 -36
- datacontract/export/html_export.py +0 -66
- datacontract/export/odcs_converter.py +0 -102
- datacontract/init/download_datacontract_file.py +0 -17
- datacontract/integration/publish_datamesh_manager.py +0 -33
- datacontract/integration/publish_opentelemetry.py +0 -107
- datacontract/lint/lint.py +0 -141
- datacontract/lint/linters/description_linter.py +0 -34
- datacontract/lint/linters/example_model_linter.py +0 -91
- datacontract/lint/linters/field_pattern_linter.py +0 -34
- datacontract/lint/linters/field_reference_linter.py +0 -38
- datacontract/lint/linters/notice_period_linter.py +0 -55
- datacontract/lint/linters/quality_schema_linter.py +0 -52
- datacontract/lint/linters/valid_constraints_linter.py +0 -99
- datacontract/model/data_contract_specification.py +0 -141
- datacontract/web.py +0 -14
- datacontract_cli-0.10.0.dist-info/METADATA +0 -951
- datacontract_cli-0.10.0.dist-info/RECORD +0 -66
- /datacontract/{model → breaking}/breaking_change.py +0 -0
- /datacontract/{lint/linters → export}/__init__.py +0 -0
- {datacontract_cli-0.10.0.dist-info → datacontract_cli-0.10.37.dist-info}/entry_points.txt +0 -0
- {datacontract_cli-0.10.0.dist-info → datacontract_cli-0.10.37.dist-info}/top_level.txt +0 -0
datacontract/__init__.py
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Configuration so that yaml.safe_dump dumps strings with line breaks with yaml literal |
|
|
2
|
+
import yaml
|
|
3
|
+
|
|
4
|
+
yaml.SafeDumper.org_represent_str = yaml.SafeDumper.represent_str
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def repr_str(dumper, data):
|
|
8
|
+
if "\n" in data:
|
|
9
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
|
10
|
+
return dumper.org_represent_str(data)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
yaml.add_representer(str, repr_str, Dumper=yaml.SafeDumper)
|
datacontract/api.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Annotated, Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from fastapi import Body, Depends, FastAPI, HTTPException, Query, status
|
|
7
|
+
from fastapi.responses import PlainTextResponse
|
|
8
|
+
from fastapi.security.api_key import APIKeyHeader
|
|
9
|
+
|
|
10
|
+
from datacontract.data_contract import DataContract, ExportFormat
|
|
11
|
+
from datacontract.model.run import Run
|
|
12
|
+
|
|
13
|
+
DATA_CONTRACT_EXAMPLE_PAYLOAD = """dataContractSpecification: 1.2.1
|
|
14
|
+
id: urn:datacontract:checkout:orders-latest
|
|
15
|
+
info:
|
|
16
|
+
title: Orders Latest
|
|
17
|
+
version: 2.0.0
|
|
18
|
+
owner: Sales Team
|
|
19
|
+
servers:
|
|
20
|
+
production:
|
|
21
|
+
type: s3
|
|
22
|
+
location: s3://datacontract-example-orders-latest/v2/{model}/*.json
|
|
23
|
+
format: json
|
|
24
|
+
delimiter: new_line
|
|
25
|
+
models:
|
|
26
|
+
orders:
|
|
27
|
+
description: One record per order. Includes cancelled and deleted orders.
|
|
28
|
+
type: table
|
|
29
|
+
fields:
|
|
30
|
+
order_id:
|
|
31
|
+
type: string
|
|
32
|
+
primaryKey: true
|
|
33
|
+
order_timestamp:
|
|
34
|
+
description: The business timestamp in UTC when the order was successfully registered in the source system and the payment was successful.
|
|
35
|
+
type: timestamp
|
|
36
|
+
required: true
|
|
37
|
+
examples:
|
|
38
|
+
- "2024-09-09T08:30:00Z"
|
|
39
|
+
order_total:
|
|
40
|
+
description: Total amount the smallest monetary unit (e.g., cents).
|
|
41
|
+
type: long
|
|
42
|
+
required: true
|
|
43
|
+
examples:
|
|
44
|
+
- 9999
|
|
45
|
+
quality:
|
|
46
|
+
- type: sql
|
|
47
|
+
description: 95% of all order total values are expected to be between 10 and 499 EUR.
|
|
48
|
+
query: |
|
|
49
|
+
SELECT quantile_cont(order_total, 0.95) AS percentile_95
|
|
50
|
+
FROM orders
|
|
51
|
+
mustBeBetween: [1000, 99900]
|
|
52
|
+
customer_id:
|
|
53
|
+
description: Unique identifier for the customer.
|
|
54
|
+
type: text
|
|
55
|
+
minLength: 10
|
|
56
|
+
maxLength: 20
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
app = FastAPI(
|
|
60
|
+
docs_url="/",
|
|
61
|
+
title="Data Contract CLI API",
|
|
62
|
+
summary="You can use the API to test, export, and lint your data contracts.",
|
|
63
|
+
license_info={
|
|
64
|
+
"name": "MIT License",
|
|
65
|
+
"identifier": "MIT",
|
|
66
|
+
},
|
|
67
|
+
contact={"name": "Data Contract CLI", "url": "https://cli.datacontract.com/"},
|
|
68
|
+
openapi_tags=[
|
|
69
|
+
{
|
|
70
|
+
"name": "test",
|
|
71
|
+
"externalDocs": {
|
|
72
|
+
"description": "Documentation",
|
|
73
|
+
"url": "https://cli.datacontract.com/#test",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "lint",
|
|
78
|
+
"externalDocs": {
|
|
79
|
+
"description": "Documentation",
|
|
80
|
+
"url": "https://cli.datacontract.com/#lint",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name": "export",
|
|
85
|
+
"externalDocs": {
|
|
86
|
+
"description": "Documentation",
|
|
87
|
+
"url": "https://cli.datacontract.com/#export",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
)
|
|
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
|
+
|
|
119
|
+
|
|
120
|
+
@app.post(
|
|
121
|
+
"/test",
|
|
122
|
+
tags=["test"],
|
|
123
|
+
summary="Run data contract tests",
|
|
124
|
+
description="""
|
|
125
|
+
Run schema and quality tests. Data Contract CLI connects to the data sources configured in the server section.
|
|
126
|
+
This usually requires credentials to access the data sources.
|
|
127
|
+
Credentials must be provided via environment variables when running the web server.
|
|
128
|
+
POST the data contract YAML as payload.
|
|
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
|
+
},
|
|
149
|
+
response_model_exclude_none=True,
|
|
150
|
+
response_model_exclude_unset=True,
|
|
151
|
+
)
|
|
152
|
+
async def test(
|
|
153
|
+
body: Annotated[
|
|
154
|
+
str,
|
|
155
|
+
Body(
|
|
156
|
+
title="Data Contract YAML",
|
|
157
|
+
media_type="application/yaml",
|
|
158
|
+
examples=[DATA_CONTRACT_EXAMPLE_PAYLOAD],
|
|
159
|
+
),
|
|
160
|
+
],
|
|
161
|
+
api_key: Annotated[str | None, Depends(api_key_header)] = None,
|
|
162
|
+
server: Annotated[
|
|
163
|
+
str | None,
|
|
164
|
+
Query(
|
|
165
|
+
description="The server name to test. Optional, if there is only one server.",
|
|
166
|
+
examples=["production"],
|
|
167
|
+
),
|
|
168
|
+
] = None,
|
|
169
|
+
publish_url: Annotated[
|
|
170
|
+
str | None,
|
|
171
|
+
Query(
|
|
172
|
+
description="URL to publish test results. Optional, if you want to publish the test results to a Data Mesh Manager or Data Contract Manager. Example: https://api.datamesh-manager.com/api/test-results",
|
|
173
|
+
examples=["https://api.datamesh-manager.com/api/test-results"],
|
|
174
|
+
),
|
|
175
|
+
] = None,
|
|
176
|
+
) -> Run:
|
|
177
|
+
check_api_key(api_key)
|
|
178
|
+
logging.info("Testing data contract...")
|
|
179
|
+
logging.info(body)
|
|
180
|
+
return DataContract(data_contract_str=body, server=server, publish_url=publish_url).test()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.post(
|
|
184
|
+
"/lint",
|
|
185
|
+
tags=["lint"],
|
|
186
|
+
summary="Validate that the datacontract.yaml is correctly formatted.",
|
|
187
|
+
description="""Validate that the datacontract.yaml is correctly formatted.""",
|
|
188
|
+
)
|
|
189
|
+
async def lint(
|
|
190
|
+
body: Annotated[
|
|
191
|
+
str,
|
|
192
|
+
Body(
|
|
193
|
+
title="Data Contract YAML",
|
|
194
|
+
media_type="application/yaml",
|
|
195
|
+
examples=[DATA_CONTRACT_EXAMPLE_PAYLOAD],
|
|
196
|
+
),
|
|
197
|
+
],
|
|
198
|
+
schema: Annotated[
|
|
199
|
+
str | None,
|
|
200
|
+
Query(
|
|
201
|
+
examples=["https://datacontract.com/datacontract.schema.json"],
|
|
202
|
+
description="The schema to use for validation. This must be a URL.",
|
|
203
|
+
),
|
|
204
|
+
] = None,
|
|
205
|
+
):
|
|
206
|
+
data_contract = DataContract(data_contract_str=body, schema_location=schema)
|
|
207
|
+
lint_result = data_contract.lint()
|
|
208
|
+
return {"result": lint_result.result, "checks": lint_result.checks}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.post(
|
|
212
|
+
"/export",
|
|
213
|
+
tags=["export"],
|
|
214
|
+
summary="Convert data contract to a specific format.",
|
|
215
|
+
response_class=PlainTextResponse,
|
|
216
|
+
)
|
|
217
|
+
def export(
|
|
218
|
+
body: Annotated[
|
|
219
|
+
str,
|
|
220
|
+
Body(
|
|
221
|
+
title="Data Contract YAML",
|
|
222
|
+
media_type="application/yaml",
|
|
223
|
+
examples=[DATA_CONTRACT_EXAMPLE_PAYLOAD],
|
|
224
|
+
),
|
|
225
|
+
],
|
|
226
|
+
format: Annotated[ExportFormat, typer.Option(help="The export format.")],
|
|
227
|
+
server: Annotated[
|
|
228
|
+
str | None,
|
|
229
|
+
Query(
|
|
230
|
+
examples=["production"],
|
|
231
|
+
description="The server name to export. Optional, if there is only one server.",
|
|
232
|
+
),
|
|
233
|
+
] = None,
|
|
234
|
+
model: Annotated[
|
|
235
|
+
str | None,
|
|
236
|
+
Query(
|
|
237
|
+
description="Use the key of the model in the data contract yaml file "
|
|
238
|
+
"to refer to a model, e.g., `orders`, or `all` for all "
|
|
239
|
+
"models (default).",
|
|
240
|
+
),
|
|
241
|
+
] = "all",
|
|
242
|
+
rdf_base: Annotated[
|
|
243
|
+
Optional[str],
|
|
244
|
+
typer.Option(help="[rdf] The base URI used to generate the RDF graph.", rich_help_panel="RDF Options"),
|
|
245
|
+
] = None,
|
|
246
|
+
sql_server_type: Annotated[
|
|
247
|
+
Optional[str],
|
|
248
|
+
Query(
|
|
249
|
+
description="[sql] The server type to determine the sql dialect. By default, it uses 'auto' to automatically detect the sql dialect via the specified servers in the data contract.",
|
|
250
|
+
),
|
|
251
|
+
] = None,
|
|
252
|
+
):
|
|
253
|
+
result = DataContract(data_contract_str=body, server=server).export(
|
|
254
|
+
export_format=format,
|
|
255
|
+
model=model,
|
|
256
|
+
rdf_base=rdf_base,
|
|
257
|
+
sql_server_type=sql_server_type,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return result
|
|
@@ -1,11 +1,223 @@
|
|
|
1
|
+
from datacontract.breaking.breaking_change import BreakingChange, Location, Severity
|
|
1
2
|
from datacontract.breaking.breaking_rules import BreakingRules
|
|
2
|
-
from datacontract.model.
|
|
3
|
-
|
|
3
|
+
from datacontract.model.data_contract_specification import Contact, DeprecatedQuality, Field, Info, Model, Terms
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def info_breaking_changes(
|
|
7
|
+
old_info: Info,
|
|
8
|
+
new_info: Info,
|
|
9
|
+
new_path: str,
|
|
10
|
+
include_severities: [Severity],
|
|
11
|
+
) -> list[BreakingChange]:
|
|
12
|
+
results = list[BreakingChange]()
|
|
13
|
+
|
|
14
|
+
composition = ["info"]
|
|
15
|
+
|
|
16
|
+
if old_info and new_info:
|
|
17
|
+
info_definition_fields = vars(new_info) | new_info.model_extra | old_info.model_extra
|
|
18
|
+
|
|
19
|
+
for info_definition_field in info_definition_fields.keys():
|
|
20
|
+
if info_definition_field == "contact":
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
old_value = getattr(old_info, info_definition_field, None)
|
|
24
|
+
new_value = getattr(new_info, info_definition_field, None)
|
|
25
|
+
|
|
26
|
+
rule_name = None
|
|
27
|
+
description = None
|
|
28
|
+
|
|
29
|
+
if old_value is None and new_value is not None:
|
|
30
|
+
rule_name = f"info_{_camel_to_snake(info_definition_field)}_added"
|
|
31
|
+
description = f"added with value: `{new_value}`"
|
|
32
|
+
|
|
33
|
+
elif old_value is not None and new_value is None:
|
|
34
|
+
rule_name = f"info_{_camel_to_snake(info_definition_field)}_removed"
|
|
35
|
+
description = "removed info property"
|
|
36
|
+
|
|
37
|
+
elif old_value != new_value:
|
|
38
|
+
rule_name = f"info_{_camel_to_snake(info_definition_field)}_updated"
|
|
39
|
+
description = f"changed from `{old_value}` to `{new_value}`"
|
|
40
|
+
|
|
41
|
+
if rule_name is not None:
|
|
42
|
+
severity = _get_rule(rule_name)
|
|
43
|
+
if severity in include_severities:
|
|
44
|
+
results.append(
|
|
45
|
+
BreakingChange(
|
|
46
|
+
description=description,
|
|
47
|
+
check_name=rule_name,
|
|
48
|
+
severity=severity,
|
|
49
|
+
location=Location(path=new_path, composition=composition + [info_definition_field]),
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
results.extend(
|
|
54
|
+
contact_breaking_changes(
|
|
55
|
+
old_contact=getattr(old_info, "contact", None),
|
|
56
|
+
new_contact=getattr(new_info, "contact", None),
|
|
57
|
+
composition=composition + ["contact"],
|
|
58
|
+
new_path=new_path,
|
|
59
|
+
include_severities=include_severities,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return results
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def contact_breaking_changes(
|
|
67
|
+
old_contact: Contact,
|
|
68
|
+
new_contact: Contact,
|
|
69
|
+
composition: list[str],
|
|
70
|
+
new_path: str,
|
|
71
|
+
include_severities: [Severity],
|
|
72
|
+
) -> list[BreakingChange]:
|
|
73
|
+
results = list[BreakingChange]()
|
|
74
|
+
|
|
75
|
+
if not old_contact and new_contact:
|
|
76
|
+
rule_name = "contact_added"
|
|
77
|
+
severity = _get_rule(rule_name)
|
|
78
|
+
description = "added contact"
|
|
79
|
+
|
|
80
|
+
if severity in include_severities:
|
|
81
|
+
results.append(
|
|
82
|
+
BreakingChange(
|
|
83
|
+
description=description,
|
|
84
|
+
check_name=rule_name,
|
|
85
|
+
severity=severity,
|
|
86
|
+
location=Location(path=new_path, composition=composition),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
elif old_contact and not new_contact:
|
|
91
|
+
rule_name = "contact_removed"
|
|
92
|
+
severity = _get_rule(rule_name)
|
|
93
|
+
description = "removed contact"
|
|
94
|
+
|
|
95
|
+
if severity in include_severities:
|
|
96
|
+
results.append(
|
|
97
|
+
BreakingChange(
|
|
98
|
+
description=description,
|
|
99
|
+
check_name=rule_name,
|
|
100
|
+
severity=severity,
|
|
101
|
+
location=Location(path=new_path, composition=composition),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
elif old_contact and new_contact:
|
|
106
|
+
contact_definition_fields = vars(new_contact) | new_contact.model_extra | old_contact.model_extra
|
|
107
|
+
|
|
108
|
+
for contact_definition_field in contact_definition_fields.keys():
|
|
109
|
+
old_value = getattr(old_contact, contact_definition_field, None)
|
|
110
|
+
new_value = getattr(new_contact, contact_definition_field, None)
|
|
111
|
+
|
|
112
|
+
rule_name = None
|
|
113
|
+
description = None
|
|
114
|
+
|
|
115
|
+
if old_value is None and new_value is not None:
|
|
116
|
+
rule_name = f"contact_{_camel_to_snake(contact_definition_field)}_added"
|
|
117
|
+
description = f"added with value: `{new_value}`"
|
|
118
|
+
|
|
119
|
+
elif old_value is not None and new_value is None:
|
|
120
|
+
rule_name = f"contact_{_camel_to_snake(contact_definition_field)}_removed"
|
|
121
|
+
description = "removed contact property"
|
|
122
|
+
|
|
123
|
+
elif old_value != new_value:
|
|
124
|
+
rule_name = f"contact_{_camel_to_snake(contact_definition_field)}_updated"
|
|
125
|
+
description = f"changed from `{old_value}` to `{new_value}`"
|
|
126
|
+
|
|
127
|
+
if rule_name is not None:
|
|
128
|
+
severity = _get_rule(rule_name)
|
|
129
|
+
if severity in include_severities:
|
|
130
|
+
results.append(
|
|
131
|
+
BreakingChange(
|
|
132
|
+
description=description,
|
|
133
|
+
check_name=rule_name,
|
|
134
|
+
severity=severity,
|
|
135
|
+
location=Location(path=new_path, composition=composition + [contact_definition_field]),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return results
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def terms_breaking_changes(
|
|
143
|
+
old_terms: Terms,
|
|
144
|
+
new_terms: Terms,
|
|
145
|
+
new_path: str,
|
|
146
|
+
include_severities: [Severity],
|
|
147
|
+
) -> list[BreakingChange]:
|
|
148
|
+
results = list[BreakingChange]()
|
|
149
|
+
|
|
150
|
+
composition = ["terms"]
|
|
151
|
+
|
|
152
|
+
if not old_terms and new_terms:
|
|
153
|
+
rule_name = "terms_added"
|
|
154
|
+
severity = _get_rule(rule_name)
|
|
155
|
+
description = "added terms"
|
|
156
|
+
|
|
157
|
+
if severity in include_severities:
|
|
158
|
+
results.append(
|
|
159
|
+
BreakingChange(
|
|
160
|
+
description=description,
|
|
161
|
+
check_name=rule_name,
|
|
162
|
+
severity=severity,
|
|
163
|
+
location=Location(path=new_path, composition=composition),
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
elif old_terms and not new_terms:
|
|
167
|
+
rule_name = "terms_removed"
|
|
168
|
+
severity = _get_rule(rule_name)
|
|
169
|
+
description = "removed terms"
|
|
170
|
+
|
|
171
|
+
if severity in include_severities:
|
|
172
|
+
results.append(
|
|
173
|
+
BreakingChange(
|
|
174
|
+
description=description,
|
|
175
|
+
check_name=rule_name,
|
|
176
|
+
severity=severity,
|
|
177
|
+
location=Location(path=new_path, composition=composition),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if old_terms and new_terms:
|
|
182
|
+
terms_definition_fields = vars(new_terms) | new_terms.model_extra | old_terms.model_extra
|
|
183
|
+
|
|
184
|
+
for terms_definition_field in terms_definition_fields.keys():
|
|
185
|
+
old_value = getattr(old_terms, terms_definition_field, None)
|
|
186
|
+
new_value = getattr(new_terms, terms_definition_field, None)
|
|
187
|
+
|
|
188
|
+
rule_name = None
|
|
189
|
+
description = None
|
|
190
|
+
|
|
191
|
+
if old_value is None and new_value is not None:
|
|
192
|
+
rule_name = f"terms_{_camel_to_snake(terms_definition_field)}_added"
|
|
193
|
+
description = f"added with value: `{new_value}`"
|
|
194
|
+
|
|
195
|
+
elif old_value is not None and new_value is None:
|
|
196
|
+
rule_name = f"terms_{_camel_to_snake(terms_definition_field)}_removed"
|
|
197
|
+
description = "removed info property"
|
|
198
|
+
|
|
199
|
+
elif old_value != new_value:
|
|
200
|
+
rule_name = f"terms_{_camel_to_snake(terms_definition_field)}_updated"
|
|
201
|
+
description = f"changed from `{old_value}` to `{new_value}`"
|
|
202
|
+
|
|
203
|
+
if rule_name is not None:
|
|
204
|
+
severity = _get_rule(rule_name)
|
|
205
|
+
if severity in include_severities:
|
|
206
|
+
results.append(
|
|
207
|
+
BreakingChange(
|
|
208
|
+
description=description,
|
|
209
|
+
check_name=rule_name,
|
|
210
|
+
severity=severity,
|
|
211
|
+
location=Location(path=new_path, composition=composition + [terms_definition_field]),
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return results
|
|
4
216
|
|
|
5
217
|
|
|
6
218
|
def quality_breaking_changes(
|
|
7
|
-
old_quality:
|
|
8
|
-
new_quality:
|
|
219
|
+
old_quality: DeprecatedQuality,
|
|
220
|
+
new_quality: DeprecatedQuality,
|
|
9
221
|
new_path: str,
|
|
10
222
|
include_severities: [Severity],
|
|
11
223
|
) -> list[BreakingChange]:
|
|
@@ -129,14 +341,14 @@ def model_breaking_changes(
|
|
|
129
341
|
) -> list[BreakingChange]:
|
|
130
342
|
results = list[BreakingChange]()
|
|
131
343
|
|
|
132
|
-
model_definition_fields = vars(new_model)
|
|
344
|
+
model_definition_fields = vars(new_model) | new_model.model_extra | old_model.model_extra
|
|
133
345
|
|
|
134
346
|
for model_definition_field in model_definition_fields.keys():
|
|
135
347
|
if model_definition_field == "fields":
|
|
136
348
|
continue
|
|
137
349
|
|
|
138
|
-
old_value = getattr(old_model, model_definition_field)
|
|
139
|
-
new_value = getattr(new_model, model_definition_field)
|
|
350
|
+
old_value = getattr(old_model, model_definition_field, None)
|
|
351
|
+
new_value = getattr(new_model, model_definition_field, None)
|
|
140
352
|
|
|
141
353
|
rule_name = None
|
|
142
354
|
description = None
|
|
@@ -237,13 +449,13 @@ def field_breaking_changes(
|
|
|
237
449
|
) -> list[BreakingChange]:
|
|
238
450
|
results = list[BreakingChange]()
|
|
239
451
|
|
|
240
|
-
field_definition_fields = vars(new_field)
|
|
452
|
+
field_definition_fields = vars(new_field) | new_field.model_extra | old_field.model_extra
|
|
241
453
|
for field_definition_field in field_definition_fields.keys():
|
|
242
454
|
if field_definition_field == "ref_obj":
|
|
243
455
|
continue
|
|
244
456
|
|
|
245
|
-
old_value = getattr(old_field, field_definition_field)
|
|
246
|
-
new_value = getattr(new_field, field_definition_field)
|
|
457
|
+
old_value = getattr(old_field, field_definition_field, None)
|
|
458
|
+
new_value = getattr(new_field, field_definition_field, None)
|
|
247
459
|
|
|
248
460
|
if field_definition_field == "fields":
|
|
249
461
|
results.extend(
|
|
@@ -257,6 +469,18 @@ def field_breaking_changes(
|
|
|
257
469
|
)
|
|
258
470
|
continue
|
|
259
471
|
|
|
472
|
+
if field_definition_field == "items" and old_field.type == "array" and new_field.type == "array":
|
|
473
|
+
results.extend(
|
|
474
|
+
field_breaking_changes(
|
|
475
|
+
old_field=old_value,
|
|
476
|
+
new_field=new_value,
|
|
477
|
+
composition=composition + ["items"],
|
|
478
|
+
new_path=new_path,
|
|
479
|
+
include_severities=include_severities,
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
continue
|
|
483
|
+
|
|
260
484
|
rule_name = None
|
|
261
485
|
description = None
|
|
262
486
|
|
|
@@ -308,9 +532,15 @@ def _get_rule(rule_name) -> Severity:
|
|
|
308
532
|
try:
|
|
309
533
|
return getattr(BreakingRules, rule_name)
|
|
310
534
|
except AttributeError:
|
|
311
|
-
|
|
312
|
-
|
|
535
|
+
try:
|
|
536
|
+
first, *_, last = rule_name.split("_")
|
|
537
|
+
short_rule = "__".join([first, last])
|
|
538
|
+
return getattr(BreakingRules, short_rule)
|
|
539
|
+
except AttributeError:
|
|
540
|
+
print(f"WARNING: Breaking Rule not found for {rule_name}!")
|
|
541
|
+
return Severity.ERROR
|
|
313
542
|
|
|
314
543
|
|
|
315
544
|
def _camel_to_snake(s):
|
|
545
|
+
s = s.replace("-", "_")
|
|
316
546
|
return "".join(["_" + c.lower() if c.isupper() else c for c in s]).lstrip("_")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from datacontract.
|
|
1
|
+
from datacontract.breaking.breaking_change import Severity
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class BreakingRules:
|
|
@@ -12,6 +12,10 @@ class BreakingRules:
|
|
|
12
12
|
|
|
13
13
|
model_type_updated = Severity.ERROR
|
|
14
14
|
|
|
15
|
+
model__removed = Severity.INFO # To support model extension keys
|
|
16
|
+
model__added = Severity.INFO
|
|
17
|
+
model__updated = Severity.INFO
|
|
18
|
+
|
|
15
19
|
# field rules
|
|
16
20
|
field_added = Severity.INFO
|
|
17
21
|
field_removed = Severity.ERROR
|
|
@@ -20,6 +24,10 @@ class BreakingRules:
|
|
|
20
24
|
field_ref_removed = Severity.WARNING
|
|
21
25
|
field_ref_updated = Severity.WARNING
|
|
22
26
|
|
|
27
|
+
field_title_added = Severity.INFO
|
|
28
|
+
field_title_removed = Severity.INFO
|
|
29
|
+
field_title_updated = Severity.INFO
|
|
30
|
+
|
|
23
31
|
field_type_added = Severity.WARNING
|
|
24
32
|
field_type_removed = Severity.WARNING
|
|
25
33
|
field_type_updated = Severity.ERROR
|
|
@@ -30,8 +38,14 @@ class BreakingRules:
|
|
|
30
38
|
|
|
31
39
|
field_required_updated = Severity.ERROR
|
|
32
40
|
|
|
41
|
+
field_primary_added = Severity.WARNING
|
|
42
|
+
field_primary_removed = Severity.WARNING
|
|
33
43
|
field_primary_updated = Severity.WARNING
|
|
34
44
|
|
|
45
|
+
field_primary_key_added = Severity.WARNING
|
|
46
|
+
field_primary_key_removed = Severity.WARNING
|
|
47
|
+
field_primary_key_updated = Severity.WARNING
|
|
48
|
+
|
|
35
49
|
field_references_added = Severity.WARNING
|
|
36
50
|
field_references_removed = Severity.WARNING
|
|
37
51
|
field_references_updated = Severity.WARNING
|
|
@@ -86,9 +100,31 @@ class BreakingRules:
|
|
|
86
100
|
field_tags_removed = Severity.INFO
|
|
87
101
|
field_tags_updated = Severity.INFO
|
|
88
102
|
|
|
103
|
+
field_example_added = Severity.INFO
|
|
104
|
+
field_example_updated = Severity.INFO
|
|
105
|
+
field_example_removed = Severity.INFO
|
|
106
|
+
|
|
107
|
+
field__removed = Severity.INFO # To support field extension keys
|
|
108
|
+
field__added = Severity.INFO
|
|
109
|
+
field__updated = Severity.INFO
|
|
110
|
+
|
|
89
111
|
# quality Rules
|
|
90
112
|
quality_added = Severity.INFO
|
|
91
113
|
quality_removed = Severity.WARNING
|
|
92
114
|
|
|
93
115
|
quality_type_updated = Severity.WARNING
|
|
94
116
|
quality_specification_updated = Severity.WARNING
|
|
117
|
+
|
|
118
|
+
# info rules
|
|
119
|
+
info__added = Severity.INFO # will match `info_<somekey>_added` etc
|
|
120
|
+
info__removed = Severity.INFO
|
|
121
|
+
info__updated = Severity.INFO
|
|
122
|
+
|
|
123
|
+
contact__added = Severity.INFO
|
|
124
|
+
contact__removed = Severity.INFO
|
|
125
|
+
contact__updated = Severity.INFO
|
|
126
|
+
|
|
127
|
+
# terms rules
|
|
128
|
+
terms__added = Severity.INFO
|
|
129
|
+
terms__removed = Severity.INFO
|
|
130
|
+
terms__updated = Severity.INFO
|