checkup 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {checkup-0.3.0 → checkup-0.5.0}/PKG-INFO +1 -1
  2. {checkup-0.3.0 → checkup-0.5.0}/pyproject.toml +1 -1
  3. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/__init__.py +6 -1
  4. checkup-0.5.0/src/checkup/cli/__init__.py +38 -0
  5. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/config_wizard/create.py +1 -1
  6. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/config_wizard/edit.py +6 -6
  7. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/executor.py +9 -5
  8. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/utils.py +2 -2
  9. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/configuration/io.py +22 -16
  10. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/configuration/models.py +6 -3
  11. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/configuration/schema.py +3 -1
  12. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/errors.py +9 -10
  13. checkup-0.5.0/src/checkup/executor/__init__.py +6 -0
  14. checkup-0.5.0/src/checkup/executor/batch_executors.py +169 -0
  15. checkup-0.5.0/src/checkup/executor/metric_calculator.py +213 -0
  16. checkup-0.5.0/src/checkup/executor/provider_executor.py +45 -0
  17. checkup-0.5.0/src/checkup/executor/state.py +101 -0
  18. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/graph.py +1 -1
  19. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/hub.py +5 -13
  20. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/materializers/base.py +1 -1
  21. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/materializers/console.py +1 -1
  22. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/materializers/csv_file.py +1 -1
  23. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/materializers/database.py +1 -1
  24. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/materializers/html_report.py +1 -1
  25. checkup-0.5.0/src/checkup/measurement.py +96 -0
  26. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/metric.py +29 -29
  27. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/provider.py +0 -13
  28. checkup-0.5.0/src/checkup/providers/tags.py +26 -0
  29. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/validators.py +15 -13
  30. checkup-0.3.0/src/checkup/cli/__init__.py +0 -18
  31. checkup-0.3.0/src/checkup/executor.py +0 -536
  32. checkup-0.3.0/src/checkup/providers/tags.py +0 -40
  33. {checkup-0.3.0 → checkup-0.5.0}/README.md +0 -0
  34. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/commands/__init__.py +0 -0
  35. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/commands/config.py +0 -0
  36. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/commands/init.py +0 -0
  37. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/commands/run.py +0 -0
  38. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/commands/schema.py +0 -0
  39. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/config_wizard/__init__.py +0 -0
  40. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/cli/config_wizard/_common.py +0 -0
  41. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/config.py +0 -0
  42. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/configuration/__init__.py +0 -0
  43. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/configuration/env.py +0 -0
  44. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/materializers/__init__.py +0 -0
  45. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/providers/__init__.py +0 -0
  46. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/registry/__init__.py +0 -0
  47. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/registry/discovery.py +0 -0
  48. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/templates/metrics_report.html +0 -0
  49. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/types.py +0 -0
  50. {checkup-0.3.0 → checkup-0.5.0}/src/checkup/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: checkup
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Computational governance framework for measuring data product health
5
5
  Author: Jan Vanbuel
6
6
  Author-email: Jan Vanbuel <jan.vanbuel@ond.vlaanderen.be>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "checkup"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "Computational governance framework for measuring data product health"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Jan Vanbuel", email = "jan.vanbuel@ond.vlaanderen.be" }]
@@ -14,18 +14,23 @@ from checkup.materializers import (
14
14
  Materializer,
15
15
  SQLAlchemyMaterializer,
16
16
  )
17
- from checkup.metric import ExecutorType, Measurement, Metric
17
+ from checkup.measurement import Measurement, Measurements
18
+ from checkup.metric import ExecutorType, Metric
18
19
  from checkup.provider import Provider
19
20
  from checkup.providers.tags import TagProvider
20
21
  from checkup.types import Context
21
22
  from checkup.utils import suppress_subprocess_output
22
23
 
