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.
- datasentry_core/__init__.py +111 -0
- datasentry_core/connectors/__init__.py +49 -0
- datasentry_core/connectors/base.py +108 -0
- datasentry_core/connectors/csv.py +312 -0
- datasentry_core/connectors/duckdb.py +105 -0
- datasentry_core/connectors/errors.py +19 -0
- datasentry_core/connectors/file_based.py +209 -0
- datasentry_core/connectors/jsonl.py +71 -0
- datasentry_core/connectors/parquet.py +64 -0
- datasentry_core/connectors/registry.py +55 -0
- datasentry_core/connectors/spec.py +37 -0
- datasentry_core/connectors/xlsx.py +109 -0
- datasentry_core/contracts/__init__.py +24 -0
- datasentry_core/contracts/exporters.py +330 -0
- datasentry_core/detectors/__init__.py +15 -0
- datasentry_core/detectors/base.py +120 -0
- datasentry_core/detectors/common.py +156 -0
- datasentry_core/detectors/cross_field.py +157 -0
- datasentry_core/detectors/cross_table.py +220 -0
- datasentry_core/detectors/datetime.py +343 -0
- datasentry_core/detectors/initial/__init__.py +117 -0
- datasentry_core/detectors/initial/anomaly_ml.py +204 -0
- datasentry_core/detectors/initial/categorical.py +252 -0
- datasentry_core/detectors/initial/formula.py +66 -0
- datasentry_core/detectors/initial/missing.py +150 -0
- datasentry_core/detectors/initial/numeric.py +302 -0
- datasentry_core/detectors/initial/textual.py +407 -0
- datasentry_core/detectors/initial/uniqueness.py +183 -0
- datasentry_core/detectors/missingness.py +424 -0
- datasentry_core/detectors/runner.py +228 -0
- datasentry_core/detectors/safe_eval.py +255 -0
- datasentry_core/detectors/textual_variants.py +300 -0
- datasentry_core/drift/__init__.py +8 -0
- datasentry_core/drift/engine.py +182 -0
- datasentry_core/engine/__init__.py +16 -0
- datasentry_core/engine/base.py +34 -0
- datasentry_core/engine/duckdb.py +79 -0
- datasentry_core/engine/fusion.py +193 -0
- datasentry_core/engine/profiler.py +165 -0
- datasentry_core/engine/sql_guard.py +23 -0
- datasentry_core/llm/provider.py +66 -0
- datasentry_core/models/__init__.py +118 -0
- datasentry_core/models/audit.py +46 -0
- datasentry_core/models/contract.py +89 -0
- datasentry_core/models/detector.py +66 -0
- datasentry_core/models/drift.py +47 -0
- datasentry_core/models/enums.py +126 -0
- datasentry_core/models/evidence.py +39 -0
- datasentry_core/models/feedback.py +30 -0
- datasentry_core/models/fingerprint.py +24 -0
- datasentry_core/models/issue.py +49 -0
- datasentry_core/models/llm.py +69 -0
- datasentry_core/models/profile.py +79 -0
- datasentry_core/models/quality.py +16 -0
- datasentry_core/models/repair.py +94 -0
- datasentry_core/models/rules.py +82 -0
- datasentry_core/models/scan.py +112 -0
- datasentry_core/models/validation.py +22 -0
- datasentry_core/plugins.py +91 -0
- datasentry_core/privacy/redactor.py +155 -0
- datasentry_core/repair/__init__.py +5 -0
- datasentry_core/repair/engine.py +455 -0
- datasentry_core/reporting/__init__.py +100 -0
- datasentry_core/reporting/html.py +230 -0
- datasentry_core/reporting/junit.py +80 -0
- datasentry_core/reporting/markdown.py +83 -0
- datasentry_core/reporting/sarif.py +92 -0
- datasentry_core/rules/engine.py +133 -0
- datasentry_core/scoring/__init__.py +30 -0
- datasentry_core/scoring/engine.py +138 -0
- datasentry_core/scoring/gate.py +96 -0
- datasentry_core/scoring/quality.py +137 -0
- datasentry_core/scoring/weights.py +33 -0
- datasentry_core/storage/__init__.py +15 -0
- datasentry_core/storage/paths.py +47 -0
- datasentry_core/storage/schema.py +343 -0
- datasentry_core/storage/store.py +675 -0
- datasentry_core-0.1.0.dist-info/METADATA +59 -0
- datasentry_core-0.1.0.dist-info/RECORD +80 -0
- datasentry_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""契约导出器(V1:Pandera / Great Expectations)。
|
|
2
|
+
|
|
3
|
+
纯函数式:Contract → 外部生态格式(零运行时依赖,不引入
|
|
4
|
+
pandera / great_expectations 包)。契约是可移植质量声明的
|
|
5
|
+
单一来源,导出使其落地既有数据栈。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from datasentry_core.models.contract import ColumnCheck, ColumnContract, Contract
|
|
13
|
+
from datasentry_core.models.enums import RuleType
|
|
14
|
+
from datasentry_core.models.rules import Rule
|
|
15
|
+
|
|
16
|
+
#: 语义类型 → Pandera dtype(映射表保持收敛,未知类型回退 object)
|
|
17
|
+
_PANDERA_DTYPE = {
|
|
18
|
+
"string": "pa.String()",
|
|
19
|
+
"text": "pa.String()",
|
|
20
|
+
"integer": "pa.Int64()",
|
|
21
|
+
"bigint": "pa.Int64()",
|
|
22
|
+
"float": "pa.Float64()",
|
|
23
|
+
"double": "pa.Float64()",
|
|
24
|
+
"boolean": "pa.Bool()",
|
|
25
|
+
"datetime": "pa.DateTime()",
|
|
26
|
+
"date": "pa.DateTime()",
|
|
27
|
+
"timestamp": "pa.DateTime()",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_GE_DTYPE = {
|
|
31
|
+
"string": "String",
|
|
32
|
+
"text": "String",
|
|
33
|
+
"integer": "Integer",
|
|
34
|
+
"bigint": "Integer",
|
|
35
|
+
"float": "Float",
|
|
36
|
+
"double": "Float",
|
|
37
|
+
"boolean": "Boolean",
|
|
38
|
+
"datetime": "DateTime",
|
|
39
|
+
"date": "DateTime",
|
|
40
|
+
"timestamp": "DateTime",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#: RuleType → Pandera Check 生成器(value 经 repr 嵌入代码)
|
|
44
|
+
_RULE_PANDERA: dict[str, str] = {
|
|
45
|
+
RuleType.VALUE_RANGE.value: "pa.Check.ge({min}) & pa.Check.le({max})",
|
|
46
|
+
RuleType.ALLOWED_VALUES.value: "pa.Check.isin({allowed})",
|
|
47
|
+
RuleType.REGEX.value: "pa.Check.str_matches({pattern})",
|
|
48
|
+
RuleType.NOT_NULL.value: "pa.Check.not_null()",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _pandera_checks(column: ColumnContract, name: str) -> list[str]:
|
|
53
|
+
"""列契约 + 内嵌 checks → Pandera Check 表达式列表。"""
|
|
54
|
+
checks: list[str] = []
|
|
55
|
+
if column.min is not None:
|
|
56
|
+
checks.append(f"pa.Check.ge({column.min!r}, title='{name}_min')")
|
|
57
|
+
if column.max is not None:
|
|
58
|
+
checks.append(f"pa.Check.le({column.max!r}, title='{name}_max')")
|
|
59
|
+
if column.allowed_values is not None:
|
|
60
|
+
checks.append(f"pa.Check.isin({column.allowed_values!r}, title='{name}_allowed')")
|
|
61
|
+
if column.regex is not None:
|
|
62
|
+
checks.append(f"pa.Check.str_matches({column.regex!r}, title='{name}_regex')")
|
|
63
|
+
for check in column.checks:
|
|
64
|
+
checks.extend(_pandera_column_check(check, name))
|
|
65
|
+
return checks
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pandera_column_check(check: ColumnCheck, name: str) -> list[str]:
|
|
69
|
+
"""单条 ColumnCheck → Pandera Check 表达式。"""
|
|
70
|
+
if check.type == "regex" and check.pattern:
|
|
71
|
+
return [f"pa.Check.str_matches({check.pattern!r}, title='{name}_check_regex')"]
|
|
72
|
+
if check.type == "range":
|
|
73
|
+
parts = []
|
|
74
|
+
if check.min is not None:
|
|
75
|
+
parts.append(f"pa.Check.ge({check.min!r})")
|
|
76
|
+
if check.max is not None:
|
|
77
|
+
parts.append(f"pa.Check.le({check.max!r})")
|
|
78
|
+
return parts
|
|
79
|
+
if check.type == "allowed_values" and check.allowed_values is not None:
|
|
80
|
+
return [f"pa.Check.isin({check.allowed_values!r}, title='{name}_check_allowed')"]
|
|
81
|
+
if check.type == "not_null":
|
|
82
|
+
return ["pa.Check.not_null()"]
|
|
83
|
+
if check.type == "unique":
|
|
84
|
+
return ["pa.Check.unique()"]
|
|
85
|
+
return [] # custom:不可表达,忽略
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _pandera_rule_check(rule: Rule) -> str | None:
|
|
89
|
+
"""顶层 Rule → Pandera Check 表达式;不可表达的返回 None。"""
|
|
90
|
+
if rule.type == RuleType.NOT_NULL:
|
|
91
|
+
return "pa.Check.not_null()"
|
|
92
|
+
if rule.type == RuleType.UNIQUENESS:
|
|
93
|
+
return "pa.Check.unique()"
|
|
94
|
+
if rule.when is None:
|
|
95
|
+
return None
|
|
96
|
+
cond = rule.when
|
|
97
|
+
if rule.type == RuleType.VALUE_RANGE:
|
|
98
|
+
if cond.operator == "between" and isinstance(cond.value, list) and len(cond.value) == 2:
|
|
99
|
+
return f"pa.Check.ge({cond.value[0]!r}) & pa.Check.le({cond.value[1]!r})"
|
|
100
|
+
if cond.operator in ("gte", "lte", "gt", "lt") and isinstance(cond.value, (int, float)):
|
|
101
|
+
op = {"gte": "ge", "lte": "le", "gt": "gt", "lt": "lt"}[cond.operator]
|
|
102
|
+
return f"pa.Check.{op}({cond.value!r})"
|
|
103
|
+
return None
|
|
104
|
+
if rule.type == RuleType.ALLOWED_VALUES and cond.operator == "in":
|
|
105
|
+
if isinstance(cond.value, list):
|
|
106
|
+
return f"pa.Check.isin({cond.value!r})"
|
|
107
|
+
return None
|
|
108
|
+
if rule.type == RuleType.REGEX and cond.operator == "matches" and cond.value:
|
|
109
|
+
return f"pa.Check.str_matches({str(cond.value)!r})"
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def to_pandera(contract: Contract) -> str:
|
|
114
|
+
"""Contract → Pandera DataFrameSchema 代码(字符串,用户可复制/执行)。"""
|
|
115
|
+
lines = [
|
|
116
|
+
"import pandera as pa",
|
|
117
|
+
"import pandas as pd",
|
|
118
|
+
"",
|
|
119
|
+
f"# generated by datasentry contract export from dataset: {contract.dataset.name}",
|
|
120
|
+
"# unsupported rules are listed as comments (custom/conditional/aggregate)",
|
|
121
|
+
"schema = pa.DataFrameSchema({",
|
|
122
|
+
]
|
|
123
|
+
for name, column in contract.columns.items():
|
|
124
|
+
dtype = _PANDERA_DTYPE.get(column.type, "pa.Object()")
|
|
125
|
+
args = [f" {name!r}: pa.Column("]
|
|
126
|
+
args.append(f" dtype={dtype},")
|
|
127
|
+
if not column.nullable:
|
|
128
|
+
args.append(" nullable=False,")
|
|
129
|
+
if column.unique:
|
|
130
|
+
args.append(" unique=True,")
|
|
131
|
+
checks = _pandera_checks(column, name)
|
|
132
|
+
for rule in contract.rules:
|
|
133
|
+
if name not in rule.columns:
|
|
134
|
+
continue
|
|
135
|
+
expr = _pandera_rule_check(rule)
|
|
136
|
+
if expr is not None:
|
|
137
|
+
checks.append(expr)
|
|
138
|
+
if checks:
|
|
139
|
+
args.append(" checks=[")
|
|
140
|
+
args.extend(f" {c}," for c in checks)
|
|
141
|
+
args.append(" ],")
|
|
142
|
+
args.append(" ),")
|
|
143
|
+
lines.extend(args)
|
|
144
|
+
lines.append("})")
|
|
145
|
+
unsupported = [
|
|
146
|
+
f"# unsupported rule '{r.id}' (type={r.type.value})"
|
|
147
|
+
for r in contract.rules
|
|
148
|
+
if _pandera_rule_check(r) is None
|
|
149
|
+
]
|
|
150
|
+
if unsupported:
|
|
151
|
+
lines.append("")
|
|
152
|
+
lines.extend(unsupported)
|
|
153
|
+
return "\n".join(lines) + "\n"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _ge_column_expectations(name: str, column: ColumnContract) -> list[dict[str, Any]]:
|
|
157
|
+
"""列契约 → GE expect_* 序列。"""
|
|
158
|
+
exps: list[dict[str, Any]] = []
|
|
159
|
+
if column.min is not None or column.max is not None:
|
|
160
|
+
kwargs: dict[str, Any] = {"column": name}
|
|
161
|
+
if column.min is not None:
|
|
162
|
+
kwargs["min_value"] = column.min
|
|
163
|
+
if column.max is not None:
|
|
164
|
+
kwargs["max_value"] = column.max
|
|
165
|
+
exps.append({"expectation_type": "expect_column_values_to_be_between", "kwargs": kwargs})
|
|
166
|
+
if column.allowed_values is not None:
|
|
167
|
+
exps.append(
|
|
168
|
+
{
|
|
169
|
+
"expectation_type": "expect_column_values_to_be_in_set",
|
|
170
|
+
"kwargs": {"column": name, "value_set": column.allowed_values},
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
if column.regex is not None:
|
|
174
|
+
exps.append(
|
|
175
|
+
{
|
|
176
|
+
"expectation_type": "expect_column_values_to_match_regex",
|
|
177
|
+
"kwargs": {"column": name, "regex": column.regex},
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
for check in column.checks:
|
|
181
|
+
exps.extend(_ge_column_check(name, check))
|
|
182
|
+
if not column.nullable:
|
|
183
|
+
exps.append(
|
|
184
|
+
{
|
|
185
|
+
"expectation_type": "expect_column_values_to_not_be_null",
|
|
186
|
+
"kwargs": {"column": name},
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
if column.unique:
|
|
190
|
+
exps.append(
|
|
191
|
+
{
|
|
192
|
+
"expectation_type": "expect_column_values_to_be_unique",
|
|
193
|
+
"kwargs": {"column": name},
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
return exps
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _ge_column_check(name: str, check: ColumnCheck) -> list[dict[str, Any]]:
|
|
200
|
+
"""单条 ColumnCheck → GE expect_* 序列。"""
|
|
201
|
+
if check.type == "regex" and check.pattern:
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
"expectation_type": "expect_column_values_to_match_regex",
|
|
205
|
+
"kwargs": {"column": name, "regex": check.pattern},
|
|
206
|
+
}
|
|
207
|
+
]
|
|
208
|
+
if check.type == "range":
|
|
209
|
+
if check.min is None and check.max is None:
|
|
210
|
+
return []
|
|
211
|
+
kwargs: dict[str, Any] = {"column": name}
|
|
212
|
+
if check.min is not None:
|
|
213
|
+
kwargs["min_value"] = check.min
|
|
214
|
+
if check.max is not None:
|
|
215
|
+
kwargs["max_value"] = check.max
|
|
216
|
+
return [
|
|
217
|
+
{
|
|
218
|
+
"expectation_type": "expect_column_values_to_be_between",
|
|
219
|
+
"kwargs": kwargs,
|
|
220
|
+
}
|
|
221
|
+
]
|
|
222
|
+
if check.type == "allowed_values" and check.allowed_values is not None:
|
|
223
|
+
return [
|
|
224
|
+
{
|
|
225
|
+
"expectation_type": "expect_column_values_to_be_in_set",
|
|
226
|
+
"kwargs": {"column": name, "value_set": check.allowed_values},
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
if check.type == "not_null":
|
|
230
|
+
return [
|
|
231
|
+
{
|
|
232
|
+
"expectation_type": "expect_column_values_to_not_be_null",
|
|
233
|
+
"kwargs": {"column": name},
|
|
234
|
+
}
|
|
235
|
+
]
|
|
236
|
+
if check.type == "unique":
|
|
237
|
+
return [
|
|
238
|
+
{
|
|
239
|
+
"expectation_type": "expect_column_values_to_be_unique",
|
|
240
|
+
"kwargs": {"column": name},
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _ge_rule_expectations(rule: Rule) -> list[dict[str, Any]]:
|
|
247
|
+
"""顶层 Rule → GE expect_* 序列。"""
|
|
248
|
+
if not rule.columns:
|
|
249
|
+
return []
|
|
250
|
+
name = rule.columns[0]
|
|
251
|
+
if rule.type == RuleType.NOT_NULL:
|
|
252
|
+
return [
|
|
253
|
+
{
|
|
254
|
+
"expectation_type": "expect_column_values_to_not_be_null",
|
|
255
|
+
"kwargs": {"column": name},
|
|
256
|
+
}
|
|
257
|
+
]
|
|
258
|
+
if rule.type == RuleType.UNIQUENESS:
|
|
259
|
+
return [
|
|
260
|
+
{
|
|
261
|
+
"expectation_type": "expect_column_values_to_be_unique",
|
|
262
|
+
"kwargs": {"column": name},
|
|
263
|
+
}
|
|
264
|
+
]
|
|
265
|
+
if rule.when is None:
|
|
266
|
+
return []
|
|
267
|
+
cond = rule.when
|
|
268
|
+
if rule.type == RuleType.VALUE_RANGE:
|
|
269
|
+
if cond.operator == "between" and isinstance(cond.value, list) and len(cond.value) == 2:
|
|
270
|
+
return [
|
|
271
|
+
{
|
|
272
|
+
"expectation_type": "expect_column_values_to_be_between",
|
|
273
|
+
"kwargs": {
|
|
274
|
+
"column": name,
|
|
275
|
+
"min_value": cond.value[0],
|
|
276
|
+
"max_value": cond.value[1],
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
]
|
|
280
|
+
if cond.operator in ("gte", "lte", "gt", "lt") and isinstance(cond.value, (int, float)):
|
|
281
|
+
exp_type = {
|
|
282
|
+
"gte": "expect_column_values_to_be_greater_than_or_equal_to",
|
|
283
|
+
"lte": "expect_column_values_to_be_less_than_or_equal_to",
|
|
284
|
+
"gt": "expect_column_values_to_be_greater_than",
|
|
285
|
+
"lt": "expect_column_values_to_be_less_than",
|
|
286
|
+
}[cond.operator]
|
|
287
|
+
return [{"expectation_type": exp_type, "kwargs": {"column": name, "value": cond.value}}]
|
|
288
|
+
return []
|
|
289
|
+
if rule.type == RuleType.ALLOWED_VALUES and cond.operator == "in":
|
|
290
|
+
if isinstance(cond.value, list):
|
|
291
|
+
return [
|
|
292
|
+
{
|
|
293
|
+
"expectation_type": "expect_column_values_to_be_in_set",
|
|
294
|
+
"kwargs": {"column": name, "value_set": cond.value},
|
|
295
|
+
}
|
|
296
|
+
]
|
|
297
|
+
return []
|
|
298
|
+
if rule.type == RuleType.REGEX and cond.operator == "matches" and cond.value:
|
|
299
|
+
return [
|
|
300
|
+
{
|
|
301
|
+
"expectation_type": "expect_column_values_to_match_regex",
|
|
302
|
+
"kwargs": {"column": name, "regex": str(cond.value)},
|
|
303
|
+
}
|
|
304
|
+
]
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def to_great_expectations(contract: Contract) -> dict[str, Any]:
|
|
309
|
+
"""Contract → GE ExpectationSuite 字典(完整 suite 文档)。"""
|
|
310
|
+
expectations: list[dict[str, Any]] = []
|
|
311
|
+
for name, column in contract.columns.items():
|
|
312
|
+
expectations.extend(_ge_column_expectations(name, column))
|
|
313
|
+
if column.type in _GE_DTYPE:
|
|
314
|
+
expectations.append(
|
|
315
|
+
{
|
|
316
|
+
"expectation_type": "expect_column_values_to_be_of_type",
|
|
317
|
+
"kwargs": {"column": name, "type_": _GE_DTYPE[column.type]},
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
for rule in contract.rules:
|
|
321
|
+
expectations.extend(_ge_rule_expectations(rule))
|
|
322
|
+
return {
|
|
323
|
+
"expectation_suite_name": f"{contract.dataset.name}.datasentry",
|
|
324
|
+
"expectations": expectations,
|
|
325
|
+
"meta": {
|
|
326
|
+
"datasentry_version": contract.version,
|
|
327
|
+
"dataset": contract.dataset.name,
|
|
328
|
+
"description": contract.dataset.description,
|
|
329
|
+
},
|
|
330
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""检测器包(Step 5 注册表 + Step 6 起接入确定性检测器)。"""
|
|
2
|
+
|
|
3
|
+
from datasentry_core.detectors.base import (
|
|
4
|
+
DetectionContext,
|
|
5
|
+
Detector,
|
|
6
|
+
DetectorRegistry,
|
|
7
|
+
filter_by_config,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DetectionContext",
|
|
12
|
+
"Detector",
|
|
13
|
+
"DetectorRegistry",
|
|
14
|
+
"filter_by_config",
|
|
15
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""检测器注册表(11.1):注册/查询/能力过滤/启停。
|
|
2
|
+
|
|
3
|
+
Detector 协议与规格 11 章一致;`metadata()` 为可选扩展(UI 与文档生成用)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import builtins
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import ClassVar, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from datasentry_core.connectors.base import DataHandle
|
|
14
|
+
from datasentry_core.models.contract import TableReference
|
|
15
|
+
from datasentry_core.models.detector import DetectorMeta, IssueCandidate
|
|
16
|
+
from datasentry_core.models.enums import QualityDimension
|
|
17
|
+
from datasentry_core.models.profile import DatasetProfile
|
|
18
|
+
from datasentry_core.models.scan import ScanConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DetectionContext:
|
|
22
|
+
"""一次检测的上下文:句柄 + 扫描配置 + 可选画像/采样。
|
|
23
|
+
|
|
24
|
+
懒加载字段由调度层注入(Step 7),检测器不得自行打开数据源。
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
dataset_id: str,
|
|
30
|
+
table_name: str | None,
|
|
31
|
+
columns: list[str],
|
|
32
|
+
handle: DataHandle,
|
|
33
|
+
config: ScanConfig | None = None,
|
|
34
|
+
profile: DatasetProfile | None = None,
|
|
35
|
+
sample_rows: int | None = None,
|
|
36
|
+
references: list[TableReference] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.dataset_id = dataset_id
|
|
39
|
+
self.table_name = table_name
|
|
40
|
+
self.columns = columns
|
|
41
|
+
self.handle = handle
|
|
42
|
+
self.config = config
|
|
43
|
+
self.profile = profile
|
|
44
|
+
self.sample_rows = sample_rows
|
|
45
|
+
self.references: list[TableReference] = references or []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class Detector(Protocol):
|
|
50
|
+
"""检测器协议(11 章)。"""
|
|
51
|
+
|
|
52
|
+
detector_id: ClassVar[str]
|
|
53
|
+
detector_version: ClassVar[str]
|
|
54
|
+
quality_dimension: ClassVar[QualityDimension]
|
|
55
|
+
|
|
56
|
+
def supports(self, context: DetectionContext) -> bool: ...
|
|
57
|
+
|
|
58
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]: ...
|
|
59
|
+
|
|
60
|
+
def metadata(self) -> DetectorMeta: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DetectorRegistry:
|
|
64
|
+
"""线程安全注册表:register/get/list/enable(11.1)。"""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._detectors: dict[str, Detector] = {}
|
|
68
|
+
self._enabled: set[str] = set()
|
|
69
|
+
self._lock = threading.RLock()
|
|
70
|
+
|
|
71
|
+
def register(self, detector: Detector) -> None:
|
|
72
|
+
"""注册检测器;重复 detector_id 抛 ValueError。"""
|
|
73
|
+
with self._lock:
|
|
74
|
+
if detector.detector_id in self._detectors:
|
|
75
|
+
raise ValueError(f"detector already registered: {detector.detector_id}")
|
|
76
|
+
self._detectors[detector.detector_id] = detector
|
|
77
|
+
self._enabled.add(detector.detector_id)
|
|
78
|
+
|
|
79
|
+
def get(self, detector_id: str) -> Detector:
|
|
80
|
+
with self._lock:
|
|
81
|
+
try:
|
|
82
|
+
return self._detectors[detector_id]
|
|
83
|
+
except KeyError as exc:
|
|
84
|
+
raise KeyError(f"detector not registered: {detector_id}") from exc
|
|
85
|
+
|
|
86
|
+
def list(self, capability: str | None = None) -> list[Detector]:
|
|
87
|
+
"""按注册顺序返回;capability 按 DetectorCapabilities 字段名过滤(True)。"""
|
|
88
|
+
with self._lock:
|
|
89
|
+
detectors = list(self._detectors.values())
|
|
90
|
+
if capability is None:
|
|
91
|
+
return detectors
|
|
92
|
+
return [d for d in detectors if getattr(d.metadata().capabilities, capability, False)]
|
|
93
|
+
|
|
94
|
+
def enable(self, detector_id: str, enabled: bool) -> None:
|
|
95
|
+
with self._lock:
|
|
96
|
+
if detector_id not in self._detectors:
|
|
97
|
+
raise KeyError(f"detector not registered: {detector_id}")
|
|
98
|
+
if enabled:
|
|
99
|
+
self._enabled.add(detector_id)
|
|
100
|
+
else:
|
|
101
|
+
self._enabled.discard(detector_id)
|
|
102
|
+
|
|
103
|
+
def is_enabled(self, detector_id: str) -> bool:
|
|
104
|
+
with self._lock:
|
|
105
|
+
return detector_id in self._enabled
|
|
106
|
+
|
|
107
|
+
def list_active(self) -> builtins.list[Detector]:
|
|
108
|
+
"""启用的检测器(按注册顺序);ScanConfig.detectors 过滤在调度层做。"""
|
|
109
|
+
with self._lock:
|
|
110
|
+
return [d for d in self._detectors.values() if d.detector_id in self._enabled]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def filter_by_config(
|
|
114
|
+
detectors: Sequence[Detector], config_detectors: list[str] | None
|
|
115
|
+
) -> list[Detector]:
|
|
116
|
+
"""ScanConfig.detectors 白名单过滤(None = 全部启用)。"""
|
|
117
|
+
if config_detectors is None:
|
|
118
|
+
return list(detectors)
|
|
119
|
+
selected = set(config_detectors)
|
|
120
|
+
return [d for d in detectors if d.detector_id in selected]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""确定性检测器公共设施:基类、SQL 工具、证据/候选构造(Step 6)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from datasentry_core.detectors.base import DetectionContext
|
|
9
|
+
from datasentry_core.models.detector import DetectorCapabilities, DetectorMeta, IssueCandidate
|
|
10
|
+
from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
|
|
11
|
+
from datasentry_core.models.evidence import Evidence
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def quote_ident(column: str) -> str:
|
|
15
|
+
"""SQL 标识符引号转义。"""
|
|
16
|
+
return '"' + column.replace('"', '""') + '"'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def quote_literal(value: str) -> str:
|
|
20
|
+
"""SQL 单引号字面量转义。"""
|
|
21
|
+
return "'" + value.replace("'", "''") + "'"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def quote_re(pattern: str) -> str:
|
|
25
|
+
"""正则字面量(SQL 单引号转义)。"""
|
|
26
|
+
return quote_literal(pattern)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_evidence(
|
|
30
|
+
detector_id: str,
|
|
31
|
+
detector_version: str,
|
|
32
|
+
evidence_type: EvidenceType,
|
|
33
|
+
description: str,
|
|
34
|
+
data: dict[str, Any] | None = None,
|
|
35
|
+
confidence: float = 1.0,
|
|
36
|
+
) -> Evidence:
|
|
37
|
+
return Evidence(
|
|
38
|
+
evidence_id=f"ev_{uuid.uuid4().hex[:12]}",
|
|
39
|
+
evidence_type=evidence_type,
|
|
40
|
+
detector_id=detector_id,
|
|
41
|
+
detector_version=detector_version,
|
|
42
|
+
description=description,
|
|
43
|
+
data=data or {},
|
|
44
|
+
confidence=confidence,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_candidate(
|
|
49
|
+
detector_id: str,
|
|
50
|
+
detector_version: str,
|
|
51
|
+
context: DetectionContext,
|
|
52
|
+
issue_type: str,
|
|
53
|
+
columns: list[str],
|
|
54
|
+
affected_count: int,
|
|
55
|
+
evidence: list[Evidence],
|
|
56
|
+
raw_score: float,
|
|
57
|
+
confidence: float,
|
|
58
|
+
severity: Severity,
|
|
59
|
+
fpr: float = 0.1,
|
|
60
|
+
affected_rows: list[str] | None = None,
|
|
61
|
+
) -> IssueCandidate:
|
|
62
|
+
return IssueCandidate(
|
|
63
|
+
issue_type=issue_type,
|
|
64
|
+
detector_id=detector_id,
|
|
65
|
+
detector_version=detector_version,
|
|
66
|
+
dataset_id=context.dataset_id,
|
|
67
|
+
table_name=context.table_name,
|
|
68
|
+
columns=columns,
|
|
69
|
+
affected_rows=affected_rows,
|
|
70
|
+
affected_count=affected_count,
|
|
71
|
+
evidence=evidence,
|
|
72
|
+
raw_score=raw_score,
|
|
73
|
+
confidence=confidence,
|
|
74
|
+
estimated_false_positive_risk=fpr,
|
|
75
|
+
suggested_severity=severity.value,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_STRING_TYPES = frozenset({"VARCHAR", "CHAR", "TEXT", "BPCHAR"})
|
|
80
|
+
# TIME 列不与 DATE/TIMESTAMP 互比(类型错误),MVP 不使用
|
|
81
|
+
_TEMPORAL_TYPES = frozenset({"DATE", "TIMESTAMP", "TIMESTAMPTZ"})
|
|
82
|
+
_NUMERIC_TYPES = frozenset(
|
|
83
|
+
{
|
|
84
|
+
"TINYINT",
|
|
85
|
+
"SMALLINT",
|
|
86
|
+
"INTEGER",
|
|
87
|
+
"BIGINT",
|
|
88
|
+
"HUGEINT",
|
|
89
|
+
"UTINYINT",
|
|
90
|
+
"USMALLINT",
|
|
91
|
+
"UINTEGER",
|
|
92
|
+
"UBIGINT",
|
|
93
|
+
"UHUGEINT",
|
|
94
|
+
"FLOAT",
|
|
95
|
+
"REAL",
|
|
96
|
+
"DOUBLE",
|
|
97
|
+
"DECIMAL",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def string_columns(context: DetectionContext) -> list[str]:
|
|
103
|
+
"""字符串列名(按 schema 顺序)。"""
|
|
104
|
+
return [
|
|
105
|
+
c.name for c in context.handle.schema().columns if c.physical_type.upper() in _STRING_TYPES
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def numeric_columns(context: DetectionContext) -> list[str]:
|
|
110
|
+
"""数值列名(按 schema 顺序)。"""
|
|
111
|
+
return [
|
|
112
|
+
c.name for c in context.handle.schema().columns if c.physical_type.upper() in _NUMERIC_TYPES
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def datetime_columns(context: DetectionContext) -> list[str]:
|
|
117
|
+
"""日期时间列名(物理类型 DATE/TIMESTAMP/TIME,按 schema 顺序)。"""
|
|
118
|
+
return [
|
|
119
|
+
c.name
|
|
120
|
+
for c in context.handle.schema().columns
|
|
121
|
+
if c.physical_type.upper() in _TEMPORAL_TYPES
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class DetectorBase:
|
|
126
|
+
"""检测器基类:类属性声明元数据,metadata() 统一构建。"""
|
|
127
|
+
|
|
128
|
+
detector_id: ClassVar[str] = ""
|
|
129
|
+
detector_version: ClassVar[str] = "1.0.0"
|
|
130
|
+
display_name: ClassVar[str] = ""
|
|
131
|
+
description: ClassVar[str] = ""
|
|
132
|
+
quality_dimension: ClassVar[QualityDimension] = QualityDimension.VALIDITY
|
|
133
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities()
|
|
134
|
+
needs_llm: ClassVar[bool] = False
|
|
135
|
+
requires_reference: ClassVar[bool] = False
|
|
136
|
+
experimental: ClassVar[bool] = False
|
|
137
|
+
default_thresholds: ClassVar[dict[str, float | int | str]] = {}
|
|
138
|
+
|
|
139
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
140
|
+
return bool(context.columns)
|
|
141
|
+
|
|
142
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
143
|
+
raise NotImplementedError
|
|
144
|
+
|
|
145
|
+
def metadata(self) -> DetectorMeta:
|
|
146
|
+
return DetectorMeta(
|
|
147
|
+
detector_id=self.detector_id,
|
|
148
|
+
display_name=self.display_name,
|
|
149
|
+
description=self.description,
|
|
150
|
+
quality_dimension=self.quality_dimension,
|
|
151
|
+
capabilities=self.capabilities,
|
|
152
|
+
default_thresholds=dict(self.default_thresholds),
|
|
153
|
+
needs_llm=self.needs_llm,
|
|
154
|
+
requires_reference=self.requires_reference,
|
|
155
|
+
experimental=self.experimental,
|
|
156
|
+
)
|