pipeverify 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pipeverify
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Check that a pipeline change left the data right, not just the job green.
5
5
  Project-URL: Homepage, https://github.com/akshaysharma1088/pipeverify
6
6
  Project-URL: Issues, https://github.com/akshaysharma1088/pipeverify/issues
@@ -37,6 +37,8 @@ Description-Content-Type: text/markdown
37
37
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
38
38
  ![Python](https://img.shields.io/badge/python-3.9%2B-blue)
39
39
  ![Status](https://img.shields.io/badge/status-alpha-orange)
40
+ [![PyPI](https://img.shields.io/pypi/v/pipeverify)](https://pypi.org/project/pipeverify/)
41
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23542278.svg)](https://doi.org/10.5281/zenodo.23542278)
40
42
 
41
43
  A pipeline change can pass every test, finish on time and keep the schema intact while quietly
42
44
  dropping rows, duplicating keys or changing what a column means. More of these changes are now
@@ -161,6 +163,44 @@ Exit code is `1` when the verdict reaches `--fail-on` (default `block`), otherwi
161
163
  Columns listed in `expect_change` are reported but never raise the verdict. All thresholds can be
162
164
  changed globally or per table; see [`pipeverify/checks.py`](pipeverify/checks.py) for every option.
163
165
 
166
+ ## Source checks
167
+
168
+ A before/after diff only sees damage to rows that already existed. Errors in **new** data need
169
+ evidence the pipeline does not control: the raw source. Add a `source` block to a table:
170
+
171
+ ```yaml
172
+ tables:
173
+ - name: silver.transactions
174
+ after: snapshots/pr/silver_transactions.parquet
175
+ before: snapshots/main/silver_transactions.parquet # optional
176
+ key: [txn_id]
177
+ source:
178
+ data: landing/raw_transactions.parquet
179
+ key: [txn_id]
180
+ columns: {amount: amount} # output column: source column
181
+ checks: [conflict] # default; also mismatch, missing, orphan
182
+ where: "status = 'POSTED'" # source rows that should reach the output
183
+ ```
184
+
185
+ | Check | Catches | Default |
186
+ |---|---|---|
187
+ | `conflict` | The source sent several values for one key and the output silently kept one | on, REVIEW |
188
+ | `mismatch` | An output value the source never sent (use only for pass-through columns) | opt-in |
189
+ | `missing` | Source keys that never reached the output (set `where` for intended filters) | opt-in |
190
+ | `orphan` | Output keys that are not in the source | opt-in |
191
+
192
+ From the command line:
193
+
194
+ ```bash
195
+ pipeverify compare - silver.parquet -k txn_id --source raw.parquet --source-column amount
196
+ ```
197
+
198
+ In a pilot on the 209 executed fixes in the PipeMend benchmark (25 harmful), production-style
199
+ monitors caught 16 harmful fixes, and the source conflict check caught the 9 that the monitors
200
+ approved. Together they caught all 25, at the cost of a REVIEW on 21 of 184 correct fixes,
201
+ all on incidents where the source data really was ambiguous. This is a small pilot on synthetic
202
+ data; a larger evaluation is in progress.
203
+
164
204
  ## GitHub Action
165
205
 
166
206
  ```yaml
@@ -6,6 +6,8 @@
6
6
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
7
7
  ![Python](https://img.shields.io/badge/python-3.9%2B-blue)
8
8
  ![Status](https://img.shields.io/badge/status-alpha-orange)
9
+ [![PyPI](https://img.shields.io/pypi/v/pipeverify)](https://pypi.org/project/pipeverify/)
10
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23542278.svg)](https://doi.org/10.5281/zenodo.23542278)
9
11
 
10
12
  A pipeline change can pass every test, finish on time and keep the schema intact while quietly
11
13
  dropping rows, duplicating keys or changing what a column means. More of these changes are now
@@ -130,6 +132,44 @@ Exit code is `1` when the verdict reaches `--fail-on` (default `block`), otherwi
130
132
  Columns listed in `expect_change` are reported but never raise the verdict. All thresholds can be
131
133
  changed globally or per table; see [`pipeverify/checks.py`](pipeverify/checks.py) for every option.
132
134
 
135
+ ## Source checks
136
+
137
+ A before/after diff only sees damage to rows that already existed. Errors in **new** data need
138
+ evidence the pipeline does not control: the raw source. Add a `source` block to a table:
139
+
140
+ ```yaml
141
+ tables:
142
+ - name: silver.transactions
143
+ after: snapshots/pr/silver_transactions.parquet
144
+ before: snapshots/main/silver_transactions.parquet # optional
145
+ key: [txn_id]
146
+ source:
147
+ data: landing/raw_transactions.parquet
148
+ key: [txn_id]
149
+ columns: {amount: amount} # output column: source column
150
+ checks: [conflict] # default; also mismatch, missing, orphan
151
+ where: "status = 'POSTED'" # source rows that should reach the output
152
+ ```
153
+
154
+ | Check | Catches | Default |
155
+ |---|---|---|
156
+ | `conflict` | The source sent several values for one key and the output silently kept one | on, REVIEW |
157
+ | `mismatch` | An output value the source never sent (use only for pass-through columns) | opt-in |
158
+ | `missing` | Source keys that never reached the output (set `where` for intended filters) | opt-in |
159
+ | `orphan` | Output keys that are not in the source | opt-in |
160
+
161
+ From the command line:
162
+
163
+ ```bash
164
+ pipeverify compare - silver.parquet -k txn_id --source raw.parquet --source-column amount
165
+ ```
166
+
167
+ In a pilot on the 209 executed fixes in the PipeMend benchmark (25 harmful), production-style
168
+ monitors caught 16 harmful fixes, and the source conflict check caught the 9 that the monitors
169
+ approved. Together they caught all 25, at the cost of a REVIEW on 21 of 184 correct fixes,
170
+ all on incidents where the source data really was ambiguous. This is a small pilot on synthetic
171
+ data; a larger evaluation is in progress.
172
+
133
173
  ## GitHub Action
134
174
 
135
175
  ```yaml
@@ -1,6 +1,6 @@
1
1
  """pipeverify: check that a pipeline change left the data right, not just the job green."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
4
4
 
5
5
  from .checks import Finding, Thresholds # noqa: E402
6
6
  from .core import RunResult, TableResult, run_config, verify # noqa: E402
@@ -38,6 +38,10 @@ class Thresholds:
38
38
  sum_shift_block_pct: float = 5.0
39
39
  category_max_distinct: int = 50 # treat text columns with few values as categories
40
40
  float_tolerance: float = 1e-9
41
+ source_mismatch_review_pct: float = 0.0
42
+ source_mismatch_block_pct: float = 5.0
43
+ source_missing_review_pct: float = 0.0
44
+ source_missing_block_pct: float = 5.0
41
45
 
42
46
  @classmethod
43
47
  def from_dict(cls, data: dict[str, Any] | None) -> "Thresholds":
@@ -97,27 +97,40 @@ def main() -> None:
97
97
  @main.command()
98
98
  @click.argument("before")
99
99
  @click.argument("after")
100
+ @click.option("--source", "source_data", help="Raw source table the output was built from (enables source checks).")
101
+ @click.option("--source-key", help="Key column(s) in the source, if named differently from --key.")
102
+ @click.option("--source-column", "source_columns", multiple=True,
103
+ help="Column to check against the source, as out=src or just name. Repeatable.")
104
+ @click.option("--source-where", help="SQL filter for source rows that should reach the output.")
100
105
  @click.option("--key", "-k", help="Comma-separated key column(s) that identify a row.")
101
106
  @click.option("--expect-change", "-e", multiple=True, help="Column this change is meant to modify. Repeatable.")
102
107
  @click.option("--ignore", "-i", multiple=True, help="Column to skip, such as a load timestamp. Repeatable.")
103
108
  @click.option("--expect-row-change", is_flag=True, help="Rows are expected to be added or removed.")
104
109
  @click.option("--name", default=None, help="Name to show for the table.")
105
110
  @with_output_options
106
- def compare(before, after, key, expect_change, ignore, expect_row_change, name,
111
+ def compare(before, after, source_data, source_key, source_columns, source_where,
112
+ key, expect_change, ignore, expect_row_change, name,
107
113
  fmt, output, summary, fail_on, explain, show_info, json_out, markdown_out):
108
114
  """Compare one table BEFORE and AFTER a change.
109
115
 
116
+ Use - as BEFORE to run only the source checks.
117
+
110
118
  \b
111
119
  Examples:
112
120
  pipeverify compare before.parquet after.parquet --key order_id
121
+ pipeverify compare - silver.parquet -k txn_id --source raw.parquet --source-column amount
113
122
  pipeverify compare duckdb:prod.db:gold.sales duckdb:dev.db:gold.sales -k id
114
123
  """
115
124
  import time
116
125
  start = time.perf_counter()
126
+ source = None
127
+ if source_data:
128
+ cols = dict((c.split("=", 1) if "=" in c else (c, c)) for c in source_columns)
129
+ source = {"data": source_data, "key": source_key, "columns": cols, "where": source_where}
117
130
  try:
118
- res = verify(before, after, name=name or os.path.basename(str(after)), key=key,
131
+ res = verify(None if before == "-" else before, after, name=name or os.path.basename(str(after)), key=key,
119
132
  expect_change=list(expect_change), ignore=list(ignore),
120
- expect_row_change=expect_row_change)
133
+ expect_row_change=expect_row_change, source=source)
121
134
  except Exception as exc:
122
135
  raise click.ClickException(str(exc))
123
136
  result = RunResult(tables=[res], seconds=time.perf_counter() - start)
@@ -10,6 +10,7 @@ import duckdb
10
10
  import yaml
11
11
 
12
12
  from .checks import BLOCK, PASS, RANK, REVIEW, Finding, Thresholds, run_checks, verdict
13
+ from .source_checks import SourceSpec, run_source_checks
13
14
  from .sources import load, parse_source
14
15
 
15
16
 
@@ -50,33 +51,47 @@ class RunResult:
50
51
  def verify(before: Any, after: Any, *, name: str = "table", key: list[str] | str | None = None,
51
52
  expect_change: list[str] | None = None, ignore: list[str] | None = None,
52
53
  expect_row_change: bool = False, thresholds: Thresholds | dict | None = None,
53
- base_dir: str = ".") -> TableResult:
54
- """Compare two snapshots of one table.
54
+ source: SourceSpec | dict | None = None, base_dir: str = ".") -> TableResult:
55
+ """Compare two snapshots of one table, and optionally the output with its raw source.
55
56
 
56
- `before` and `after` can be file paths, duckdb:<file>:<table> strings,
57
- config mappings, or pandas DataFrames.
57
+ `before`, `after` and `source["data"]` can be file paths, duckdb:<file>:<table>
58
+ strings, config mappings, or pandas DataFrames. `before` may be None when only
59
+ source checks are wanted (for example when no earlier snapshot exists).
58
60
  """
59
61
  if isinstance(key, str):
60
62
  key = [k.strip() for k in key.split(",") if k.strip()]
61
63
  if not isinstance(thresholds, Thresholds):
62
64
  thresholds = Thresholds.from_dict(thresholds)
63
- src_b, src_a = parse_source(before, base_dir), parse_source(after, base_dir)
65
+ if before is None and source is None:
66
+ raise ValueError("Give a before snapshot, a source, or both")
67
+ src_b = parse_source(before, base_dir) if before is not None else None
68
+ src_a = parse_source(after, base_dir)
64
69
  con = duckdb.connect()
65
70
  try:
66
- load(con, src_b, "before")
67
71
  load(con, src_a, "after")
68
- findings, stats = run_checks(con, "before", "after", key=key, expect_change=expect_change,
69
- ignore=ignore, expect_row_change=expect_row_change,
70
- thresholds=thresholds)
71
- if not key:
72
- findings.append(Finding("keys", "info",
73
- "No key given, so row-level changes were not compared. Add a key for stronger checks."))
72
+ if src_b is not None:
73
+ load(con, src_b, "before")
74
+ findings, stats = run_checks(con, "before", "after", key=key, expect_change=expect_change,
75
+ ignore=ignore, expect_row_change=expect_row_change,
76
+ thresholds=thresholds)
77
+ if not key:
78
+ findings.append(Finding("keys", "info",
79
+ "No key given, so row-level changes were not compared. Add a key for stronger checks."))
80
+ else:
81
+ findings = []
82
+ stats = {"rows_after": con.execute('SELECT count(*) FROM "after"').fetchone()[0]}
83
+ if source is not None:
84
+ spec = SourceSpec.from_dict(source)
85
+ load(con, parse_source(spec.data, base_dir), "source")
86
+ sf, sstats = run_source_checks(con, "after", "source", spec, key or [], thresholds)
87
+ findings += sf
88
+ stats.update(sstats)
74
89
  finally:
75
90
  con.close()
76
91
  order = {BLOCK: 0, REVIEW: 1, "info": 2, PASS: 3}
77
92
  findings.sort(key=lambda f: order[f.severity])
78
93
  return TableResult(name=name, verdict=verdict(findings), findings=findings, stats=stats,
79
- before=src_b.describe(), after=src_a.describe())
94
+ before=src_b.describe() if src_b else "(none)", after=src_a.describe())
80
95
 
81
96
 
82
97
  def load_config(path: str) -> dict[str, Any]:
@@ -99,10 +114,10 @@ def run_config(path: str, only: list[str] | None = None) -> RunResult:
99
114
  continue
100
115
  th = {**defaults, **(spec.get("thresholds") or {})}
101
116
  try:
102
- res = verify(spec["before"], spec["after"], name=str(name), key=spec.get("key"),
117
+ res = verify(spec.get("before"), spec["after"], name=str(name), key=spec.get("key"),
103
118
  expect_change=spec.get("expect_change"), ignore=spec.get("ignore"),
104
119
  expect_row_change=bool(spec.get("expect_row_change", False)),
105
- thresholds=th, base_dir=base_dir)
120
+ thresholds=th, source=spec.get("source"), base_dir=base_dir)
106
121
  except Exception as exc: # report the table as blocked, keep checking the rest
107
122
  res = TableResult(name=str(name), verdict=BLOCK, stats={},
108
123
  findings=[Finding("error", BLOCK, f"Could not check this table: {exc}")],
@@ -36,7 +36,10 @@ def to_text(result: RunResult, show_info: bool = False) -> str:
36
36
  s = t.stats
37
37
  if s:
38
38
  extra = f", matched {s['matched_rows']:,}" if "matched_rows" in s else ""
39
- lines.append(f" rows {s.get('rows_before', 0):,} -> {s.get('rows_after', 0):,}{extra}")
39
+ if "rows_before" in s:
40
+ lines.append(f" rows {s['rows_before']:,} -> {s.get('rows_after', 0):,}{extra}")
41
+ else:
42
+ lines.append(f" rows {s.get('rows_after', 0):,} (checked against source)")
40
43
  for f in t.findings:
41
44
  if f.severity == "info" and not show_info:
42
45
  continue
@@ -57,7 +60,12 @@ def to_markdown(result: RunResult) -> str:
57
60
  for t in result.tables:
58
61
  issues = sum(1 for f in t.findings if f.severity in ("review", "block"))
59
62
  s = t.stats or {}
60
- rows = f"{s.get('rows_before', '?'):,} → {s.get('rows_after', '?'):,}" if s else "n/a"
63
+ if "rows_before" in s:
64
+ rows = f"{s['rows_before']:,} → {s.get('rows_after', 0):,}"
65
+ elif "rows_after" in s:
66
+ rows = f"{s['rows_after']:,} (source check)"
67
+ else:
68
+ rows = "n/a"
61
69
  out.append(f"| `{t.name}` | {ICON[t.verdict]} {LABEL[t.verdict]} | {rows} | {issues} |")
62
70
  for t in result.tables:
63
71
  notable = [f for f in t.findings if f.severity in ("review", "block")]
@@ -0,0 +1,125 @@
1
+ """Checks against the source: compare an output table with the raw data it was built from.
2
+
3
+ A before/after diff can only see damage to rows that existed before. Errors in new data
4
+ need evidence the pipeline does not control. These checks use the raw source table:
5
+
6
+ source_conflict the source sent several different values for one key and the output
7
+ kept one of them, so a choice was made that a person should confirm
8
+ source_mismatch an output value matches none of the values the source sent for that key
9
+ source_missing keys in the source (after an optional filter) that never reached the output
10
+ source_orphan keys in the output that do not exist in the source
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ import duckdb
18
+
19
+ from .checks import BLOCK, INFO, PASS, REVIEW, Finding, Thresholds, q
20
+
21
+
22
+ @dataclass
23
+ class SourceSpec:
24
+ data: Any
25
+ key: list[str] | None = None # key column(s) in the source, default: the table key
26
+ columns: dict[str, str] = field(default_factory=dict) # output column -> source column
27
+ where: str | None = None # SQL filter for source rows that should reach the output
28
+ checks: list[str] | None = None # default ["conflict"]; also mismatch, missing, orphan
29
+
30
+ @classmethod
31
+ def from_dict(cls, d: Any) -> "SourceSpec":
32
+ if isinstance(d, SourceSpec):
33
+ return d
34
+ if not isinstance(d, dict) or "data" not in d:
35
+ raise ValueError("source needs at least 'data' (where the raw table is)")
36
+ cols = d.get("columns") or {}
37
+ if isinstance(cols, list):
38
+ cols = {c: c for c in cols}
39
+ key = d.get("key")
40
+ if isinstance(key, str):
41
+ key = [k.strip() for k in key.split(",") if k.strip()]
42
+ return cls(data=d["data"], key=key, columns=dict(cols), where=d.get("where"),
43
+ checks=d.get("checks"))
44
+
45
+
46
+ def _grade(pct: float, review: float, block: float) -> str:
47
+ if pct >= block and pct > 0:
48
+ return BLOCK
49
+ if pct > review or (review == 0 and pct > 0):
50
+ return REVIEW
51
+ return PASS
52
+
53
+
54
+ def run_source_checks(con: duckdb.DuckDBPyConnection, after: str, source: str, spec: SourceSpec,
55
+ key: list[str], t: Thresholds) -> tuple[list[Finding], dict[str, Any]]:
56
+ if not key:
57
+ raise ValueError("Source checks need a key on the table")
58
+ skey = spec.key or key
59
+ if len(skey) != len(key):
60
+ raise ValueError("The source key must have as many columns as the table key")
61
+ wanted = set(spec.checks or ["conflict"]) # others are opt-in: they need a filter or pass-through columns
62
+ where = f"WHERE {spec.where}" if spec.where else ""
63
+ # normalized source view: key columns renamed to the output's key names, compared as text
64
+ sel = [f"CAST({q(s)} AS VARCHAR) AS {q('_k' + str(i))}" for i, s in enumerate(skey)]
65
+ sel += [f"{q(sc)} AS {q('_v_' + oc)}" for oc, sc in spec.columns.items()]
66
+ con.execute(f"CREATE OR REPLACE TEMP VIEW _pv_src AS SELECT {', '.join(sel)} FROM {q(source)} {where}")
67
+ akeys = ", ".join(f"CAST({q(k)} AS VARCHAR) AS {q('_k' + str(i))}" for i, k in enumerate(key))
68
+ con.execute(f"CREATE OR REPLACE TEMP VIEW _pv_out AS SELECT {akeys}, * FROM {q(after)}")
69
+ on = " AND ".join(f"o.{q('_k' + str(i))} IS NOT DISTINCT FROM s.{q('_k' + str(i))}" for i in range(len(key)))
70
+ kcols = ", ".join(q("_k" + str(i)) for i in range(len(key)))
71
+
72
+ findings: list[Finding] = []
73
+ n_out = con.execute("SELECT count(*) FROM _pv_out").fetchone()[0]
74
+ n_src = con.execute(f"SELECT count(DISTINCT ({kcols})) FROM _pv_src").fetchone()[0]
75
+ stats = {"source_keys": n_src, "output_rows": n_out}
76
+
77
+ for oc in spec.columns:
78
+ v = q("_v_" + oc)
79
+ if "conflict" in wanted:
80
+ n = con.execute(
81
+ f"SELECT count(*) FROM (SELECT {kcols} FROM _pv_src GROUP BY ALL "
82
+ f"HAVING count(DISTINCT CAST({v} AS VARCHAR)) > 1) s "
83
+ f"WHERE EXISTS (SELECT 1 FROM _pv_out o WHERE {on})").fetchone()[0]
84
+ if n:
85
+ ex = con.execute(
86
+ f"SELECT {kcols}, list(DISTINCT CAST({v} AS VARCHAR) ORDER BY CAST({v} AS VARCHAR)) FROM _pv_src "
87
+ f"GROUP BY ALL HAVING count(DISTINCT CAST({v} AS VARCHAR)) > 1 LIMIT 3").fetchall()
88
+ pct = n / max(n_out, 1) * 100
89
+ findings.append(Finding(
90
+ "source_conflict", REVIEW,
91
+ f"{n:,} key(s) arrived from the source with conflicting '{oc}' values and the output kept one "
92
+ f"({pct:.2f}% of rows). Confirm which value is right.",
93
+ oc, {"count": n, "pct": round(pct, 4), "examples": [[str(x) for x in r] for r in ex]}))
94
+ if "mismatch" in wanted:
95
+ num = (f"TRY_CAST(o.{q(oc)} AS DOUBLE) IS NOT NULL AND TRY_CAST(s.{v} AS DOUBLE) IS NOT NULL AND "
96
+ f"abs(TRY_CAST(o.{q(oc)} AS DOUBLE) - TRY_CAST(s.{v} AS DOUBLE)) <= "
97
+ f"{t.float_tolerance} * greatest(1.0, abs(TRY_CAST(s.{v} AS DOUBLE)))")
98
+ txt = f"trim(CAST(o.{q(oc)} AS VARCHAR)) = trim(CAST(s.{v} AS VARCHAR))"
99
+ n = con.execute(
100
+ f"SELECT count(*) FROM _pv_out o WHERE EXISTS (SELECT 1 FROM _pv_src s WHERE {on}) "
101
+ f"AND NOT EXISTS (SELECT 1 FROM _pv_src s WHERE {on} AND (({num}) OR {txt} "
102
+ f"OR (o.{q(oc)} IS NULL AND s.{v} IS NULL)))").fetchone()[0]
103
+ if n:
104
+ pct = n / max(n_out, 1) * 100
105
+ sev = _grade(pct, t.source_mismatch_review_pct, t.source_mismatch_block_pct)
106
+ findings.append(Finding("source_mismatch", sev,
107
+ f"{n:,} row(s) have a '{oc}' value the source never sent ({pct:.2f}%)",
108
+ oc, {"count": n, "pct": round(pct, 4)}))
109
+ if "missing" in wanted:
110
+ n = con.execute(f"SELECT count(*) FROM (SELECT DISTINCT {kcols} FROM _pv_src) s "
111
+ f"WHERE NOT EXISTS (SELECT 1 FROM _pv_out o WHERE {on})").fetchone()[0]
112
+ if n:
113
+ pct = n / max(n_src, 1) * 100
114
+ findings.append(Finding("source_missing", _grade(pct, t.source_missing_review_pct, t.source_missing_block_pct),
115
+ f"{n:,} source key(s) never reached the output ({pct:.2f}%)", None,
116
+ {"count": n, "pct": round(pct, 4)}))
117
+ if "orphan" in wanted:
118
+ n = con.execute(f"SELECT count(*) FROM _pv_out o WHERE NOT EXISTS (SELECT 1 FROM _pv_src s WHERE {on})").fetchone()[0]
119
+ if n:
120
+ pct = n / max(n_out, 1) * 100
121
+ findings.append(Finding("source_orphan", REVIEW if pct > 0 else INFO,
122
+ f"{n:,} output row(s) have keys that are not in the source ({pct:.2f}%)", None,
123
+ {"count": n, "pct": round(pct, 4)}))
124
+ stats["source_checks"] = sorted(wanted)
125
+ return findings, stats
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pipeverify"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Check that a pipeline change left the data right, not just the job green."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
File without changes
File without changes
File without changes