datacontract-cli 0.10.14__py3-none-any.whl → 0.10.16__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 (69) hide show
  1. datacontract/breaking/breaking.py +229 -11
  2. datacontract/breaking/breaking_rules.py +24 -0
  3. datacontract/catalog/catalog.py +1 -1
  4. datacontract/cli.py +100 -33
  5. datacontract/data_contract.py +26 -4
  6. datacontract/engines/datacontract/check_that_datacontract_file_exists.py +1 -1
  7. datacontract/engines/fastjsonschema/check_jsonschema.py +114 -22
  8. datacontract/engines/soda/check_soda_execute.py +7 -5
  9. datacontract/engines/soda/connections/duckdb.py +1 -0
  10. datacontract/engines/soda/connections/kafka.py +12 -12
  11. datacontract/export/avro_idl_converter.py +1 -2
  12. datacontract/export/bigquery_converter.py +4 -3
  13. datacontract/export/data_caterer_converter.py +1 -1
  14. datacontract/export/dbml_converter.py +2 -4
  15. datacontract/export/dbt_converter.py +45 -39
  16. datacontract/export/exporter.py +2 -1
  17. datacontract/export/exporter_factory.py +7 -2
  18. datacontract/export/go_converter.py +3 -2
  19. datacontract/export/great_expectations_converter.py +202 -40
  20. datacontract/export/html_export.py +1 -1
  21. datacontract/export/iceberg_converter.py +188 -0
  22. datacontract/export/jsonschema_converter.py +3 -2
  23. datacontract/export/odcs_v2_exporter.py +1 -1
  24. datacontract/export/odcs_v3_exporter.py +44 -30
  25. datacontract/export/pandas_type_converter.py +40 -0
  26. datacontract/export/protobuf_converter.py +1 -1
  27. datacontract/export/rdf_converter.py +4 -5
  28. datacontract/export/sodacl_converter.py +9 -4
  29. datacontract/export/spark_converter.py +7 -6
  30. datacontract/export/sql_converter.py +1 -2
  31. datacontract/export/sqlalchemy_converter.py +1 -2
  32. datacontract/export/terraform_converter.py +1 -1
  33. datacontract/imports/avro_importer.py +1 -1
  34. datacontract/imports/bigquery_importer.py +1 -1
  35. datacontract/imports/dbml_importer.py +2 -2
  36. datacontract/imports/dbt_importer.py +80 -15
  37. datacontract/imports/glue_importer.py +5 -3
  38. datacontract/imports/iceberg_importer.py +17 -7
  39. datacontract/imports/importer.py +1 -0
  40. datacontract/imports/importer_factory.py +7 -1
  41. datacontract/imports/jsonschema_importer.py +3 -2
  42. datacontract/imports/odcs_v2_importer.py +2 -2
  43. datacontract/imports/odcs_v3_importer.py +7 -2
  44. datacontract/imports/parquet_importer.py +81 -0
  45. datacontract/imports/spark_importer.py +2 -1
  46. datacontract/imports/sql_importer.py +1 -1
  47. datacontract/imports/unity_importer.py +3 -3
  48. datacontract/integration/opentelemetry.py +0 -1
  49. datacontract/lint/lint.py +2 -1
  50. datacontract/lint/linters/description_linter.py +1 -0
  51. datacontract/lint/linters/example_model_linter.py +1 -0
  52. datacontract/lint/linters/field_pattern_linter.py +1 -0
  53. datacontract/lint/linters/field_reference_linter.py +1 -0
  54. datacontract/lint/linters/notice_period_linter.py +1 -0
  55. datacontract/lint/linters/quality_schema_linter.py +1 -0
  56. datacontract/lint/linters/valid_constraints_linter.py +1 -0
  57. datacontract/lint/resolve.py +7 -3
  58. datacontract/lint/schema.py +1 -1
  59. datacontract/model/data_contract_specification.py +13 -6
  60. datacontract/model/run.py +21 -12
  61. datacontract/templates/index.html +6 -6
  62. datacontract/web.py +2 -3
  63. {datacontract_cli-0.10.14.dist-info → datacontract_cli-0.10.16.dist-info}/METADATA +163 -60
  64. datacontract_cli-0.10.16.dist-info/RECORD +106 -0
  65. {datacontract_cli-0.10.14.dist-info → datacontract_cli-0.10.16.dist-info}/WHEEL +1 -1
  66. datacontract_cli-0.10.14.dist-info/RECORD +0 -103
  67. {datacontract_cli-0.10.14.dist-info → datacontract_cli-0.10.16.dist-info}/LICENSE +0 -0
  68. {datacontract_cli-0.10.14.dist-info → datacontract_cli-0.10.16.dist-info}/entry_points.txt +0 -0
  69. {datacontract_cli-0.10.14.dist-info → datacontract_cli-0.10.16.dist-info}/top_level.txt +0 -0
