From f06f2caed84f0ad6bfe3cfeb0b3805d2a42a3b55 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Fri, 11 Sep 2026 19:13:09 -0400 Subject: [PATCH 01/12] Add reusable Steps validation builder --- pointblank/steps.py | 1004 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1004 insertions(+) create mode 100644 pointblank/steps.py diff --git a/pointblank/steps.py b/pointblank/steps.py new file mode 100644 index 000000000..0a2f9da9f --- /dev/null +++ b/pointblank/steps.py @@ -0,0 +1,1004 @@ +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Any, Callable + +from pointblank.contract import Step + +if TYPE_CHECKING: + from collections.abc import Collection + + from pointblank._typing import SegmentSpec, Tolerance + from pointblank.actions import Actions + from pointblank.column import Column, ColumnSelector, ColumnSelectorNarwhals + from pointblank.missing import MissingSpec + from pointblank.schema import Schema + from pointblank.thresholds import Thresholds + from pointblank.validate import Validate + +__all__ = ["Steps"] + + +class Steps: + """A reusable collection of validation step definitions. + + A `Steps` object records validation steps without binding them to data, thresholds, or + metadata. It mirrors the validation method API on + [`Validate`](`pointblank.Validate`) so that defining steps feels identical, but + the result is a portable recipe that can be imported into any number of pipelines via + [`Validate.add_steps()`](`pointblank.Validate.add_steps`). + + Parameters + ---------- + steps + An optional list of [`Step`](`pointblank.Step`) objects to initialize with. + + Examples + -------- + ```python + import pointblank as pb + + completeness = ( + pb.Steps() + .col_vals_not_null(columns=pb.ends_with("_id")) + .col_vals_not_null(columns="email") + ) + + positive_amounts = ( + pb.Steps() + .col_vals_ge(columns=pb.starts_with("amt_"), value=0) + .col_vals_gt(columns="amt_total", value=0) + ) + + validation = ( + pb.Validate(data=orders, label="Order quality") + .add_steps(completeness, positive_amounts) + .interrogate() + ) + ``` + """ + + def __init__(self, steps: list[Step] | None = None) -> None: + self._steps: list[Step] = list(steps) if steps is not None else [] + + def _add(self, method: str, **kwargs: Any) -> Steps: + self._steps.append(Step(method, **kwargs)) + return self + + def __len__(self) -> int: + return len(self._steps) + + @staticmethod + def _is_default(key: str, value: Any) -> bool: + _DEFAULTS = { + "na_pass": False, + "inverse": False, + "active": True, + "allow_stationary": False, + "allow_tz_mismatch": False, + "complete": True, + "in_order": True, + "case_sensitive_colnames": True, + "case_sensitive_dtypes": True, + "full_match_dtypes": True, + "inclusive": (True, True), + } + if value is None: + return True + if key in _DEFAULTS: + return value == _DEFAULTS[key] + return False + + def __repr__(self) -> str: + lines = [] + for i, step in enumerate(self._steps): + shown = {k: v for k, v in step.kwargs.items() if not self._is_default(k, v)} + if shown: + kwargs_str = ", ".join(f"{k}={v!r}" for k, v in shown.items()) + lines.append(f" {i + 1}. {step.method}({kwargs_str})") + else: + lines.append(f" {i + 1}. {step.method}()") + header = f"Steps({len(self._steps)} step{'s' if len(self._steps) != 1 else ''})" + if not lines: + return header + return header + "\n" + "\n".join(lines) + + def _repr_html_(self) -> str: + from pointblank._utils_html import _create_steps_html + + return _create_steps_html(self) + + # -- Validation methods ------------------------------------------------------- + # Each method mirrors the corresponding Validate method signature but simply + # records the call as a Step for later application via add_steps(). + + def col_vals_gt( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + value: float | int | Column, + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_gt", + columns=columns, + value=value, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_lt( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + value: float | int | Column, + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_lt", + columns=columns, + value=value, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_eq( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + value: float | int | Column, + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_eq", + columns=columns, + value=value, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_ne( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + value: float | int | Column, + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_ne", + columns=columns, + value=value, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_ge( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + value: float | int | Column, + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_ge", + columns=columns, + value=value, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_le( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + value: float | int | Column, + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_le", + columns=columns, + value=value, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_between( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + left: float | int | Column, + right: float | int | Column, + inclusive: tuple[bool, bool] = (True, True), + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_between", + columns=columns, + left=left, + right=right, + inclusive=inclusive, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_outside( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + left: float | int | Column, + right: float | int | Column, + inclusive: tuple[bool, bool] = (True, True), + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_outside", + columns=columns, + left=left, + right=right, + inclusive=inclusive, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_in_set( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + set: Collection[Any], + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_in_set", + columns=columns, + set=set, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_not_in_set( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + set: Collection[Any], + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_not_in_set", + columns=columns, + set=set, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_increasing( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + allow_stationary: bool = False, + decreasing_tol: float | None = None, + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_increasing", + columns=columns, + allow_stationary=allow_stationary, + decreasing_tol=decreasing_tol, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_decreasing( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + allow_stationary: bool = False, + increasing_tol: float | None = None, + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_decreasing", + columns=columns, + allow_stationary=allow_stationary, + increasing_tol=increasing_tol, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_null( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_null", + columns=columns, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_not_null( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_not_null", + columns=columns, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_regex( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + pattern: str, + na_pass: bool = False, + inverse: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_regex", + columns=columns, + pattern=pattern, + na_pass=na_pass, + inverse=inverse, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_within_spec( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + spec: str, + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_within_spec", + columns=columns, + spec=spec, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_str_len( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + min_val: int | None = None, + max_val: int | None = None, + na_pass: bool = False, + missing: MissingSpec | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_str_len", + columns=columns, + min_val=min_val, + max_val=max_val, + na_pass=na_pass, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_expr( + self, + expr: Any, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_expr", + expr=expr, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_exists( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_exists", + columns=columns, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_pct_null( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + p: float, + tol: Tolerance = 0, + thresholds: int | float | None | bool | tuple | dict | Thresholds = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_pct_null", + columns=columns, + p=p, + tol=tol, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_pct_missing( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + missing: MissingSpec, + max_pct: float, + reason: str | None = None, + category: str | None = None, + thresholds: int | float | None | bool | tuple | dict | Thresholds = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_pct_missing", + columns=columns, + missing=missing, + max_pct=max_pct, + reason=reason, + category=category, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_missing_coded( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + missing: MissingSpec, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_missing_coded", + columns=columns, + missing=missing, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_missing_only_coded( + self, + columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, + missing: MissingSpec, + allowed: Collection[Any] | None = None, + min_val: float | int | None = None, + max_val: float | int | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_missing_only_coded", + columns=columns, + missing=missing, + allowed=allowed, + min_val=min_val, + max_val=max_val, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def rows_distinct( + self, + columns_subset: str | list[str] | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "rows_distinct", + columns_subset=columns_subset, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def rows_complete( + self, + columns_subset: str | list[str] | None = None, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "rows_complete", + columns_subset=columns_subset, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_missing_consistent( + self, + columns: list[str], + missing: MissingSpec, + when_reason: str, + pre: Callable | None = None, + segments: SegmentSpec | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_missing_consistent", + columns=columns, + missing=missing, + when_reason=when_reason, + pre=pre, + segments=segments, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_schema_match( + self, + schema: Schema, + complete: bool = True, + in_order: bool = True, + case_sensitive_colnames: bool = True, + case_sensitive_dtypes: bool = True, + full_match_dtypes: bool = True, + pre: Callable | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_schema_match", + schema=schema, + complete=complete, + in_order=in_order, + case_sensitive_colnames=case_sensitive_colnames, + case_sensitive_dtypes=case_sensitive_dtypes, + full_match_dtypes=full_match_dtypes, + pre=pre, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def row_count_match( + self, + count: int | Any, + tol: Tolerance = 0, + inverse: bool = False, + pre: Callable | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "row_count_match", + count=count, + tol=tol, + inverse=inverse, + pre=pre, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def data_freshness( + self, + column: str, + max_age: str | datetime.timedelta, + reference_time: datetime.datetime | str | None = None, + timezone: str | None = None, + allow_tz_mismatch: bool = False, + pre: Callable | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "data_freshness", + column=column, + max_age=max_age, + reference_time=reference_time, + timezone=timezone, + allow_tz_mismatch=allow_tz_mismatch, + pre=pre, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_count_match( + self, + count: int | Any, + inverse: bool = False, + pre: Callable | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_count_match", + count=count, + inverse=inverse, + pre=pre, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def col_vals_in_table( + self, + columns: str | list[str], + ref_table: Any, + ref_column: str | list[str], + na_pass: bool = False, + pre: Callable | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "col_vals_in_table", + columns=columns, + ref_table=ref_table, + ref_column=ref_column, + na_pass=na_pass, + pre=pre, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def tbl_match( + self, + tbl_compare: Any, + pre: Callable | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "tbl_match", + tbl_compare=tbl_compare, + pre=pre, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) + + def conjointly( + self, + *exprs: Callable, + pre: Callable | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + actions: Actions | None = None, + brief: str | bool | None = None, + active: bool | Callable = True, + dimension: str | None = None, + ) -> Steps: + return self._add( + "conjointly", + exprs=exprs, + pre=pre, + thresholds=thresholds, + actions=actions, + brief=brief, + active=active, + dimension=dimension, + ) From dfab81855e189bbc3174bc946903e197f233486b Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Fri, 11 Sep 2026 19:13:42 -0400 Subject: [PATCH 02/12] Add HTML formatter for Steps objects --- pointblank/_utils_html.py | 54 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/pointblank/_utils_html.py b/pointblank/_utils_html.py index 7538c58e8..bb06f226d 100644 --- a/pointblank/_utils_html.py +++ b/pointblank/_utils_html.py @@ -1,12 +1,15 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from great_tables import html from pointblank._constants import TABLE_TYPE_STYLES from pointblank._utils import _format_to_integer_value +if TYPE_CHECKING: + from pointblank.steps import Steps + def _fmt_frac(vec) -> list[str | None]: res: list[str | None] = [] @@ -94,3 +97,52 @@ def _create_table_dims_html(columns: int, rows: int, font_size: str = "10px") -> f"border: solid 1px #BDE7B4; padding: 2px 15px 2px 15px; font-size: {font_size};'>" f"{columns_fmt}" ) + + +def _create_steps_html(steps_obj: Steps) -> str: + import html as html_module + + n = len(steps_obj) + header = ( + f"
" + f"
" + f"Steps — {n} step{'s' if n != 1 else ''}" + f"
" + ) + + if n == 0: + body = ( + "
" + "No steps defined." + "
" + ) + return header + body + "
" + + rows = [] + for i, step in enumerate(steps_obj._steps, start=1): + kwargs_parts = [] + for k, v in step.kwargs.items(): + if steps_obj._is_default(k, v): + continue + v_repr = html_module.escape(repr(v)) + kwargs_parts.append( + f"{html_module.escape(k)}=" + f"{v_repr}" + ) + kwargs_str = ", ".join(kwargs_parts) + + bg = "#ffffff" if i % 2 == 1 else "#fafafa" + radius = "0 0 4px 4px" if i == n else "0" + rows.append( + f"
" + f"{i}." + f"" + f"{html_module.escape(step.method)}" + f"({kwargs_str})" + f"
" + ) + + return header + "".join(rows) + "" From 88adc81d3e939fdcaf2ad09a311c12c78ce78144 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Fri, 11 Sep 2026 19:14:06 -0400 Subject: [PATCH 03/12] Add Validate.add_steps for reusable step imports --- pointblank/validate.py | 168 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/pointblank/validate.py b/pointblank/validate.py index e07864e22..58192251f 100644 --- a/pointblank/validate.py +++ b/pointblank/validate.py @@ -153,6 +153,7 @@ from narwhals.typing import IntoDataFrame, IntoFrame from pointblank._typing import AbsoluteBounds, Tolerance, _CompliantValue, _CompliantValues + from pointblank.steps import Steps __all__ = [ @@ -5071,6 +5072,40 @@ def print_database_tables(connection_string: str) -> list[str]: _handle_connection_errors(e, connection_string) +def _extract_steps_from_validate(validation: Validate) -> list: + from pointblank.contract import Step + + steps = [] + for vi in validation.validation_info: + kwargs: dict[str, Any] = {} + if vi.column is not None: + kwargs["columns"] = vi.column + if vi.values is not None: + kwargs["value"] = vi.values + if vi.inclusive is not None: + kwargs["inclusive"] = vi.inclusive + if vi.na_pass is not None: + kwargs["na_pass"] = vi.na_pass + if vi.missing is not None: + kwargs["missing"] = vi.missing + if vi.pre is not None: + kwargs["pre"] = vi.pre + if vi.segments is not None: + kwargs["segments"] = vi.segments + if vi.thresholds is not None: + kwargs["thresholds"] = vi.thresholds + if vi.actions is not None: + kwargs["actions"] = vi.actions + if vi.brief is not None: + kwargs["brief"] = vi.brief + if vi.active is not None and vi.active is not True: + kwargs["active"] = vi.active + if vi.dimension is not None: + kwargs["dimension"] = vi.dimension + steps.append(Step(vi.assertion_type, **kwargs)) + return steps + + @dataclass class Validate: """ @@ -5988,6 +6023,139 @@ def set_tbl( def _repr_html_(self) -> str: return self.get_tabular_report()._repr_html_() # pragma: no cover + def add_steps( + self, + *steps: Steps | Validate, + active: bool | Callable | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + exclude: list[str | int] | None = None, + columns_map: dict[str, str] | None = None, + ) -> Validate: + """ + Add validation steps from one or more Steps or Validate objects. + + This method appends step definitions from the supplied objects to this validation plan. + When a [`Steps`](`pointblank.Steps`) object is provided, its recorded step definitions are + applied as method calls on this `Validate` instance. When a `Validate` object is provided, + its step definitions are extracted and applied the same way. This enables composing + validation plans from reusable step libraries. + + Parameters + ---------- + *steps + One or more [`Steps`](`pointblank.Steps`) or `Validate` objects whose step definitions + should be appended to this validation plan. + active + Override the `active=` setting for all imported steps. If `None` (the default), each + step's own `active=` setting is preserved. If `False`, all imported steps are deactivated. + A callable can also be provided to dynamically determine activation. + thresholds + Override the `thresholds=` setting for all imported steps. If `None` (the default), each + step's own `thresholds=` setting is preserved. + exclude + A list of step method names (strings) or 1-based step indices (integers) to skip when + importing. For example, `exclude=["col_vals_regex"]` skips all regex steps, and + `exclude=[2]` skips the second step. + columns_map + A dictionary mapping original column names to replacement column names. This allows the + same step definitions to work on tables with different column naming conventions. + + Returns + ------- + Validate + The Validate object with the imported steps appended (for method chaining). + + Examples + -------- + ```python + import pointblank as pb + + completeness = ( + pb.Steps() + .col_vals_not_null(columns="order_id") + .col_vals_not_null(columns="email") + ) + + positive_amounts = ( + pb.Steps() + .col_vals_ge(columns="amount", value=0) + ) + + validation = ( + pb.Validate(data=orders) + .add_steps(completeness, positive_amounts) + .interrogate() + ) + ``` + """ + from pointblank.steps import Steps + + if not steps: + raise ValueError("At least one Steps or Validate object must be provided.") + + exclude_set: set[str] = set() + exclude_indices: set[int] = set() + if exclude is not None: + for item in exclude: + if isinstance(item, str): + exclude_set.add(item) + elif isinstance(item, int): + exclude_indices.add(item) + else: + raise TypeError( + f"Items in `exclude=` must be strings (method names) or integers " + f"(1-based step indices), got {type(item).__name__}." + ) + + for step_source in steps: + if isinstance(step_source, Steps): + step_list = step_source._steps + elif isinstance(step_source, Validate): + step_list = _extract_steps_from_validate(step_source) + else: + raise TypeError( + f"`add_steps()` accepts Steps or Validate objects, " + f"got {type(step_source).__name__}." + ) + + for i, step in enumerate(step_list, start=1): + if step.method in exclude_set or i in exclude_indices: + continue + + kwargs = dict(step.kwargs) + + if active is not None: + kwargs["active"] = active + if thresholds is not None: + kwargs["thresholds"] = thresholds + if columns_map is not None: + for param in ("columns", "column", "columns_subset"): + if param in kwargs and kwargs[param] is not None: + val = kwargs[param] + if isinstance(val, str) and val in columns_map: + kwargs[param] = columns_map[val] + elif isinstance(val, list): + kwargs[param] = [ + columns_map.get(c, c) if isinstance(c, str) else c + for c in val + ] + + method = getattr(self, step.method, None) + if method is None: + raise AttributeError( + f"Validate has no method '{step.method}'. " + f"Check that the step definitions are compatible with this version." + ) + + # conjointly uses *exprs positionally + if step.method == "conjointly" and "exprs" in kwargs: + exprs = kwargs.pop("exprs") + method(*exprs, **kwargs) + else: + method(**kwargs) + + return self + def col_vals_gt( self, columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, From c74fe9fc42fbe0a0aab8201e0b0b8f2d1b708807 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Fri, 11 Sep 2026 19:14:09 -0400 Subject: [PATCH 04/12] Update __init__.py --- pointblank/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pointblank/__init__.py b/pointblank/__init__.py index b5a344020..753f9b666 100644 --- a/pointblank/__init__.py +++ b/pointblank/__init__.py @@ -33,6 +33,7 @@ ) from pointblank.contract import Contract, Step from pointblank.datascan import DataScan, DataScanDiff, col_summary_tbl +from pointblank.steps import Steps from pointblank.draft import DraftValidation from pointblank.edit import EditValidation from pointblank.field import ( @@ -123,6 +124,7 @@ "Schema", "Contract", "Step", + "Steps", "Pipeline", "PipelineResult", "DataScan", From 908eb491892245f6a7fa1d689a9dff984bf326a1 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Fri, 11 Sep 2026 19:14:19 -0400 Subject: [PATCH 05/12] Update great-docs.yml --- great-docs.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/great-docs.yml b/great-docs.yml index 8b18a0045..f77edc671 100644 --- a/great-docs.yml +++ b/great-docs.yml @@ -173,14 +173,17 @@ reference: - title: Contracts and Pipelines desc: > Use `Contract` and `Step` to define declarative data quality contracts that specify what - valid data looks like. Use `Pipeline` to enforce contracts at both boundaries of a data - transformation (source and target), producing a `PipelineResult` with full introspection - into what passed and what failed. + valid data looks like. Use `Steps` to build reusable step libraries with a fluent API and + compose them into `Validate` plans via `add_steps()`. Use `Pipeline` to enforce contracts + at both boundaries of a data transformation (source and target), producing a + `PipelineResult` with full introspection into what passed and what failed. contents: - name: Contract members: true - name: Step members: true + - name: Steps + members: true - name: Pipeline members: true - name: PipelineResult From 67d3886e7a6d47c31f29ac64b14240be99115cd3 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Fri, 11 Sep 2026 19:14:22 -0400 Subject: [PATCH 06/12] Create test_steps.py --- tests/test_steps.py | 511 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100644 tests/test_steps.py diff --git a/tests/test_steps.py b/tests/test_steps.py new file mode 100644 index 000000000..c972e77f6 --- /dev/null +++ b/tests/test_steps.py @@ -0,0 +1,511 @@ +from __future__ import annotations + +import pytest +import pandas as pd + +import pointblank as pb +from pointblank.steps import Steps +from pointblank.contract import Step + + +@pytest.fixture +def sample_df(): + return pd.DataFrame( + { + "id": [1, 2, 3], + "email": ["a@b.c", "d@e.f", "g@h.i"], + "amount": [10, 20, 30], + "score": [0.5, 0.8, 0.9], + } + ) + + +@pytest.fixture +def df_with_issues(): + return pd.DataFrame( + { + "id": [1, None, 3], + "email": ["a@b.c", None, "g@h.i"], + "amount": [10, -5, 30], + "score": [0.5, 0.8, 1.5], + } + ) + + +# --------------------------------------------------------------------------- +# Steps construction +# --------------------------------------------------------------------------- + + +class TestStepsConstruction: + def test_empty_steps(self): + s = Steps() + assert len(s) == 0 + assert s._steps == [] + + def test_init_with_step_list(self): + step_list = [ + Step("col_vals_not_null", columns="id"), + Step("col_vals_gt", columns="amount", value=0), + ] + s = Steps(steps=step_list) + assert len(s) == 2 + assert s._steps[0].method == "col_vals_not_null" + assert s._steps[1].method == "col_vals_gt" + + def test_chained_construction(self): + s = ( + Steps() + .col_vals_not_null(columns="id") + .col_vals_gt(columns="amount", value=0) + .col_vals_regex(columns="email", pattern=r".+@.+") + ) + assert len(s) == 3 + assert s._steps[0].method == "col_vals_not_null" + assert s._steps[1].method == "col_vals_gt" + assert s._steps[2].method == "col_vals_regex" + + def test_returns_self(self): + s = Steps() + result = s.col_vals_not_null(columns="id") + assert result is s + + def test_kwargs_captured(self): + s = Steps().col_vals_gt(columns="amount", value=0, na_pass=True) + step = s._steps[0] + assert step.kwargs["columns"] == "amount" + assert step.kwargs["value"] == 0 + assert step.kwargs["na_pass"] is True + + +# --------------------------------------------------------------------------- +# Steps repr +# --------------------------------------------------------------------------- + + +class TestStepsRepr: + def test_repr_empty(self): + s = Steps() + assert repr(s) == "Steps(0 steps)" + + def test_repr_single(self): + s = Steps().col_vals_not_null(columns="id") + r = repr(s) + assert "Steps(1 step)" in r + assert "col_vals_not_null" in r + assert "columns='id'" in r + + def test_repr_filters_defaults(self): + s = Steps().col_vals_gt(columns="x", value=0) + r = repr(s) + assert "na_pass" not in r + assert "active" not in r + assert "thresholds" not in r + + def test_repr_shows_non_defaults(self): + s = Steps().col_vals_gt(columns="x", value=0, na_pass=True) + r = repr(s) + assert "na_pass=True" in r + + def test_repr_html(self): + s = Steps().col_vals_not_null(columns="id").col_vals_gt(columns="amount", value=0) + html = s._repr_html_() + assert "Steps" in html + assert "2 steps" in html + assert "col_vals_not_null" in html + assert "col_vals_gt" in html + + def test_repr_html_empty(self): + html = Steps()._repr_html_() + assert "0 steps" in html + assert "No steps defined" in html + + +# --------------------------------------------------------------------------- +# All validation methods exist on Steps +# --------------------------------------------------------------------------- + + +class TestStepsMethodCoverage: + EXPECTED_METHODS = [ + "col_vals_gt", + "col_vals_lt", + "col_vals_eq", + "col_vals_ne", + "col_vals_ge", + "col_vals_le", + "col_vals_between", + "col_vals_outside", + "col_vals_in_set", + "col_vals_not_in_set", + "col_vals_increasing", + "col_vals_decreasing", + "col_vals_null", + "col_vals_not_null", + "col_vals_regex", + "col_vals_within_spec", + "col_vals_str_len", + "col_vals_expr", + "col_exists", + "col_pct_null", + "col_pct_missing", + "col_missing_coded", + "col_missing_only_coded", + "rows_distinct", + "rows_complete", + "col_missing_consistent", + "col_schema_match", + "row_count_match", + "data_freshness", + "col_count_match", + "col_vals_in_table", + "tbl_match", + "conjointly", + ] + + @pytest.mark.parametrize("method_name", EXPECTED_METHODS) + def test_method_exists(self, method_name): + assert hasattr(Steps, method_name), f"Steps is missing method: {method_name}" + assert callable(getattr(Steps, method_name)) + + +# --------------------------------------------------------------------------- +# add_steps() basic behavior +# --------------------------------------------------------------------------- + + +class TestAddSteps: + def test_add_single_steps_obj(self, sample_df): + s = Steps().col_vals_not_null(columns="id").col_vals_gt(columns="amount", value=0) + v = pb.Validate(data=sample_df).add_steps(s) + assert len(v.validation_info) == 2 + assert v.validation_info[0].assertion_type == "col_vals_not_null" + assert v.validation_info[1].assertion_type == "col_vals_gt" + + def test_add_multiple_steps_objs(self, sample_df): + s1 = Steps().col_vals_not_null(columns="id") + s2 = Steps().col_vals_gt(columns="amount", value=0) + v = pb.Validate(data=sample_df).add_steps(s1, s2) + assert len(v.validation_info) == 2 + + def test_add_steps_preserves_existing(self, sample_df): + s = Steps().col_vals_gt(columns="amount", value=0) + v = pb.Validate(data=sample_df).col_vals_not_null(columns="id").add_steps(s) + assert len(v.validation_info) == 2 + assert v.validation_info[0].assertion_type == "col_vals_not_null" + assert v.validation_info[1].assertion_type == "col_vals_gt" + + def test_add_steps_returns_validate(self, sample_df): + s = Steps().col_vals_not_null(columns="id") + v = pb.Validate(data=sample_df) + result = v.add_steps(s) + assert result is v + + def test_add_empty_steps(self, sample_df): + s = Steps() + v = pb.Validate(data=sample_df).add_steps(s) + assert len(v.validation_info) == 0 + + def test_add_steps_no_args_raises(self, sample_df): + with pytest.raises(ValueError, match="At least one"): + pb.Validate(data=sample_df).add_steps() + + def test_add_steps_wrong_type_raises(self, sample_df): + with pytest.raises(TypeError, match="Steps or Validate"): + pb.Validate(data=sample_df).add_steps("not a steps object") + + def test_add_steps_chains_with_interrogate(self, sample_df): + s = Steps().col_vals_gt(columns="amount", value=0) + result = pb.Validate(data=sample_df).add_steps(s).interrogate() + assert result.validation_info[0].all_passed is True + + +# --------------------------------------------------------------------------- +# add_steps() from Validate +# --------------------------------------------------------------------------- + + +class TestAddStepsFromValidate: + def test_extract_from_validate(self, sample_df): + source = pb.Validate(data=sample_df).col_vals_gt(columns="amount", value=0) + v = pb.Validate(data=sample_df).add_steps(source) + assert len(v.validation_info) == 1 + assert v.validation_info[0].assertion_type == "col_vals_gt" + + def test_extract_preserves_params(self, sample_df): + source = pb.Validate(data=sample_df).col_vals_gt( + columns="amount", value=5, na_pass=True + ) + v = pb.Validate(data=sample_df).add_steps(source) + vi = v.validation_info[0] + assert vi.values == 5 + assert vi.na_pass is True + + def test_mixed_steps_and_validate(self, sample_df): + s = Steps().col_vals_not_null(columns="id") + v_source = pb.Validate(data=sample_df).col_vals_gt(columns="amount", value=0) + v = pb.Validate(data=sample_df).add_steps(s, v_source) + assert len(v.validation_info) == 2 + assert v.validation_info[0].assertion_type == "col_vals_not_null" + assert v.validation_info[1].assertion_type == "col_vals_gt" + + +# --------------------------------------------------------------------------- +# add_steps() active= override +# --------------------------------------------------------------------------- + + +class TestAddStepsActive: + def test_active_false_deactivates(self, sample_df): + s = Steps().col_vals_not_null(columns="id").col_vals_gt(columns="amount", value=0) + v = pb.Validate(data=sample_df).add_steps(s, active=False) + for vi in v.validation_info: + assert vi.active is False + + def test_active_none_preserves(self, sample_df): + s = Steps().col_vals_not_null(columns="id", active=False) + v = pb.Validate(data=sample_df).add_steps(s, active=None) + assert v.validation_info[0].active is False + + def test_active_true_activates(self, sample_df): + s = Steps().col_vals_not_null(columns="id", active=False) + v = pb.Validate(data=sample_df).add_steps(s, active=True) + assert v.validation_info[0].active is True + + +# --------------------------------------------------------------------------- +# add_steps() thresholds= override +# --------------------------------------------------------------------------- + + +class TestAddStepsThresholds: + def test_thresholds_override(self, sample_df): + s = Steps().col_vals_gt(columns="amount", value=0) + thresh = pb.Thresholds(warning=0.1) + v = pb.Validate(data=sample_df).add_steps(s, thresholds=thresh) + assert v.validation_info[0].thresholds is not None + + def test_thresholds_none_preserves_step_thresholds(self, sample_df): + step_thresh = pb.Thresholds(warning=0.5) + s = Steps().col_vals_gt(columns="amount", value=0, thresholds=step_thresh) + v = pb.Validate(data=sample_df).add_steps(s, thresholds=None) + assert v.validation_info[0].thresholds is not None + + +# --------------------------------------------------------------------------- +# add_steps() exclude= +# --------------------------------------------------------------------------- + + +class TestAddStepsExclude: + def test_exclude_by_method_name(self, sample_df): + s = ( + Steps() + .col_vals_not_null(columns="id") + .col_vals_gt(columns="amount", value=0) + .col_vals_regex(columns="email", pattern=r".+@.+") + ) + v = pb.Validate(data=sample_df).add_steps(s, exclude=["col_vals_regex"]) + assert len(v.validation_info) == 2 + methods = [vi.assertion_type for vi in v.validation_info] + assert "col_vals_regex" not in methods + + def test_exclude_by_index(self, sample_df): + s = ( + Steps() + .col_vals_not_null(columns="id") + .col_vals_gt(columns="amount", value=0) + .col_vals_regex(columns="email", pattern=r".+@.+") + ) + v = pb.Validate(data=sample_df).add_steps(s, exclude=[2]) + assert len(v.validation_info) == 2 + methods = [vi.assertion_type for vi in v.validation_info] + assert "col_vals_not_null" in methods + assert "col_vals_regex" in methods + assert "col_vals_gt" not in methods + + def test_exclude_mixed(self, sample_df): + s = ( + Steps() + .col_vals_not_null(columns="id") + .col_vals_gt(columns="amount", value=0) + .col_vals_regex(columns="email", pattern=r".+@.+") + ) + v = pb.Validate(data=sample_df).add_steps(s, exclude=[1, "col_vals_regex"]) + assert len(v.validation_info) == 1 + assert v.validation_info[0].assertion_type == "col_vals_gt" + + def test_exclude_invalid_type_raises(self, sample_df): + s = Steps().col_vals_not_null(columns="id") + with pytest.raises(TypeError, match="strings.*or integers"): + pb.Validate(data=sample_df).add_steps(s, exclude=[3.14]) + + def test_exclude_per_source(self, sample_df): + s1 = Steps().col_vals_not_null(columns="id").col_vals_gt(columns="amount", value=0) + s2 = Steps().col_vals_regex(columns="email", pattern=r".+@.+") + v = pb.Validate(data=sample_df).add_steps(s1, s2, exclude=["col_vals_gt"]) + assert len(v.validation_info) == 2 + methods = [vi.assertion_type for vi in v.validation_info] + assert "col_vals_not_null" in methods + assert "col_vals_regex" in methods + + +# --------------------------------------------------------------------------- +# add_steps() columns_map= +# --------------------------------------------------------------------------- + + +class TestAddStepsColumnsMap: + def test_remap_columns_param(self): + df = pd.DataFrame({"order_id": [1, 2], "total": [10, 20]}) + s = Steps().col_vals_gt(columns="amount", value=0) + v = pb.Validate(data=df).add_steps(s, columns_map={"amount": "total"}) + assert v.validation_info[0].column == "total" + + def test_remap_list_columns(self): + df = pd.DataFrame({"a": [1], "b": [2], "c": [3]}) + s = Steps().rows_distinct(columns_subset=["x", "y"]) + v = pb.Validate(data=df).add_steps(s, columns_map={"x": "a", "y": "b"}) + vi = v.validation_info[0] + # rows_distinct stores columns_subset differently - check the assertion type was applied + assert vi.assertion_type == "rows_distinct" + + def test_remap_preserves_unmapped(self): + df = pd.DataFrame({"id": [1], "total": [10]}) + s = Steps().col_vals_not_null(columns="id").col_vals_gt(columns="amount", value=0) + v = pb.Validate(data=df).add_steps(s, columns_map={"amount": "total"}) + assert v.validation_info[0].column == "id" + assert v.validation_info[1].column == "total" + + def test_remap_with_data_freshness(self): + df = pd.DataFrame({"updated_at": pd.to_datetime(["2026-09-11"])}) + s = Steps().data_freshness(column="ts", max_age="1d") + v = pb.Validate(data=df).add_steps(s, columns_map={"ts": "updated_at"}) + assert v.validation_info[0].column == "updated_at" + + +# --------------------------------------------------------------------------- +# Integration: end-to-end with interrogate +# --------------------------------------------------------------------------- + + +class TestStepsIntegration: + def test_full_workflow(self, df_with_issues): + completeness = Steps().col_vals_not_null(columns="id").col_vals_not_null(columns="email") + range_checks = Steps().col_vals_ge(columns="amount", value=0) + + result = ( + pb.Validate(data=df_with_issues) + .add_steps(completeness, range_checks) + .interrogate() + ) + + assert len(result.validation_info) == 3 + assert result.validation_info[0].all_passed is False # id has null + assert result.validation_info[1].all_passed is False # email has null + assert result.validation_info[2].all_passed is False # amount has -5 + + def test_reuse_across_datasets(self): + checks = Steps().col_vals_gt(columns="value", value=0) + + df1 = pd.DataFrame({"value": [1, 2, 3]}) + df2 = pd.DataFrame({"value": [10, 20, 30]}) + + r1 = pb.Validate(data=df1).add_steps(checks).interrogate() + r2 = pb.Validate(data=df2).add_steps(checks).interrogate() + + assert r1.validation_info[0].all_passed is True + assert r2.validation_info[0].all_passed is True + + def test_conditional_inclusion(self, sample_df): + strict = Steps().col_vals_gt(columns="score", value=0.9) + + v_prod = pb.Validate(data=sample_df).add_steps(strict, active=True).interrogate() + v_dev = pb.Validate(data=sample_df).add_steps(strict, active=False).interrogate() + + assert v_prod.validation_info[0].all_passed is False + # Inactive steps aren't executed + assert v_dev.validation_info[0].n is None + + +# --------------------------------------------------------------------------- +# Edge cases: multi-column, selectors, segments +# --------------------------------------------------------------------------- + + +class TestStepsEdgeCases: + def test_multi_column_list_expands(self): + df = pd.DataFrame({"a": [1], "b": [2], "c": [3]}) + s = Steps().col_vals_not_null(columns=["a", "b", "c"]) + v = pb.Validate(data=df).add_steps(s).interrogate() + assert len(v.validation_info) == 3 + columns = [vi.column for vi in v.validation_info] + assert columns == ["a", "b", "c"] + + def test_column_selector_resolves(self): + df = pd.DataFrame({"amt_a": [10], "amt_b": [20], "name": ["x"]}) + s = Steps().col_vals_gt(columns=pb.starts_with("amt_"), value=0) + v = pb.Validate(data=df).add_steps(s).interrogate() + assert len(v.validation_info) == 2 + columns = sorted(vi.column for vi in v.validation_info) + assert columns == ["amt_a", "amt_b"] + + def test_segments_expand(self): + df = pd.DataFrame({"group": ["A", "A", "B", "B"], "val": [1, 2, 3, 4]}) + s = Steps().col_vals_gt(columns="val", value=0, segments="group") + v = pb.Validate(data=df).add_steps(s).interrogate() + assert len(v.validation_info) == 2 + assert all(vi.all_passed for vi in v.validation_info) + + def test_columns_map_with_list(self): + df = pd.DataFrame({"a": [1], "b": [2]}) + s = Steps().col_vals_not_null(columns=["x", "y"]) + v = pb.Validate(data=df).add_steps(s, columns_map={"x": "a", "y": "b"}) + assert len(v.validation_info) == 2 + columns = sorted(vi.column for vi in v.validation_info) + assert columns == ["a", "b"] + + def test_columns_map_ignores_selectors(self): + df = pd.DataFrame({"amt_total": [10], "amt_tax": [2]}) + s = Steps().col_vals_gt(columns=pb.starts_with("amt_"), value=0) + v = pb.Validate(data=df).add_steps( + s, columns_map={"irrelevant": "other"} + ).interrogate() + assert len(v.validation_info) == 2 + + def test_extract_from_validate_expanded_columns(self): + df = pd.DataFrame({"id": [1], "name": ["a"]}) + source = pb.Validate(data=df).col_vals_not_null(columns=["id", "name"]) + v = pb.Validate(data=df).add_steps(source) + assert len(v.validation_info) == 2 + columns = [vi.column for vi in v.validation_info] + assert columns == ["id", "name"] + + def test_columns_map_with_data_freshness(self): + df = pd.DataFrame({"updated_at": pd.to_datetime(["2026-09-11"])}) + s = Steps().data_freshness(column="ts", max_age="30d") + v = pb.Validate(data=df).add_steps(s, columns_map={"ts": "updated_at"}) + assert v.validation_info[0].column == "updated_at" + + def test_steps_immutable_across_add_steps_calls(self): + s = Steps().col_vals_gt(columns="x", value=0) + df1 = pd.DataFrame({"x": [1]}) + df2 = pd.DataFrame({"x": [2]}) + pb.Validate(data=df1).add_steps(s) + pb.Validate(data=df2).add_steps(s) + assert len(s) == 1 + + +# --------------------------------------------------------------------------- +# Steps accessible from top-level import +# --------------------------------------------------------------------------- + + +class TestStepsExport: + def test_in_all(self): + assert "Steps" in pb.__all__ + + def test_importable(self): + from pointblank import Steps as S + + assert S is Steps From a927bf8297d56d784cdfbb89bc18e539e0416530 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Fri, 11 Sep 2026 19:14:24 -0400 Subject: [PATCH 07/12] Create 07-composable-steps.qmd --- .../07-composable-steps.qmd | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 user_guide/02-advanced-validation/07-composable-steps.qmd diff --git a/user_guide/02-advanced-validation/07-composable-steps.qmd b/user_guide/02-advanced-validation/07-composable-steps.qmd new file mode 100644 index 000000000..1fbe93596 --- /dev/null +++ b/user_guide/02-advanced-validation/07-composable-steps.qmd @@ -0,0 +1,439 @@ +--- +title: Composable Steps +jupyter: python3 +toc-expand: 2 +html-table-processing: none +bread-crumbs: true +--- + +```{python} +#| echo: false +#| output: false +import pointblank as pb +pb.config(report_incl_footer_timings=False) +``` + +When validation logic grows beyond a handful of checks, you often want to **reuse** groups of steps +across datasets and **compose** them into larger plans. Pointblank provides two complementary tools +for this: + +- **`Step`** (singular): a declarative data object used inside `Contract` definitions +- **`Steps`** (plural): a fluent builder that collects validation steps for use in any + `Validate` plan + +This page focuses on `Steps` and the `Validate.add_steps()` method, which together let you define +portable step libraries and compose them into readable, top-to-bottom validation plans. + +## The Problem + +Without composable steps, reusing validation logic means writing helper functions: + +```python +def add_completeness_checks(v): + return v.col_vals_not_null(columns=["order_id", "email"]) + +def add_range_checks(v): + return v.col_vals_ge(columns="amount", value=0) + +validation = ( + add_range_checks(add_completeness_checks( + pb.Validate(data=orders) + )) + .interrogate() +) +``` + +This works but reads *inside-out*. With more than two or three groups, the nesting becomes hard to +follow. The step definitions are also coupled to whichever `Validate` object they happen to be +called on. + +## Building Step Libraries with `Steps` + +The `Steps` class lets you record validation steps without binding them to data, thresholds, or any +other `Validate` configuration. You use the same method names and parameters you already know from +`Validate` (`col_vals_gt()`, `col_vals_not_null()`, `col_vals_regex()`, etc.), but instead of +running the checks, `Steps` stores them for later use. + +The key benefit is **portability**: a `Steps` object can live in a shared Python module and be +imported into any number of validation pipelines. The step definitions stay the same and only the +data and configuration change at the point of use. + +```{python} +import pointblank as pb +import polars as pl + +completeness = ( + pb.Steps() + .col_vals_not_null(columns="order_id") + .col_vals_not_null(columns="email") +) + +positive_amounts = ( + pb.Steps() + .col_vals_ge(columns="amount", value=0) + .col_vals_gt(columns="total", value=0) +) + +format_checks = ( + pb.Steps() + .col_vals_regex(columns="email", pattern=r".+@.+\..+") +) +``` + +Each `Steps` object above is a self-contained recipe. You can inspect one to see what it contains: + +```{python} +print(completeness) +``` + +```{python} +print(f"Number of steps: {len(completeness)}") +``` + +In a Jupyter notebook or other rich-display environment, `Steps` renders as an HTML summary table. + +## Composing Plans with `add_steps()` + +The `add_steps()` method on `Validate` accepts one or more `Steps` objects and appends their +recorded steps to the validation plan: + +```{python} +orders = pl.DataFrame( + { + "order_id": ["ORD-001", "ORD-002", "ORD-003"], + "email": ["alice@example.com", "bob@corp.io", "charlie@mail.org"], + "amount": [29.99, 149.50, 9.99], + "total": [34.99, 155.00, 14.99], + } +) + +( + pb.Validate(data=orders, label="Order quality") + .add_steps(completeness, positive_amounts, format_checks) + .interrogate() +) +``` + +Because `add_steps()` returns the `Validate` object, it chains naturally and reads top-to-bottom. + +You can also mix `add_steps()` calls with regular validation method calls. This is useful when a +pipeline needs a shared library of checks plus a few ad-hoc rules specific to that dataset: + +```{python} +( + pb.Validate(data=orders, label="Orders (extended)") + .add_steps(completeness) + .col_vals_lt(columns="amount", value=500) + .add_steps(format_checks) + .interrogate() +) +``` + +### Passing Multiple Sources + +The `add_steps()` method accepts any number of positional arguments. Each can be a `Steps` object or +even a `Validate` object (more on that [below](#importing-steps-from-a-validate-object)). All steps +are appended in order: + +```{python} +all_checks = ( + pb.Validate(data=orders) + .add_steps(completeness, positive_amounts, format_checks) +) + +print(f"Total validation steps: {len(all_checks.validation_info)}") +``` + +## Selectors and Multi-Column Expansion + +One of the strengths of `Steps` is that step definitions are not locked to specific columns at +definition time. When a step uses a column selector like `starts_with()` or a multi-column list, +the columns are resolved when `add_steps()` applies them to the `Validate` plan, not when the +`Steps` object is created. This means the same step library can validate tables with different +schemas as long as they follow a common naming convention. + +```{python} +products = pl.DataFrame( + { + "product_id": ["P1", "P2", "P3"], + "amt_price": [19.99, 49.99, 9.99], + "amt_tax": [1.60, 4.00, 0.80], + "amt_total": [21.59, 53.99, 10.79], + } +) + +# Column selectors resolve at add_steps() time against the target data +price_checks = ( + pb.Steps() + .col_vals_gt(columns=pb.starts_with("amt_"), value=0) +) + +( + pb.Validate(data=products) + .add_steps(price_checks) + .interrogate() +) +``` + +The selector `starts_with("amt_")` is recorded verbatim in the `Steps` object and resolved when the +steps are applied to the `Validate` plan. This means the same `Steps` object adapts to tables +with different numbers of matching columns. + +## Overrides in `add_steps()` + +A shared step library represents a general-purpose set of checks, but individual pipelines often +need to tweak how those checks are applied: tighter thresholds in production, certain checks +disabled during development, or column names that differ across teams. Rather than creating a +separate `Steps` object for every variation, `add_steps()` supports keyword arguments that modify +the imported steps at the point of use, without changing the original `Steps` object. + +### Conditional Inclusion with `active=` + +Add steps only when a condition is met, keeping the chain flat instead of wrapping in `if` blocks: + +```{python} +is_production = True + +strict_checks = ( + pb.Steps() + .col_vals_gt(columns="total", value=0) + .col_vals_lt(columns="total", value=10_000) +) + +( + pb.Validate(data=orders) + .add_steps(completeness) + .add_steps(strict_checks, active=is_production) + .interrogate() +) +``` + +When `active=False`, the steps are still added to the plan (so they appear in the report) but they +are not executed during interrogation. + +### Threshold Override with `thresholds=` + +Different groups of checks often warrant different levels of strictness. Completeness checks might +need a very low failure tolerance (even 1% missing IDs is a problem), while range checks can +tolerate slightly more variance. The `thresholds=` parameter lets you set per-group thresholds +without modifying the shared step library: + +```{python} +( + pb.Validate(data=orders, thresholds=pb.Thresholds(warning=0.05)) + .add_steps(completeness, thresholds=pb.Thresholds(warning=0.01)) + .add_steps(positive_amounts) + .interrogate() +) +``` + +Here, the completeness checks use a stricter 1% warning threshold while the amount checks inherit +the 5% global threshold. + +### Step Filtering with `exclude=` + +Sometimes a shared library is *almost* right for a particular pipeline, but one or two checks don't +apply. Rather than forking the library, you can exclude specific steps at import time. This keeps +the shared definition as the single source of truth while giving individual pipelines the +flexibility to opt out of checks that aren't relevant. + +You can exclude by method name (a string, which removes all steps using that method) or by 1-based +step index (an integer, which removes only the step at that position): + +```{python} +full_checks = ( + pb.Steps() + .col_vals_not_null(columns="order_id") + .col_vals_not_null(columns="email") + .col_vals_regex(columns="email", pattern=r".+@.+\..+") +) + +# Exclude the regex check by method name +( + pb.Validate(data=orders) + .add_steps(full_checks, exclude=["col_vals_regex"]) + .interrogate() +) +``` + +```{python} +# Or exclude the second step by index +( + pb.Validate(data=orders) + .add_steps(full_checks, exclude=[2]) + .interrogate() +) +``` + +Excluding by method name removes *all* steps with that method name from the source. Excluding by +index removes only the step at that position (1-based) within the source. + +### Column Remapping with `columns_map=` + +In practice, teams often have tables that contain the same kind of data but use different column +names. A finance team might call it `amount` while the warehouse table uses `txn_amount`. Rather +than maintaining separate step libraries for each naming convention, `columns_map=` lets you remap +column names at import time: + +```{python} +# Steps written for one naming convention +amount_checks = ( + pb.Steps() + .col_vals_ge(columns="amount", value=0) + .col_vals_lt(columns="amount", value=100_000) +) + +# Table uses a different column name +transactions = pl.DataFrame( + { + "txn_id": ["T1", "T2"], + "txn_amount": [50.00, 75.00], + } +) + +( + pb.Validate(data=transactions) + .add_steps(amount_checks, columns_map={"amount": "txn_amount"}) + .interrogate() +) +``` + +The remapping applies to the `columns=`, `column=`, and `columns_subset=` parameters. Column +selectors (like `starts_with()`) pass through unchanged since they resolve dynamically against the +target table. + +## Importing Steps from a `Validate` Object + +You don't always start from a `Steps` object. Sometimes you already have a `Validate` object with +steps defined on it, perhaps loaded from a YAML file with `Validate.from_yaml()`, generated from a +prompt with `Validate.from_prompt()`, or simply built up in another part of your codebase. Rather +than re-expressing those steps from scratch, you can pass the `Validate` object directly to +`add_steps()` and its step definitions will be extracted and applied: + +```{python} +existing_plan = ( + pb.Validate(data=orders) + .col_vals_not_null(columns="order_id") + .col_vals_gt(columns="amount", value=0) +) + +( + pb.Validate(data=orders) + .add_steps(existing_plan) + .col_vals_regex(columns="email", pattern=r".+@.+\..+") + .interrogate() +) +``` + +When a `Validate` object is passed as a step source, `add_steps()` extracts its step definitions and +applies them. The data, thresholds, and metadata from the source `Validate` are *not* carried over, +only the step recipes. + +::: {.callout-note} +When a `Validate` object used multi-column lists (e.g., `columns=["id", "name"]`), those columns +were already expanded into separate internal entries. The extracted steps will be individual +single-column steps, not the original grouped call. +::: + +## `Steps` vs. `Step`: When to Use Which + +Pointblank has two similarly named classes that serve different purposes: + +| | `Step` | `Steps` | +|---|---|---| +| **What it is** | A single step as a data object | A builder that collects multiple steps | +| **How you create it** | `pb.Step("col_vals_gt", columns="x", value=0)` | `pb.Steps().col_vals_gt(columns="x", value=0)` | +| **Primary use** | Inside `Contract(steps=[...])` | With `Validate.add_steps()` | +| **API style** | Declarative (method name as string) | Fluent (same methods as `Validate`) | +| **Composition** | List concatenation: `steps_a + steps_b` | Chaining: `.add_steps(a).add_steps(b)` | + +**Use `Step`** when you're defining data contracts. Contracts are declarative specifications that +serialize to YAML and represent a fixed agreement about data quality. + +**Use `Steps`** when you're building reusable validation logic for `Validate` workflows. The fluent +API gives you autocomplete, type checking, and the same familiar syntax as writing steps directly on +`Validate`. + +Both ultimately produce the same validation steps when applied to data. You can even initialize a +`Steps` object from a list of `Step` objects: + +```{python} +step_list = [ + pb.Step("col_vals_not_null", columns="order_id"), + pb.Step("col_vals_gt", columns="amount", value=0), +] + +s = pb.Steps(steps=step_list) +print(s) +``` + +## Putting It All Together + +Imagine you're a data platform team maintaining validation rules for an order-processing pipeline. +You have shared step libraries for common concerns (completeness, format, range), and each pipeline +applies them with environment-specific configuration. Here's how that looks in practice: + +```{python} +# --- Shared step libraries (defined once, imported anywhere) --- + +completeness_lib = ( + pb.Steps() + .col_vals_not_null(columns="order_id") + .col_vals_not_null(columns="email") + .col_vals_not_null(columns="amount") +) + +format_lib = ( + pb.Steps() + .col_vals_regex(columns="email", pattern=r".+@.+\..+") +) + +range_lib = ( + pb.Steps() + .col_vals_ge(columns="amount", value=0) + .col_vals_lt(columns="amount", value=100_000) +) + +# --- Pipeline-specific configuration --- + +is_production = True +strict_thresholds = pb.Thresholds(warning=0.01, error=0.05) + +orders = pl.DataFrame( + { + "order_id": ["ORD-001", "ORD-002", "ORD-003", "ORD-004"], + "email": ["alice@example.com", "bob@corp.io", "charlie@mail.org", "dave@startup.co"], + "amount": [29.99, 149.50, 9.99, 75.00], + "total": [34.99, 155.00, 14.99, 80.00], + } +) + +( + pb.Validate(data=orders, label="Order validation (production)") + .add_steps(completeness_lib, thresholds=strict_thresholds) + .add_steps(format_lib) + .add_steps(range_lib, active=is_production) + .rows_distinct(columns_subset=["order_id"]) + .interrogate() +) +``` + +The validation plan reads as a sequence of concerns: completeness first (with strict thresholds), +then format checks, then range checks (only in production), and finally a uniqueness check added +directly. Each step library can be version-controlled, tested independently, and shared across +teams. + +## Conclusion + +The [`Steps`](`pointblank.Steps`) class and +[`Validate.add_steps()`](`pointblank.Validate.add_steps`) method bring composability to Pointblank's +validation workflows. By separating step *definitions* from step *execution*, you can build reusable +rule libraries that stay portable across datasets, teams, and environments. The override parameters +(`active=`, `thresholds=`, `exclude=`, and `columns_map=`) give each pipeline the flexibility to +tailor shared checks without forking the underlying library. + +For a complementary approach to reusable validation rules, see +[Data Contracts](../10-contracts-and-pipelines/01-contracts.qmd), which define expectations as +declarative, serializable specifications using the [`Step`](`pointblank.Step`) class. Both +approaches produce the same validation steps at interrogation time. Choose `Steps` for fluent, +code-first composition and `Contract` for declarative, YAML-friendly definitions. From 27b1489e716d6c195167c3068b4b422e0495c074 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Fri, 11 Sep 2026 19:17:42 -0400 Subject: [PATCH 08/12] Update validate.py --- pointblank/validate.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pointblank/validate.py b/pointblank/validate.py index 58192251f..d17f733ec 100644 --- a/pointblank/validate.py +++ b/pointblank/validate.py @@ -6136,8 +6136,7 @@ def add_steps( kwargs[param] = columns_map[val] elif isinstance(val, list): kwargs[param] = [ - columns_map.get(c, c) if isinstance(c, str) else c - for c in val + columns_map.get(c, c) if isinstance(c, str) else c for c in val ] method = getattr(self, step.method, None) From d8be2bd4f3c12d7c62cbec4b8afb64b860634494 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Fri, 11 Sep 2026 19:17:44 -0400 Subject: [PATCH 09/12] Update test_steps.py --- tests/test_steps.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/test_steps.py b/tests/test_steps.py index c972e77f6..87f6ab76f 100644 --- a/tests/test_steps.py +++ b/tests/test_steps.py @@ -233,9 +233,7 @@ def test_extract_from_validate(self, sample_df): assert v.validation_info[0].assertion_type == "col_vals_gt" def test_extract_preserves_params(self, sample_df): - source = pb.Validate(data=sample_df).col_vals_gt( - columns="amount", value=5, na_pass=True - ) + source = pb.Validate(data=sample_df).col_vals_gt(columns="amount", value=5, na_pass=True) v = pb.Validate(data=sample_df).add_steps(source) vi = v.validation_info[0] assert vi.values == 5 @@ -395,9 +393,7 @@ def test_full_workflow(self, df_with_issues): range_checks = Steps().col_vals_ge(columns="amount", value=0) result = ( - pb.Validate(data=df_with_issues) - .add_steps(completeness, range_checks) - .interrogate() + pb.Validate(data=df_with_issues).add_steps(completeness, range_checks).interrogate() ) assert len(result.validation_info) == 3 @@ -468,9 +464,7 @@ def test_columns_map_with_list(self): def test_columns_map_ignores_selectors(self): df = pd.DataFrame({"amt_total": [10], "amt_tax": [2]}) s = Steps().col_vals_gt(columns=pb.starts_with("amt_"), value=0) - v = pb.Validate(data=df).add_steps( - s, columns_map={"irrelevant": "other"} - ).interrogate() + v = pb.Validate(data=df).add_steps(s, columns_map={"irrelevant": "other"}).interrogate() assert len(v.validation_info) == 2 def test_extract_from_validate_expanded_columns(self): From b9d0193bd65bfecf931f0dc607d49553f59c4c18 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sat, 12 Sep 2026 10:35:42 -0400 Subject: [PATCH 10/12] Update 07-composable-steps.qmd --- user_guide/02-advanced-validation/07-composable-steps.qmd | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/user_guide/02-advanced-validation/07-composable-steps.qmd b/user_guide/02-advanced-validation/07-composable-steps.qmd index 1fbe93596..2b90cdf29 100644 --- a/user_guide/02-advanced-validation/07-composable-steps.qmd +++ b/user_guide/02-advanced-validation/07-composable-steps.qmd @@ -420,8 +420,7 @@ orders = pl.DataFrame( The validation plan reads as a sequence of concerns: completeness first (with strict thresholds), then format checks, then range checks (only in production), and finally a uniqueness check added -directly. Each step library can be version-controlled, tested independently, and shared across -teams. +directly. Each step library can be version-controlled, tested independently, and easily shared. ## Conclusion From 7ce6a649153571cdf2e36004d952f41ab9536d1e Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sat, 12 Sep 2026 10:36:30 -0400 Subject: [PATCH 11/12] Update validate.pyi --- pointblank/validate.pyi | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pointblank/validate.pyi b/pointblank/validate.pyi index 500158c0c..71c110a51 100644 --- a/pointblank/validate.pyi +++ b/pointblank/validate.pyi @@ -10,6 +10,7 @@ from pointblank._utils import _PBUnresolvedColumn from pointblank.column import Column, ColumnSelector, ColumnSelectorNarwhals, ReferenceColumn from pointblank.missing import MissingSpec from pointblank.schema import Schema +from pointblank.steps import Steps from pointblank.thresholds import Actions, FinalActions, Thresholds from typing import Any, Callable, Literal, ParamSpec, TypeVar @@ -198,6 +199,14 @@ class Validate: self, tbl: Any, tbl_name: str | None = None, label: str | None = None ) -> Validate: ... def _repr_html_(self) -> str: ... + def add_steps( + self, + *steps: Steps | Validate, + active: bool | Callable | None = None, + thresholds: int | float | bool | tuple | dict | Thresholds | None = None, + exclude: list[str | int] | None = None, + columns_map: dict[str, str] | None = None, + ) -> Validate: ... def col_vals_gt( self, columns: str | list[str] | Column | ColumnSelector | ColumnSelectorNarwhals, From 1547913f4b2ef4a78c2e1d38da463b04382deeca Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Sat, 12 Sep 2026 21:57:49 -0500 Subject: [PATCH 12/12] Update steps.py --- pointblank/steps.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pointblank/steps.py b/pointblank/steps.py index 0a2f9da9f..8196658dc 100644 --- a/pointblank/steps.py +++ b/pointblank/steps.py @@ -14,7 +14,6 @@ from pointblank.missing import MissingSpec from pointblank.schema import Schema from pointblank.thresholds import Thresholds - from pointblank.validate import Validate __all__ = ["Steps"]