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
58 changes: 27 additions & 31 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ readme = "README.md"
authors = [{ name = "Jan Vanbuel", email = "jan.vanbuel@ond.vlaanderen.be" }]
requires-python = ">=3.12"
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"jinja2>=3.1.6",
"pydantic>=2.11.7",
"pyyaml>=6.0",
"questionary>=2.0",
"rich>=13.0",
"sqlalchemy>=2.0",
"typer>=0.24",
"jinja2>=3.1.6",
"pydantic>=2.11.7",
"pyyaml>=6.0",
"questionary>=2.0",
"rich>=13.0",
"sqlalchemy>=2.0",
"typer>=0.24",
]

[project.urls]
Expand All @@ -41,23 +41,19 @@ build-backend = "uv_build"

[tool.uv.workspace]
members = [
"plugins/checkup-git",
"plugins/checkup-dbt",
"plugins/checkup-python",
"plugins/checkup-conveyor",
"plugins/checkup-gitlab",
"plugins/checkup-bitbucket",
"plugins/checkup-github",
"plugins/checkup-airflow",
"plugins/checkup-git",
"plugins/checkup-dbt",
"plugins/checkup-python",
"plugins/checkup-conveyor",
"plugins/checkup-gitlab",
"plugins/checkup-bitbucket",
"plugins/checkup-github",
"plugins/checkup-airflow",
]

[dependency-groups]
dev = ["pytest>=9.0.2", "pytest-cov>=7.0.0", "ruff>=0.8.6", "prek>=0.3.0"]
docs = [
"mkdocs>=1.6.0",
"mkdocs-material>=9.5.0",
"pymdown-extensions>=10.0",
]
docs = ["mkdocs>=1.6.0", "mkdocs-material>=9.5.0", "pymdown-extensions>=10.0"]

[tool.pytest.ini_options]
pythonpath = [".", "tests"]
Expand All @@ -69,16 +65,16 @@ line-length = 88

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long (handled by formatter)
"E501", # line too long (handled by formatter)
]