24
+ # Rebuild models to resolve forward references after all classes are imported
25
+ Measurement.model_rebuild()
26
+
23
27
  __all__ = [
24
28
  # Core
25
29
  "CheckHub",
26
30
  "MeasurementResult",
27
31
  "Metric",
28
32
  "Measurement",
33
+ "Measurements",
29
34
  "ExecutorType",
30
35
  "Provider",
31
36
  "TagProvider",
@@ -0,0 +1,38 @@
1
+ """
2
+ Checkup CLI application.
3
+ """
4
+
5
+ from importlib.metadata import version as pkg_version
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from checkup.cli.commands import config, init, run, schema
11
+
12
+ app = typer.Typer(
13
+ name="checkup",
14
+ help="CheckUp - Computational governance framework for measuring data product health",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+
19
+ def version_callback(value: bool) -> None:
20
+ if value:
21
+ typer.echo(pkg_version("checkup"))
22
+ raise typer.Exit()
23
+
24
+
25
+ @app.callback()
26
+ def main(
27
+ version: Annotated[
28
+ bool | None,
29
+ typer.Option("--version", "-v", callback=version_callback),
30
+ ] = None,
31
+ ) -> None:
32
+ pass
33
+
34
+
35
+ app.command()(run)
36
+ app.command()(init)
37
+ app.command()(config)
38
+ app.command()(schema)
@@ -79,7 +79,7 @@ def _build_config(registry: "PluginRegistry") -> CheckupConfig | None:
79
79
  return CheckupConfig(
80
80
  tags=tags,
81
81
  providers=[ProviderConfig(name=p) for p in provider_names],
82
- metrics=[MetricConfig(name=m) for m in metric_names],
82
+ metrics=[MetricConfig(type=m) for m in metric_names],
83
83
  materializer=MaterializerConfig(type=mat) if mat != "console" else None,
84
84
  )
85
85
 
@@ -58,7 +58,7 @@ def _build_config(
58
58
  config: CheckupConfig, registry: "PluginRegistry"
59
59
  ) -> CheckupConfig | None:
60
60
  provider_configs = {p.name: p.config for p in config.providers}
61
- metric_configs = {m.name: m.config for m in config.metrics}
61
+ metric_configs = {m.type: m.config for m in config.metrics}
62
62
 
63
63
  tags = _prompt_edit_tags(config)
64
64
  if tags is None:
@@ -83,7 +83,7 @@ def _build_config(
83
83
  for p in provider_names
84
84
  ],
85
85
  metrics=[
86
- MetricConfig(name=m, config=metric_configs.get(m, {})) for m in metric_names
86
+ MetricConfig(type=m, config=metric_configs.get(m, {})) for m in metric_names
87
87
  ],
88
88
  materializer=MaterializerConfig(type=mat) if mat else None,
89
89
  )
@@ -121,7 +121,7 @@ def _prompt_edit_metrics(
121
121
  registry: "PluginRegistry",
122
122
  provider_names: list[str],
123
123
  ) -> list[str] | None:
124
- current_names = [m.name for m in config.metrics]
124
+ current_types = [m.type for m in config.metrics]
125
125
 
126
126
  with console.status("Loading metrics..."):
127
127
  available = registry.list_compatible_metric_names(provider_names)
@@ -130,8 +130,8 @@ def _prompt_edit_metrics(
130
130
  if edit is None:
131
131
  return None
132
132
  elif edit:
133
- return select_multiple(available, current_names, "metrics")
134
- return [m.name for m in config.metrics if m.name in available]
133
+ return select_multiple(available, current_types, "metrics")
134
+ return [m.type for m in config.metrics if m.type in available]
135
135
 
136
136
 
137
137
  def _prompt_edit_materializer(
@@ -194,7 +194,7 @@ def _show_current_config(config: "CheckupConfig") -> None:
194
194
  console.print("[bold]Current configuration:[/bold]", markup=True)
195
195
  console.print(f" Tags: {dict(config.tags) or '(none)'}")
196
196
  console.print(f" Providers: {[p.name for p in config.providers] or '(none)'}")
197
- console.print(f" Metrics: {[m.name for m in config.metrics] or '(none)'}")
197
+ console.print(f" Metrics: {[m.type for m in config.metrics] or '(none)'}")
198
198
  mat = config.materializer.type if config.materializer else "(none)"
199
199
  console.print(f" Materializer: {mat}")
200
200
  console.print()
@@ -98,17 +98,21 @@ def _resolve_metrics(
98
98
  metrics: list[Metric] = []
99
99
 
100
100
  for metric_config in config.metrics:
101
- metric_cls = registry.get_metric(metric_config.name)
101
+ metric_cls = registry.get_metric(metric_config.type)
102
102
  if metric_cls is None:
103
- console.print(f"[yellow]Unknown metric: {metric_config.name}[/yellow]")
103
+ console.print(f"[yellow]Unknown metric: {metric_config.type}[/yellow]")
104
104
  continue
105
105
 
106
106
  try:
107
- metric = metric_cls(**metric_config.config)
108
- metrics.append(metric)
107
+ metrics.append(
108
+ metric_cls(
109
+ **metric_config.config,
110
+ **({"name": metric_config.name} if metric_config.name else {}),
111
+ )
112
+ )
109
113
  except Exception as e:
110
114
  console.print(
111
- f"[red]Failed to instantiate metric {metric_config.name}: {e}[/red]"
115
+ f"[red]Failed to instantiate metric {metric_config.type}: {e}[/red]"
112
116
  )
113
117
 
114
118
  return metrics
@@ -41,8 +41,8 @@ def apply_cli_overrides(
41
41
  if metrics:
42
42
  new_metrics = []
43
43
  for m in metrics:
44
- name, config = parse_cli_item(m)
45
- new_metrics.append(MetricConfig(name=name, config=config))
44
+ metric_type, config = parse_cli_item(m)
45
+ new_metrics.append(MetricConfig(type=metric_type, config=config))
46
46
  else:
47
47
  new_metrics = list(cfg.metrics)
48
48
 
@@ -80,13 +80,17 @@ def merge_configs(base: dict[str, Any], override: dict[str, Any]) -> dict[str, A
80
80
  result[key] = value
81
81
  elif key == "metrics":
82
82
  if key in result:
83
- base_metrics = {
84
- (m if isinstance(m, str) else list(m.keys())[0]): m
85
- for m in result.get(key, [])
86
- }
83
+
84
+ def _metric_key(m: dict) -> tuple:
85
+ """
86
+ Key by (type, name) to allow multiple instances of same type
87
+ """
88
+
89
+ return (m.get("type"), m.get("name"))
90
+
91
+ base_metrics = {_metric_key(m): m for m in result.get(key, [])}
87
92
  for metric in value:
88
- name = metric if isinstance(metric, str) else list(metric.keys())[0]
89
- base_metrics[name] = metric
93
+ base_metrics[_metric_key(metric)] = metric
90
94
  result[key] = list(base_metrics.values())
91
95
  else:
92
96
  result[key] = value
@@ -126,9 +130,10 @@ def parse_metrics(raw: list[Any] | None) -> list[MetricConfig]:
126
130
  Parse metric configuration from raw YAML.
127
131
 
128
132
  Supports:
129
- - name: git_days_since_last_update
130
- - name: python_version
131
- version: "3.12"
133
+ - type: git_tracked_file_count
134
+ - type: git_tracked_file_count
135
+ name: cruft_file_exists
136
+ pattern: ".cruft.json"
132
137
  """
133
138
 
134
139
  if not raw:
@@ -136,13 +141,14 @@ def parse_metrics(raw: list[Any] | None) -> list[MetricConfig]:
136
141
 
137
142
  metrics = []
138
143
  for item in raw:
139
- if isinstance(item, str):
140
- metrics.append(MetricConfig(name=item))
141
- elif isinstance(item, dict):
142
- name = item.get("name")
143
- if name:
144
- config = {k: v for k, v in item.items() if k != "name"}
145
- metrics.append(MetricConfig(name=name, config=config))
144
+ if not isinstance(item, dict):
145
+ continue
146
+ metric_type = item.get("type")
147
+ if not metric_type:
148
+ continue
149
+ name = item.get("name")
150
+ config = {k: v for k, v in item.items() if k not in ("type", "name")}
151
+ metrics.append(MetricConfig(type=metric_type, name=name, config=config))
146
152
  return metrics
147
153
 
148
154
 
@@ -15,11 +15,14 @@ class ProviderConfig(BaseModel):
15
15
 
16
16
 
17
17
  class MetricConfig(BaseModel):
18
- """Configuration for a single metric."""
19
-
20
- name: str
18
+ type: str
19
+ name: str | None = None
21
20
  config: dict[str, Any] = Field(default_factory=dict)
22
21
 
22
+ @property
23
+ def instance_name(self) -> str:
24
+ return self.name or self.type
25
+
23
26
 
24
27
  class MaterializerConfig(BaseModel):
25
28
  """Configuration for the materializer."""
@@ -190,7 +190,9 @@ def generate_schema() -> dict:
190
190
  "metrics": {
191
191
  "type": "array",
192
192
  "description": "Metrics to calculate",
193
- "items": _build_oneof_schema(metric_names, metric_schemas),
193
+ "items": _build_oneof_schema(
194
+ metric_names, metric_schemas, key_field="type"
195
+ ),
194
196
  },
195
197
  "materializer": _build_oneof_schema(
196
198
  materializer_names, materializer_schemas, key_field="type"
@@ -27,7 +27,7 @@ class MetricPicklingError(Exception):
27
27
  self.metric_cls = metric_cls
28
28
  self.original_error = original_error
29
29
  super().__init__(
30
- f"Metric '{metric_cls.name}' cannot be pickled for process execution. "
30
+ f"Metric '{metric_cls.__name__}' cannot be pickled for process execution. "
31
31
  f"Consider using ExecutorType.THREAD instead. Original error: {original_error}"
32
32
  )
33
33
 
@@ -39,15 +39,14 @@ class MetricPicklingError(Exception):
39
39
  class DuplicateMetricNameError(Exception):
40
40
  """Exception raised when multiple metrics have the same name."""
41
41
 
42
- def __init__(self, name: str, metric_classes: "list[type[Metric]]"):
43
- self.name = name
44
- self.metric_classes = metric_classes
45
- class_names = ", ".join(cls.__name__ for cls in metric_classes)
46
- super().__init__(
47
- f"Duplicate metric name '{name}' found in classes: {class_names}. "
48
- f"Each metric must have a unique name."
49
- )
42
+ def __init__(self, duplicates: "dict[str, list[type[Metric]]]"):
43
+ self.duplicates = duplicates
44
+ lines = []
45
+ for name, classes in duplicates.items():
46
+ class_names = ", ".join(cls.__name__ for cls in classes)
47
+ lines.append(f" '{name}': {class_names}")
48
+ super().__init__("Duplicate metric names found:\n" + "\n".join(lines))
50
49
 
51
50
  def __reduce__(self):
52
51
  """Enable pickling across process boundaries."""
53
- return (self.__class__, (self.name, self.metric_classes))
52
+ return (self.__class__, (self.duplicates,))
@@ -0,0 +1,6 @@
1
+ """Executor classes for provider and metric execution."""
2
+
3
+ from checkup.executor.metric_calculator import MetricCalculator
4
+ from checkup.executor.provider_executor import ProviderExecutor
5
+
6
+ __all__ = ["MetricCalculator", "ProviderExecutor"]
@@ -0,0 +1,169 @@
1
+ """Batch execution strategies for different executor types."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
7
+ from typing import Any
8
+
9
+ from checkup.measurement import Measurement, Measurements
10
+ from checkup.metric import Metric
11
+ from checkup.types import Context
12
+ from checkup.validators import validate_pickleable
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _calculate_metric_in_process(
18
+ metric: Metric,
19
+ context: Context,
20
+ tags: dict[str, Any],
21
+ calculated: Measurements,
22
+ ) -> Measurement:
23
+ """
24
+ Calculate a single metric in a subprocess.
25
+
26
+ Module-level function for ProcessPoolExecutor compatibility.
27
+ """
28
+
29
+ measurement = metric.calculate(context, calculated)
30
+ measurement.tags.update(tags)
31
+ return measurement
32
+
33
+
34
+ def execute_batch_thread(
35
+ batch: list[Metric],
36
+ context: Context,
37
+ tags: dict[str, Any],
38
+ calculated: Measurements,
39
+ ) -> list[tuple[Metric, Measurement]]:
40
+ """
41
+ Execute metrics using ThreadPoolExecutor.
42
+ """
43
+
44
+ results: list[tuple[Metric, Measurement]] = []
45
+
46
+ with ThreadPoolExecutor(max_workers=len(batch)) as executor:
47
+ future_to_metric = {
48
+ executor.submit(
49
+ _calculate_single_metric,
50
+ metric,
51
+ context,
52
+ tags,
53
+ calculated,
54
+ ): metric
55
+ for metric in batch
56
+ }
57
+
58
+ for future in as_completed(future_to_metric):
59
+ metric = future_to_metric[future]
60
+ measurement = future.result()
61
+ results.append((metric, measurement))
62
+
63
+ return results
64
+
65
+
66
+ def execute_batch_process(
67
+ batch: list[Metric],
68
+ context: Context,
69
+ tags: dict[str, Any],
70
+ calculated: Measurements,
71
+ ) -> list[tuple[Metric, Measurement]]:
72
+ """
73
+ Execute metrics using ProcessPoolExecutor.
74
+ """
75
+
76
+ for metric in batch:
77
+ validate_pickleable(type(metric))
78
+
79
+ results: list[tuple[Metric, Measurement]] = []
80
+
81
+ with ProcessPoolExecutor(max_workers=len(batch)) as executor:
82
+ future_to_metric = {
83
+ executor.submit(
84
+ _calculate_metric_in_process,
85
+ metric,
86
+ context,
87
+ tags,
88
+ calculated,
89
+ ): metric
90
+ for metric in batch
91
+ }
92
+
93
+ for future in as_completed(future_to_metric):
94
+ metric = future_to_metric[future]
95
+ try:
96
+ measurement = future.result()
97
+ results.append((metric, measurement))
98
+ except Exception as e:
99
+ logger.error(
100
+ "Metric %s failed in process executor: %s",
101
+ metric.name,
102
+ e,
103
+ )
104
+ raise
105
+
106
+ return results
107
+
108
+
109
+ def execute_batch_asyncio(
110
+ batch: list[Metric],
111
+ context: Context,
112
+ tags: dict[str, Any],
113
+ calculated: Measurements,
114
+ ) -> list[tuple[Metric, Measurement]]:
115
+ """
116
+ Execute metrics using asyncio.
117
+ """
118
+
119
+ return asyncio.run(_execute_batch_asyncio_impl(batch, context, tags, calculated))
120
+
121
+
122
+ async def _execute_batch_asyncio_impl(
123
+ batch: list[Metric],
124
+ context: Context,
125
+ tags: dict[str, Any],
126
+ calculated: Measurements,
127
+ ) -> list[tuple[Metric, Measurement]]:
128
+ """
129
+ Async implementation of batch execution.
130
+ """
131
+
132
+ tasks = [
133
+ _calculate_async_metric(metric, context, tags, calculated) for metric in batch
134
+ ]
135
+ measurements = await asyncio.gather(*tasks)
136
+ return list(zip(batch, measurements, strict=True))
137
+
138
+
139
+ async def _calculate_async_metric(
140
+ metric: Metric,
141
+ context: Context,
142
+ tags: dict[str, Any],
143
+ calculated: Measurements,
144
+ ) -> Measurement:
145
+ """
146
+ Calculate a single metric, handling both sync and async calculate methods.
147
+ """
148
+
149
+ if inspect.iscoroutinefunction(metric.calculate):
150
+ measurement = await metric.calculate(context, calculated)
151
+ else:
152
+ measurement = metric.calculate(context, calculated)
153
+ measurement.tags.update(tags)
154
+ return measurement
155
+
156
+
157
+ def _calculate_single_metric(
158
+ metric: Metric,
159
+ context: Context,
160
+ tags: dict[str, Any],
161
+ calculated: Measurements,
162
+ ) -> Measurement:
163
+ """
164
+ Calculate a single metric (for thread executor).
165
+ """
166
+
167
+ measurement = metric.calculate(context, calculated)
168
+ measurement.tags.update(tags)
169
+ return measurement