datasentry-core 0.1.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.
Files changed (80) hide show
  1. datasentry_core/__init__.py +111 -0
  2. datasentry_core/connectors/__init__.py +49 -0
  3. datasentry_core/connectors/base.py +108 -0
  4. datasentry_core/connectors/csv.py +312 -0
  5. datasentry_core/connectors/duckdb.py +105 -0
  6. datasentry_core/connectors/errors.py +19 -0
  7. datasentry_core/connectors/file_based.py +209 -0
  8. datasentry_core/connectors/jsonl.py +71 -0
  9. datasentry_core/connectors/parquet.py +64 -0
  10. datasentry_core/connectors/registry.py +55 -0
  11. datasentry_core/connectors/spec.py +37 -0
  12. datasentry_core/connectors/xlsx.py +109 -0
  13. datasentry_core/contracts/__init__.py +24 -0
  14. datasentry_core/contracts/exporters.py +330 -0
  15. datasentry_core/detectors/__init__.py +15 -0
  16. datasentry_core/detectors/base.py +120 -0
  17. datasentry_core/detectors/common.py +156 -0
  18. datasentry_core/detectors/cross_field.py +157 -0
  19. datasentry_core/detectors/cross_table.py +220 -0
  20. datasentry_core/detectors/datetime.py +343 -0
  21. datasentry_core/detectors/initial/__init__.py +117 -0
  22. datasentry_core/detectors/initial/anomaly_ml.py +204 -0
  23. datasentry_core/detectors/initial/categorical.py +252 -0
  24. datasentry_core/detectors/initial/formula.py +66 -0
  25. datasentry_core/detectors/initial/missing.py +150 -0
  26. datasentry_core/detectors/initial/numeric.py +302 -0
  27. datasentry_core/detectors/initial/textual.py +407 -0
  28. datasentry_core/detectors/initial/uniqueness.py +183 -0
  29. datasentry_core/detectors/missingness.py +424 -0
  30. datasentry_core/detectors/runner.py +228 -0
  31. datasentry_core/detectors/safe_eval.py +255 -0
  32. datasentry_core/detectors/textual_variants.py +300 -0
  33. datasentry_core/drift/__init__.py +8 -0
  34. datasentry_core/drift/engine.py +182 -0
  35. datasentry_core/engine/__init__.py +16 -0
  36. datasentry_core/engine/base.py +34 -0
  37. datasentry_core/engine/duckdb.py +79 -0
  38. datasentry_core/engine/fusion.py +193 -0
  39. datasentry_core/engine/profiler.py +165 -0
  40. datasentry_core/engine/sql_guard.py +23 -0
  41. datasentry_core/llm/provider.py +66 -0
  42. datasentry_core/models/__init__.py +118 -0
  43. datasentry_core/models/audit.py +46 -0
  44. datasentry_core/models/contract.py +89 -0
  45. datasentry_core/models/detector.py +66 -0
  46. datasentry_core/models/drift.py +47 -0
  47. datasentry_core/models/enums.py +126 -0
  48. datasentry_core/models/evidence.py +39 -0
  49. datasentry_core/models/feedback.py +30 -0
  50. datasentry_core/models/fingerprint.py +24 -0
  51. datasentry_core/models/issue.py +49 -0
  52. datasentry_core/models/llm.py +69 -0
  53. datasentry_core/models/profile.py +79 -0
  54. datasentry_core/models/quality.py +16 -0
  55. datasentry_core/models/repair.py +94 -0
  56. datasentry_core/models/rules.py +82 -0
  57. datasentry_core/models/scan.py +112 -0
  58. datasentry_core/models/validation.py +22 -0
  59. datasentry_core/plugins.py +91 -0
  60. datasentry_core/privacy/redactor.py +155 -0
  61. datasentry_core/repair/__init__.py +5 -0
  62. datasentry_core/repair/engine.py +455 -0
  63. datasentry_core/reporting/__init__.py +100 -0
  64. datasentry_core/reporting/html.py +230 -0
  65. datasentry_core/reporting/junit.py +80 -0
  66. datasentry_core/reporting/markdown.py +83 -0
  67. datasentry_core/reporting/sarif.py +92 -0
  68. datasentry_core/rules/engine.py +133 -0
  69. datasentry_core/scoring/__init__.py +30 -0
  70. datasentry_core/scoring/engine.py +138 -0
  71. datasentry_core/scoring/gate.py +96 -0
  72. datasentry_core/scoring/quality.py +137 -0
  73. datasentry_core/scoring/weights.py +33 -0
  74. datasentry_core/storage/__init__.py +15 -0
  75. datasentry_core/storage/paths.py +47 -0
  76. datasentry_core/storage/schema.py +343 -0
  77. datasentry_core/storage/store.py +675 -0
  78. datasentry_core-0.1.0.dist-info/METADATA +59 -0
  79. datasentry_core-0.1.0.dist-info/RECORD +80 -0
  80. datasentry_core-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,157 @@
