Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/release-finalize.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
- main

env:
PACKAGES: checkup checkup-git checkup-dbt checkup-python checkup-conveyor checkup-airflow checkup-github checkup-gitlab checkup-bitbucket
PACKAGES: checkup checkup-git checkup-cruft checkup-dbt checkup-python checkup-conveyor checkup-airflow checkup-github checkup-gitlab checkup-bitbucket

jobs:
finalize:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-prepare.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
options:
- checkup
- checkup-git
- checkup-cruft
- checkup-dbt
- checkup-python
- checkup-conveyor
Expand Down
101 changes: 101 additions & 0 deletions plugins/checkup-cruft/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# checkup-cruft

Cruft template metrics plugin for [checkup](https://pypi.org/project/checkup/).

Tracks how well a project stays in sync with its [cruft](https://cruft.github.io/cruft/) (cookiecutter) template.

## Installation

```bash
pip install checkup-cruft
```

## Requirements

- Python >= 3.12
- [checkup](https://pypi.org/project/checkup/)
- Git installed on the system
- Network access to the template repository (only for the drift metrics)

## Usage

```python
from checkup import CheckHub
from checkup_cruft import (
CruftProvider,
CruftLinkedMetric,
CruftDaysSinceUpdateMetric,
CruftConflictCountMetric,
CruftUpToDateMetric,
)

results = (
CheckHub()
.with_metrics([
CruftLinkedMetric(),
CruftDaysSinceUpdateMetric(),
CruftConflictCountMetric(),
CruftUpToDateMetric(),
])
.with_providers([[
CruftProvider(project_path="./my_product", fetch_template=True),
]])
.measure()
)
```

## Provider

### CruftProvider

Reads `.cruft.json` from the project. With `fetch_template=True` it also clones the template repository
to compare the pinned commit against the template head; leave it off (the default) for a fully local,
offline run, in which case the drift metrics report `None`.

## Available Metrics

### Local Metrics

#### CruftLinkedMetric

Whether a `.cruft.json` template link is present.

#### CruftDaysSinceUpdateMetric

Days since `.cruft.json` last changed in git.

#### CruftConflictCountMetric

Number of `*.rej` files left by a failed `cruft update`.

### Template Drift Metrics

These require the provider to run with `fetch_template=True`.

#### CruftUpToDateMetric

Whether the pinned commit matches the latest template commit.

#### CruftCommitsBehindMetric

Number of template commits between the pinned commit and the head.

#### CruftDaysBehindTemplateMetric

Days between the pinned commit and the template head.

## Creating Custom Metrics

Extend `CruftMetric` to read the cruft context directly:

```python
from checkup_cruft import CruftMetric

class TemplateUrlMetric(CruftMetric):
name = "cruft_template_url"
description = "Configured cruft template URL"

def calculate(self, context, measurements):
cruft = self.get_context(context)
return self.measure(value=cruft.get("template"))
```
36 changes: 36 additions & 0 deletions plugins/checkup-cruft/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
[project]
name = "checkup-cruft"
version = "0.1.0"
description = "Cruft template metrics for checkup"
readme = "README.md"
requires-python = ">=3.12"
dependencies = ["checkup"]

[project.urls]
Homepage = "https://github.com/datamindedbe/checkup"
Source = "https://github.com/datamindedbe/checkup"
Issues = "https://github.com/datamindedbe/checkup/issues"

[dependency-groups]
dev = ["pytest>=8.0"]

[tool.uv.sources]
checkup = { workspace = true }

[project.entry-points."checkup.providers"]
cruft = "checkup_cruft:CruftProvider"

[project.entry-points."checkup.metrics"]
cruft_linked = "checkup_cruft:CruftLinkedMetric"
cruft_days_since_update = "checkup_cruft:CruftDaysSinceUpdateMetric"
cruft_conflicts = "checkup_cruft:CruftConflictCountMetric"
cruft_commits_behind = "checkup_cruft:CruftCommitsBehindMetric"
cruft_up_to_date = "checkup_cruft:CruftUpToDateMetric"
cruft_days_behind_template = "checkup_cruft:CruftDaysBehindTemplateMetric"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/checkup_cruft"]
21 changes: 21 additions & 0 deletions plugins/checkup-cruft/src/checkup_cruft/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from checkup_cruft.metrics import (
CruftCommitsBehindMetric,
CruftConflictCountMetric,
CruftDaysBehindTemplateMetric,
CruftDaysSinceUpdateMetric,
CruftLinkedMetric,
CruftMetric,
CruftUpToDateMetric,
)
from checkup_cruft.provider import CruftProvider

__all__ = [
"CruftProvider",
"CruftMetric",
"CruftLinkedMetric",
"CruftDaysSinceUpdateMetric",
"CruftConflictCountMetric",
"CruftCommitsBehindMetric",
"CruftUpToDateMetric",
"CruftDaysBehindTemplateMetric",
]
128 changes: 128 additions & 0 deletions plugins/checkup-cruft/src/checkup_cruft/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
from datetime import UTC, datetime

from checkup.measurement import Measurement, Measurements
from checkup.metric import Metric
from checkup.provider import Provider
from checkup.types import Context
from checkup_cruft.provider import CruftProvider


class CruftMetric(Metric):
"""
Base class for cruft-related metrics.
"""

@classmethod
def providers(cls) -> list[type[Provider]]:
return [CruftProvider]

def get_context(self, context: Context) -> dict:
return context.get(CruftProvider.name, {})


class CruftLinkedMetric(CruftMetric):
"""
Whether the project is linked to a cruft template.
"""

name: str = "cruft_linked"
description: str = "Whether a .cruft.json template link is present"
unit: str = "boolean"

def calculate(self, context: Context, measurements: Measurements) -> Measurement:
cruft = self.get_context(context)
return self.measure(value=1 if cruft.get("present") else 0)


class CruftDaysSinceUpdateMetric(CruftMetric):
"""
Days since the cruft template link (.cruft.json) was last updated.
"""

name: str = "cruft_days_since_update"
description: str = "Days since the last cruft template update"
unit: str = "days"

def calculate(self, context: Context, measurements: Measurements) -> Measurement:
cruft = self.get_context(context)
last_update = cruft.get("last_update_date")
if not isinstance(last_update, datetime):
return self.measure(value=None, diagnostic="No .cruft.json found")
delta = datetime.now(UTC) - last_update
return self.measure(
value=delta.days,
diagnostic=f"Last cruft update: {last_update.strftime('%Y-%m-%d')}",
)


class CruftConflictCountMetric(CruftMetric):
"""
Number of unresolved cruft template conflicts (*.rej files).
"""

name: str = "cruft_conflicts"
description: str = "Number of unresolved cruft template conflicts"
unit: str = "files"

def calculate(self, context: Context, measurements: Measurements) -> Measurement:
cruft = self.get_context(context)
conflicts = cruft.get("conflict_files", [])
return self.measure(value=len(conflicts), diagnostic=", ".join(conflicts))


class CruftCommitsBehindMetric(CruftMetric):
"""
Template commits between the pinned commit and the template head.

Requires the provider to run with fetch_template=True.
"""

name: str = "cruft_commits_behind"
description: str = "Number of template commits the project is behind"
unit: str = "commits"

def calculate(self, context: Context, measurements: Measurements) -> Measurement:
cruft = self.get_context(context)
behind = cruft.get("commits_behind")
if behind is None:
return self.measure(value=None, diagnostic="Template not fetched")
return self.measure(value=behind)


class CruftUpToDateMetric(CruftMetric):
"""
Whether the pinned template commit matches the template head.

Requires the provider to run with fetch_template=True.
"""

name: str = "cruft_up_to_date"
description: str = "Whether the project matches the latest template commit"
unit: str = "boolean"

def calculate(self, context: Context, measurements: Measurements) -> Measurement:
cruft = self.get_context(context)
behind = cruft.get("commits_behind")
if behind is None:
return self.measure(value=None, diagnostic="Template not fetched")
return self.measure(value=1 if behind == 0 else 0)


class CruftDaysBehindTemplateMetric(CruftMetric):
"""
Days between the pinned template commit and the latest template commit.

Requires the provider to run with fetch_template=True.
"""

name: str = "cruft_days_behind_template"
description: str = "Days between the pinned commit and the template head"
unit: str = "days"

def calculate(self, context: Context, measurements: Measurements) -> Measurement:
cruft = self.get_context(context)
pinned = cruft.get("pinned_date")
head = cruft.get("head_date")
if not isinstance(pinned, datetime) or not isinstance(head, datetime):
return self.measure(value=None, diagnostic="Template not fetched")
return self.measure(value=(head - pinned).days)
Loading
Loading