datacontract-cli 0.10.13__py3-none-any.whl → 0.10.15__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 (77) hide show
  1. datacontract/breaking/breaking.py +227 -9
  2. datacontract/breaking/breaking_rules.py +24 -0
  3. datacontract/catalog/catalog.py +1 -1
  4. datacontract/cli.py +104 -32
  5. datacontract/data_contract.py +35 -5
  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 +5 -3
  9. datacontract/engines/soda/connections/duckdb.py +1 -0
  10. datacontract/engines/soda/connections/kafka.py +38 -17
  11. datacontract/export/avro_converter.py +8 -1
  12. datacontract/export/avro_idl_converter.py +2 -2
  13. datacontract/export/bigquery_converter.py +4 -3
  14. datacontract/export/data_caterer_converter.py +1 -1
  15. datacontract/export/dbml_converter.py +2 -4
  16. datacontract/export/dbt_converter.py +2 -3
  17. datacontract/export/dcs_exporter.py +6 -0
  18. datacontract/export/exporter.py +5 -2
  19. datacontract/export/exporter_factory.py +16 -3
  20. datacontract/export/go_converter.py +3 -2
  21. datacontract/export/great_expectations_converter.py +202 -40
  22. datacontract/export/html_export.py +1 -1
  23. datacontract/export/jsonschema_converter.py +3 -2
  24. datacontract/export/{odcs_converter.py → odcs_v2_exporter.py} +5 -5
  25. datacontract/export/odcs_v3_exporter.py +294 -0
  26. datacontract/export/pandas_type_converter.py +40 -0
  27. datacontract/export/protobuf_converter.py +1 -1
  28. datacontract/export/rdf_converter.py +4 -5
  29. datacontract/export/sodacl_converter.py +86 -2
  30. datacontract/export/spark_converter.py +10 -7
  31. datacontract/export/sql_converter.py +1 -2
  32. datacontract/export/sql_type_converter.py +55 -11
  33. datacontract/export/sqlalchemy_converter.py +1 -2
  34. datacontract/export/terraform_converter.py +1 -1
  35. datacontract/imports/avro_importer.py +1 -1
  36. datacontract/imports/bigquery_importer.py +1 -1
  37. datacontract/imports/dbml_importer.py +2 -2
  38. datacontract/imports/dbt_importer.py +3 -2
  39. datacontract/imports/glue_importer.py +5 -3
  40. datacontract/imports/iceberg_importer.py +161 -0
  41. datacontract/imports/importer.py +2 -0
  42. datacontract/imports/importer_factory.py +12 -1
  43. datacontract/imports/jsonschema_importer.py +3 -2
  44. datacontract/imports/odcs_importer.py +25 -168
  45. datacontract/imports/odcs_v2_importer.py +177 -0
  46. datacontract/imports/odcs_v3_importer.py +309 -0
  47. datacontract/imports/parquet_importer.py +81 -0
  48. datacontract/imports/spark_importer.py +2 -1
  49. datacontract/imports/sql_importer.py +1 -1
  50. datacontract/imports/unity_importer.py +3 -3
  51. datacontract/integration/datamesh_manager.py +1 -1
  52. datacontract/integration/opentelemetry.py +0 -1
  53. datacontract/lint/lint.py +2 -1
  54. datacontract/lint/linters/description_linter.py +1 -0
  55. datacontract/lint/linters/example_model_linter.py +1 -0
  56. datacontract/lint/linters/field_pattern_linter.py +1 -0
  57. datacontract/lint/linters/field_reference_linter.py +1 -0
  58. datacontract/lint/linters/notice_period_linter.py +1 -0
  59. datacontract/lint/linters/quality_schema_linter.py +1 -0
  60. datacontract/lint/linters/valid_constraints_linter.py +1 -0
  61. datacontract/lint/resolve.py +14 -9
  62. datacontract/lint/resources.py +21 -0
  63. datacontract/lint/schema.py +1 -1
  64. datacontract/lint/urls.py +4 -2
  65. datacontract/model/data_contract_specification.py +83 -13
  66. datacontract/model/odcs.py +11 -0
  67. datacontract/model/run.py +21 -12
  68. datacontract/templates/index.html +6 -6
  69. datacontract/web.py +2 -3
  70. {datacontract_cli-0.10.13.dist-info → datacontract_cli-0.10.15.dist-info}/METADATA +176 -93
  71. datacontract_cli-0.10.15.dist-info/RECORD +105 -0
  72. {datacontract_cli-0.10.13.dist-info → datacontract_cli-0.10.15.dist-info}/WHEEL +1 -1
  73. datacontract/engines/datacontract/check_that_datacontract_str_is_valid.py +0 -48
  74. datacontract_cli-0.10.13.dist-info/RECORD +0 -97
  75. {datacontract_cli-0.10.13.dist-info → datacontract_cli-0.10.15.dist-info}/LICENSE +0 -0
  76. {datacontract_cli-0.10.13.dist-info → datacontract_cli-0.10.15.dist-info}/entry_points.txt +0 -0
  77. {datacontract_cli-0.10.13.dist-info → datacontract_cli-0.10.15.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,218 @@
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, Field, Info, Model, Quality, 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(
@@ -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,6 +217,7 @@ 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:
@@ -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]],
@@ -244,9 +276,13 @@ def import_(
244
276
  help="List of table names to import from the DBML file (repeat for multiple table names, leave empty for all tables in the file)."
245
277
  ),