1
+ """跨字段规则检测器(11.10):安全表达式求值(读操作子集,ADR-015)。
2
+
3
+ 内置规则按列名语义对自动绑定(MVP 确定性子集,ADR-004 契约 DSL 归 V1):
4
+ - {name}_start / {name}_begin / {name}_from <= {name}_end / {name}_finish / {name}_to
5
+ - {name}_min / {name}_lower <= {name}_max / {name}_upper
6
+ 仅同类型(数值/日期)配对;表达式须通过 AST 白名单校验。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import ClassVar
14
+
15
+ from datasentry_core.detectors.base import DetectionContext
16
+ from datasentry_core.detectors.common import (
17
+ DetectorBase,
18
+ make_candidate,
19
+ make_evidence,
20
+ quote_ident,
21
+ )
22
+ from datasentry_core.detectors.safe_eval import ExpressionSecurityError, SafeExpressionEvaluator
23
+ from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
24
+ from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
25
+
26
+ _IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
27
+ _PREFIX_PAIRS: tuple[tuple[tuple[str, ...], tuple[str, ...]], ...] = (
28
+ (("start", "begin", "from"), ("end", "finish", "to")),
29
+ (("min", "lower"), ("max", "upper")),
30
+ )
31
+ _MAX_EXAMPLES = 20
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class BoundRule:
36
+ """绑定到具体列对的一条规则。"""
37
+
38
+ rule_id: str
39
+ expression: str
40
+ left: str
41
+ right: str
42
+
43
+
44
+ class CrossFieldRuleDetector(DetectorBase):
45
+ """跨字段规则(11.10):start<=end / min<=max 等内置语义对,行级安全求值。"""
46
+
47
+ detector_id = "cross_field_rule"
48
+ display_name = "Cross-Field Rule"
49
+ description = "Evaluates whitelisted cross-column expressions (e.g. start <= end)."
50
+ quality_dimension = QualityDimension.VALIDITY
51
+ capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
52
+ default_thresholds: ClassVar[dict[str, float | int | str]] = {"timeout_s": 10.0}
53
+
54
+ def __init__(self, timeout_s: float = 10.0) -> None:
55
+ self._evaluator = SafeExpressionEvaluator(timeout_s=timeout_s)
56
+
57
+ def supports(self, context: DetectionContext) -> bool:
58
+ return bool(self._bind_rules(context))
59
+
60
+ def _bind_rules(self, context: DetectionContext) -> list[BoundRule]:
61
+ rules: list[BoundRule] = []
62
+ physical = {c.name: c.physical_type.upper() for c in context.handle.schema().columns}
63
+ for left_prefixes, right_prefixes in _PREFIX_PAIRS:
64
+ for left in context.columns:
65
+ m = re.match(rf"^({'|'.join(left_prefixes)})_(.+)$", left)
66
+ if not m or left not in physical:
67
+ continue
68
+ name = m.group(2)
69
+ for right in context.columns:
70
+ r = re.match(rf"^({'|'.join(right_prefixes)})_{name}$", right)
71
+ if not r or right == left:
72
+ continue
73
+ if not (_IDENT_RE.fullmatch(left) and _IDENT_RE.fullmatch(right)):
74
+ continue
75
+ if self._same_family(physical.get(left), physical.get(right)):
76
+ rules.append(
77
+ BoundRule(
78
+ rule_id=f"{name}_range_order",
79
+ expression=f"{left} <= {right}",
80
+ left=left,
81
+ right=right,
82
+ )
83
+ )
84
+ return rules
85
+
86
+ def _same_family(self, left: str | None, right: str | None) -> bool:
87
+ if left is None or right is None:
88
+ return False
89
+ if left == right:
90
+ return True
91
+ numeric = {
92
+ "INTEGER",
93
+ "BIGINT",
94
+ "SMALLINT",
95
+ "TINYINT",
96
+ "FLOAT",
97
+ "DOUBLE",
98
+ "DECIMAL",
99
+ "HUGEINT",
100
+ }
101
+ temporal = {"DATE", "TIMESTAMP", "TIME"}
102
+ return (left in numeric and right in numeric) or (left in temporal and right in temporal)
103
+
104
+ def detect(self, context: DetectionContext) -> list[IssueCandidate]:
105
+ candidates: list[IssueCandidate] = []
106
+ for rule in self._bind_rules(context):
107
+ try:
108
+ self._evaluator.validate(rule.expression)
109
+ except ExpressionSecurityError:
110
+ # 内置规则不应失败;失败视为规则无效并跳过(不中断扫描)
111
+ continue
112
+ rows = self._load_rows(context, rule)
113
+ result = self._evaluator.evaluate(
114
+ rule.expression, [rule.left, rule.right], [r[:2] for r in rows]
115
+ )
116
+ if result.timed_out:
117
+ continue
118
+ bad_rows = [r[2] for r, ok in zip(rows, result.values, strict=True) if ok is False]
119
+ if not bad_rows:
120
+ continue
121
+ candidates.append(
122
+ make_candidate(
123
+ detector_id=self.detector_id,
124
+ detector_version=self.detector_version,
125
+ context=context,
126
+ issue_type="cross_field_violation",
127
+ columns=[rule.left, rule.right],
128
+ affected_count=len(bad_rows),
129
+ affected_rows=[str(i) for i in bad_rows[:_MAX_EXAMPLES]],
130
+ evidence=[
131
+ make_evidence(
132
+ detector_id=self.detector_id,
133
+ detector_version=self.detector_version,
134
+ evidence_type=EvidenceType.CONSTRAINT_VIOLATION,
135
+ description=(f"{len(bad_rows)} rows violate {rule.expression}"),
136
+ data={"rule_id": rule.rule_id, "expression": rule.expression},
137
+ )
138
+ ],
139
+ raw_score=len(bad_rows),
140
+ confidence=0.95,
141
+ severity=Severity.MEDIUM,
142
+ )
143
+ )
144
+ return candidates
145
+
146
+ def _load_rows(
147
+ self, context: DetectionContext, rule: BoundRule
148
+ ) -> list[tuple[object, object, int]]:
149
+ """取左右列值 + 行号(1 基,ROW_NUMBER 稳定序)。"""
150
+ ql, qr = quote_ident(rule.left), quote_ident(rule.right)
151
+ table = context.handle.sql_aggregate(
152
+ f"SELECT {ql} AS l, {qr} AS r, ROW_NUMBER() OVER () AS rn FROM data"
153
+ ).table
154
+ lefts = table.column("l").to_pylist()
155
+ rights = table.column("r").to_pylist()
156
+ rns = table.column("rn").to_pylist()
157
+ return list(zip(lefts, rights, rns, strict=True))
@@ -0,0 +1,220 @@
1
+ """跨表检测器(Step 40,integrity 维度第一个真实检测器)。
2
+
3
+ 外键完整性:主表列值必须存在于引用表对应列。对每个契约声明
4
+ 的 TableReference 计算孤儿行(主表非 NULL 但引用表无匹配的行)。
5
+
6
+ 实现说明:DetectionContext 只持有主表句柄,引用源由契约显式声明
7
+ (调用方信任域),因此本检测器自建 DuckDBExecutor 并把主表与所有
8
+ 引用表建成只读视图后执行 LEFT JOIN。支持主表/引用文件为
9
+ CSV/Parquet/JSONL/DuckDB(DuckDB 需 table 名;XLSX 引用文件 MVP
10
+ 不支持,supports 会排除)。路径与标识符一律转义,防注入。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import ClassVar
17
+
18
+ from datasentry_core.connectors.spec import DataSourceType
19
+ from datasentry_core.detectors.base import DetectionContext
20
+ from datasentry_core.detectors.common import DetectorBase, make_candidate, make_evidence
21
+ from datasentry_core.engine import DuckDBExecutor
22
+ from datasentry_core.models.contract import TableReference
23
+ from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
24
+ from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
25
+
26
+
27
+ def _sql_string_literal(value: str) -> str:
28
+ return "'" + value.replace("'", "''") + "'"
29
+
30
+
31
+ def _ident(value: str) -> str:
32
+ return '"' + value.replace('"', '""') + '"'
33
+
34
+
35
+ def _source_type_for_path(path: str) -> DataSourceType | None:
36
+ suffix = Path(path).suffix.lower()
37
+ mapping: dict[str, DataSourceType] = {
38
+ ".csv": DataSourceType.CSV,
39
+ ".tsv": DataSourceType.CSV,
40
+ ".parquet": DataSourceType.PARQUET,
41
+ ".jsonl": DataSourceType.JSONL,
42
+ ".ndjson": DataSourceType.JSONL,
43
+ ".duckdb": DataSourceType.DUCKDB,
44
+ }
45
+ return mapping.get(suffix)
46
+
47
+
48
+ class ForeignKeyViolationDetector(DetectorBase):
49
+ """外键完整性(11 章 integrity 维度):契约 references 逐条比较。
50
+
51
+ 无 references 时 supports=False,不影响单表扫描。孤儿行按行统计,
52
+ issue 细化为列级(每个主表列一条)。
53
+ """
54
+
55
+ detector_id = "foreign_key_violation"
56
+ display_name = "Foreign-Key Violation"
57
+ description = (
58
+ "Compares contract-declared cross-table references: rows whose key "
59
+ "has no matching value in the referenced table are orphaned."
60
+ )
61
+ quality_dimension = QualityDimension.INTEGRITY
62
+ capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(
63
+ supports_sql_pushdown=True,
64
+ requires_row_materialization=False,
65
+ )
66
+ default_thresholds: ClassVar[dict[str, float | int | str]] = {"max_orphan_ratio": 0.0}
67
+
68
+ def supports(self, context: DetectionContext) -> bool:
69
+ if not context.references:
70
+ return False
71
+ main_type = context.handle.source_type
72
+ if main_type not in (DataSourceType.CSV, DataSourceType.PARQUET, DataSourceType.DUCKDB):
73
+ return False
74
+ return any(
75
+ _source_type_for_path(ref.path) is not None
76
+ and not (_source_type_for_path(ref.path) == DataSourceType.DUCKDB and not ref.table)
77
+ for ref in context.references
78
+ )
79
+
80
+ def _main_view(self, context: DetectionContext, alias: str) -> str | None:
81
+ """主表 → CREATE VIEW alias AS ...;不支持的类型返回 None。"""
82
+ source_type = context.handle.source_type
83
+ path = context.handle.source_path
84
+ if path is None:
85
+ return None
86
+ if source_type == DataSourceType.CSV:
87
+ return (
88
+ f"CREATE OR REPLACE VIEW {alias} AS "
89
+ f"SELECT * FROM read_csv_auto({_sql_string_literal(str(path))})"
90
+ )
91
+ if source_type == DataSourceType.PARQUET:
92
+ return (
93
+ f"CREATE OR REPLACE VIEW {alias} AS "
94
+ f"SELECT * FROM read_parquet({_sql_string_literal(str(path))})"
95
+ )
96
+ if source_type == DataSourceType.DUCKDB:
97
+ table_name = context.handle.table_name
98
+ if table_name is None:
99
+ return None
100
+ attach = f"ATTACH {_sql_string_literal(str(path))} AS _main_db (READ_ONLY)"
101
+ return (
102
+ attach + ";" + f"CREATE OR REPLACE VIEW {alias} AS "
103
+ f"SELECT * FROM _main_db.{_ident(table_name)}"
104
+ )
105
+ return None
106
+
107
+ def _ref_view(self, executor: DuckDBExecutor, ref: TableReference, alias: str) -> bool:
108
+ """引用表 → CREATE VIEW alias AS ...;成功返回 True。"""
109
+ ref_type = _source_type_for_path(ref.path)
110
+ if ref_type is None:
111
+ return False
112
+ if ref_type == DataSourceType.DUCKDB:
113
+ if ref.table is None:
114
+ return False
115
+ qualified = (
116
+ f"{_ident(str(ref.schema_name))}.{_ident(ref.table)}"
117
+ if ref.schema_name
118
+ else _ident(ref.table)
119
+ )
120
+ executor.execute_setup(f"ATTACH {_sql_string_literal(ref.path)} AS {alias} (READ_ONLY)")
121
+ executor.execute_setup(
122
+ f"CREATE OR REPLACE VIEW {alias}_tbl AS SELECT * FROM {alias}.{qualified}"
123
+ )
124
+ return True
125
+ if ref_type == DataSourceType.CSV:
126
+ sql = f"SELECT * FROM read_csv_auto({_sql_string_literal(ref.path)})"
127
+ elif ref_type == DataSourceType.PARQUET:
128
+ sql = f"SELECT * FROM read_parquet({_sql_string_literal(ref.path)})"
129
+ else:
130
+ sql = (
131
+ "SELECT * FROM read_json_auto("
132
+ f"{_sql_string_literal(ref.path)}, format='newline_delimited')"
133
+ )
134
+ executor.execute_setup(f"CREATE OR REPLACE VIEW {alias}_tbl AS {sql}")
135
+ return True
136
+
137
+ def detect(self, context: DetectionContext) -> list[IssueCandidate]:
138
+ candidates: list[IssueCandidate] = []
139
+ main_sql = self._main_view(context, "_main")
140
+ if main_sql is None:
141
+ return candidates
142
+ executor = DuckDBExecutor()
143
+ try:
144
+ for stmt in main_sql.split(";"):
145
+ if stmt.strip():
146
+ executor.execute_setup(stmt)
147
+ for ref in context.references:
148
+ alias = f"_ref_{len(candidates)}"
149
+ if not self._ref_view(executor, ref, alias):
150
+ continue
151
+ ref_table = f"{alias}_tbl"
152
+ for main_col, ref_col in ref.columns.items():
153
+ if main_col not in context.columns:
154
+ continue
155
+ try:
156
+ row = executor.execute(
157
+ "SELECT "
158
+ "(SELECT count(*) FROM _main WHERE _main."
159
+ + _ident(main_col)
160
+ + " IS NOT NULL) AS total, count(*) AS orphan_count "
161
+ "FROM _main LEFT JOIN "
162
+ + ref_table
163
+ + " ON _main."
164
+ + _ident(main_col)
165
+ + " = "
166
+ + ref_table
167
+ + "."
168
+ + _ident(ref_col)
169
+ + " AND "
170
+ + ref_table
171
+ + "."
172
+ + _ident(ref_col)
173
+ + " IS NOT NULL WHERE _main."
174
+ + _ident(main_col)
175
+ + " IS NOT NULL AND "
176
+ + ref_table
177
+ + "."
178
+ + _ident(ref_col)
179
+ + " IS NULL"
180
+ ).to_pylist()[0]
181
+ except Exception:
182
+ continue
183
+ total = int(row["total"]) if row["total"] is not None else 0
184
+ orphan_count = (
185
+ int(row["orphan_count"]) if row["orphan_count"] is not None else 0
186
+ )
187
+ orphan_ratio = orphan_count / total if total > 0 else 0.0
188
+ if orphan_count == 0:
189
+ continue
190
+ evidence = make_evidence(
191
+ detector_id=self.detector_id,
192
+ detector_version=self.detector_version,
193
+ evidence_type=EvidenceType.CONSTRAINT_VIOLATION,
194
+ description=(f"orphan rows for {main_col} -> {ref.name}.{ref_col}"),
195
+ data={
196
+ "reference": ref.name,
197
+ "reference_column": ref_col,
198
+ "orphan_count": orphan_count,
199
+ "orphan_ratio": orphan_ratio,
200
+ "main_total_non_null": total,
201
+ },
202
+ )
203
+ candidates.append(
204
+ make_candidate(
205
+ detector_id=self.detector_id,
206
+ detector_version=self.detector_version,
207
+ context=context,
208
+ issue_type="foreign_key_violation",
209
+ columns=[main_col],
210
+ affected_count=orphan_count,
211
+ evidence=[evidence],
212
+ raw_score=min(orphan_ratio * 100.0, 100.0),
213
+ confidence=0.95,
214
+ severity=Severity.HIGH,
215
+ fpr=0.05,
216
+ )
217
+ )
218
+ finally:
219
+ executor.close()
220
+ return candidates