Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `datacontract test` supports SAP HANA Cloud and SAP Datasphere through the optional `hana` extra (#1332)
- `datacontract export excel` and `datacontract import excel` now support all versions of the Excel template (ODCS v3.0.2, v3.1.0, v3.2.0)
- `datacontract export great-expectations --checks` restricts the suite to `quality` and/or `properties` expectations (#1617)

### Fixed
- `datacontract lint` validates against the ODCS schema for the `apiVersion` the contract declares, instead of always the newest one
Expand Down
118 changes: 78 additions & 40 deletions datacontract/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
import os
import sys
from enum import Enum
from importlib import metadata
from pathlib import Path
from typing import Iterable, Optional
Expand Down Expand Up @@ -265,49 +266,86 @@ def _print_publish_failure(run, out=None):
out.print(f"[{color}]{escape(log.message)}[/{color}]", highlight=False)


def _parse_enum_csv(
value: str | None,
enum_cls: type[Enum],
option: str,
label: str,
aliases: dict[str, Enum] | None = None,
available: str | None = None,
) -> set[str] | None:
"""Parse a comma-separated option into a set of enum values, or None if unset.

Matching is case-insensitive; `aliases` maps additional lowercase spellings
to their enum value. `available` overrides the choices shown in errors.
"""
if value is None:
return None
allowed = [e.value for e in enum_cls]
raw = [v.strip() for v in value.split(",") if v.strip()]
if not raw:
console.print(f"[red]Empty {option} specified.[/red]")
console.print(f"Available {label}: {available or ', '.join(allowed)}")
raise typer.Exit(code=1)
aliases = aliases or {}
values = set()
invalid = set()
for v in raw:
key = v.lower()
if key in aliases:
values.add(aliases[key].value)
elif key in allowed:
values.add(key)
else:
invalid.add(v)
if invalid:
console.print(f"[red]Invalid {option} specified: {', '.join(sorted(invalid))}[/red]")
console.print(f"Available {label}: {available or ', '.join(allowed)}")
raise typer.Exit(code=1)
return values


# ---------------------------------------------------------------------------
# Register commands (must be after app and shared helpers are defined so the
# command_* modules can import from this module without circular-import issues)
# Register commands. Kept in a function so command modules can be imported
# directly without creating a cli -> command -> cli initialization cycle.
# ---------------------------------------------------------------------------
# Display order for `--help` is controlled by COMMAND_ORDER above, not by import order.
from datacontract import ( # noqa: E402, F401
command_api,
command_breaking,
command_catalog,
command_changelog,
command_ci,
command_dbt,
command_edit,
command_export,
command_import,
command_init,
command_lint,
command_publish,
command_test,
)
def register_commands():
global _commands_registered
if _commands_registered:
return

app.add_typer(
command_import.import_app,
name="import",
help="Create a data contract from a source format.",
epilog="Example: datacontract import sql --source ddl.sql --dialect postgres --output datacontract.yaml",
)
app.add_typer(
command_export.export_app,
name="export",
help="Convert a data contract to a target format.",
epilog=(
"Example: datacontract export html datacontract.yaml --output datacontract.html\n\n"
"For SQL dialects (postgres, mysql, snowflake, databricks, sqlserver, trino, oracle, clickhouse), "
"use `datacontract export sql --dialect <dialect>`."
),
)
app.add_typer(
command_dbt.dbt_app,
name="dbt",
help="Work with data contracts in your dbt project.",
epilog="Example: datacontract dbt sync orders.odcs.yaml --project-dir ./warehouse",
)
from datacontract import (
command_api,
command_breaking,
command_catalog,
command_changelog,
command_ci,
command_dbt,
command_edit,
command_export,
command_import,
command_init,
command_lint,
command_publish,
command_test,
)

# A direct import of a command module reaches here while that module is
# still being initialized. Let it finish, then it will call us again.
if not all(hasattr(module, name) for module, name in (
(command_export, "export_app"),
(command_test, "CheckCategory"),
)):
return

app.add_typer(command_import.import_app, name="import", help="Create a data contract from a source format.", epilog="Example: datacontract import sql --source ddl.sql --dialect postgres --output datacontract.yaml")
app.add_typer(command_export.export_app, name="export", help="Convert a data contract to a target format.", epilog=("Example: datacontract export html datacontract.yaml --output datacontract.html\n\n" "For SQL dialects (postgres, mysql, snowflake, databricks, sqlserver, trino, oracle, clickhouse), use `datacontract export sql --dialect <dialect>`."))
app.add_typer(command_dbt.dbt_app, name="dbt", help="Work with data contracts in your dbt project.", epilog="Example: datacontract dbt sync orders.odcs.yaml --project-dir ./warehouse")
_commands_registered = True


_commands_registered = False
register_commands()


def main():
Expand Down
23 changes: 21 additions & 2 deletions datacontract/command_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from rich.console import Console
from typing_extensions import Annotated

from datacontract.cli import OrderedCommandsWithMigrationHints, debug_option, enable_debug_logging
from datacontract.cli import OrderedCommandsWithMigrationHints, _parse_enum_csv, debug_option, enable_debug_logging
from datacontract.config import cli_config
from datacontract.data_contract import DataContract
from datacontract.export.exporter import ExportFormat, SqlServerType
from datacontract.export.great_expectations_exporter import GreatExpectationsEngine
from datacontract.export.great_expectations_exporter import GreatExpectationsCheckCategory, GreatExpectationsEngine

console = Console()

Expand Down Expand Up @@ -67,6 +67,7 @@ def _export(
clickhouse_engine: Optional[str] = None,
clickhouse_order_by: Optional[str] = None,
suite_name: Optional[str] = None,
check_categories: Optional[set[str]] = None,
):
result = DataContract(
config=cli_config(),
Expand All @@ -85,6 +86,7 @@ def _export(
clickhouse_engine=clickhouse_engine,
clickhouse_order_by=clickhouse_order_by,
suite_name=suite_name,
check_categories=check_categories,
)
if output is None:
console.print(result, markup=False, soft_wrap=True)
Expand Down Expand Up @@ -572,6 +574,9 @@ def export_sodacl(
_export(ExportFormat.sodacl, location, output, server, schema_name, schema, inline_references=inline_references)





@export_app.command(
name="great-expectations",
epilog="Example: datacontract export great-expectations datacontract.yaml --engine sql --dialect postgres --output expectations.json",
Expand All @@ -593,9 +598,22 @@ def export_great_expectations(
Optional[str],
typer.Option(help="The suite name for the Great Expectations run."),
] = None,
checks: Annotated[
Optional[str],
typer.Option(
help="Comma-separated list of check categories to export "
f"(available: {', '.join(c.value for c in GreatExpectationsCheckCategory)}). Omit to export everything."
),
] = None,
):
"""Export a data contract to Great Expectations suite."""
enable_debug_logging(debug)
check_categories = _parse_enum_csv(
checks,
GreatExpectationsCheckCategory,
"--checks",
"categories",
)
_export(
ExportFormat.great_expectations,
location,
Expand All @@ -607,6 +625,7 @@ def export_great_expectations(
sql_server_type=dialect.value,
inline_references=inline_references,
suite_name=suite_name,
check_categories=check_categories,
)


Expand Down
44 changes: 6 additions & 38 deletions datacontract/command_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing_extensions import Annotated

from datacontract.cli import (
_parse_enum_csv,
_print_logs,
_print_publish_failure,
app,
Expand Down Expand Up @@ -51,44 +52,6 @@ class QualityDimension(str, Enum):
uniqueness = "uniqueness"


def _parse_enum_csv(
value: str | None,
enum_cls: type[Enum],
option: str,
label: str,
aliases: dict[str, Enum] | None = None,
available: str | None = None,
) -> set[str] | None:
"""Parse a comma-separated option into a set of enum values, or None if unset.

Matching is case-insensitive; `aliases` maps additional lowercase spellings
to their enum value. `available` overrides the choices shown in errors.
"""
if value is None:
return None
allowed = [e.value for e in enum_cls]
raw = [v.strip() for v in value.split(",") if v.strip()]
if not raw:
console.print(f"[red]Empty {option} specified.[/red]")
console.print(f"Available {label}: {available or ', '.join(allowed)}")
raise typer.Exit(code=1)
aliases = aliases or {}
values = set()
invalid = set()
for v in raw:
key = v.lower()
if key in aliases:
values.add(aliases[key].value)
elif key in allowed:
values.add(key)
else:
invalid.add(v)
if invalid:
console.print(f"[red]Invalid {option} specified: {', '.join(sorted(invalid))}[/red]")
console.print(f"Available {label}: {available or ', '.join(allowed)}")
raise typer.Exit(code=1)
return values


def _parse_filters(value: str | None) -> dict[str, str] | None:
"""Parse the `--filters` JSON object mapping schema name to predicate, or None if unset."""
Expand Down Expand Up @@ -307,3 +270,8 @@ def test(
_print_publish_failure(run)
if run.publish_succeeded is False:
raise typer.Exit(code=1)


# Complete deferred CLI registration after this module is fully initialized.
from datacontract.cli import register_commands
register_commands()
Loading
Loading