From dc2f445a4972a6950cdd2eb4ba205a8d0164b11b Mon Sep 17 00:00:00 2001 From: CasperTeirlinck Date: Tue, 14 Jul 2026 13:52:43 +0200 Subject: [PATCH 1/2] update --- pyproject.toml | 58 ++++++++-------- src/checkup/cli/commands/run.py | 17 +++++ src/checkup/cli/executor.py | 25 +++++++ src/checkup/configuration/io.py | 2 + src/checkup/configuration/models.py | 8 +-- src/checkup/configuration/schema.py | 8 +++ src/checkup/metric.py | 3 +- src/checkup/selection.py | 103 ++++++++++++++++++++++++++++ tests/test_selection.py | 96 ++++++++++++++++++++++++++ 9 files changed, 282 insertions(+), 38 deletions(-) create mode 100644 src/checkup/selection.py create mode 100644 tests/test_selection.py diff --git a/pyproject.toml b/pyproject.toml index 485c255..5872015 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] @@ -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"] @@ -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] diff --git a/src/checkup/cli/commands/run.py b/src/checkup/cli/commands/run.py index 7efca38..84b031e 100644 --- a/src/checkup/cli/commands/run.py +++ b/src/checkup/cli/commands/run.py @@ -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"), @@ -71,4 +86,6 @@ def run( materializer="console" if dry_run else materializer, multiprocessing=multiprocessing, quiet=quiet, + select=select, + exclude=exclude, ) diff --git a/src/checkup/cli/executor.py b/src/checkup/cli/executor.py index 67964d5..ba2c644 100644 --- a/src/checkup/cli/executor.py +++ b/src/checkup/cli/executor.py @@ -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 @@ -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. @@ -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 @@ -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 = ( @@ -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) diff --git a/src/checkup/configuration/io.py b/src/checkup/configuration/io.py index 5ca4bbc..5e6ccd4 100644 --- a/src/checkup/configuration/io.py +++ b/src/checkup/configuration/io.py @@ -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"), ) diff --git a/src/checkup/configuration/models.py b/src/checkup/configuration/models.py index 6f512c0..3437c18 100644 --- a/src/checkup/configuration/models.py +++ b/src/checkup/configuration/models.py @@ -8,8 +8,6 @@ class ProviderConfig(BaseModel): - """Configuration for a single provider.""" - name: str config: dict[str, Any] = Field(default_factory=dict) @@ -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": diff --git a/src/checkup/configuration/schema.py b/src/checkup/configuration/schema.py index 28804ca..31e154b 100644 --- a/src/checkup/configuration/schema.py +++ b/src/checkup/configuration/schema.py @@ -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", diff --git a/src/checkup/metric.py b/src/checkup/metric.py index 8bdc764..41f0cba 100644 --- a/src/checkup/metric.py +++ b/src/checkup/metric.py @@ -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 @@ -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} diff --git a/src/checkup/selection.py b/src/checkup/selection.py new file mode 100644 index 0000000..cd8a6d9 --- /dev/null +++ b/src/checkup/selection.py @@ -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) diff --git a/tests/test_selection.py b/tests/test_selection.py new file mode 100644 index 0000000..056ec6d --- /dev/null +++ b/tests/test_selection.py @@ -0,0 +1,96 @@ +import pytest + +from checkup.measurement import Measurement, Measurements +from checkup.metric import Metric +from checkup.selection import select_metrics +from checkup.types import Context + + +class _FakeMetric(Metric): + def calculate(self, context: Context, measurements: Measurements) -> Measurement: + return self.measure(value=1) + + +def _metrics() -> list[Metric]: + return [ + _FakeMetric(name="healthcheck_code_in_monorepo", tags=["healthcheck"]), + _FakeMetric(name="healthcheck_cruft_linked", tags=["healthcheck"]), + _FakeMetric(name="git_tracked_file_count", tags=["git"]), + _FakeMetric(name="dbt_supported_version"), + ] + + +_TYPE_RESOLVER = { + "healthcheck_code_in_monorepo": "healthcheck_code_in_monorepo", + "healthcheck_cruft_linked": "healthcheck_cruft_linked", + "git_tracked_file_count": "git_tracked_file_count", + "dbt_supported_version": "dbt_supported_version", +} + + +def _select(select=None, exclude=None): + return select_metrics( + _metrics(), + select=select, + exclude=exclude, + type_resolver=lambda m: _TYPE_RESOLVER.get(m.name), + ) + + +def test_no_selector_includes_everything(): + assert _select() == { + "healthcheck_code_in_monorepo", + "healthcheck_cruft_linked", + "git_tracked_file_count", + "dbt_supported_version", + } + + +def test_name_wildcard_is_the_default_method(): + assert _select("healthcheck_*") == { + "healthcheck_code_in_monorepo", + "healthcheck_cruft_linked", + } + assert _select("name:healthcheck_*") == _select("healthcheck_*") + + +def test_tag_method(): + assert _select("tag:healthcheck") == { + "healthcheck_code_in_monorepo", + "healthcheck_cruft_linked", + } + + +def test_type_method(): + assert _select("type:git_tracked_file_count") == {"git_tracked_file_count"} + + +def test_union_of_atoms(): + assert _select("tag:git tag:healthcheck") == { + "healthcheck_code_in_monorepo", + "healthcheck_cruft_linked", + "git_tracked_file_count", + } + + +def test_exclude_removes_from_selection(): + assert _select(select="*", exclude="tag:healthcheck") == { + "git_tracked_file_count", + "dbt_supported_version", + } + + +def test_exclude_without_select_applies_to_all(): + assert _select(exclude="healthcheck_*") == { + "git_tracked_file_count", + "dbt_supported_version", + } + + +def test_exact_name_match(): + assert _select("dbt_supported_version") == {"dbt_supported_version"} + + +def test_unknown_method_raises(): + with pytest.raises(ValueError): + _select("nope:healthcheck") From f1d6c5bbfddb0dbebbf27bb469ed75e4d714a3ae Mon Sep 17 00:00:00 2001 From: CasperTeirlinck Date: Tue, 14 Jul 2026 14:34:09 +0200 Subject: [PATCH 2/2] update --- src/checkup/cli/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/checkup/cli/utils.py b/src/checkup/cli/utils.py index feda0d0..28f29e7 100644 --- a/src/checkup/cli/utils.py +++ b/src/checkup/cli/utils.py @@ -51,6 +51,8 @@ def apply_cli_overrides( providers=new_providers, metrics=new_metrics, materializer=cfg.materializer, + select=cfg.select, + exclude=cfg.exclude, )