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.
Files changed (136) hide show
  1. datacontract/__init__.py +13 -0
  2. datacontract/api.py +260 -0
  3. datacontract/breaking/breaking.py +242 -12
  4. datacontract/breaking/breaking_rules.py +37 -1
  5. datacontract/catalog/catalog.py +80 -0
  6. datacontract/cli.py +387 -117
  7. datacontract/data_contract.py +216 -353
  8. datacontract/engines/data_contract_checks.py +1041 -0
  9. datacontract/engines/data_contract_test.py +113 -0
  10. datacontract/engines/datacontract/check_that_datacontract_contains_valid_servers_configuration.py +2 -3
  11. datacontract/engines/datacontract/check_that_datacontract_file_exists.py +1 -1
  12. datacontract/engines/fastjsonschema/check_jsonschema.py +176 -42
  13. datacontract/engines/fastjsonschema/s3/s3_read_files.py +16 -1
  14. datacontract/engines/soda/check_soda_execute.py +100 -56
  15. datacontract/engines/soda/connections/athena.py +79 -0
  16. datacontract/engines/soda/connections/bigquery.py +8 -1
  17. datacontract/engines/soda/connections/databricks.py +12 -3
  18. datacontract/engines/soda/connections/duckdb_connection.py +241 -0
  19. datacontract/engines/soda/connections/kafka.py +206 -113
  20. datacontract/engines/soda/connections/snowflake.py +8 -5
  21. datacontract/engines/soda/connections/sqlserver.py +43 -0
  22. datacontract/engines/soda/connections/trino.py +26 -0
  23. datacontract/export/avro_converter.py +72 -8
  24. datacontract/export/avro_idl_converter.py +31 -25
  25. datacontract/export/bigquery_converter.py +130 -0
  26. datacontract/export/custom_converter.py +40 -0
  27. datacontract/export/data_caterer_converter.py +161 -0
  28. datacontract/export/dbml_converter.py +148 -0
  29. datacontract/export/dbt_converter.py +141 -54
  30. datacontract/export/dcs_exporter.py +6 -0
  31. datacontract/export/dqx_converter.py +126 -0
  32. datacontract/export/duckdb_type_converter.py +57 -0
  33. datacontract/export/excel_exporter.py +923 -0
  34. datacontract/export/exporter.py +100 -0
  35. datacontract/export/exporter_factory.py +216 -0
  36. datacontract/export/go_converter.py +105 -0
  37. datacontract/export/great_expectations_converter.py +257 -36
  38. datacontract/export/html_exporter.py +86 -0
  39. datacontract/export/iceberg_converter.py +188 -0
  40. datacontract/export/jsonschema_converter.py +71 -16
  41. datacontract/export/markdown_converter.py +337 -0
  42. datacontract/export/mermaid_exporter.py +110 -0
  43. datacontract/export/odcs_v3_exporter.py +375 -0
  44. datacontract/export/pandas_type_converter.py +40 -0
  45. datacontract/export/protobuf_converter.py +168 -68
  46. datacontract/export/pydantic_converter.py +6 -0
  47. datacontract/export/rdf_converter.py +13 -6
  48. datacontract/export/sodacl_converter.py +36 -188
  49. datacontract/export/spark_converter.py +245 -0
  50. datacontract/export/sql_converter.py +37 -3
  51. datacontract/export/sql_type_converter.py +269 -8
  52. datacontract/export/sqlalchemy_converter.py +170 -0
  53. datacontract/export/terraform_converter.py +7 -2
  54. datacontract/imports/avro_importer.py +246 -26
  55. datacontract/imports/bigquery_importer.py +221 -0
  56. datacontract/imports/csv_importer.py +143 -0
  57. datacontract/imports/dbml_importer.py +112 -0
  58. datacontract/imports/dbt_importer.py +240 -0
  59. datacontract/imports/excel_importer.py +1111 -0
  60. datacontract/imports/glue_importer.py +288 -0
  61. datacontract/imports/iceberg_importer.py +172 -0
  62. datacontract/imports/importer.py +51 -0
  63. datacontract/imports/importer_factory.py +128 -0
  64. datacontract/imports/json_importer.py +325 -0
  65. datacontract/imports/jsonschema_importer.py +146 -0
  66. datacontract/imports/odcs_importer.py +60 -0
  67. datacontract/imports/odcs_v3_importer.py +516 -0
  68. datacontract/imports/parquet_importer.py +81 -0
  69. datacontract/imports/protobuf_importer.py +264 -0
  70. datacontract/imports/spark_importer.py +262 -0
  71. datacontract/imports/sql_importer.py +274 -35
  72. datacontract/imports/unity_importer.py +219 -0
  73. datacontract/init/init_template.py +20 -0
  74. datacontract/integration/datamesh_manager.py +86 -0
  75. datacontract/lint/resolve.py +271 -49
  76. datacontract/lint/resources.py +21 -0
  77. datacontract/lint/schema.py +53 -17
  78. datacontract/lint/urls.py +32 -12
  79. datacontract/model/data_contract_specification/__init__.py +1 -0
  80. datacontract/model/exceptions.py +4 -1
  81. datacontract/model/odcs.py +24 -0
  82. datacontract/model/run.py +49 -29
  83. datacontract/output/__init__.py +0 -0
  84. datacontract/output/junit_test_results.py +135 -0
  85. datacontract/output/output_format.py +10 -0
  86. datacontract/output/test_results_writer.py +79 -0
  87. datacontract/py.typed +0 -0
  88. datacontract/schemas/datacontract-1.1.0.init.yaml +91 -0
  89. datacontract/schemas/datacontract-1.1.0.schema.json +1975 -0
  90. datacontract/schemas/datacontract-1.2.0.init.yaml +91 -0
  91. datacontract/schemas/datacontract-1.2.0.schema.json +2029 -0
  92. datacontract/schemas/datacontract-1.2.1.init.yaml +91 -0
  93. datacontract/schemas/datacontract-1.2.1.schema.json +2058 -0
  94. datacontract/schemas/odcs-3.0.1.schema.json +2634 -0
  95. datacontract/schemas/odcs-3.0.2.schema.json +2382 -0
  96. datacontract/templates/datacontract.html +139 -294
  97. datacontract/templates/datacontract_odcs.html +685 -0
  98. datacontract/templates/index.html +236 -0
  99. datacontract/templates/partials/datacontract_information.html +86 -0
  100. datacontract/templates/partials/datacontract_servicelevels.html +253 -0
  101. datacontract/templates/partials/datacontract_terms.html +51 -0
  102. datacontract/templates/partials/definition.html +25 -0
  103. datacontract/templates/partials/example.html +27 -0
  104. datacontract/templates/partials/model_field.html +144 -0
  105. datacontract/templates/partials/quality.html +49 -0
  106. datacontract/templates/partials/server.html +211 -0
  107. datacontract/templates/style/output.css +491 -72
  108. datacontract_cli-0.10.37.dist-info/METADATA +2235 -0
  109. datacontract_cli-0.10.37.dist-info/RECORD +119 -0
  110. {datacontract_cli-0.10.0.dist-info → datacontract_cli-0.10.37.dist-info}/WHEEL +1 -1
  111. {datacontract_cli-0.10.0.dist-info → datacontract_cli-0.10.37.dist-info/licenses}/LICENSE +1 -1
  112. datacontract/engines/datacontract/check_that_datacontract_str_is_valid.py +0 -48
  113. datacontract/engines/soda/connections/dask.py +0 -28
  114. datacontract/engines/soda/connections/duckdb.py +0 -76
  115. datacontract/export/csv_type_converter.py +0 -36
  116. datacontract/export/html_export.py +0 -66
  117. datacontract/export/odcs_converter.py +0 -102
  118. datacontract/init/download_datacontract_file.py +0 -17
  119. datacontract/integration/publish_datamesh_manager.py +0 -33
  120. datacontract/integration/publish_opentelemetry.py +0 -107
  121. datacontract/lint/lint.py +0 -141
  122. datacontract/lint/linters/description_linter.py +0 -34
  123. datacontract/lint/linters/example_model_linter.py +0 -91
  124. datacontract/lint/linters/field_pattern_linter.py +0 -34
  125. datacontract/lint/linters/field_reference_linter.py +0 -38
  126. datacontract/lint/linters/notice_period_linter.py +0 -55
  127. datacontract/lint/linters/quality_schema_linter.py +0 -52
  128. datacontract/lint/linters/valid_constraints_linter.py +0 -99
  129. datacontract/model/data_contract_specification.py +0 -141
  130. datacontract/web.py +0 -14
  131. datacontract_cli-0.10.0.dist-info/METADATA +0 -951
  132. datacontract_cli-0.10.0.dist-info/RECORD +0 -66
  133. /datacontract/{model → breaking}/breaking_change.py +0 -0
  134. /datacontract/{lint/linters → export}/__init__.py +0 -0
  135. {datacontract_cli-0.10.0.dist-info → datacontract_cli-0.10.37.dist-info}/entry_points.txt +0 -0
  136. {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.breaking_change import BreakingChange, Location, Severity
3
- from datacontract.model.data_contract_specification import Field, Model, Quality
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: Quality,
8
- new_quality: 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
- print(f"WARNING: Breaking Rule not found for {rule_name}!")
312
- return Severity.ERROR
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.model.breaking_change import Severity
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