246
278
  ] = None,
279
+ iceberg_table: Annotated[
280
+ Optional[str],
281
+ typer.Option(help="Table name to assign to the model created from the Iceberg schema."),
282
+ ] = None,
247
283
  ):
248
284
  """
249
- 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.
250
286
  """
251
287
  result = DataContract().import_from_source(
252
288
  format=format,
@@ -259,17 +295,25 @@ def import_(
259
295
  dbt_model=dbt_model,
260
296
  dbml_schema=dbml_schema,
261
297
  dbml_table=dbml_table,
298
+ iceberg_table=iceberg_table,
262
299
  )
263
- console.print(result.to_yaml())
300
+ if output is None:
301
+ console.print(result.to_yaml())
302
+ else:
303
+ with output.open("w") as f:
304
+ f.write(result.to_yaml())
305
+ console.print(f"Written result to {output}")
264
306
 
265
307
 
266
308
  @app.command(name="publish")
267
309
  def publish(
268
310
  location: Annotated[
269
- 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."),
270
313
  ] = "datacontract.yaml",
271
314
  schema: Annotated[
272
- 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"),
273
317
  ] = DEFAULT_DATA_CONTRACT_SCHEMA_URL,
274
318
  ):
275
319
  """
@@ -285,11 +329,15 @@ def publish(
285
329
  @app.command(name="catalog")
286
330
  def catalog(
287
331
  files: Annotated[
288
- 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
+ ),
289
336
  ] = "*.yaml",
290
337
  output: Annotated[Optional[str], typer.Option(help="Output directory for the catalog html files.")] = "catalog/",
291
338
  schema: Annotated[
292
- 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"),
293
341
  ] = DEFAULT_DATA_CONTRACT_SCHEMA_URL,
294
342
  ):
295
343
  """
@@ -300,7 +348,7 @@ def catalog(
300
348
  console.print(f"Created {output}")
301
349
 
302
350
  contracts = []
303
- for file in Path().glob(files):
351
+ for file in Path().rglob(files):
304
352
  try:
305
353
  create_data_contract_html(contracts, file, path, schema)
306
354
  except Exception as e:
@@ -311,8 +359,14 @@ def catalog(
311
359
 
312
360
  @app.command()
313
361
  def breaking(
314
- location_old: Annotated[str, typer.Argument(help="The location (url or path) of the old data contract yaml.")],
315
- 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
+ ],
316
370
  ):
317
371
  """
318
372
  Identifies breaking changes between data contracts. Prints to stdout.
@@ -331,8 +385,14 @@ def breaking(
331
385
 
332
386
  @app.command()
333
387
  def changelog(
334
- location_old: Annotated[str, typer.Argument(help="The location (url or path) of the old data contract yaml.")],
335
- 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
+ ],
336
396
  ):
337
397
  """
338
398
  Generate a changelog between data contracts. Prints to stdout.
@@ -348,8 +408,14 @@ def changelog(
348
408
 
349
409
  @app.command()
350
410
  def diff(
351
- location_old: Annotated[str, typer.Argument(help="The location (url or path) of the old data contract yaml.")],
352
- 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
+ ],
353
419
  ):
354
420
  """
355
421
  PLACEHOLDER. Currently works as 'changelog' does.
@@ -386,7 +452,13 @@ def _handle_result(run):
386
452
  i = 1
387
453
  for check in run.checks:
388
454
  if check.result != "passed":
389
- 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
390
462
  raise typer.Exit(code=1)
391
463
 
392
464
 
@@ -396,7 +468,7 @@ def _print_table(run):
396
468
  table.add_column("Check", max_width=100)
397
469
  table.add_column("Field", max_width=32)
398
470
  table.add_column("Details", max_width=50)
399
- for check in run.checks:
471
+ for check in sorted(run.checks, key=lambda c: (c.result or "", c.model or "", c.field or "")):
400
472
  table.add_row(with_markup(check.result), check.name, to_field(run, check), check.reason)
401
473
  console.print(table)
402
474