datasemver 0.2.0__py3-none-any.whl

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.
datasemver/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """SemVer-style versioning for datasets."""
2
+
3
+ from datasemver.core.analyzer import analyze
4
+ from datasemver.core.models import AnalysisReport, ChangeType, DiffResult, Severity
5
+
6
+ __version__ = "0.2.0"
7
+
8
+ __all__ = ["analyze", "AnalysisReport", "ChangeType", "DiffResult", "Severity", "__version__"]
datasemver/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from datasemver.cli.main import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
File without changes
datasemver/cli/main.py ADDED
@@ -0,0 +1,177 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ from datasemver.core.analyzer import DEFAULT_VERSION, analyze
15
+ from datasemver.core.changelog import render_entry, severity_label, write_changelog
16
+ from datasemver.core.models import AnalysisReport, ColumnStatus, Severity
17
+ from datasemver.rules.engine import EVALUATION_ORDER, RuleError, load_rules
18
+ from datasemver.utils.version import InvalidVersionError
19
+
20
+ app = typer.Typer(
21
+ name="datasemver",
22
+ help="Semantic versioning for datasets.",
23
+ add_completion=False,
24
+ no_args_is_help=True,
25
+ )
26
+
27
+ console = Console()
28
+ error_console = Console(stderr=True)
29
+
30
+ SEVERITY_COLORS: dict[Severity, str] = {
31
+ Severity.MAJOR: "bold red",
32
+ Severity.MINOR: "bold yellow",
33
+ Severity.PATCH: "bold green",
34
+ }
35
+
36
+ STATUS_COLORS: dict[ColumnStatus, str] = {
37
+ ColumnStatus.ADDED: "green",
38
+ ColumnStatus.REMOVED: "red",
39
+ ColumnStatus.RENAMED: "magenta",
40
+ ColumnStatus.MODIFIED: "yellow",
41
+ ColumnStatus.UNCHANGED: "dim",
42
+ }
43
+
44
+
45
+ @app.command()
46
+ def diff(
47
+ old: Annotated[Path, typer.Argument(help="Previous version of the dataset (CSV or JSON).")],
48
+ new: Annotated[Path, typer.Argument(help="New version of the dataset (CSV or JSON).")],
49
+ rules: Annotated[
50
+ Path | None,
51
+ typer.Option("--rules", "-r", help="Custom rules file overriding the defaults."),
52
+ ] = None,
53
+ as_json: Annotated[
54
+ bool, typer.Option("--json", help="Print the report as JSON instead of a table.")
55
+ ] = False,
56
+ output: Annotated[
57
+ Path | None,
58
+ typer.Option("--output", "-o", help="Write the changelog entry to this file."),
59
+ ] = None,
60
+ current_version: Annotated[
61
+ str,
62
+ typer.Option("--current-version", "-c", help="Version the new dataset is bumped from."),
63
+ ] = DEFAULT_VERSION,
64
+ ) -> None:
65
+ """Compare two dataset versions and suggest a semantic version bump."""
66
+ try:
67
+ report = analyze(old, new, rules=rules, current_version=current_version)
68
+ except (FileNotFoundError, ValueError, RuleError, InvalidVersionError) as error:
69
+ error_console.print(f"[bold red]error:[/] {error}")
70
+ raise typer.Exit(code=2) from error
71
+
72
+ if output is not None:
73
+ write_changelog(report, output)
74
+
75
+ if as_json:
76
+ console.print_json(json.dumps(report.model_dump(mode="json")))
77
+ return
78
+
79
+ _render_report(report, output)
80
+
81
+
82
+ def _render_report(report: AnalysisReport, output: Path | None) -> None:
83
+ bump = severity_label(report.bump)
84
+ style = SEVERITY_COLORS.get(report.bump, "bold blue") if report.bump else "bold blue"
85
+
86
+ console.print(
87
+ Panel(
88
+ f"[{style}]Suggested bump: {bump}[/]\n"
89
+ f"{report.current_version} -> {report.next_version}\n\n"
90
+ f"old: {report.old_source} ({report.diff.old.row_count} rows)\n"
91
+ f"new: {report.new_source} ({report.diff.new.row_count} rows)",
92
+ title="DataSemver",
93
+ expand=False,
94
+ )
95
+ )
96
+
97
+ console.print(_columns_table(report))
98
+ console.print(_changes_table(report))
99
+
100
+ if output is not None:
101
+ console.print(f"[dim]changelog written to {output}[/]")
102
+ else:
103
+ console.print(Panel(render_entry(report).rstrip(), title="CHANGELOG", expand=False))
104
+
105
+
106
+ def _columns_table(report: AnalysisReport) -> Table:
107
+ table = Table(title="Columns", header_style="bold")
108
+ for header in ("column", "status", "type old", "type new", "nulls", "cardinality"):
109
+ table.add_column(header)
110
+
111
+ for column in report.diff.columns:
112
+ name = column.name if not column.renamed_from else f"{column.renamed_from} -> {column.name}"
113
+ table.add_row(
114
+ name,
115
+ f"[{STATUS_COLORS[column.status]}]{column.status.value}[/]",
116
+ column.dtype_old or "-",
117
+ column.dtype_new or "-",
118
+ f"{_percent(column.null_ratio_old)} -> {_percent(column.null_ratio_new)}",
119
+ f"{_number(column.cardinality_old)} -> {_number(column.cardinality_new)}",
120
+ )
121
+ return table
122
+
123
+
124
+ def _changes_table(report: AnalysisReport) -> Table:
125
+ table = Table(title="Changes", header_style="bold")
126
+ table.add_column("severity")
127
+ table.add_column("rule")
128
+ table.add_column("description")
129
+
130
+ ordered = sorted(
131
+ report.classified,
132
+ key=lambda item: -item.severity.rank if item.severity else 1,
133
+ )
134
+ for item in ordered:
135
+ severity = item.severity
136
+ label = severity.value.upper() if severity else "unclassified"
137
+ style = SEVERITY_COLORS.get(severity, "dim") if severity else "dim"
138
+ table.add_row(f"[{style}]{label}[/]", item.rule or "-", item.change.description)
139
+
140
+ if not ordered:
141
+ table.add_row("[dim]none[/]", "-", "Datasets are identical")
142
+ return table
143
+
144
+
145
+ def _percent(value: float | None) -> str:
146
+ return "-" if value is None else f"{value:.1%}"
147
+
148
+
149
+ def _number(value: int | None) -> str:
150
+ return "-" if value is None else str(value)
151
+
152
+
153
+ @app.command("rules")
154
+ def show_rules(
155
+ path: Annotated[
156
+ Path | None, typer.Argument(help="Rules file to inspect; defaults to the bundled rules.")
157
+ ] = None,
158
+ ) -> None:
159
+ """Print the rules that will be applied, grouped by severity."""
160
+ try:
161
+ rule_set = load_rules(path)
162
+ except (FileNotFoundError, RuleError) as error:
163
+ error_console.print(f"[bold red]error:[/] {error}")
164
+ raise typer.Exit(code=2) from error
165
+
166
+ for severity in EVALUATION_ORDER:
167
+ entries = rule_set.rules.get(severity, [])
168
+ console.print(f"[{SEVERITY_COLORS[severity]}]{severity.value}[/]")
169
+ for rule in entries or []:
170
+ suffix = f" > {rule.threshold:g}" if rule.threshold is not None else ""
171
+ console.print(f" - {rule.name}{suffix}")
172
+ if not entries:
173
+ console.print(" [dim]- none[/]")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ app()
File without changes
@@ -0,0 +1,58 @@
1
+ """Analysis pipeline: load, diff, classify and version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from pathlib import Path
7
+
8
+ from datasemver.core.differ import DiffConfig, diff_schemas
9
+ from datasemver.core.models import AnalysisReport, DatasetSchema
10
+ from datasemver.formats.loader import load_schema
11
+ from datasemver.rules.engine import RuleSet, highest_severity, load_rules
12
+ from datasemver.utils.version import bump_version
13
+
14
+ DEFAULT_VERSION = "0.0.0"
15
+
16
+
17
+ def analyze(
18
+ old_path: str | Path,
19
+ new_path: str | Path,
20
+ rules: RuleSet | str | Path | None = None,
21
+ current_version: str = DEFAULT_VERSION,
22
+ diff_config: DiffConfig | None = None,
23
+ ) -> AnalysisReport:
24
+ """Compare two dataset files and return the suggested version bump."""
25
+ old_schema = load_schema(old_path)
26
+ new_schema = load_schema(new_path)
27
+ return analyze_schemas(
28
+ old_schema,
29
+ new_schema,
30
+ rules=rules,
31
+ current_version=current_version,
32
+ diff_config=diff_config,
33
+ )
34
+
35
+
36
+ def analyze_schemas(
37
+ old: DatasetSchema,
38
+ new: DatasetSchema,
39
+ rules: RuleSet | str | Path | None = None,
40
+ current_version: str = DEFAULT_VERSION,
41
+ diff_config: DiffConfig | None = None,
42
+ ) -> AnalysisReport:
43
+ """Compare two already loaded dataset profiles."""
44
+ rule_set = rules if isinstance(rules, RuleSet) else load_rules(rules)
45
+ diff = diff_schemas(old, new, config=diff_config)
46
+ classified = rule_set.evaluate(diff)
47
+ bump = highest_severity(classified)
48
+
49
+ return AnalysisReport(
50
+ generated_at=date.today(),
51
+ old_source=old.source,
52
+ new_source=new.source,
53
+ current_version=current_version,
54
+ next_version=bump_version(current_version, bump),
55
+ bump=bump,
56
+ diff=diff,
57
+ classified=classified,
58
+ )
@@ -0,0 +1,56 @@
1
+ """Changelog rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from datasemver.core.models import AnalysisReport, Severity
8
+ from datasemver.rules.engine import EVALUATION_ORDER
9
+
10
+ CHANGELOG_TITLE = "# Changelog"
11
+
12
+
13
+ def render_entry(report: AnalysisReport) -> str:
14
+ """Render a single changelog entry for an analysis report."""
15
+ lines = [f"## [{report.next_version}] - {report.generated_at.isoformat()}", ""]
16
+
17
+ for severity in EVALUATION_ORDER:
18
+ items = report.by_severity(severity)
19
+ if not items:
20
+ continue
21
+ lines.append(f"### {severity.value.capitalize()}")
22
+ lines.extend(f"- {item.change.description}" for item in items)
23
+ lines.append("")
24
+
25
+ if len(lines) == 2:
26
+ lines.append("No classified changes detected.")
27
+ lines.append("")
28
+
29
+ return "\n".join(lines).rstrip() + "\n"
30
+
31
+
32
+ def render_changelog(report: AnalysisReport) -> str:
33
+ """Render a full changelog document containing a single entry."""
34
+ return f"{CHANGELOG_TITLE}\n\n{render_entry(report)}"
35
+
36
+
37
+ def write_changelog(report: AnalysisReport, path: str | Path) -> Path:
38
+ """Write the entry to a changelog file, prepending it when the file already exists."""
39
+ path = Path(path)
40
+ entry = render_entry(report)
41
+
42
+ if not path.exists():
43
+ path.write_text(render_changelog(report), encoding="utf-8")
44
+ return path
45
+
46
+ existing = path.read_text(encoding="utf-8").lstrip()
47
+ if existing.startswith(CHANGELOG_TITLE):
48
+ body = existing[len(CHANGELOG_TITLE) :].lstrip("\n")
49
+ path.write_text(f"{CHANGELOG_TITLE}\n\n{entry}\n{body}", encoding="utf-8")
50
+ else:
51
+ path.write_text(f"{entry}\n{existing}", encoding="utf-8")
52
+ return path
53
+
54
+
55
+ def severity_label(severity: Severity | None) -> str:
56
+ return severity.value.upper() if severity else "NONE"
@@ -0,0 +1,352 @@
1
+ """Comparison of two dataset profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from datasemver.core.models import (
8
+ Change,
9
+ ChangeType,
10
+ ColumnComparison,
11
+ ColumnStats,
12
+ ColumnStatus,
13
+ DatasetSchema,
14
+ DiffResult,
15
+ )
16
+ from datasemver.utils.similarity import column_similarity
17
+
18
+ COMPATIBLE_WIDENINGS: set[tuple[str, str]] = {
19
+ ("bool", "int64"),
20
+ ("bool", "float64"),
21
+ ("int64", "float64"),
22
+ }
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class DiffConfig:
27
+ """Sensitivity thresholds used while comparing two datasets."""
28
+
29
+ rename_threshold: float = 0.7
30
+ null_ratio_tolerance: float = 0.01
31
+ distribution_sigma: float = 0.5
32
+ stat_change_tolerance: float = 0.01
33
+ cardinality_tolerance: float = 0.1
34
+
35
+
36
+ def diff_schemas(
37
+ old: DatasetSchema,
38
+ new: DatasetSchema,
39
+ config: DiffConfig | None = None,
40
+ ) -> DiffResult:
41
+ """Compare two dataset profiles and return every detected change."""
42
+ config = config or DiffConfig()
43
+
44
+ removed = [name for name in old.column_names if name not in new.columns]
45
+ added = [name for name in new.column_names if name not in old.columns]
46
+ renames = _detect_renames(old, new, removed, added, config)
47
+
48
+ removed = [name for name in removed if name not in renames]
49
+ added = [name for name in added if name not in renames.values()]
50
+
51
+ changes: list[Change] = list(_row_count_changes(old, new))
52
+ comparisons: list[ColumnComparison] = []
53
+
54
+ for name in removed:
55
+ stats = old.columns[name]
56
+ changes.append(
57
+ Change(
58
+ type=ChangeType.COLUMN_REMOVED,
59
+ column=name,
60
+ description=f"Column '{name}' was removed",
61
+ details={"dtype": stats.dtype},
62
+ )
63
+ )
64
+ comparisons.append(
65
+ ColumnComparison(
66
+ name=name,
67
+ status=ColumnStatus.REMOVED,
68
+ dtype_old=stats.dtype,
69
+ null_ratio_old=stats.null_ratio,
70
+ cardinality_old=stats.cardinality,
71
+ )
72
+ )
73
+
74
+ for name in added:
75
+ stats = new.columns[name]
76
+ changes.append(
77
+ Change(
78
+ type=ChangeType.COLUMN_ADDED,
79
+ column=name,
80
+ description=f"Column '{name}' was added",
81
+ metrics={"null_ratio": stats.null_ratio},
82
+ details={"dtype": stats.dtype},
83
+ )
84
+ )
85
+ comparisons.append(
86
+ ColumnComparison(
87
+ name=name,
88
+ status=ColumnStatus.ADDED,
89
+ dtype_new=stats.dtype,
90
+ null_ratio_new=stats.null_ratio,
91
+ cardinality_new=stats.cardinality,
92
+ )
93
+ )
94
+
95
+ for old_name, new_name in renames.items():
96
+ changes.append(
97
+ Change(
98
+ type=ChangeType.COLUMN_RENAMED,
99
+ column=new_name,
100
+ description=f"Column '{old_name}' was renamed to '{new_name}'",
101
+ details={"previous_name": old_name},
102
+ )
103
+ )
104
+
105
+ paired = [(name, name) for name in old.column_names if name in new.columns]
106
+ paired.extend(renames.items())
107
+
108
+ for old_name, new_name in paired:
109
+ old_stats = old.columns[old_name]
110
+ new_stats = new.columns[new_name]
111
+ column_changes = list(_column_changes(old_stats, new_stats, config))
112
+ changes.extend(column_changes)
113
+
114
+ if old_name != new_name:
115
+ status = ColumnStatus.RENAMED
116
+ elif column_changes:
117
+ status = ColumnStatus.MODIFIED
118
+ else:
119
+ status = ColumnStatus.UNCHANGED
120
+
121
+ comparisons.append(
122
+ ColumnComparison(
123
+ name=new_name,
124
+ status=status,
125
+ renamed_from=old_name if old_name != new_name else None,
126
+ dtype_old=old_stats.dtype,
127
+ dtype_new=new_stats.dtype,
128
+ null_ratio_old=old_stats.null_ratio,
129
+ null_ratio_new=new_stats.null_ratio,
130
+ cardinality_old=old_stats.cardinality,
131
+ cardinality_new=new_stats.cardinality,
132
+ )
133
+ )
134
+
135
+ comparisons.sort(key=lambda item: (item.status.value, item.name))
136
+ return DiffResult(old=old, new=new, changes=changes, columns=comparisons)
137
+
138
+
139
+ def _detect_renames(
140
+ old: DatasetSchema,
141
+ new: DatasetSchema,
142
+ removed: list[str],
143
+ added: list[str],
144
+ config: DiffConfig,
145
+ ) -> dict[str, str]:
146
+ """Pair removed and added columns that look like the same column renamed."""
147
+ candidates: list[tuple[float, str, str]] = []
148
+ for old_name in removed:
149
+ for new_name in added:
150
+ score = column_similarity(
151
+ old_name,
152
+ new_name,
153
+ old.columns[old_name].categories,
154
+ new.columns[new_name].categories,
155
+ )
156
+ if score >= config.rename_threshold:
157
+ candidates.append((score, old_name, new_name))
158
+
159
+ candidates.sort(reverse=True)
160
+ renames: dict[str, str] = {}
161
+ taken: set[str] = set()
162
+ for _, old_name, new_name in candidates:
163
+ if old_name in renames or new_name in taken:
164
+ continue
165
+ renames[old_name] = new_name
166
+ taken.add(new_name)
167
+ return renames
168
+
169
+
170
+ def _row_count_changes(old: DatasetSchema, new: DatasetSchema):
171
+ if old.row_count == new.row_count:
172
+ return
173
+
174
+ delta = new.row_count - old.row_count
175
+ base = old.row_count or 1
176
+ percentage = round(abs(delta) / base * 100, 4)
177
+ metrics = {
178
+ "old_rows": float(old.row_count),
179
+ "new_rows": float(new.row_count),
180
+ "delta": float(delta),
181
+ "change_pct": percentage,
182
+ }
183
+
184
+ if delta > 0:
185
+ yield Change(
186
+ type=ChangeType.ROW_COUNT_INCREASED,
187
+ description=f"Row count grew from {old.row_count} to {new.row_count} (+{percentage:.2f}%)",
188
+ metrics=metrics | {"increase_pct": percentage},
189
+ )
190
+ else:
191
+ yield Change(
192
+ type=ChangeType.ROW_COUNT_DECREASED,
193
+ description=f"Row count fell from {old.row_count} to {new.row_count} (-{percentage:.2f}%)",
194
+ metrics=metrics | {"decrease_pct": percentage},
195
+ )
196
+
197
+
198
+ def _column_changes(old: ColumnStats, new: ColumnStats, config: DiffConfig):
199
+ yield from _type_changes(old, new)
200
+ yield from _null_changes(old, new, config)
201
+ yield from _category_changes(old, new)
202
+ yield from _numeric_changes(old, new, config)
203
+ yield from _cardinality_changes(old, new, config)
204
+
205
+
206
+ def _type_changes(old: ColumnStats, new: ColumnStats):
207
+ if old.dtype == new.dtype:
208
+ return
209
+
210
+ details = {"dtype_old": old.dtype, "dtype_new": new.dtype}
211
+ if (old.dtype, new.dtype) in COMPATIBLE_WIDENINGS:
212
+ yield Change(
213
+ type=ChangeType.TYPE_CHANGED_COMPATIBLE,
214
+ column=new.name,
215
+ description=f"Column '{new.name}' widened from {old.dtype} to {new.dtype}",
216
+ details=details,
217
+ )
218
+ else:
219
+ yield Change(
220
+ type=ChangeType.TYPE_CHANGED_INCOMPATIBLE,
221
+ column=new.name,
222
+ description=f"Column '{new.name}' changed type from {old.dtype} to {new.dtype}",
223
+ details=details,
224
+ )
225
+
226
+
227
+ def _null_changes(old: ColumnStats, new: ColumnStats, config: DiffConfig):
228
+ delta = new.null_ratio - old.null_ratio
229
+ if abs(delta) < config.null_ratio_tolerance:
230
+ return
231
+
232
+ metrics = {
233
+ "null_ratio_old": round(old.null_ratio * 100, 4),
234
+ "null_ratio_new": round(new.null_ratio * 100, 4),
235
+ "delta_pct": round(abs(delta) * 100, 4),
236
+ }
237
+ if delta < 0:
238
+ yield Change(
239
+ type=ChangeType.NULLS_FIXED,
240
+ column=new.name,
241
+ description=(
242
+ f"Column '{new.name}' nulls dropped from {old.null_ratio:.1%} to {new.null_ratio:.1%}"
243
+ ),
244
+ metrics=metrics,
245
+ )
246
+ else:
247
+ yield Change(
248
+ type=ChangeType.NULLS_INTRODUCED,
249
+ column=new.name,
250
+ description=(
251
+ f"Column '{new.name}' nulls rose from {old.null_ratio:.1%} to {new.null_ratio:.1%}"
252
+ ),
253
+ metrics=metrics,
254
+ )
255
+
256
+
257
+ def _category_changes(old: ColumnStats, new: ColumnStats):
258
+ if old.categories is None or new.categories is None:
259
+ return
260
+
261
+ old_set, new_set = set(old.categories), set(new.categories)
262
+ gained = sorted(new_set - old_set)
263
+ lost = sorted(old_set - new_set)
264
+
265
+ if gained:
266
+ yield Change(
267
+ type=ChangeType.NEW_CATEGORY_ADDED,
268
+ column=new.name,
269
+ description=f"Column '{new.name}' gained {len(gained)} category value(s)",
270
+ metrics={"added_count": float(len(gained))},
271
+ details={"categories": gained[:20]},
272
+ )
273
+ if lost:
274
+ yield Change(
275
+ type=ChangeType.CATEGORY_REMOVED,
276
+ column=new.name,
277
+ description=f"Column '{new.name}' lost {len(lost)} category value(s)",
278
+ metrics={"removed_count": float(len(lost))},
279
+ details={"categories": lost[:20]},
280
+ )
281
+
282
+
283
+ def _numeric_changes(old: ColumnStats, new: ColumnStats, config: DiffConfig):
284
+ if not (old.is_numeric and new.is_numeric):
285
+ return
286
+ if old.mean is None or new.mean is None:
287
+ return
288
+ if _is_sequential_key(old) and _is_sequential_key(new):
289
+ return
290
+
291
+ shift = abs(new.mean - old.mean)
292
+ if shift == 0:
293
+ return
294
+
295
+ base = abs(old.mean) or 1.0
296
+ relative = round(shift / base * 100, 4)
297
+ spread = old.std or 0.0
298
+ sigma = round(shift / spread, 4) if spread else float("inf")
299
+ metrics = {"mean_old": old.mean, "mean_new": new.mean, "mean_shift_pct": relative}
300
+
301
+ if spread and sigma >= config.distribution_sigma:
302
+ yield Change(
303
+ type=ChangeType.DISTRIBUTION_SHIFT,
304
+ column=new.name,
305
+ description=(
306
+ f"Column '{new.name}' mean moved from {old.mean:.4g} to {new.mean:.4g} "
307
+ f"({sigma:.2f} sigma)"
308
+ ),
309
+ metrics=metrics | {"sigma_shift": sigma},
310
+ )
311
+ elif relative >= config.stat_change_tolerance * 100:
312
+ yield Change(
313
+ type=ChangeType.MINOR_STAT_CHANGE,
314
+ column=new.name,
315
+ description=(
316
+ f"Column '{new.name}' mean moved from {old.mean:.4g} to {new.mean:.4g} "
317
+ f"({relative:.2f}%)"
318
+ ),
319
+ metrics=metrics,
320
+ )
321
+
322
+
323
+ def _is_sequential_key(stats: ColumnStats) -> bool:
324
+ """Detect contiguous integer keys, whose statistics carry no business meaning."""
325
+ if stats.dtype != "int64" or stats.minimum is None or stats.maximum is None:
326
+ return False
327
+ if stats.uniqueness < 1.0 or stats.null_ratio > 0:
328
+ return False
329
+ return stats.cardinality == int(stats.maximum - stats.minimum) + 1
330
+
331
+
332
+ def _cardinality_changes(old: ColumnStats, new: ColumnStats, config: DiffConfig):
333
+ if old.categories is not None and new.categories is not None:
334
+ return
335
+ if abs(new.uniqueness - old.uniqueness) < config.cardinality_tolerance:
336
+ return
337
+
338
+ base = old.cardinality or 1
339
+ change = round(abs(new.cardinality - old.cardinality) / base * 100, 4)
340
+
341
+ yield Change(
342
+ type=ChangeType.CARDINALITY_CHANGED,
343
+ column=new.name,
344
+ description=(
345
+ f"Column '{new.name}' cardinality moved from {old.cardinality} to {new.cardinality}"
346
+ ),
347
+ metrics={
348
+ "cardinality_old": float(old.cardinality),
349
+ "cardinality_new": float(new.cardinality),
350
+ "change_pct": change,
351
+ },
352
+ )