[tool.ruff.format]
Expand Down
17 changes: 17 additions & 0 deletions src/checkup/cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ def run(
"--materializer", help="Set materializer (type or type:key=value)"
),
] = None,
select: Annotated[
str | None,
typer.Option(
"--select",
"-s",
help="Only materialize matching metrics ('method:pattern'). Methods: name (default), tag, type.",
),
] = None,
exclude: Annotated[
str | None,
typer.Option(
"--exclude",
help="Exclude matching metrics from materialization. Same format as --select.",
),
] = None,
dry_run: Annotated[
bool,
typer.Option("--dry-run", help="Don't materialize, just print"),
Expand Down Expand Up @@ -71,4 +86,6 @@ def run(
materializer="console" if dry_run else materializer,
multiprocessing=multiprocessing,
quiet=quiet,
select=select,
exclude=exclude,
)
25 changes: 25 additions & 0 deletions src/checkup/cli/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from checkup.materializers import ConsoleMaterializer
from checkup.providers.tags import TagProvider
from checkup.registry import get_registry
from checkup.selection import select_metrics

if TYPE_CHECKING:
from checkup.materializers import Materializer
Expand All @@ -28,6 +29,8 @@ def execute_checkup(
materializer: str | None = None,
multiprocessing: bool = True,
quiet: bool = False,
select: str | None = None,
exclude: str | None = None,
) -> None:
"""
Execute checkup with the given configuration.
Expand All @@ -37,6 +40,8 @@ def execute_checkup(
materializer: Override materializer type (e.g., "console")
multiprocessing: If False, run sequentially without subprocesses
quiet: If True, send status/errors to stderr so stdout holds only the materializer output.
select: Selector for which metrics to materialize.
exclude: Selector for metrics to exclude from materialization.
"""

out = Console(stderr=True) if quiet else console
Expand All @@ -55,6 +60,10 @@ def execute_checkup(

materializer = _resolve_materializer(config, registry, materializer, out)

select = select if select is not None else config.select
exclude = exclude if exclude is not None else config.exclude
type_by_name = {mc.instance_name: mc.type for mc in config.metrics}

out.print(f"[blue]Running {len(metrics)} metrics...[/blue]")

result = (
Expand All @@ -68,6 +77,22 @@ def execute_checkup(
for _, error in result.errors:
out.print(f"[red]Error: {error}[/red]")

if select or exclude:
selected = select_metrics(
metrics,
select=select,
exclude=exclude,
type_resolver=lambda metric: type_by_name.get(metric.name),
)
result.measurements = [
m for m in result.measurements if m.metric.name in selected
]
result.direct_metric_names = result.direct_metric_names & selected

console.print(
f"[blue]Materializing {len(selected)} selected of {len(metrics)} total metrics[/blue]"
)

result.materialize(materializer)


Expand Down
2 changes: 2 additions & 0 deletions src/checkup/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ def apply_cli_overrides(
providers=new_providers,
metrics=new_metrics,
materializer=cfg.materializer,
select=cfg.select,
exclude=cfg.exclude,
)


Expand Down
2 changes: 2 additions & 0 deletions src/checkup/configuration/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,6 @@ def load_config(
providers=parse_providers(raw.get("providers")),
metrics=parse_metrics(raw.get("metrics")),
materializer=parse_materializer(raw.get("materializer")),
select=raw.get("select"),
exclude=raw.get("exclude"),
)
8 changes: 2 additions & 6 deletions src/checkup/configuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@


class ProviderConfig(BaseModel):
"""Configuration for a single provider."""

name: str
config: dict[str, Any] = Field(default_factory=dict)

Expand All @@ -25,19 +23,17 @@ def instance_name(self) -> str:


class MaterializerConfig(BaseModel):
"""Configuration for the materializer."""

type: str
config: dict[str, Any] = Field(default_factory=dict)


class CheckupConfig(BaseModel):
"""Complete checkup configuration."""

tags: dict[str, Any] = Field(default_factory=dict)
providers: list[ProviderConfig] = Field(default_factory=list)
metrics: list[MetricConfig] = Field(default_factory=list)
materializer: MaterializerConfig | None = None
select: str | None = None
exclude: str | None = None

@classmethod
def empty(cls) -> "CheckupConfig":
Expand Down
8 changes: 8 additions & 0 deletions src/checkup/configuration/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ def generate_schema() -> dict:
"description": "Tags to identify the data product (e.g., product, team)",
"additionalProperties": {"type": "string"},
},
"select": {
"type": "string",
"description": "Default selector for materialized output.",
},
"exclude": {
"type": "string",
"description": "Default selector for materialization to exclude.",
},
"providers": {
"type": "array",
"description": "Data providers for context enrichment",
Expand Down
3 changes: 2 additions & 1 deletion src/checkup/metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from enum import Enum
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel
from pydantic import BaseModel, Field

from checkup.types import Context

Expand Down Expand Up @@ -44,6 +44,7 @@ class Metric(ABC, BaseModel):
name: str
description: str = ""
unit: str = ""
tags: list[str] = Field(default_factory=list)

model_config = {"frozen": True}

Expand Down
103 changes: 103 additions & 0 deletions src/checkup/selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
Metric selection for filtering materialized output.

Selection filters which measurements get materialized, it never changes what
is calculated, dependencies are always computed.

A selector is a set of whitespace-separated atoms (a union).
Each atom is ``[method:]pattern`` where:

- ``method`` is ``name`` (the default), ``tag`` or ``type``
- ``pattern`` supports ``*`` wildcards

Examples::

healthcheck_*
name:healthcheck_*
tag:healthcheck
type:git_tracked_file_count tag:healthcheck
"""

import fnmatch
from collections.abc import Callable, Iterable
from enum import StrEnum
from typing import assert_never

from checkup.metric import Metric


class SelectionMethod(StrEnum):
NAME = "name"
TAG = "tag"
TYPE = "type"


# Resolves a metric instance to its registered type name.
# Names are needed by the filter, with a resolver being offered as argument
# because the name-type mapping is defined by the config.
TypeResolver = Callable[[Metric], str | None]


def select_metrics(
metrics: Iterable[Metric],
select: str | None = None,
exclude: str | None = None,
type_resolver: TypeResolver | None = None,
) -> set[str]:
"""
Return the names of the metrics whose measurements should be materialized.

With no ``select`` every metric is included;
``exclude`` (if given) is then removed from the set.
"""

metrics = list(metrics)
type_resolver = type_resolver or (lambda _name: None)

if select:
selected = _match_selector(select, metrics, type_resolver)
else:
selected = {metric.name for metric in metrics}

if exclude:
selected -= _match_selector(exclude, metrics, type_resolver)

return selected


def _match_selector(
selector: str,
metrics: list[Metric],
type_resolver: TypeResolver,
) -> set[str]:
atoms = selector.split()
return {
metric.name
for metric in metrics
if any(_match_atom(atom, metric, type_resolver) for atom in atoms)
}


def _match_atom(
atom: str,
metric: Metric,
type_resolver: TypeResolver,
*,
default_method: SelectionMethod = SelectionMethod.NAME,
) -> bool:
method_name, separator, pattern = atom.partition(":")
if not separator:
method, pattern = default_method, atom
else:
method = SelectionMethod(method_name)

match method:
case SelectionMethod.NAME:
return fnmatch.fnmatch(metric.name, pattern)
case SelectionMethod.TAG:
return any(fnmatch.fnmatch(tag, pattern) for tag in metric.tags)
case SelectionMethod.TYPE:
metric_type = type_resolver(metric)
return metric_type is not None and fnmatch.fnmatch(metric_type, pattern)
case _:
assert_never(method)
Loading
Loading