@@ -1,11 +1,223 @@
1
1
  from datacontract.breaking.breaking_rules import BreakingRules
2
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(
@@ -320,9 +532,15 @@ def _get_rule(rule_name) -> Severity:
320
532
  try:
321
533
  return getattr(BreakingRules, rule_name)
322
534
  except AttributeError:
323
- print(f"WARNING: Breaking Rule not found for {rule_name}!")
324
- 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
325
542
 
326
543
 
327
544
  def _camel_to_snake(s):
545
+ s = s.replace("-", "_")
328
546
  return "".join(["_" + c.lower() if c.isupper() else c for c in s]).lstrip("_")
@@ -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
@@ -34,6 +38,8 @@ class BreakingRules:
34
38
 
35
39
  field_required_updated = Severity.ERROR
36
40
 
41
+ field_primary_added = Severity.WARNING
42
+ field_primary_removed = Severity.WARNING
37
43
  field_primary_updated = Severity.WARNING
38
44
 
39
45
  field_references_added = Severity.WARNING
@@ -94,9 +100,27 @@ class BreakingRules:
94
100
  field_example_updated = Severity.INFO
95
101
  field_example_removed = Severity.INFO
96
102
 
103
+ field__removed = Severity.INFO # To support field extension keys
104
+ field__added = Severity.INFO
105
+ field__updated = Severity.INFO
106
+
97
107
  # quality Rules
98
108
  quality_added = Severity.INFO
99
109
  quality_removed = Severity.WARNING
100
110
 
101
111
  quality_type_updated = Severity.WARNING
102
112
  quality_specification_updated = Severity.WARNING
113
+
114
+ # info rules
115
+ info__added = Severity.INFO # will match `info_<somekey>_added` etc
116
+ info__removed = Severity.INFO
117
+ info__updated = Severity.INFO
118
+
119
+ contact__added = Severity.INFO
120
+ contact__removed = Severity.INFO
121
+ contact__updated = Severity.INFO
122
+
123
+ # terms rules
124
+ terms__added = Severity.INFO
125
+ terms__removed = Severity.INFO
126
+ terms__updated = Severity.INFO
@@ -3,7 +3,7 @@ from datetime import datetime
3
3
  from pathlib import Path
4
4
 
5
5
  import pytz
6
- from jinja2 import PackageLoader, Environment, select_autoescape
6
+ from jinja2 import Environment, PackageLoader, select_autoescape
7
7
 
8
8
  from datacontract.data_contract import DataContract
9
9
  from datacontract.export.html_export import get_version
datacontract/cli.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from importlib import metadata
2
2
  from pathlib import Path
3
- from typing import Iterable, Optional
4
- from typing import List
3
+ from typing import Iterable, List, Optional
5
4
 
6
5
  import typer
7
6
  import uvicorn
@@ -13,11 +12,16 @@ from typer.core import TyperGroup
13
12
  from typing_extensions import Annotated
14
13
 
15
14
  from datacontract import web
16
- from datacontract.catalog.catalog import create_index_html, create_data_contract_html
15
+ from datacontract.catalog.catalog import create_data_contract_html, create_index_html
17
16
  from datacontract.data_contract import DataContract, ExportFormat
18
17
  from datacontract.imports.importer import ImportFormat
19
- from datacontract.init.download_datacontract_file import download_datacontract_file, FileExistsException
20
- from datacontract.integration.datamesh_manager import publish_data_contract_to_datamesh_manager
18
+ from datacontract.init.download_datacontract_file import (
19
+ FileExistsException,
20
+ download_datacontract_file,
21
+ )
22
+ from datacontract.integration.datamesh_manager import (
23
+ publish_data_contract_to_datamesh_manager,
24
+ )
21
25
 
22
26
  DEFAULT_DATA_CONTRACT_SCHEMA_URL = "https://datacontract.com/datacontract.schema.json"
23
27
 
@@ -46,7 +50,11 @@ def version_callback(value: bool):
46
50
  def common(
47
51
  ctx: typer.Context,
48
52
  version: bool = typer.Option(
49
- None, "--version", help="Prints the current version.", callback=version_callback, is_eager=True
53
+ None,
54
+ "--version",
55
+ help="Prints the current version.",
56
+ callback=version_callback,
57
+ is_eager=True,
50
58
  ),
51
59
  ):
52
60
  """
@@ -62,7 +70,8 @@ def common(
62
70
  @app.command()
63
71
  def init(
64
72
  location: Annotated[
65
- str, typer.Argument(help="The location (url or path) of the data contract yaml to create.")
73
+ str,
74
+ typer.Argument(help="The location (url or path) of the data contract yaml to create."),
66
75
  ] = "datacontract.yaml",
67
76
  template: Annotated[
68
77
  str, typer.Option(help="URL of a template or data contract")
@@ -84,10 +93,12 @@ def init(
84
93
  @app.command()
85
94
  def lint(
86
95
  location: Annotated[
87
- str, typer.Argument(help="The location (url or path) of the data contract yaml.")
96
+ str,
97
+ typer.Argument(help="The location (url or path) of the data contract yaml."),
88
98
  ] = "datacontract.yaml",
89
99
  schema: Annotated[
90
- str, typer.Option(help="The location (url or path) of the Data Contract Specification JSON Schema")
100
+ str,
101
+ typer.Option(help="The location (url or path) of the Data Contract Specification JSON Schema"),
91
102
  ] = DEFAULT_DATA_CONTRACT_SCHEMA_URL,
92
103
  ):
93
104
  """
@@ -100,10 +111,12 @@ def lint(
100
111
  @app.command()
101
112
  def test(
102
113
  location: Annotated[
103
- str, typer.Argument(help="The location (url or path) of the data contract yaml.")
114
+ str,
115
+ typer.Argument(help="The location (url or path) of the data contract yaml."),
104
116
  ] = "datacontract.yaml",
105
117
  schema: Annotated[
106
- str, typer.Option(help="The location (url or path) of the Data Contract Specification JSON Schema")
118
+ str,
119
+ typer.Option(help="The location (url or path) of the Data Contract Specification JSON Schema"),
107
120
  ] = DEFAULT_DATA_CONTRACT_SCHEMA_URL,
108
121
  server: Annotated[
109
122
  str,
@@ -115,7 +128,8 @@ def test(
115
128
  ),
116
129
  ] = "all",
117
130
  examples: Annotated[
118
- bool, typer.Option(help="Run the schema and quality tests on the example data within the data contract.")
131
+ bool,
132
+ typer.Option(help="Run the schema and quality tests on the example data within the data contract."),
119
133
  ] = None,
120
134
  publish: Annotated[str, typer.Option(help="The url to publish the results after the test")] = None,
121
135
  publish_to_opentelemetry: Annotated[
@@ -166,7 +180,10 @@ def export(
166
180
  # TODO: this should be a subcommand
167
181
  rdf_base: Annotated[
168
182
  Optional[str],
169
- typer.Option(help="[rdf] The base URI used to generate the RDF graph.", rich_help_panel="RDF Options"),
183
+ typer.Option(
184
+ help="[rdf] The base URI used to generate the RDF graph.",
185
+ rich_help_panel="RDF Options",
186
+ ),
170
187
  ] = None,
171
188
  # TODO: this should be a subcommand
172
189
  sql_server_type: Annotated[
@@ -177,14 +194,21 @@ def export(
177
194
  ),
178
195
  ] = "auto",
179
196
  location: Annotated[
180
- str, typer.Argument(help="The location (url or path) of the data contract yaml.")
197
+ str,
198
+ typer.Argument(help="The location (url or path) of the data contract yaml."),
181
199
  ] = "datacontract.yaml",
182
200
  schema: Annotated[
183
- str, typer.Option(help="The location (url or path) of the Data Contract Specification JSON Schema")
201
+ str,
202
+ typer.Option(help="The location (url or path) of the Data Contract Specification JSON Schema"),
184
203
  ] = DEFAULT_DATA_CONTRACT_SCHEMA_URL,
204
+ # TODO: this should be a subcommand
205
+ engine: Annotated[
206
+ Optional[str],
207
+ typer.Option(help="[engine] The engine used for great expection run."),
208
+ ] = None,
185
209
  ):
186
210
  """
187
- Convert data contract to a specific format. console.prints to stdout.
211
+ Convert data contract to a specific format. Saves to file specified by `output` option if present, otherwise prints to stdout.
188
212
  """
189
213
  # TODO exception handling
190
214
  result = DataContract(data_contract_file=location, schema_location=schema, server=server).export(
@@ -193,10 +217,11 @@ def export(
193
217
  server=server,
194
218
  rdf_base=rdf_base,
195
219
  sql_server_type=sql_server_type,
220
+ engine=engine,
196
221
  )
197
222
  # Don't interpret console markup in output.
198
223
  if output is None:
199
- console.print(result, markup=False)
224
+ console.print(result, markup=False, soft_wrap=True)
200
225
  else:
201
226
  with output.open("w") as f:
202
227
  f.write(result)
@@ -206,8 +231,15 @@ def export(
206
231
  @app.command(name="import")
207
232
  def import_(
208
233
  format: Annotated[ImportFormat, typer.Option(help="The format of the source file.")],
234
+ output: Annotated[
235
+ Path,
236
+ typer.Option(
237
+ help="Specify the file path where the Data Contract will be saved. If no path is provided, the output will be printed to stdout."
238
+ ),
239
+ ] = None,
209
240
  source: Annotated[
210
- Optional[str], typer.Option(help="The path to the file or Glue Database that should be imported.")
241
+ Optional[str],
242
+ typer.Option(help="The path to the file or Glue Database that should be imported."),
211
243
  ] = None,
212
244
  glue_table: Annotated[
213
245
  Optional[List[str]],
@@ -250,7 +282,7 @@ def import_(
250
282
  ] = None,
251
283
  ):
252
284
  """
253
- Create a data contract from the given source location. Prints to stdout.
285
+ Create a data contract from the given source location. Saves to file specified by `output` option if present, otherwise prints to stdout.
254
286
  """
255
287
  result = DataContract().import_from_source(
256
288
  format=format,
@@ -265,16 +297,23 @@ def import_(
265
297
  dbml_table=dbml_table,
266
298
  iceberg_table=iceberg_table,
267
299
  )
268
- console.print(result.to_yaml())
300
+ if output is None:
301
+ console.print(result.to_yaml(), markup=False, soft_wrap=True)
302
+ else:
303
+ with output.open("w") as f:
304
+ f.write(result.to_yaml())
305
+ console.print(f"Written result to {output}")
269
306
 
270
307
 
271
308
  @app.command(name="publish")
272
309
  def publish(
273
310
  location: Annotated[
274
- str, typer.Argument(help="The location (url or path) of the data contract yaml.")
311
+ str,
312
+ typer.Argument(help="The location (url or path) of the data contract yaml."),
275
313
  ] = "datacontract.yaml",
276
314
  schema: Annotated[
277
- str, typer.Option(help="The location (url or path) of the Data Contract Specification JSON Schema")
315
+ str,
316
+ typer.Option(help="The location (url or path) of the Data Contract Specification JSON Schema"),
278
317
  ] = DEFAULT_DATA_CONTRACT_SCHEMA_URL,
279
318
  ):
280
319
  """
@@ -290,11 +329,15 @@ def publish(
290
329
  @app.command(name="catalog")
291
330
  def catalog(
292
331
  files: Annotated[
293
- Optional[str], typer.Option(help="Glob pattern for the data contract files to include in the catalog.")
332
+ Optional[str],
333
+ typer.Option(
334
+ help="Glob pattern for the data contract files to include in the catalog. Applies recursively to any subfolders."
335
+ ),
294
336
  ] = "*.yaml",
295
337
  output: Annotated[Optional[str], typer.Option(help="Output directory for the catalog html files.")] = "catalog/",
296
338
  schema: Annotated[
297
- str, typer.Option(help="The location (url or path) of the Data Contract Specification JSON Schema")
339
+ str,
340
+ typer.Option(help="The location (url or path) of the Data Contract Specification JSON Schema"),
298
341
  ] = DEFAULT_DATA_CONTRACT_SCHEMA_URL,
299
342
  ):
300
343
  """
@@ -305,7 +348,7 @@ def catalog(
305
348
  console.print(f"Created {output}")
306
349
 
307
350
  contracts = []
308
- for file in Path().glob(files):
351
+ for file in Path().rglob(files):
309
352
  try:
310
353
  create_data_contract_html(contracts, file, path, schema)
311
354
  except Exception as e:
@@ -316,8 +359,14 @@ def catalog(
316
359
 
317
360
  @app.command()
318
361
  def breaking(
319
- location_old: Annotated[str, typer.Argument(help="The location (url or path) of the old data contract yaml.")],
320
- location_new: Annotated[str, typer.Argument(help="The location (url or path) of the new data contract yaml.")],
362
+ location_old: Annotated[
363
+ str,
364
+ typer.Argument(help="The location (url or path) of the old data contract yaml."),
365
+ ],
366
+ location_new: Annotated[
367
+ str,
368
+ typer.Argument(help="The location (url or path) of the new data contract yaml."),
369
+ ],
321
370
  ):
322
371
  """
323
372
  Identifies breaking changes between data contracts. Prints to stdout.
@@ -336,8 +385,14 @@ def breaking(
336
385
 
337
386
  @app.command()
338
387
  def changelog(
339
- location_old: Annotated[str, typer.Argument(help="The location (url or path) of the old data contract yaml.")],
340
- location_new: Annotated[str, typer.Argument(help="The location (url or path) of the new data contract yaml.")],
388
+ location_old: Annotated[
389
+ str,
390
+ typer.Argument(help="The location (url or path) of the old data contract yaml."),
391
+ ],
392
+ location_new: Annotated[
393
+ str,
394
+ typer.Argument(help="The location (url or path) of the new data contract yaml."),
395
+ ],
341
396
  ):
342
397
  """
343
398
  Generate a changelog between data contracts. Prints to stdout.
@@ -353,8 +408,14 @@ def changelog(
353
408
 
354
409
  @app.command()
355
410
  def diff(
356
- location_old: Annotated[str, typer.Argument(help="The location (url or path) of the old data contract yaml.")],
357
- location_new: Annotated[str, typer.Argument(help="The location (url or path) of the new data contract yaml.")],
411
+ location_old: Annotated[
412
+ str,
413
+ typer.Argument(help="The location (url or path) of the old data contract yaml."),
414
+ ],
415
+ location_new: Annotated[
416
+ str,
417
+ typer.Argument(help="The location (url or path) of the new data contract yaml."),
418
+ ],
358
419
  ):
359
420
  """
360
421
  PLACEHOLDER. Currently works as 'changelog' does.
@@ -391,7 +452,13 @@ def _handle_result(run):
391
452
  i = 1
392
453
  for check in run.checks:
393
454
  if check.result != "passed":
394
- console.print(str(++i) + ") " + check.reason)
455
+ field = to_field(run, check)
456
+ if field:
457
+ field = field + " "
458
+ else:
459
+ field = ""
460
+ console.print(f"{i}) {field}{check.name}: {check.reason}")
461
+ i += 1
395
462
  raise typer.Exit(code=1)
396
463
 
397
464
 
@@ -401,7 +468,7 @@ def _print_table(run):
401
468
  table.add_column("Check", max_width=100)
402
469
  table.add_column("Field", max_width=32)
403
470
  table.add_column("Details", max_width=50)
404
- for check in run.checks:
471
+ for check in sorted(run.checks, key=lambda c: (c.result or "", c.model or "", c.field or "")):
405
472
  table.add_row(with_markup(check.result), check.name, to_field(run, check), check.reason)
406
473
  console.print(table)
407
474
 
@@ -8,7 +8,12 @@ import yaml
8
8
  if typing.TYPE_CHECKING:
9
9
  from pyspark.sql import SparkSession
10
10
 
11
- from datacontract.breaking.breaking import models_breaking_changes, quality_breaking_changes
11
+ from datacontract.breaking.breaking import (
12
+ info_breaking_changes,
13
+ models_breaking_changes,
14
+ quality_breaking_changes,
15
+ terms_breaking_changes,
16
+ )
12
17
  from datacontract.engines.datacontract.check_that_datacontract_contains_valid_servers_configuration import (
13
18
  check_that_datacontract_contains_valid_server_configuration,
14
19
  )
@@ -17,7 +22,6 @@ from datacontract.engines.soda.check_soda_execute import check_soda_execute
17
22
  from datacontract.export.exporter import ExportFormat
18
23
  from datacontract.export.exporter_factory import exporter_factory
19
24
  from datacontract.imports.importer_factory import importer_factory
20
-
21
25
  from datacontract.integration.datamesh_manager import publish_test_results_to_datamesh_manager
22
26
  from datacontract.integration.opentelemetry import publish_test_results_to_opentelemetry
23
27
  from datacontract.lint import resolve
@@ -28,10 +32,10 @@ from datacontract.lint.linters.field_reference_linter import FieldReferenceLinte
28
32
  from datacontract.lint.linters.notice_period_linter import NoticePeriodLinter
29
33
  from datacontract.lint.linters.quality_schema_linter import QualityUsesSchemaLinter
30
34
  from datacontract.lint.linters.valid_constraints_linter import ValidFieldConstraintsLinter
31
- from datacontract.model.breaking_change import BreakingChanges, BreakingChange, Severity
35
+ from datacontract.model.breaking_change import BreakingChange, BreakingChanges, Severity
32
36
  from datacontract.model.data_contract_specification import DataContractSpecification, Server
33
37
  from datacontract.model.exceptions import DataContractException
34
- from datacontract.model.run import Run, Check
38
+ from datacontract.model.run import Check, Run
35
39
 
36
40
 
37
41
  class DataContract:
@@ -276,6 +280,24 @@ class DataContract:
276
280
 
277
281
  breaking_changes = list[BreakingChange]()
278
282
 
283
+ breaking_changes.extend(
284
+ info_breaking_changes(
285
+ old_info=old.info,
286
+ new_info=new.info,
287
+ new_path=other._data_contract_file,
288
+ include_severities=include_severities,
289
+ )
290
+ )
291
+
292
+ breaking_changes.extend(
293
+ terms_breaking_changes(
294
+ old_terms=old.terms,
295
+ new_terms=new.terms,
296
+ new_path=other._data_contract_file,
297
+ include_severities=include_severities,
298
+ )
299
+ )
300
+
279
301
  breaking_changes.extend(
280
302
  quality_breaking_changes(
281
303
  old_quality=old.quality,