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
491 changes: 433 additions & 58 deletions checkup.schema.json

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions checkup.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion src/checkup/cli/config_wizard/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
12 changes: 6 additions & 6 deletions src/checkup/cli/config_wizard/edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
)
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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()
14 changes: 9 additions & 5 deletions src/checkup/cli/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/checkup/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
38 changes: 22 additions & 16 deletions src/checkup/configuration/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -126,23 +130,25 @@ 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:
return []

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


Expand Down
9 changes: 6 additions & 3 deletions src/checkup/configuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
4 changes: 3 additions & 1 deletion src/checkup/configuration/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 27 additions & 8 deletions tests/test_cli_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,28 +61,47 @@ 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",
},
]
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):
Expand Down Expand Up @@ -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"}],
}
)
)
Expand Down