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

datacontract/cli.py CHANGED
@@ -210,12 +210,21 @@ def export(
210
210
  # TODO: this should be a subcommand
211
211
  template: Annotated[
212
212
  Optional[Path],
213
- typer.Option(help="[custom] The file path of Jinja template."),
213
+ typer.Option(
214
+ help="The file path or URL of a template. For Excel format: path/URL to custom Excel template. For custom format: path to Jinja template."
215
+ ),
214
216
  ] = None,
215
217
  ):
216
218
  """
217
219
  Convert data contract to a specific format. Saves to file specified by `output` option if present, otherwise prints to stdout.
218
220
  """
221
+ # Validate that Excel format requires an output file path
222
+ if format == ExportFormat.excel and output is None:
223
+ console.print("❌ Error: Excel export requires an output file path.")
224
+ console.print("💡 Hint: Use --output to specify where to save the Excel file, e.g.:")
225
+ console.print(" datacontract export --format excel --output datacontract.xlsx")
226
+ raise typer.Exit(code=1)
227
+
219
228
  # TODO exception handling
220
229
  result = DataContract(data_contract_file=location, schema_location=schema, server=server).export(
221
230
  export_format=format,
@@ -230,8 +239,13 @@ def export(
230
239
  if output is None:
231
240
  console.print(result, markup=False, soft_wrap=True)
232
241
  else:
233
- with output.open(mode="w", encoding="utf-8") as f:
234
- f.write(result)
242
+ if isinstance(result, bytes):
243
+ # If the result is bytes, we assume it's a binary file (e.g., Excel, PDF)
244
+ with output.open(mode="wb") as f:
245
+ f.write(result)
246
+ else:
247
+ with output.open(mode="w", encoding="utf-8") as f:
248
+ f.write(result)
235
249
  console.print(f"Written result to {output}")
236
250
 
237
251
 
@@ -482,13 +496,14 @@ def _get_uvicorn_arguments(port: int, host: str, context: typer.Context) -> dict
482
496
  }
483
497
 
484
498
  # Create a list of the extra arguments, remove the leading -- from the cli arguments
485
- trimmed_keys = list(map(lambda x : str(x).replace("--", ""),context.args[::2]))
499
+ trimmed_keys = list(map(lambda x: str(x).replace("--", ""), context.args[::2]))
486
500
  # Merge the two dicts and return them as one dict
487
501
  return default_args | dict(zip(trimmed_keys, context.args[1::2]))
488
502
 
503
+
489
504
  @app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
490
505
  def api(
491
- ctx: Annotated[typer.Context, typer.Option(help="Extra arguments to pass to uvicorn.run().")],
506
+ ctx: Annotated[typer.Context, typer.Option(help="Extra arguments to pass to uvicorn.run().")],
492
507
  port: Annotated[int, typer.Option(help="Bind socket to this port.")] = 4242,
493
508
  host: Annotated[
494
509
  str, typer.Option(help="Bind socket to this host. Hint: For running in docker, set it to 0.0.0.0")
@@ -250,8 +250,14 @@ class DataContract:
250
250
  inline_quality=self._inline_quality,
251
251
  )
252
252
 
253
- def export(self, export_format: ExportFormat, model: str = "all", sql_server_type: str = "auto", **kwargs) -> str:
254
- if export_format == ExportFormat.html or export_format == ExportFormat.mermaid:
253
+ def export(
254
+ self, export_format: ExportFormat, model: str = "all", sql_server_type: str = "auto", **kwargs
255
+ ) -> str | bytes:
256
+ if (
257
+ export_format == ExportFormat.html
258
+ or export_format == ExportFormat.mermaid
259
+ or export_format == ExportFormat.excel
260
+ ):
255
261
  data_contract = resolve.resolve_data_contract_v2(
256
262
  self._data_contract_file,
257
263
  self._data_contract_str,
@@ -0,0 +1,922 @@
1
+ import io
2
+ import logging
3
+ from decimal import Decimal
4
+ from typing import Any, List, Optional
5
+
6
+ import openpyxl
7
+ import requests
8
+ from open_data_contract_standard.model import (
9
+ DataQuality,
10
+ OpenDataContractStandard,
11
+ SchemaObject,
12
+ SchemaProperty,
13
+ )
14
+ from openpyxl.cell.cell import Cell
15
+ from openpyxl.workbook.defined_name import DefinedName
16
+ from openpyxl.workbook.workbook import Workbook
17
+ from openpyxl.worksheet.worksheet import Worksheet
18
+
19
+ from datacontract.export.exporter import Exporter
20
+ from datacontract.model.data_contract_specification import DataContractSpecification
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ ODCS_EXCEL_TEMPLATE_URL = (
25
+ "https://github.com/datacontract/open-data-contract-standard-excel-template/raw/refs/heads/main/odcs-template.xlsx"
26
+ )
27
+
28
+
29
+ class ExcelExporter(Exporter):
30
+ """Excel exporter that uses the official ODCS template"""
31
+
32
+ def __init__(self, export_format):
33
+ super().__init__(export_format)
34
+
35
+ def export(self, data_contract, model, server, sql_server_type, export_args) -> bytes:
36
+ """
37
+ Export data contract to Excel using the official ODCS template
38
+
39
+ Args:
40
+ data_contract: DataContractSpecification or OpenDataContractStandard to export
41
+ model: Model name (not used for Excel export)
42
+ server: Server name (not used for Excel export)
43
+ sql_server_type: SQL server type (not used for Excel export)
44
+ export_args: Additional export arguments (template can be specified here)
45
+
46
+ Returns:
47
+ Excel file as bytes
48
+ """
49
+ # Convert to ODCS if needed
50
+ if isinstance(data_contract, DataContractSpecification):
51
+ # First convert DCS to ODCS format via YAML
52
+ yaml_content = data_contract.to_yaml()
53
+ odcs = OpenDataContractStandard.from_string(yaml_content)
54
+ else:
55
+ odcs = data_contract
56
+
57
+ # Get template from export_args if provided, otherwise use default
58
+ template = export_args.get("template") if export_args else None
59
+ return export_to_excel_bytes(odcs, template)
60
+
61
+
62
+ def export_to_excel_bytes(odcs: OpenDataContractStandard, template_path: Optional[str] = None) -> bytes:
63
+ """
64
+ Export ODCS to Excel format using the official template and return as bytes
65
+
66
+ Args:
67
+ odcs: OpenDataContractStandard object to export
68
+ template_path: Optional path/URL to custom Excel template. If None, uses default template.
69
+
70
+ Returns:
71
+ Excel file as bytes
72
+ """
73
+ if template_path:
74
+ workbook = create_workbook_from_template(template_path)
75
+ else:
76
+ workbook = create_workbook_from_template(ODCS_EXCEL_TEMPLATE_URL)
77
+
78
+ try:
79
+ fill_fundamentals(workbook, odcs)
80
+ fill_schema(workbook, odcs)
81
+ fill_quality(workbook, odcs)
82
+ fill_custom_properties(workbook, odcs)
83
+ fill_support(workbook, odcs)
84
+ fill_team(workbook, odcs)
85
+ fill_roles(workbook, odcs)
86
+ fill_sla_properties(workbook, odcs)
87
+ fill_servers(workbook, odcs)
88
+ fill_pricing(workbook, odcs)
89
+
90
+ # Set focus on the Fundamentals sheet
91
+ workbook.active = workbook["Fundamentals"]
92
+
93
+ # Force formula recalculation
94
+ try:
95
+ workbook.calculation.calcMode = "auto"
96
+ except (AttributeError, ValueError):
97
+ # Fallback for older openpyxl versions or if calcMode doesn't exist
98
+ pass
99
+
100
+ # Write to output stream
101
+ output = io.BytesIO()
102
+ workbook.save(output)
103
+ output.seek(0)
104
+ return output.getvalue()
105
+ finally:
106
+ workbook.close()
107
+
108
+
109
+ def create_workbook_from_template(template_path: str) -> Workbook:
110
+ """Load Excel template from file path or URL"""
111
+ try:
112
+ # Convert Path object to string if needed
113
+ template_path_str = str(template_path)
114
+ logger.info(f"Processing template path: {template_path_str}")
115
+
116
+ # Check if it's a URL
117
+ if template_path_str.startswith(("http://", "https://")):
118
+ logger.info(f"Identified as URL, downloading from: {template_path_str}")
119
+ # Download from URL
120
+ response = requests.get(template_path_str, timeout=30)
121
+ response.raise_for_status()
122
+ template_bytes = response.content
123
+ workbook = openpyxl.load_workbook(io.BytesIO(template_bytes))
124
+ else:
125
+ logger.info(f"Identified as local file: {template_path_str}")
126
+ # Load from local file
127
+ workbook = openpyxl.load_workbook(template_path_str)
128
+
129
+ return workbook
130
+ except Exception as e:
131
+ logger.error(f"Failed to load Excel template from {template_path}: {e}")
132
+ raise RuntimeError(f"Failed to load Excel template: {e}")
133
+
134
+
135
+ def fill_fundamentals(workbook: Workbook, odcs: OpenDataContractStandard):
136
+ """Fill the Fundamentals sheet with basic contract information"""
137
+ set_cell_value_by_name(workbook, "apiVersion", odcs.apiVersion)
138
+ set_cell_value_by_name(workbook, "kind", odcs.kind)
139
+ set_cell_value_by_name(workbook, "id", odcs.id)
140
+ set_cell_value_by_name(workbook, "name", odcs.name)
141
+ set_cell_value_by_name(workbook, "version", odcs.version)
142
+ set_cell_value_by_name(workbook, "status", odcs.status)
143
+ set_cell_value_by_name(workbook, "domain", odcs.domain)
144
+ set_cell_value_by_name(workbook, "dataProduct", odcs.dataProduct)
145
+ set_cell_value_by_name(workbook, "tenant", odcs.tenant)
146
+
147
+ # Set owner from custom properties
148
+ owner_value = None
149
+ if odcs.customProperties:
150
+ for prop in odcs.customProperties:
151
+ if prop.property == "owner":
152
+ owner_value = prop.value
153
+ break
154
+ set_cell_value_by_name(workbook, "owner", owner_value)
155
+
156
+ set_cell_value_by_name(workbook, "slaDefaultElement", odcs.slaDefaultElement)
157
+
158
+ # Set description fields
159
+ if odcs.description:
160
+ set_cell_value_by_name(workbook, "description.purpose", odcs.description.purpose)
161
+ set_cell_value_by_name(workbook, "description.limitations", odcs.description.limitations)
162
+ set_cell_value_by_name(workbook, "description.usage", odcs.description.usage)
163
+
164
+ # Set tags as comma-separated string
165
+ if odcs.tags:
166
+ set_cell_value_by_name(workbook, "tags", ",".join(odcs.tags))
167
+
168
+
169
+ def fill_pricing(workbook: Workbook, odcs: OpenDataContractStandard):
170
+ """Fill pricing information"""
171
+ if odcs.price:
172
+ set_cell_value_by_name(workbook, "price.priceAmount", odcs.price.priceAmount)
173
+ set_cell_value_by_name(workbook, "price.priceCurrency", odcs.price.priceCurrency)
174
+ set_cell_value_by_name(workbook, "price.priceUnit", odcs.price.priceUnit)
175
+
176
+
177
+ def fill_schema(workbook: Workbook, odcs: OpenDataContractStandard):
178
+ """Fill schema information by cloning template sheets"""
179
+ # Get template sheet "Schema <table_name>"
180
+ schema_template_sheet = workbook["Schema <table_name>"]
181
+
182
+ if odcs.schema_:
183
+ # Create copies for all schemas first
184
+ new_sheets = []
185
+ for schema in odcs.schema_:
186
+ # Clone the template sheet
187
+ new_sheet = workbook.copy_worksheet(schema_template_sheet)
188
+ new_sheet.title = f"Schema {schema.name}"
189
+
190
+ # Copy defined names with schema sheet scope to the new sheet
191
+ copy_sheet_names(workbook, schema_template_sheet, new_sheet)
192
+
193
+ # Move the new sheet before the template sheet
194
+ schema_template_sheet_index = workbook.index(schema_template_sheet)
195
+ new_sheet_index = workbook.index(new_sheet)
196
+
197
+ workbook.move_sheet(new_sheet, offset=schema_template_sheet_index - new_sheet_index)
198
+
199
+ new_sheets.append((new_sheet, schema))
200
+
201
+ # Remove the template sheet before filling
202
+ workbook.remove(schema_template_sheet)
203
+
204
+ # Now fill in schema information for each copied sheet
205
+ for new_sheet, schema in new_sheets:
206
+ # Copy named ranges from template to new sheet (if needed)
207
+ # Note: copy_worksheet should have copied the named ranges already
208
+
209
+ # Fill in schema information
210
+ fill_single_schema(new_sheet, schema)
211
+ else:
212
+ # Remove the template sheet even if no schemas
213
+ workbook.remove(schema_template_sheet)
214
+
215
+
216
+ def copy_sheet_names(workbook: Workbook, template_sheet: Worksheet, new_sheet: Worksheet):
217
+ """Copy worksheet-scoped named ranges from template sheet to new sheet"""
218
+ try:
219
+ # Copy worksheet-scoped defined names from template sheet to new sheet
220
+ for name_str in template_sheet.defined_names:
221
+ try:
222
+ # Get the DefinedName object
223
+ defined_name = template_sheet.defined_names[name_str]
224
+
225
+ # Get the original range reference
226
+ original_ref = defined_name.attr_text
227
+
228
+ # Create new defined name with same name and reference but scoped to new sheet
229
+ new_name = DefinedName(name_str, attr_text=original_ref.replace(template_sheet.title, new_sheet.title))
230
+
231
+ # Add to the new sheet's defined names (worksheet-scoped)
232
+ new_sheet.defined_names.add(new_name)
233
+
234
+ except Exception as e:
235
+ logger.warning(f"Failed to copy worksheet-scoped named range {name_str}: {e}")
236
+
237
+ except Exception as e:
238
+ logger.warning(f"Error copying sheet names: {e}")
239
+
240
+
241
+ def fill_single_schema(sheet: Worksheet, schema: SchemaObject):
242
+ """Fill a single schema sheet with schema information using named ranges"""
243
+ # Use worksheet-scoped named ranges that were copied from the template
244
+ set_cell_value_by_name_in_sheet(sheet, "schema.name", schema.name)
245
+ set_cell_value_by_name_in_sheet(
246
+ sheet, "schema.physicalType", schema.physicalType if schema.physicalType else "table"
247
+ )
248
+ set_cell_value_by_name_in_sheet(sheet, "schema.description", schema.description)
249
+ set_cell_value_by_name_in_sheet(sheet, "schema.businessName", schema.businessName)
250
+ set_cell_value_by_name_in_sheet(sheet, "schema.physicalName", schema.physicalName)
251
+ set_cell_value_by_name_in_sheet(sheet, "schema.dataGranularityDescription", schema.dataGranularityDescription)
252
+
253
+ # Set tags as comma-separated string
254
+ if schema.tags:
255
+ set_cell_value_by_name_in_sheet(sheet, "schema.tags", ",".join(schema.tags))
256
+
257
+ # Fill properties using the template's properties table structure
258
+ if schema.properties:
259
+ fill_properties_in_schema_sheet(sheet, schema.properties)
260
+
261
+
262
+ def fill_properties_in_schema_sheet(sheet: Worksheet, properties: List[SchemaProperty], prefix: str = ""):
263
+ """Fill properties in the schema sheet using the template's existing properties table"""
264
+ try:
265
+ # The template already has a properties table starting at row 13 with headers
266
+ # Find the header row and map column names to indices
267
+ header_row_index = 13
268
+ headers = get_headers_from_header_row(sheet, header_row_index)
269
+
270
+ # Reverse the headers dict to map header_name -> column_index
271
+ header_map = {header_name.lower(): col_idx for col_idx, header_name in headers.items()}
272
+
273
+ # Fill properties starting after header row
274
+ row_index = header_row_index + 1
275
+ for property in properties:
276
+ row_index = fill_single_property_template(sheet, row_index, prefix, property, header_map)
277
+
278
+ except Exception as e:
279
+ logger.warning(f"Error filling properties: {e}")
280
+
281
+
282
+ def fill_single_property_template(
283
+ sheet: Worksheet, row_index: int, prefix: str, property: SchemaProperty, header_map: dict
284
+ ) -> int:
285
+ """Fill a single property row using the template's column structure"""
286
+ property_name = f"{prefix}.{property.name}" if prefix else property.name
287
+
288
+ # Helper function to set cell value by header name
289
+ def set_by_header(header_name: str, value: Any):
290
+ col_idx = header_map.get(header_name.lower())
291
+ if col_idx is not None:
292
+ sheet.cell(row=row_index, column=col_idx + 1).value = value
293
+
294
+ # Fill property fields based on template headers
295
+ set_by_header("Property", property_name)
296
+ set_by_header("Business Name", property.businessName)
297
+ set_by_header("Logical Type", property.logicalType)
298
+ set_by_header("Physical Type", property.physicalType)
299
+ set_by_header("Physical Name", property.physicalName)
300
+ set_by_header("Description", property.description)
301
+ set_by_header("Required", property.required)
302
+ set_by_header("Unique", property.unique)
303
+ set_by_header("Primary Key", property.primaryKey)
304
+ set_by_header("Primary Key Position", property.primaryKeyPosition)
305
+ set_by_header("Partitioned", property.partitioned)
306
+ set_by_header("Partition Key Position", property.partitionKeyPosition)
307
+ set_by_header("Classification", property.classification)
308
+ set_by_header("Tags", ",".join(property.tags) if property.tags else "")
309
+ set_by_header(
310
+ "Example(s)", ",".join(property.examples) if property.examples else ""
311
+ ) # Note: using "Example(s)" as in template
312
+ set_by_header("Encrypted Name", property.encryptedName)
313
+ set_by_header(
314
+ "Transform Sources", ",".join(property.transformSourceObjects) if property.transformSourceObjects else ""
315
+ )
316
+ set_by_header("Transform Logic", property.transformLogic)
317
+
318
+ # Authoritative definitions
319
+ if property.authoritativeDefinitions and len(property.authoritativeDefinitions) > 0:
320
+ set_by_header("Authoritative Definition URL", property.authoritativeDefinitions[0].url)
321
+ set_by_header("Authoritative Definition Type", property.authoritativeDefinitions[0].type)
322
+
323
+ next_row_index = row_index + 1
324
+
325
+ # Handle nested properties
326
+ if property.properties:
327
+ for nested_property in property.properties:
328
+ next_row_index = fill_single_property_template(
329
+ sheet, next_row_index, property_name, nested_property, header_map
330
+ )
331
+
332
+ # Handle array items
333
+ if property.items:
334
+ next_row_index = fill_single_property_template(
335
+ sheet, next_row_index, f"{property_name}.items", property.items, header_map
336
+ )
337
+
338
+ return next_row_index
339
+
340
+
341
+ def fill_single_property_simple(
342
+ sheet: Worksheet, row_index: int, prefix: str, property: SchemaProperty, header_map: dict = None
343
+ ) -> int:
344
+ """Fill a single property row using header names (deprecated - use fill_single_property_template instead)"""
345
+ # This function is kept for backward compatibility but should use header_map if provided
346
+ if header_map is None:
347
+ # Fallback to the template-based approach
348
+ header_row_index = 13
349
+ headers = get_headers_from_header_row(sheet, header_row_index)
350
+ header_map = {header_name.lower(): col_idx for col_idx, header_name in headers.items()}
351
+
352
+ # Delegate to the template-based function
353
+ return fill_single_property_template(sheet, row_index, prefix, property, header_map)
354
+
355
+
356
+ def fill_quality(workbook: Workbook, odcs: OpenDataContractStandard):
357
+ """Fill the Quality sheet with quality data"""
358
+ quality_sheet = workbook["Quality"]
359
+
360
+ try:
361
+ ref = name_to_ref(workbook, "quality")
362
+ if not ref:
363
+ logger.warning("No quality range found")
364
+ return
365
+
366
+ # Parse range to find header row
367
+ header_row_index = parse_range_safely(ref)
368
+
369
+ headers = get_headers_from_header_row(quality_sheet, header_row_index)
370
+ current_row_index = header_row_index + 1
371
+
372
+ # Iterate through all schemas
373
+ if odcs.schema_:
374
+ for schema in odcs.schema_:
375
+ # Add schema-level quality attributes
376
+ if schema.quality:
377
+ for quality in schema.quality:
378
+ row = get_or_create_row(quality_sheet, current_row_index)
379
+ fill_quality_row(row, headers, schema.name, None, quality)
380
+ current_row_index += 1
381
+
382
+ # Add property-level quality attributes
383
+ if schema.properties:
384
+ current_row_index = fill_properties_quality(
385
+ quality_sheet, headers, schema.name, schema.properties, current_row_index
386
+ )
387
+
388
+ except Exception as e:
389
+ logger.warning(f"Error filling quality: {e}")
390
+
391
+
392
+ def fill_properties_quality(
393
+ sheet: Worksheet,
394
+ headers: dict,
395
+ schema_name: str,
396
+ properties: List[SchemaProperty],
397
+ start_row_index: int,
398
+ prefix: str = "",
399
+ ) -> int:
400
+ """Recursively fill quality data for properties"""
401
+ current_row_index = start_row_index
402
+
403
+ for property in properties:
404
+ if not property.name:
405
+ continue
406
+
407
+ full_property_name = f"{prefix}.{property.name}" if prefix else property.name
408
+
409
+ # Add quality attributes for this property
410
+ if property.quality:
411
+ for quality in property.quality:
412
+ row = get_or_create_row(sheet, current_row_index)
413
+ fill_quality_row(row, headers, schema_name, full_property_name, quality)
414
+ current_row_index += 1
415
+
416
+ # Recursively handle nested properties
417
+ if property.properties:
418
+ current_row_index = fill_properties_quality(
419
+ sheet, headers, schema_name, property.properties, current_row_index, full_property_name
420
+ )
421
+
422
+ # Handle array items
423
+ if property.items:
424
+ items_property_name = f"{full_property_name}.items"
425
+ if property.items.quality:
426
+ for quality in property.items.quality:
427
+ row = get_or_create_row(sheet, current_row_index)
428
+ fill_quality_row(row, headers, schema_name, items_property_name, quality)
429
+ current_row_index += 1
430
+
431
+ # Handle nested properties in array items
432
+ if property.items.properties:
433
+ current_row_index = fill_properties_quality(
434
+ sheet, headers, schema_name, property.items.properties, current_row_index, items_property_name
435
+ )
436
+
437
+ return current_row_index
438
+
439
+
440
+ def fill_quality_row(row, headers: dict, schema_name: str, property_name: Optional[str], quality: DataQuality):
441
+ """Fill a single quality row"""
442
+ for cell_index, header_name in headers.items():
443
+ header_lower = header_name.lower().strip()
444
+
445
+ if header_lower == "schema":
446
+ set_cell_value(row, cell_index, schema_name)
447
+ elif header_lower == "property":
448
+ set_cell_value(row, cell_index, property_name)
449
+ elif header_lower == "quality type":
450
+ set_cell_value(row, cell_index, quality.type)
451
+ elif header_lower == "description":
452
+ set_cell_value(row, cell_index, quality.description)
453
+ elif header_lower == "rule (library)":
454
+ set_cell_value(row, cell_index, quality.rule)
455
+ elif header_lower == "query (sql)":
456
+ set_cell_value(row, cell_index, quality.query)
457
+ elif header_lower == "threshold operator":
458
+ operator = get_threshold_operator(quality)
459
+ set_cell_value(row, cell_index, operator)
460
+ elif header_lower == "threshold value":
461
+ value = get_threshold_value(quality)
462
+ set_cell_value(row, cell_index, value)
463
+ elif header_lower == "quality engine (custom)":
464
+ set_cell_value(row, cell_index, quality.engine)
465
+ elif header_lower == "implementation (custom)":
466
+ set_cell_value(row, cell_index, quality.implementation)
467
+ elif header_lower == "severity":
468
+ set_cell_value(row, cell_index, quality.severity)
469
+ elif header_lower == "scheduler":
470
+ set_cell_value(row, cell_index, quality.scheduler)
471
+ elif header_lower == "schedule":
472
+ set_cell_value(row, cell_index, quality.schedule)
473
+
474
+
475
+ def get_threshold_operator(quality: DataQuality) -> Optional[str]:
476
+ """Get the threshold operator from quality object"""
477
+ if hasattr(quality, "mustBe") and quality.mustBe is not None:
478
+ return "mustBe"
479
+ elif hasattr(quality, "mustNotBe") and quality.mustNotBe is not None:
480
+ return "mustNotBe"
481
+ elif hasattr(quality, "mustBeGreaterThan") and quality.mustBeGreaterThan is not None:
482
+ return "mustBeGreaterThan"
483
+ elif hasattr(quality, "mustBeGreaterThanOrEqualTo") and quality.mustBeGreaterThanOrEqualTo is not None:
484
+ return "mustBeGreaterThanOrEqualTo"
485
+ elif hasattr(quality, "mustBeLessThan") and quality.mustBeLessThan is not None:
486
+ return "mustBeLessThan"
487
+ elif hasattr(quality, "mustBeLessThanOrEqualTo") and quality.mustBeLessThanOrEqualTo is not None:
488
+ return "mustBeLessThanOrEqualTo"
489
+ elif hasattr(quality, "mustBeBetween") and quality.mustBeBetween is not None:
490
+ return "mustBeBetween"
491
+ elif hasattr(quality, "mustNotBeBetween") and quality.mustNotBeBetween is not None:
492
+ return "mustNotBeBetween"
493
+ return None
494
+
495
+
496
+ def get_threshold_value(quality: DataQuality) -> Optional[str]:
497
+ """Get the threshold value from quality object"""
498
+ if hasattr(quality, "mustBe") and quality.mustBe is not None:
499
+ return str(quality.mustBe)
500
+ elif hasattr(quality, "mustNotBe") and quality.mustNotBe is not None:
501
+ return str(quality.mustNotBe)
502
+ elif hasattr(quality, "mustBeGreaterThan") and quality.mustBeGreaterThan is not None:
503
+ return str(quality.mustBeGreaterThan)
504
+ elif hasattr(quality, "mustBeGreaterThanOrEqualTo") and quality.mustBeGreaterThanOrEqualTo is not None:
505
+ return str(quality.mustBeGreaterThanOrEqualTo)
506
+ elif hasattr(quality, "mustBeLessThan") and quality.mustBeLessThan is not None:
507
+ return str(quality.mustBeLessThan)
508
+ elif hasattr(quality, "mustBeLessThanOrEqualTo") and quality.mustBeLessThanOrEqualTo is not None:
509
+ return str(quality.mustBeLessThanOrEqualTo)
510
+ elif hasattr(quality, "mustBeBetween") and quality.mustBeBetween is not None and len(quality.mustBeBetween) >= 2:
511
+ return f"[{quality.mustBeBetween[0]}, {quality.mustBeBetween[1]}]"
512
+ elif (
513
+ hasattr(quality, "mustNotBeBetween")
514
+ and quality.mustNotBeBetween is not None
515
+ and len(quality.mustNotBeBetween) >= 2
516
+ ):
517
+ return f"[{quality.mustNotBeBetween[0]}, {quality.mustNotBeBetween[1]}]"
518
+ return None
519
+
520
+
521
+ def fill_custom_properties(workbook: Workbook, odcs: OpenDataContractStandard):
522
+ """Fill the Custom Properties sheet"""
523
+ try:
524
+ ref = name_to_ref(workbook, "CustomProperties")
525
+ if not ref:
526
+ logger.warning("No CustomProperties range found")
527
+ return
528
+
529
+ custom_properties_sheet = workbook["Custom Properties"]
530
+
531
+ # Parse range to find header row
532
+ header_row_index = parse_range_safely(ref)
533
+
534
+ # Fill custom properties excluding owner
535
+ if odcs.customProperties:
536
+ row_index = header_row_index + 1
537
+ for prop in odcs.customProperties:
538
+ if prop.property != "owner" and prop.property:
539
+ row = get_or_create_row(custom_properties_sheet, row_index)
540
+ set_cell_value(row, 0, prop.property) # Property column
541
+ set_cell_value(row, 1, prop.value) # Value column
542
+ row_index += 1
543
+
544
+ except Exception as e:
545
+ logger.warning(f"Error filling custom properties: {e}")
546
+
547
+
548
+ def fill_support(workbook: Workbook, odcs: OpenDataContractStandard):
549
+ """Fill the Support sheet"""
550
+ try:
551
+ ref = name_to_ref(workbook, "support")
552
+ if not ref:
553
+ logger.warning("No support range found")
554
+ return
555
+
556
+ support_sheet = workbook["Support"]
557
+
558
+ # Parse range to find header row
559
+ header_row_index = parse_range_safely(ref)
560
+
561
+ headers = get_headers_from_header_row(support_sheet, header_row_index)
562
+
563
+ if odcs.support:
564
+ for support_index, support_channel in enumerate(odcs.support):
565
+ row = get_or_create_row(support_sheet, header_row_index + 1 + support_index)
566
+
567
+ for cell_index, header_name in headers.items():
568
+ header_lower = header_name.lower()
569
+ if header_lower == "channel":
570
+ set_cell_value(row, cell_index, support_channel.channel)
571
+ elif header_lower == "channel url":
572
+ set_cell_value(row, cell_index, support_channel.url)
573
+ elif header_lower == "description":
574
+ set_cell_value(row, cell_index, support_channel.description)
575
+ elif header_lower == "tool":
576
+ set_cell_value(row, cell_index, support_channel.tool)
577
+ elif header_lower == "scope":
578
+ set_cell_value(row, cell_index, support_channel.scope)
579
+ elif header_lower == "invitation url":
580
+ set_cell_value(row, cell_index, support_channel.invitationUrl)
581
+
582
+ except Exception as e:
583
+ logger.warning(f"Error filling support: {e}")
584
+
585
+
586
+ def fill_team(workbook: Workbook, odcs: OpenDataContractStandard):
587
+ """Fill the Team sheet"""
588
+ try:
589
+ ref = name_to_ref(workbook, "team")
590
+ if not ref:
591
+ logger.warning("No team range found")
592
+ return
593
+
594
+ team_sheet = workbook["Team"]
595
+
596
+ # Parse range to find header row
597
+ header_row_index = parse_range_safely(ref)
598
+
599
+ headers = get_headers_from_header_row(team_sheet, header_row_index)
600
+
601
+ if odcs.team:
602
+ for team_index, team_member in enumerate(odcs.team):
603
+ row = get_or_create_row(team_sheet, header_row_index + 1 + team_index)
604
+
605
+ for cell_index, header_name in headers.items():
606
+ header_lower = header_name.lower()
607
+ if header_lower == "username":
608
+ set_cell_value(row, cell_index, team_member.username)
609
+ elif header_lower == "name":
610
+ set_cell_value(row, cell_index, team_member.name)
611
+ elif header_lower == "description":
612
+ set_cell_value(row, cell_index, team_member.description)
613
+ elif header_lower == "role":
614
+ set_cell_value(row, cell_index, team_member.role)
615
+ elif header_lower == "date in":
616
+ set_cell_value(row, cell_index, team_member.dateIn)
617
+ elif header_lower == "date out":
618
+ set_cell_value(row, cell_index, team_member.dateOut)
619
+ elif header_lower == "replaced by username":
620
+ set_cell_value(row, cell_index, team_member.replacedByUsername)
621
+
622
+ except Exception as e:
623
+ logger.warning(f"Error filling team: {e}")
624
+
625
+
626
+ def fill_roles(workbook: Workbook, odcs: OpenDataContractStandard):
627
+ """Fill the Roles sheet using fixed table structure"""
628
+ try:
629
+ roles_sheet = workbook["Roles"]
630
+
631
+ # From template analysis: Row 4 has headers
632
+ header_row_index = 4
633
+ headers = get_headers_from_header_row(roles_sheet, header_row_index)
634
+
635
+ if odcs.roles:
636
+ for role_index, role in enumerate(odcs.roles):
637
+ row = get_or_create_row(roles_sheet, header_row_index + 1 + role_index)
638
+
639
+ for cell_index, header_name in headers.items():
640
+ header_lower = header_name.lower()
641
+ if header_lower == "role":
642
+ set_cell_value(row, cell_index, role.role)
643
+ elif header_lower == "description":
644
+ set_cell_value(row, cell_index, role.description)
645
+ elif header_lower == "access":
646
+ set_cell_value(row, cell_index, role.access)
647
+ elif header_lower == "1st level approvers":
648
+ set_cell_value(row, cell_index, role.firstLevelApprovers)
649
+ elif header_lower == "2nd level approvers":
650
+ set_cell_value(row, cell_index, role.secondLevelApprovers)
651
+
652
+ except Exception as e:
653
+ logger.warning(f"Error filling roles: {e}")
654
+
655
+
656
+ def fill_sla_properties(workbook: Workbook, odcs: OpenDataContractStandard):
657
+ """Fill the SLA sheet using fixed table structure"""
658
+ try:
659
+ sla_sheet = workbook["SLA"]
660
+
661
+ # From template analysis: Row 6 has the SLA properties table headers
662
+ header_row_index = 6
663
+
664
+ headers = get_headers_from_header_row(sla_sheet, header_row_index)
665
+
666
+ if odcs.slaProperties:
667
+ for sla_index, sla_prop in enumerate(odcs.slaProperties):
668
+ row = get_or_create_row(sla_sheet, header_row_index + 1 + sla_index)
669
+
670
+ for cell_index, header_name in headers.items():
671
+ header_lower = header_name.lower()
672
+ if header_lower == "property":
673
+ set_cell_value(row, cell_index, sla_prop.property)
674
+ elif header_lower == "value":
675
+ set_cell_value(row, cell_index, sla_prop.value)
676
+ elif header_lower == "extended value":
677
+ set_cell_value(row, cell_index, sla_prop.valueExt)
678
+ elif header_lower == "unit":
679
+ set_cell_value(row, cell_index, sla_prop.unit)
680
+ elif header_lower == "element":
681
+ set_cell_value(row, cell_index, sla_prop.element)
682
+ elif header_lower == "driver":
683
+ set_cell_value(row, cell_index, sla_prop.driver)
684
+
685
+ except Exception as e:
686
+ logger.warning(f"Error filling SLA properties: {e}")
687
+
688
+
689
+ def fill_servers(workbook: Workbook, odcs: OpenDataContractStandard):
690
+ """Fill the Servers sheet"""
691
+ try:
692
+ servers_sheet = workbook["Servers"]
693
+
694
+ if odcs.servers:
695
+ for index, server in enumerate(odcs.servers):
696
+ set_cell_value_by_column_index(servers_sheet, "servers.server", index, server.server)
697
+ set_cell_value_by_column_index(servers_sheet, "servers.description", index, server.description)
698
+ set_cell_value_by_column_index(servers_sheet, "servers.environment", index, server.environment)
699
+ set_cell_value_by_column_index(servers_sheet, "servers.type", index, server.type)
700
+
701
+ # Type-specific fields
702
+ server_type = server.type
703
+ if server_type == "azure":
704
+ set_cell_value_by_column_index(servers_sheet, "servers.azure.location", index, server.location)
705
+ set_cell_value_by_column_index(servers_sheet, "servers.azure.format", index, server.format)
706
+ set_cell_value_by_column_index(servers_sheet, "servers.azure.delimiter", index, server.delimiter)
707
+ elif server_type == "bigquery":
708
+ set_cell_value_by_column_index(servers_sheet, "servers.bigquery.project", index, server.project)
709
+ set_cell_value_by_column_index(servers_sheet, "servers.bigquery.dataset", index, server.dataset)
710
+ elif server_type == "databricks":
711
+ set_cell_value_by_column_index(servers_sheet, "servers.databricks.catalog", index, server.catalog)
712
+ set_cell_value_by_column_index(servers_sheet, "servers.databricks.host", index, server.host)
713
+ set_cell_value_by_column_index(servers_sheet, "servers.databricks.schema", index, server.schema_)
714
+ # Add other server types as needed...
715
+
716
+ except Exception as e:
717
+ logger.warning(f"Error filling servers: {e}")
718
+
719
+
720
+ # Helper functions
721
+
722
+
723
+ def find_cell_by_name(workbook: Workbook, name: str) -> Optional[Cell]:
724
+ """Find a cell by its named range"""
725
+ try:
726
+ ref = name_to_ref(workbook, name)
727
+ if not ref:
728
+ return None
729
+ return find_cell_by_ref(workbook, ref)
730
+ except Exception:
731
+ return None
732
+
733
+
734
+ def find_cell_by_name_in_sheet(sheet: Worksheet, name: str) -> Optional[Cell]:
735
+ """Find a cell by its named range within a specific sheet"""
736
+ try:
737
+ # Access worksheet-scoped defined names directly
738
+ for named_range in sheet.defined_names:
739
+ if named_range == name:
740
+ destinations = sheet.defined_names[named_range].destinations
741
+ for sheet_title, coordinate in destinations:
742
+ if sheet_title == sheet.title:
743
+ return sheet[coordinate]
744
+ except Exception:
745
+ return None
746
+ return None
747
+
748
+
749
+ def find_cell_by_ref(workbook: Workbook, cell_ref: str) -> Optional[Cell]:
750
+ """Find a cell by its reference"""
751
+ try:
752
+ from openpyxl.utils.cell import column_index_from_string, coordinate_from_string
753
+
754
+ # Parse the reference
755
+ if "!" in cell_ref:
756
+ sheet_name, coord = cell_ref.split("!")
757
+ sheet_name = sheet_name.strip("'")
758
+ sheet = workbook[sheet_name]
759
+ else:
760
+ coord = cell_ref
761
+ sheet = workbook.active
762
+
763
+ # Remove $ signs
764
+ coord = coord.replace("$", "")
765
+ col_letter, row_num = coordinate_from_string(coord)
766
+ col_num = column_index_from_string(col_letter)
767
+
768
+ return sheet.cell(row=int(row_num), column=col_num)
769
+ except Exception:
770
+ return None
771
+
772
+
773
+ def find_cell_by_ref_in_sheet(sheet: Worksheet, cell_ref: str) -> Optional[Cell]:
774
+ """Find a cell by its reference within a specific sheet"""
775
+ try:
776
+ from openpyxl.utils.cell import column_index_from_string, coordinate_from_string
777
+
778
+ # Remove sheet name if present
779
+ if "!" in cell_ref:
780
+ _, coord = cell_ref.split("!")
781
+ else:
782
+ coord = cell_ref
783
+
784
+ # Remove $ signs
785
+ coord = coord.replace("$", "")
786
+ col_letter, row_num = coordinate_from_string(coord)
787
+ col_num = column_index_from_string(col_letter)
788
+
789
+ return sheet.cell(row=int(row_num), column=col_num)
790
+ except Exception:
791
+ return None
792
+
793
+
794
+ def name_to_ref(workbook: Workbook, name: str) -> Optional[str]:
795
+ """Get the reference for a named range in the workbook"""
796
+ try:
797
+ defined_name = workbook.defined_names.get(name)
798
+ if defined_name:
799
+ return defined_name.attr_text
800
+ except Exception:
801
+ pass
802
+ return None
803
+
804
+
805
+ def name_to_ref_in_sheet(sheet: Worksheet, name: str) -> Optional[str]:
806
+ """Get the reference for a named range in a specific sheet"""
807
+ try:
808
+ workbook = sheet.parent
809
+ defined_names = [dn for dn in workbook.defined_names if dn.name == name]
810
+ for dn in defined_names:
811
+ if sheet.title in dn.attr_text:
812
+ return dn.attr_text
813
+ except Exception:
814
+ pass
815
+ return None
816
+
817
+
818
+ def set_cell_value_by_name(workbook: Workbook, cell_name: str, value: Any):
819
+ """Set cell value by named range"""
820
+ cell = find_cell_by_name(workbook, cell_name)
821
+ if cell:
822
+ set_cell_value_direct(cell, value)
823
+ else:
824
+ logger.warning(f"Cell with name {cell_name} not found in workbook")
825
+
826
+
827
+ def set_cell_value_by_name_in_sheet(sheet: Worksheet, cell_name: str, value: Any):
828
+ """Set cell value by named range within a specific sheet"""
829
+ cell = find_cell_by_name_in_sheet(sheet, cell_name)
830
+ if cell:
831
+ set_cell_value_direct(cell, value)
832
+ else:
833
+ logger.warning(f"Cell with name {cell_name} not found in sheet {sheet.title}")
834
+
835
+
836
+ def set_cell_value_by_column_index(sheet: Worksheet, name: str, column_index: int, value: Any):
837
+ """Set cell value by column offset from named range"""
838
+ try:
839
+ workbook = sheet.parent
840
+ first_cell = find_cell_by_name(workbook, name)
841
+ if first_cell:
842
+ target_cell = sheet.cell(row=first_cell.row, column=first_cell.column + column_index)
843
+ set_cell_value_direct(target_cell, value)
844
+ except Exception as e:
845
+ logger.warning(f"Error setting cell value by column index: {e}")
846
+
847
+
848
+ def set_cell_value_direct(cell: Cell, value: Any):
849
+ """Set cell value directly"""
850
+ if value is not None:
851
+ if isinstance(value, bool):
852
+ cell.value = value
853
+ elif isinstance(value, (int, float, Decimal)):
854
+ cell.value = float(value)
855
+ else:
856
+ cell.value = str(value)
857
+ else:
858
+ cell.value = None
859
+
860
+
861
+ def set_cell_value(row, cell_index: int, value: Any):
862
+ """Set cell value in a row at specific index"""
863
+ cell = get_or_create_cell(row, cell_index)
864
+ set_cell_value_direct(cell, value)
865
+
866
+
867
+ def get_or_create_row(sheet: Worksheet, row_index: int):
868
+ """Get or create a row at the specified index"""
869
+ try:
870
+ return sheet[row_index]
871
+ except (IndexError, KeyError):
872
+ # If row doesn't exist, create it
873
+ while len(list(sheet.rows)) < row_index:
874
+ sheet.append([])
875
+ return sheet[row_index]
876
+
877
+
878
+ def get_or_create_cell(row, cell_index: int) -> Cell:
879
+ """Get or create a cell at the specified index in a row"""
880
+ try:
881
+ return row[cell_index]
882
+ except IndexError:
883
+ # Extend the row if needed
884
+ while len(row) <= cell_index:
885
+ row.append(None)
886
+ return row[cell_index]
887
+
888
+
889
+ def parse_range_safely(ref: str) -> int:
890
+ """Parse a range reference and return the starting row number"""
891
+ try:
892
+ from openpyxl.utils import range_boundaries
893
+
894
+ min_col, min_row, max_col, max_row = range_boundaries(ref)
895
+ return min_row
896
+ except Exception:
897
+ # Handle malformed ranges - extract row number from range like "Quality!$A$4:$AZ$300"
898
+ if ":" in ref:
899
+ start_ref = ref.split(":")[0]
900
+ if "!" in start_ref:
901
+ start_ref = start_ref.split("!")[-1]
902
+ start_ref = start_ref.replace("$", "")
903
+ # Extract row number
904
+ import re
905
+
906
+ row_match = re.search(r"(\d+)", start_ref)
907
+ if row_match:
908
+ return int(row_match.group(1))
909
+ return 1
910
+
911
+
912
+ def get_headers_from_header_row(sheet: Worksheet, header_row_index: int) -> dict:
913
+ """Get headers from a row and return as dict mapping cell_index -> header_name"""
914
+ headers = {}
915
+ try:
916
+ header_row = sheet[header_row_index]
917
+ for cell_index, cell in enumerate(header_row):
918
+ if cell.value:
919
+ headers[cell_index] = str(cell.value).strip()
920
+ except Exception as e:
921
+ logger.warning(f"Error getting headers from row {header_row_index}: {e}")
922
+ return headers
@@ -45,6 +45,7 @@ class ExportFormat(str, Enum):
45
45
  markdown = "markdown"
46
46
  iceberg = "iceberg"
47
47
  custom = "custom"
48
+ excel = "excel"
48
49
 
49
50
  @classmethod
50
51
  def get_supported_formats(cls):
@@ -204,3 +204,7 @@ exporter_factory.register_lazy_exporter(
204
204
  exporter_factory.register_lazy_exporter(
205
205
  name=ExportFormat.custom, module_path="datacontract.export.custom_converter", class_name="CustomExporter"
206
206
  )
207
+
208
+ exporter_factory.register_lazy_exporter(
209
+ name=ExportFormat.excel, module_path="datacontract.export.excel_exporter", class_name="ExcelExporter"
210
+ )
@@ -415,7 +415,8 @@ def get_cell_value_by_name(workbook: Workbook, name: str) -> str | None:
415
415
  try:
416
416
  cell = get_cell_by_name_in_workbook(workbook, name)
417
417
  if cell.value is not None:
418
- return str(cell.value)
418
+ value = str(cell.value).strip()
419
+ return value if value else None
419
420
  except Exception as e:
420
421
  logger.warning(f"Error getting cell value by name {name}: {str(e)}")
421
422
  return None
@@ -431,7 +432,8 @@ def get_cell_value_by_name_in_sheet(sheet: Worksheet, name: str) -> str | None:
431
432
  if sheet_title == sheet.title:
432
433
  cell = sheet[coordinate]
433
434
  if cell.value is not None:
434
- return str(cell.value)
435
+ value = str(cell.value).strip()
436
+ return value if value else None
435
437
  except Exception as e:
436
438
  logger.warning(f"Error getting cell value by name {name} in sheet {sheet.title}: {str(e)}")
437
439
  return None
@@ -443,7 +445,10 @@ def get_cell_value(row, col_idx):
443
445
  return None
444
446
  try:
445
447
  cell = row[col_idx]
446
- return str(cell.value) if cell.value is not None else None
448
+ if cell.value is not None:
449
+ value = str(cell.value).strip()
450
+ return value if value else None
451
+ return None
447
452
  except (IndexError, AttributeError):
448
453
  return None
449
454
 
@@ -452,7 +457,10 @@ def get_cell_value_by_position(sheet, row_idx, col_idx):
452
457
  """Get cell value by row and column indices (0-based)"""
453
458
  try:
454
459
  cell = sheet.cell(row=row_idx + 1, column=col_idx + 1) # Convert to 1-based indices
455
- return str(cell.value) if cell.value is not None else None
460
+ if cell.value is not None:
461
+ value = str(cell.value).strip()
462
+ return value if value else None
463
+ return None
456
464
  except Exception as e:
457
465
  logger.warning(f"Error getting cell value by position ({row_idx}, {col_idx}): {str(e)}")
458
466
  return None
@@ -823,7 +831,7 @@ def import_custom_properties(workbook: Workbook) -> List[CustomProperty]:
823
831
  except Exception as e:
824
832
  logger.warning(f"Error importing custom properties: {str(e)}")
825
833
 
826
- return custom_properties
834
+ return custom_properties if custom_properties else None
827
835
 
828
836
 
829
837
  def parse_property_value(value: str) -> Any:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datacontract-cli
3
- Version: 0.10.32
3
+ Version: 0.10.33
4
4
  Summary: The datacontract CLI is an open source command-line tool for working with Data Contracts. It uses data contract YAML files to lint the data contract, connect to data sources and execute schema and quality tests, detect breaking changes, and export to different formats. The tool is written in Python. It can be used as a standalone CLI tool, in a CI/CD pipeline, or directly as a Python library.
5
5
  Author-email: Jochen Christ <jochen.christ@innoq.com>, Stefan Negele <stefan.negele@innoq.com>, Simon Harrer <simon.harrer@innoq.com>
6
6
  License-Expression: MIT
@@ -42,7 +42,7 @@ Provides-Extra: databricks
42
42
  Requires-Dist: soda-core-spark-df<3.6.0,>=3.3.20; extra == "databricks"
43
43
  Requires-Dist: soda-core-spark[databricks]<3.6.0,>=3.3.20; extra == "databricks"
44
44
  Requires-Dist: databricks-sql-connector<4.1.0,>=3.7.0; extra == "databricks"
45
- Requires-Dist: databricks-sdk<0.60.0; extra == "databricks"
45
+ Requires-Dist: databricks-sdk<0.61.0; extra == "databricks"
46
46
  Requires-Dist: pyspark<4.0.0,>=3.5.5; extra == "databricks"
47
47
  Provides-Extra: iceberg
48
48
  Requires-Dist: pyiceberg==0.9.1; extra == "iceberg"
@@ -214,9 +214,15 @@ $ datacontract export --format odcs datacontract.yaml --output odcs.yaml
214
214
  # import ODCS to data contract
215
215
  $ datacontract import --format odcs odcs.yaml --output datacontract.yaml
216
216
 
217
- # import sql (other formats: avro, glue, bigquery, jsonschema ...)
217
+ # import sql (other formats: avro, glue, bigquery, jsonschema, excel ...)
218
218
  $ datacontract import --format sql --source my-ddl.sql --dialect postgres --output datacontract.yaml
219
219
 
220
+ # import from Excel template
221
+ $ datacontract import --format excel --source odcs.xlsx --output datacontract.yaml
222
+
223
+ # export to Excel template
224
+ $ datacontract export --format excel --output odcs.xlsx datacontract.yaml
225
+
220
226
  # find differences between two data contracts
221
227
  $ datacontract diff datacontract-v1.yaml datacontract-v2.yaml
222
228
 
@@ -933,7 +939,7 @@ models:
933
939
  │ terraform|avro-idl|sql|sql-query|mer │
934
940
  │ maid|html|go|bigquery|dbml|spark|sql │
935
941
  │ alchemy|data-caterer|dcs|markdown|ic │
936
- │ eberg|custom]
942
+ │ eberg|custom|excel]
937
943
  │ --output PATH Specify the file path where the │
938
944
  │ exported data will be saved. If no │
939
945
  │ path is provided, the output will be │
@@ -1003,6 +1009,7 @@ Available export options:
1003
1009
  | `dcs` | Export to Data Contract Specification in YAML format | ✅ |
1004
1010
  | `markdown` | Export to Markdown | ✅ |
1005
1011
  | `iceberg` | Export to an Iceberg JSON Schema Definition | partial |
1012
+ | `excel` | Export to ODCS Excel Template | ✅ |
1006
1013
  | `custom` | Export to Custom format with Jinja | ✅ |
1007
1014
  | Missing something? | Please create an issue on GitHub | TBD |
1008
1015
 
@@ -1274,6 +1281,22 @@ FROM
1274
1281
  {{ ref('orders') }}
1275
1282
  ```
1276
1283
 
1284
+ #### ODCS Excel Templace
1285
+
1286
+ The `export` function converts a data contract into an ODCS (Open Data Contract Standard) Excel template. This creates a user-friendly Excel spreadsheet that can be used for authoring, sharing, and managing data contracts using the familiar Excel interface.
1287
+
1288
+ ```shell
1289
+ datacontract export --format excel --output datacontract.xlsx datacontract.yaml
1290
+ ```
1291
+
1292
+ The Excel format enables:
1293
+ - **User-friendly authoring**: Create and edit data contracts in Excel's familiar interface
1294
+ - **Easy sharing**: Distribute data contracts as standard Excel files
1295
+ - **Collaboration**: Enable non-technical stakeholders to contribute to data contract definitions
1296
+ - **Round-trip conversion**: Import Excel templates back to YAML data contracts
1297
+
1298
+ For more information about the Excel template structure, visit the [ODCS Excel Template repository](https://github.com/datacontract/open-data-contract-standard-excel-template).
1299
+
1277
1300
  ### import
1278
1301
  ```
1279
1302
 
@@ -1392,6 +1415,7 @@ Available import options:
1392
1415
  | `spark` | Import from Spark StructTypes, Variant | ✅ |
1393
1416
  | `sql` | Import from SQL DDL | ✅ |
1394
1417
  | `unity` | Import from Databricks Unity Catalog | partial |
1418
+ | `excel` | Import from ODCS Excel Template | ✅ |
1395
1419
  | Missing something? | Please create an issue on GitHub | TBD |
1396
1420
 
1397
1421
 
@@ -1,7 +1,7 @@
1
1
  datacontract/__init__.py,sha256=ThDdxDJsd7qNErLoh628nK5M7RzhJNYCmN-C6BAJFoo,405
2
2
  datacontract/api.py,sha256=Ze6pVD3Ub0oyMJI3iYSNXH78K2nPKbXKKHA-0DerJ48,8175
3
- datacontract/cli.py,sha256=KSXii4MsrdmEwFTDN9F7A-OC250gdY0R914FBqA2RuY,18614
4
- datacontract/data_contract.py,sha256=yU0Ys4-MK16tTm5RAnALGaNfqpvFmAjfabZg7ePqV5Y,15074
3
+ datacontract/cli.py,sha256=MxtTI15tnkPieSbHdqtU-wCiwj1oCiEnlMHFGzB4OUg,19364
4
+ datacontract/data_contract.py,sha256=Jlgkbzj6UN8RtFDK5VFcqm7v8oitVs-q10msU8W3Uo8,15183
5
5
  datacontract/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  datacontract/breaking/breaking.py,sha256=DnqgxUjD-EAZcg5RBizOP9a2WxsFTaQBik0AB_m3K00,20431
7
7
  datacontract/breaking/breaking_change.py,sha256=BIDEUo1U2CQLVT2-I5PyFttxAj6zQPI1UUkEoOOQXMY,2249
@@ -34,8 +34,9 @@ datacontract/export/dbml_converter.py,sha256=f_OZEFwRUyL-Kg2yn_G58I8iz1VfFrZh8Nb
34
34
  datacontract/export/dbt_converter.py,sha256=U2x7rtEnq1s3pHhM0L2B6D6OQtKdCdm4PBSqNCHczHk,10577
35
35
  datacontract/export/dcs_exporter.py,sha256=RALQ7bLAjak7EsoFFL2GFX2Oju7pnCDPCdRN_wo9wHM,210
36
36
  datacontract/export/duckdb_type_converter.py,sha256=hUAAbImhJUMJOXEG-UoOKQqYGrJM6UILpn2YjUuAUOw,2216
37
- datacontract/export/exporter.py,sha256=P_6J5d7k5GPm-DUyfrbgEXmU_o45FHu5nlOTT2CdkUk,3049
38
- datacontract/export/exporter_factory.py,sha256=PWA2j82Vjenj4hXlYXUISTzttrMIILdx8LxNv6hM0cg,6014
37
+ datacontract/export/excel_exporter.py,sha256=ySZL93oaENIjaLyctwoXOiT3yWf311YG3vYtLttjImI,38274
38
+ datacontract/export/exporter.py,sha256=Xo4RyPq9W42hH3xfAX2v8FeQdMFoW0eVzgahY6JjlWI,3069
39
+ datacontract/export/exporter_factory.py,sha256=JRrfcQ9CXiZCw56nFNu9uPSLjlDJLfUC7xPdVTyk6K8,6164
39
40
  datacontract/export/go_converter.py,sha256=Ttvbfu3YU-3GBwRD6nwCsFyZuc_hiIvJD-Jg2sT5WLw,3331
40
41
  datacontract/export/great_expectations_converter.py,sha256=Wx0mESRy4xAf8y7HjESsGsQaaei8k9xOVu3RbC6BlQM,12257
41
42
  datacontract/export/html_exporter.py,sha256=EyTMj25_Df3irZiYw1hxVZeLYWp6YSG6z3IuFUviP14,3066
@@ -59,7 +60,7 @@ datacontract/imports/bigquery_importer.py,sha256=7TcP9FDsIas5LwJZ-HrOPXZ-NuR056s
59
60
  datacontract/imports/csv_importer.py,sha256=mBsmyTvfB8q64Z3NYqv4zTDUOvoXG896hZvp3oLt5YM,5330
60
61
  datacontract/imports/dbml_importer.py,sha256=o0IOgvXN34lU1FICDHm_QUTv0DKsgwbHPHUDxQhIapE,3872
61
62
  datacontract/imports/dbt_importer.py,sha256=hQwqD9vbvwLLc6Yj3tQbar5ldI0pV-ynSiz7CZZ0JCc,8290
62
- datacontract/imports/excel_importer.py,sha256=C9aETQhzWjzFtVWMi2pD-G1cVKgPwRJT_puyEgvkbVA,46110
63
+ datacontract/imports/excel_importer.py,sha256=eBLc9VS9OYVFYFcHFHq9HYOStAPBDfVHwmgnBHjxOmc,46415
63
64
  datacontract/imports/glue_importer.py,sha256=fiJPkvfwOCsaKKCGW19-JM5CCGXZ2mkNrVtUzp2iw6g,8370
64
65
  datacontract/imports/iceberg_importer.py,sha256=vadGJVqQKgG-j8swUytZALFB8QjbGRqZPCcPcCy0vco,5923
65
66
  datacontract/imports/importer.py,sha256=NRhR_9AWPWDNq2ac_DVUHGoJuvkVpwwaao8nDfJG_l0,1257
@@ -112,9 +113,9 @@ datacontract/templates/partials/model_field.html,sha256=2YBF95ypNCPFYuYKoeilRnDG
112
113
  datacontract/templates/partials/quality.html,sha256=ynEDWRn8I90Uje-xhGYgFcfwOgKI1R-CDki-EvTsauQ,1785
113
114
  datacontract/templates/partials/server.html,sha256=dHFJtonMjhiUHtT69RUgTpkoRwmNdTRzkCdH0LtGg_4,6279
114
115
  datacontract/templates/style/output.css,sha256=ioIo1f96VW7LHhDifj6QI8QbRChJl-LlQ59EwM8MEmA,28692
115
- datacontract_cli-0.10.32.dist-info/licenses/LICENSE,sha256=23h64qnSeIZ0DKeziWAKC-zBCt328iSbRbWBrXoYRb4,2210
116
- datacontract_cli-0.10.32.dist-info/METADATA,sha256=jL4AHRxnL24naxZ0nhBwzXOWo5iz00nljbV07g1Dl50,110144
117
- datacontract_cli-0.10.32.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
118
- datacontract_cli-0.10.32.dist-info/entry_points.txt,sha256=D3Eqy4q_Z6bHauGd4ppIyQglwbrm1AJnLau4Ppbw9Is,54
119
- datacontract_cli-0.10.32.dist-info/top_level.txt,sha256=VIRjd8EIUrBYWjEXJJjtdUgc0UAJdPZjmLiOR8BRBYM,13
120
- datacontract_cli-0.10.32.dist-info/RECORD,,
116
+ datacontract_cli-0.10.33.dist-info/licenses/LICENSE,sha256=23h64qnSeIZ0DKeziWAKC-zBCt328iSbRbWBrXoYRb4,2210
117
+ datacontract_cli-0.10.33.dist-info/METADATA,sha256=gqEgdS3X0NnbsbAuATDP2YmwNVcqQMgXEJmVayqQbVA,111469
118
+ datacontract_cli-0.10.33.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
119
+ datacontract_cli-0.10.33.dist-info/entry_points.txt,sha256=D3Eqy4q_Z6bHauGd4ppIyQglwbrm1AJnLau4Ppbw9Is,54
120
+ datacontract_cli-0.10.33.dist-info/top_level.txt,sha256=VIRjd8EIUrBYWjEXJJjtdUgc0UAJdPZjmLiOR8BRBYM,13
121
+ datacontract_cli-0.10.33.dist-info/RECORD,,