diff --git a/checkup.schema.json b/checkup.schema.json index f418c89..fd3b183 100644 --- a/checkup.schema.json +++ b/checkup.schema.json @@ -141,117 +141,222 @@ { "type": "object", "properties": { - "name": { + "type": { "const": "conveyor_is_dirty_deployment" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "conveyor_last_deployment_time" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "conveyor_last_run_status" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_column_test_coverage" + }, + "name": { + "default": "dbt_column_test_coverage", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Percentage of columns with at least one test", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "percent", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_column_tests" + }, + "name": { + "default": "dbt_column_tests", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of tests targeting specific columns", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "tests", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_columns" + }, + "name": { + "default": "dbt_columns", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Total number of columns across all models", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "columns", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_columns_with_description" + }, + "name": { + "default": "dbt_columns_with_description", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of columns with descriptions", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "columns", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_columns_without_description" + }, + "name": { + "default": "dbt_columns_without_description", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of columns without descriptions", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "columns", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_data_tests" + }, + "name": { + "default": "dbt_data_tests", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of generic (data) tests", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "tests", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_flagged_packages" }, + "name": { + "default": "dbt_flagged_packages", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of flagged packages in packages.yml", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "packages", + "title": "Unit", + "type": "string" + }, "flagged_packages": { "items": { "type": "string" @@ -261,124 +366,259 @@ } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_models" + }, + "name": { + "default": "dbt_models", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Total number of dbt models", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "models", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_models_not_adhering_to_naming_convention" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_models_with_description" + }, + "name": { + "default": "dbt_models_with_description", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of models with descriptions", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "models", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_models_without_description" + }, + "name": { + "default": "dbt_models_without_description", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of models without descriptions", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "models", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_output_columns_without_data_type" + }, + "name": { + "default": "dbt_output_columns_without_data_type", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of columns in output models without data type", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "columns", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_output_models" + }, + "name": { + "default": "dbt_output_models", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of output models (non-internal schema)", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "models", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_output_models_with_description" + }, + "name": { + "default": "dbt_output_models_with_description", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of output models with descriptions", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "models", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_output_models_without_contracts" + }, + "name": { + "default": "dbt_output_models_without_contracts", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of output models without enforced contracts", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "models", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_output_models_without_description" + }, + "name": { + "default": "dbt_output_models_without_description", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of output models without descriptions", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "models", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_profile_host" }, + "name": { + "default": "dbt_profile_host", + "title": "Name", + "type": "string" + }, + "description": { + "default": "The host configured in profiles.yml", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "url", + "title": "Unit", + "type": "string" + }, "profile": { "anyOf": [ { @@ -397,92 +637,197 @@ } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_supported_version" }, + "name": { + "default": "dbt_supported_version", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Whether dbt version meets minimum requirement", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "boolean", + "title": "Unit", + "type": "string" + }, "min_version": { "title": "Min Version", "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_tested_columns" + }, + "name": { + "default": "dbt_tested_columns", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of columns with at least one test", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "columns", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_tests" + }, + "name": { + "default": "dbt_tests", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Total number of dbt tests", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "tests", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_unit_tests" + }, + "name": { + "default": "dbt_unit_tests", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of singular (unit) tests", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "tests", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "dbt_version" + }, + "name": { + "default": "dbt_version", + "title": "Name", + "type": "string" + }, + "description": { + "default": "The dbt version used to generate the manifest", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "version", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "git_days_since_last_update" + }, + "name": { + "default": "git_days_since_last_update", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Days since the last git commit", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "days", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "git_tracked_file_count" }, + "name": { + "default": "git_tracked_file_count", + "title": "Name", + "type": "string" + }, + "description": { + "default": "Number of git tracked files", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "files", + "title": "Unit", + "type": "string" + }, "pattern": { "default": "*", "title": "Pattern", @@ -490,28 +835,58 @@ } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "python_version" + }, + "name": { + "default": "python_version", + "title": "Name", + "type": "string" + }, + "description": { + "default": "The Python version configured for the project", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "version", + "title": "Unit", + "type": "string" } }, "required": [ - "name" + "type" ], "additionalProperties": false }, { "type": "object", "properties": { - "name": { + "type": { "const": "python_version_check" }, + "name": { + "default": "python_version_check", + "title": "Name", + "type": "string" + }, + "description": { + "default": "The Python version adheres to a minimum and maximum boundary", + "title": "Description", + "type": "string" + }, + "unit": { + "default": "bool", + "title": "Unit", + "type": "string" + }, "min_version": { "title": "Min Version", "type": "string" @@ -522,7 +897,7 @@ } }, "required": [ - "name" + "type" ], "additionalProperties": false } diff --git a/checkup.yaml b/checkup.yaml index 85e4ca8..50499d6 100644 --- a/checkup.yaml +++ b/checkup.yaml @@ -7,10 +7,16 @@ providers: - name: git metrics: -- name: git_days_since_last_update -- name: git_tracked_file_count +- type: git_days_since_last_update +- type: git_tracked_file_count pattern: src/checkup/* -- name: python_version -- name: python_version_check +- type: git_tracked_file_count + name: checkup_yaml_exists + pattern: checkup.yaml +- type: git_tracked_file_count + name: pre_commit_config_exists + pattern: .pre-commit-config.yaml +- type: python_version +- type: python_version_check min_version: '3.13' max_version: '3.15' diff --git a/src/checkup/cli/config_wizard/create.py b/src/checkup/cli/config_wizard/create.py index 77171cb..2e2e7db 100644 --- a/src/checkup/cli/config_wizard/create.py +++ b/src/checkup/cli/config_wizard/create.py @@ -79,7 +79,7 @@ def _build_config(registry: "PluginRegistry") -> CheckupConfig | None: return CheckupConfig( tags=tags, providers=[ProviderConfig(name=p) for p in provider_names], - metrics=[MetricConfig(name=m) for m in metric_names], + metrics=[MetricConfig(type=m) for m in metric_names], materializer=MaterializerConfig(type=mat) if mat != "console" else None, ) diff --git a/src/checkup/cli/config_wizard/edit.py b/src/checkup/cli/config_wizard/edit.py index 56b6ac5..2bab96d 100644 --- a/src/checkup/cli/config_wizard/edit.py +++ b/src/checkup/cli/config_wizard/edit.py @@ -58,7 +58,7 @@ def _build_config( config: CheckupConfig, registry: "PluginRegistry" ) -> CheckupConfig | None: provider_configs = {p.name: p.config for p in config.providers} - metric_configs = {m.name: m.config for m in config.metrics} + metric_configs = {m.type: m.config for m in config.metrics} tags = _prompt_edit_tags(config) if tags is None: @@ -83,7 +83,7 @@ def _build_config( for p in provider_names ], metrics=[ - MetricConfig(name=m, config=metric_configs.get(m, {})) for m in metric_names + MetricConfig(type=m, config=metric_configs.get(m, {})) for m in metric_names ], materializer=MaterializerConfig(type=mat) if mat else None, ) @@ -121,7 +121,7 @@ def _prompt_edit_metrics( registry: "PluginRegistry", provider_names: list[str], ) -> list[str] | None: - current_names = [m.name for m in config.metrics] + current_types = [m.type for m in config.metrics] with console.status("Loading metrics..."): available = registry.list_compatible_metric_names(provider_names) @@ -130,8 +130,8 @@ def _prompt_edit_metrics( if edit is None: return None elif edit: - return select_multiple(available, current_names, "metrics") - return [m.name for m in config.metrics if m.name in available] + return select_multiple(available, current_types, "metrics") + return [m.type for m in config.metrics if m.type in available] def _prompt_edit_materializer( @@ -194,7 +194,7 @@ def _show_current_config(config: "CheckupConfig") -> None: console.print("[bold]Current configuration:[/bold]", markup=True) console.print(f" Tags: {dict(config.tags) or '(none)'}") console.print(f" Providers: {[p.name for p in config.providers] or '(none)'}") - console.print(f" Metrics: {[m.name for m in config.metrics] or '(none)'}") + console.print(f" Metrics: {[m.type for m in config.metrics] or '(none)'}") mat = config.materializer.type if config.materializer else "(none)" console.print(f" Materializer: {mat}") console.print() diff --git a/src/checkup/cli/executor.py b/src/checkup/cli/executor.py index c587c76..c26d3c0 100644 --- a/src/checkup/cli/executor.py +++ b/src/checkup/cli/executor.py @@ -98,17 +98,21 @@ def _resolve_metrics( metrics: list[Metric] = [] for metric_config in config.metrics: - metric_cls = registry.get_metric(metric_config.name) + metric_cls = registry.get_metric(metric_config.type) if metric_cls is None: - console.print(f"[yellow]Unknown metric: {metric_config.name}[/yellow]") + console.print(f"[yellow]Unknown metric: {metric_config.type}[/yellow]") continue try: - metric = metric_cls(**metric_config.config) - metrics.append(metric) + metrics.append( + metric_cls( + **metric_config.config, + **({"name": metric_config.name} if metric_config.name else {}), + ) + ) except Exception as e: console.print( - f"[red]Failed to instantiate metric {metric_config.name}: {e}[/red]" + f"[red]Failed to instantiate metric {metric_config.type}: {e}[/red]" ) return metrics diff --git a/src/checkup/cli/utils.py b/src/checkup/cli/utils.py index 32ed4bd..feda0d0 100644 --- a/src/checkup/cli/utils.py +++ b/src/checkup/cli/utils.py @@ -41,8 +41,8 @@ def apply_cli_overrides( if metrics: new_metrics = [] for m in metrics: - name, config = parse_cli_item(m) - new_metrics.append(MetricConfig(name=name, config=config)) + metric_type, config = parse_cli_item(m) + new_metrics.append(MetricConfig(type=metric_type, config=config)) else: new_metrics = list(cfg.metrics) diff --git a/src/checkup/configuration/io.py b/src/checkup/configuration/io.py index 1ae4823..5ca4bbc 100644 --- a/src/checkup/configuration/io.py +++ b/src/checkup/configuration/io.py @@ -80,13 +80,17 @@ def merge_configs(base: dict[str, Any], override: dict[str, Any]) -> dict[str, A result[key] = value elif key == "metrics": if key in result: - base_metrics = { - (m if isinstance(m, str) else list(m.keys())[0]): m - for m in result.get(key, []) - } + + def _metric_key(m: dict) -> tuple: + """ + Key by (type, name) to allow multiple instances of same type + """ + + return (m.get("type"), m.get("name")) + + base_metrics = {_metric_key(m): m for m in result.get(key, [])} for metric in value: - name = metric if isinstance(metric, str) else list(metric.keys())[0] - base_metrics[name] = metric + base_metrics[_metric_key(metric)] = metric result[key] = list(base_metrics.values()) else: result[key] = value @@ -126,9 +130,10 @@ def parse_metrics(raw: list[Any] | None) -> list[MetricConfig]: Parse metric configuration from raw YAML. Supports: - - name: git_days_since_last_update - - name: python_version - version: "3.12" + - type: git_tracked_file_count + - type: git_tracked_file_count + name: cruft_file_exists + pattern: ".cruft.json" """ if not raw: @@ -136,13 +141,14 @@ def parse_metrics(raw: list[Any] | None) -> list[MetricConfig]: metrics = [] for item in raw: - if isinstance(item, str): - metrics.append(MetricConfig(name=item)) - elif isinstance(item, dict): - name = item.get("name") - if name: - config = {k: v for k, v in item.items() if k != "name"} - metrics.append(MetricConfig(name=name, config=config)) + if not isinstance(item, dict): + continue + metric_type = item.get("type") + if not metric_type: + continue + name = item.get("name") + config = {k: v for k, v in item.items() if k not in ("type", "name")} + metrics.append(MetricConfig(type=metric_type, name=name, config=config)) return metrics diff --git a/src/checkup/configuration/models.py b/src/checkup/configuration/models.py index 5a499ea..6f512c0 100644 --- a/src/checkup/configuration/models.py +++ b/src/checkup/configuration/models.py @@ -15,11 +15,14 @@ class ProviderConfig(BaseModel): class MetricConfig(BaseModel): - """Configuration for a single metric.""" - - name: str + type: str + name: str | None = None config: dict[str, Any] = Field(default_factory=dict) + @property + def instance_name(self) -> str: + return self.name or self.type + class MaterializerConfig(BaseModel): """Configuration for the materializer.""" diff --git a/src/checkup/configuration/schema.py b/src/checkup/configuration/schema.py index e9b2301..28804ca 100644 --- a/src/checkup/configuration/schema.py +++ b/src/checkup/configuration/schema.py @@ -190,7 +190,9 @@ def generate_schema() -> dict: "metrics": { "type": "array", "description": "Metrics to calculate", - "items": _build_oneof_schema(metric_names, metric_schemas), + "items": _build_oneof_schema( + metric_names, metric_schemas, key_field="type" + ), }, "materializer": _build_oneof_schema( materializer_names, materializer_schemas, key_field="type" diff --git a/tests/test_cli_configuration.py b/tests/test_cli_configuration.py index 717a995..83d29cf 100644 --- a/tests/test_cli_configuration.py +++ b/tests/test_cli_configuration.py @@ -61,18 +61,16 @@ def test_empty_list_returns_empty(self): class TestParseMetrics: - def test_string_shorthand_creates_metric_with_empty_config(self): + def test_string_shorthand_is_ignored(self): raw = ["git_days_since_last_update", "python_version"] result = parse_metrics(raw) - assert len(result) == 2 - assert result[0].name == "git_days_since_last_update" - assert result[0].config == {} + assert len(result) == 0 - def test_dict_with_name_field_extracts_config(self): + def test_dict_with_type_field_extracts_config(self): raw = [ { - "name": "python_version_check", + "type": "python_version_check", "min_version": "3.10", "max_version": "3.13", }, @@ -80,9 +78,30 @@ def test_dict_with_name_field_extracts_config(self): result = parse_metrics(raw) assert len(result) == 1 - assert result[0].name == "python_version_check" + assert result[0].type == "python_version_check" + assert result[0].name is None assert result[0].config == {"min_version": "3.10", "max_version": "3.13"} + def test_dict_with_type_and_name_for_multiple_instances(self): + raw = [ + { + "type": "git_tracked_file_count", + "name": "readme_exists", + "pattern": "README.md", + }, + { + "type": "git_tracked_file_count", + "name": "license_exists", + "pattern": "LICENSE", + }, + ] + result = parse_metrics(raw) + + assert [(m.type, m.name) for m in result] == [ + ("git_tracked_file_count", "readme_exists"), + ("git_tracked_file_count", "license_exists"), + ] + class TestParseMaterializer: def test_extracts_type_and_remaining_fields_as_config(self): @@ -232,7 +251,7 @@ def test_loads_yaml_file(self, tmp_path): { "tags": {"product": "test"}, "providers": [{"name": "git"}], - "metrics": [{"name": "dummy_metric"}], + "metrics": [{"type": "dummy_metric"}], } ) )