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,111 @@
|
|
|
1
|
+
"""DataSentry AI core package."""
|
|
2
|
+
|
|
3
|
+
from datasentry_core.models import (
|
|
4
|
+
AIExplanation,
|
|
5
|
+
AuditEvent,
|
|
6
|
+
BusinessCriticality,
|
|
7
|
+
CauseHypothesis,
|
|
8
|
+
ColumnCheck,
|
|
9
|
+
ColumnContract,
|
|
10
|
+
ColumnDrift,
|
|
11
|
+
ColumnProfile,
|
|
12
|
+
Condition,
|
|
13
|
+
Contract,
|
|
14
|
+
DatasetContract,
|
|
15
|
+
DatasetFingerprint,
|
|
16
|
+
DatasetProfile,
|
|
17
|
+
DetectorRun,
|
|
18
|
+
DriftReport,
|
|
19
|
+
Evidence,
|
|
20
|
+
EvidenceProvenance,
|
|
21
|
+
EvidenceType,
|
|
22
|
+
FeedbackEntry,
|
|
23
|
+
Issue,
|
|
24
|
+
IssueStatus,
|
|
25
|
+
LLMInvocation,
|
|
26
|
+
LLMUsageSummary,
|
|
27
|
+
MaskConfig,
|
|
28
|
+
QualityDimension,
|
|
29
|
+
QualityGate,
|
|
30
|
+
QualityScore,
|
|
31
|
+
RecommendedAction,
|
|
32
|
+
RepairOperation,
|
|
33
|
+
RepairOperationRecord,
|
|
34
|
+
RepairPreview,
|
|
35
|
+
RepairProposal,
|
|
36
|
+
RepairProposalStatus,
|
|
37
|
+
RepairRun,
|
|
38
|
+
RepairRunStatus,
|
|
39
|
+
ReproducibilityInfo,
|
|
40
|
+
RiskLevel,
|
|
41
|
+
RowBeforeAfter,
|
|
42
|
+
Rule,
|
|
43
|
+
RuleCandidate,
|
|
44
|
+
RulePreflightReport,
|
|
45
|
+
RuleType,
|
|
46
|
+
SamplingConfig,
|
|
47
|
+
SamplingInfo,
|
|
48
|
+
ScanConfig,
|
|
49
|
+
ScanRun,
|
|
50
|
+
SchemaChange,
|
|
51
|
+
SemanticEvidence,
|
|
52
|
+
SemanticProfile,
|
|
53
|
+
Severity,
|
|
54
|
+
ValidationResult,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"AIExplanation",
|
|
59
|
+
"AuditEvent",
|
|
60
|
+
"BusinessCriticality",
|
|
61
|
+
"CauseHypothesis",
|
|
62
|
+
"ColumnCheck",
|
|
63
|
+
"ColumnContract",
|
|
64
|
+
"ColumnDrift",
|
|
65
|
+
"ColumnProfile",
|
|
66
|
+
"Condition",
|
|
67
|
+
"Contract",
|
|
68
|
+
"DatasetContract",
|
|
69
|
+
"DatasetFingerprint",
|
|
70
|
+
"DatasetProfile",
|
|
71
|
+
"DetectorRun",
|
|
72
|
+
"DriftReport",
|
|
73
|
+
"Evidence",
|
|
74
|
+
"EvidenceProvenance",
|
|
75
|
+
"EvidenceType",
|
|
76
|
+
"FeedbackEntry",
|
|
77
|
+
"Issue",
|
|
78
|
+
"IssueStatus",
|
|
79
|
+
"LLMInvocation",
|
|
80
|
+
"LLMUsageSummary",
|
|
81
|
+
"MaskConfig",
|
|
82
|
+
"QualityDimension",
|
|
83
|
+
"QualityGate",
|
|
84
|
+
"QualityScore",
|
|
85
|
+
"RecommendedAction",
|
|
86
|
+
"RepairOperation",
|
|
87
|
+
"RepairOperationRecord",
|
|
88
|
+
"RepairPreview",
|
|
89
|
+
"RepairProposal",
|
|
90
|
+
"RepairProposalStatus",
|
|
91
|
+
"RepairRun",
|
|
92
|
+
"RepairRunStatus",
|
|
93
|
+
"ReproducibilityInfo",
|
|
94
|
+
"RiskLevel",
|
|
95
|
+
"RowBeforeAfter",
|
|
96
|
+
"Rule",
|
|
97
|
+
"RuleCandidate",
|
|
98
|
+
"RulePreflightReport",
|
|
99
|
+
"RuleType",
|
|
100
|
+
"SamplingConfig",
|
|
101
|
+
"SamplingInfo",
|
|
102
|
+
"ScanConfig",
|
|
103
|
+
"ScanRun",
|
|
104
|
+
"SchemaChange",
|
|
105
|
+
"SemanticEvidence",
|
|
106
|
+
"SemanticProfile",
|
|
107
|
+
"Severity",
|
|
108
|
+
"ValidationResult",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""连接器包:数据源读取抽象(49.1/四十一 Step 2)。"""
|
|
2
|
+
|
|
3
|
+
from datasentry_core.connectors.base import (
|
|
4
|
+
ColumnInfo,
|
|
5
|
+
DataConnector,
|
|
6
|
+
DataHandle,
|
|
7
|
+
FingerprintMode,
|
|
8
|
+
FrameBatch,
|
|
9
|
+
LoadWarning,
|
|
10
|
+
SamplingMethod,
|
|
11
|
+
SchemaInfo,
|
|
12
|
+
)
|
|
13
|
+
from datasentry_core.connectors.csv import CsvConnector
|
|
14
|
+
from datasentry_core.connectors.duckdb import DuckdbConnector
|
|
15
|
+
from datasentry_core.connectors.errors import (
|
|
16
|
+
ConnectorError,
|
|
17
|
+
DataSourceNotFoundError,
|
|
18
|
+
UnsafeSqlError,
|
|
19
|
+
UnsupportedFormatError,
|
|
20
|
+
)
|
|
21
|
+
from datasentry_core.connectors.jsonl import JsonlConnector
|
|
22
|
+
from datasentry_core.connectors.parquet import ParquetConnector
|
|
23
|
+
from datasentry_core.connectors.registry import ConnectorRegistry, default_registry
|
|
24
|
+
from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
|
|
25
|
+
from datasentry_core.connectors.xlsx import XlsxConnector
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ColumnInfo",
|
|
29
|
+
"ConnectorError",
|
|
30
|
+
"ConnectorRegistry",
|
|
31
|
+
"CsvConnector",
|
|
32
|
+
"DataConnector",
|
|
33
|
+
"DataHandle",
|
|
34
|
+
"DataSourceNotFoundError",
|
|
35
|
+
"DataSourceSpec",
|
|
36
|
+
"DataSourceType",
|
|
37
|
+
"DuckdbConnector",
|
|
38
|
+
"FingerprintMode",
|
|
39
|
+
"FrameBatch",
|
|
40
|
+
"JsonlConnector",
|
|
41
|
+
"LoadWarning",
|
|
42
|
+
"ParquetConnector",
|
|
43
|
+
"SamplingMethod",
|
|
44
|
+
"SchemaInfo",
|
|
45
|
+
"UnsafeSqlError",
|
|
46
|
+
"UnsupportedFormatError",
|
|
47
|
+
"XlsxConnector",
|
|
48
|
+
"default_registry",
|
|
49
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""连接器协议与共享类型(49.1)。
|
|
2
|
+
|
|
3
|
+
统一返回 PyArrow 帧(FrameBatch),与 DuckDB 零拷贝互操作(ADR-005)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Literal, Protocol
|
|
11
|
+
|
|
12
|
+
import pyarrow as pa
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
|
|
16
|
+
from datasentry_core.models.enums import Severity
|
|
17
|
+
from datasentry_core.models.fingerprint import DatasetFingerprint
|
|
18
|
+
|
|
19
|
+
SamplingMethod = Literal[
|
|
20
|
+
"random", "stratified", "reservoir", "time_based", "rare_oversampling", "none"
|
|
21
|
+
]
|
|
22
|
+
FingerprintMode = Literal["full", "sampled", "metadata_only"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FrameBatch(BaseModel):
|
|
26
|
+
"""一批数据(49.1):PyArrow Table + 来源与位置元信息。"""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
29
|
+
|
|
30
|
+
source: DataSourceSpec
|
|
31
|
+
table: pa.Table
|
|
32
|
+
batch_index: int = 0
|
|
33
|
+
row_offset: int = 0
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def row_count(self) -> int:
|
|
37
|
+
"""本批行数。"""
|
|
38
|
+
rows = self.table.num_rows
|
|
39
|
+
assert isinstance(rows, int)
|
|
40
|
+
return rows
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def column_names(self) -> list[str]:
|
|
44
|
+
"""本批列名。"""
|
|
45
|
+
return list(self.table.column_names)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ColumnInfo(BaseModel):
|
|
49
|
+
"""单列信息(49.1 describe 输出)。"""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
physical_type: str
|
|
53
|
+
nullable: bool = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SchemaInfo(BaseModel):
|
|
57
|
+
"""数据源 Schema 描述。"""
|
|
58
|
+
|
|
59
|
+
columns: list[ColumnInfo] = Field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def column_names(self) -> list[str]:
|
|
63
|
+
"""列名列表。"""
|
|
64
|
+
return [c.name for c in self.columns]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LoadWarning(BaseModel):
|
|
68
|
+
"""加载期警告(如 CSV 公式注入标记),value_preview 必须截断脱敏。"""
|
|
69
|
+
|
|
70
|
+
row: int = Field(ge=0)
|
|
71
|
+
column: str
|
|
72
|
+
message: str
|
|
73
|
+
severity: Severity = Severity.MEDIUM
|
|
74
|
+
value_preview: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DataHandle(Protocol):
|
|
78
|
+
"""打开后的数据句柄(49.1)。连接器不持有分析逻辑。"""
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def source_type(self) -> DataSourceType: ...
|
|
82
|
+
@property
|
|
83
|
+
def source_path(self) -> Path | None: ...
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def table_name(self) -> str | None:
|
|
87
|
+
"""DuckDB 等需要表名的数据源返回表名,其他返回 None(Step 40)。"""
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def schema(self) -> SchemaInfo: ...
|
|
91
|
+
def read_batches(self, batch_size: int = 65536) -> Iterator[FrameBatch]: ...
|
|
92
|
+
def read_sample(self, n: int, method: SamplingMethod = "random") -> FrameBatch: ...
|
|
93
|
+
def sql_aggregate(self, sql: str, params: dict[str, Any] | None = None) -> FrameBatch: ...
|
|
94
|
+
def count_rows(self) -> int: ...
|
|
95
|
+
def fingerprint(self, mode: FingerprintMode = "full") -> DatasetFingerprint: ...
|
|
96
|
+
def warnings(self) -> list[LoadWarning]: ...
|
|
97
|
+
def close(self) -> None: ...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DataConnector(Protocol):
|
|
101
|
+
"""数据源连接器(49.1)。"""
|
|
102
|
+
|
|
103
|
+
connector_id: str
|
|
104
|
+
display_name: str
|
|
105
|
+
|
|
106
|
+
def supports(self, source: DataSourceSpec) -> bool: ...
|
|
107
|
+
def open(self, source: DataSourceSpec) -> DataHandle: ...
|
|
108
|
+
def close(self) -> None: ...
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""CSV 连接器(49.3/四十五 Issue #6)。
|
|
2
|
+
|
|
3
|
+
能力:编码探测(charset-normalizer)、分隔符嗅探(csv.Sniffer)、BOM 处理、
|
|
4
|
+
公式注入标记(11.7 suspicious_formula_injection 的加载期预警)、
|
|
5
|
+
只读聚合(ADR-005,SQL 经 engine.sql_guard 守卫)、三档指纹(19.1)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pyarrow as pa
|
|
17
|
+
import pyarrow.compute as pc
|
|
18
|
+
import pyarrow.csv as pa_csv
|
|
19
|
+
from charset_normalizer import from_path as charset_from_path
|
|
20
|
+
|
|
21
|
+
from datasentry_core.connectors.base import (
|
|
22
|
+
ColumnInfo,
|
|
23
|
+
DataHandle,
|
|
24
|
+
FingerprintMode,
|
|
25
|
+
FrameBatch,
|
|
26
|
+
LoadWarning,
|
|
27
|
+
SamplingMethod,
|
|
28
|
+
SchemaInfo,
|
|
29
|
+
)
|
|
30
|
+
from datasentry_core.connectors.errors import DataSourceNotFoundError
|
|
31
|
+
from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
|
|
32
|
+
from datasentry_core.engine import DuckDBExecutor
|
|
33
|
+
from datasentry_core.models.enums import Severity
|
|
34
|
+
from datasentry_core.models.fingerprint import DatasetFingerprint
|
|
35
|
+
|
|
36
|
+
FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
|
|
37
|
+
_WARNING_CAP = 100
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _detect_encoding(path: Path, override: str | None = None) -> str:
|
|
41
|
+
"""编码探测:用户指定 > BOM 探测 > 检测器 > utf-8 回退。
|
|
42
|
+
检测器对短文件可能误判,因此 options["encoding"] 是权威通道(测试锁定该行为)。"""
|
|
43
|
+
if override:
|
|
44
|
+
return override
|
|
45
|
+
try:
|
|
46
|
+
raw = path.read_bytes()[:65536]
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
raise DataSourceNotFoundError(f"cannot read {path}: {exc}") from exc
|
|
49
|
+
if raw.startswith(b"\xef\xbb\xbf"):
|
|
50
|
+
return "utf-8"
|
|
51
|
+
try:
|
|
52
|
+
raw.decode("utf-8")
|
|
53
|
+
return "utf-8"
|
|
54
|
+
except UnicodeDecodeError:
|
|
55
|
+
pass
|
|
56
|
+
try:
|
|
57
|
+
result = charset_from_path(path).best()
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
raise DataSourceNotFoundError(f"cannot read {path}: {exc}") from exc
|
|
60
|
+
if result is None:
|
|
61
|
+
return "utf-8"
|
|
62
|
+
return result.encoding or "utf-8"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sniff_delimiter(path: Path, encoding: str) -> str:
|
|
66
|
+
"""基于文件头部样本嗅探分隔符,失败回退逗号。"""
|
|
67
|
+
try:
|
|
68
|
+
with path.open("r", encoding=encoding, errors="replace") as fh:
|
|
69
|
+
head = fh.read(8192)
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
raise DataSourceNotFoundError(f"cannot read {path}: {exc}") from exc
|
|
72
|
+
try:
|
|
73
|
+
dialect = csv.Sniffer().sniff(head, delimiters=",;\t|")
|
|
74
|
+
return dialect.delimiter
|
|
75
|
+
except Exception:
|
|
76
|
+
return ","
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _schema_hash(column_signature: list[tuple[str, str]]) -> str:
|
|
80
|
+
"""schema_hash = 列签名规范 JSON 的 SHA-256(19.1)。"""
|
|
81
|
+
canonical = json.dumps(column_signature, sort_keys=True, ensure_ascii=True)
|
|
82
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _sql_string_literal(value: str) -> str:
|
|
86
|
+
"""SQL 单引号字面量(视图创建时内联路径/分隔符用)。"""
|
|
87
|
+
return "'" + value.replace("'", "''") + "'"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CsvDataHandle:
|
|
91
|
+
"""CSV 数据句柄:pyarrow 流式读取 + duckdb 只读聚合。"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, spec: DataSourceSpec) -> None:
|
|
94
|
+
if spec.path is None:
|
|
95
|
+
raise DataSourceNotFoundError("csv source requires a path")
|
|
96
|
+
self._spec = spec
|
|
97
|
+
self._path = spec.path
|
|
98
|
+
self._encoding = _detect_encoding(spec.path, override=spec.options.get("encoding"))
|
|
99
|
+
self._delimiter = _sniff_delimiter(spec.path, self._encoding)
|
|
100
|
+
self._executor = DuckDBExecutor()
|
|
101
|
+
self._view_ready = False
|
|
102
|
+
self._schema_info: SchemaInfo | None = None
|
|
103
|
+
self._warnings: list[LoadWarning] | None = None
|
|
104
|
+
self._closed = False
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def source_type(self) -> DataSourceType:
|
|
108
|
+
return self._spec.source_type
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def source_path(self) -> Path | None:
|
|
112
|
+
return self._path
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def table_name(self) -> str | None:
|
|
116
|
+
"""CSV 无表名语义(Step 40 协议成员)。"""
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def _ensure_view(self) -> None:
|
|
120
|
+
"""注册只读 view。注意:duckdb 默认将空字符串视为 NULL,
|
|
121
|
+
pyarrow 读取路径保留空字符串——两路径语义差异在检测器层统一(Step 6 文档约定)。"""
|
|
122
|
+
if self._view_ready:
|
|
123
|
+
return
|
|
124
|
+
if self._encoding in ("utf-8", "utf-8-sig", "ascii"):
|
|
125
|
+
self._executor.execute_setup(
|
|
126
|
+
"CREATE OR REPLACE VIEW data AS "
|
|
127
|
+
"SELECT * FROM read_csv_auto("
|
|
128
|
+
f"{_sql_string_literal(str(self._path))}, "
|
|
129
|
+
"header=true, "
|
|
130
|
+
"strict_mode=false, "
|
|
131
|
+
"quote='\"', "
|
|
132
|
+
f"delim={_sql_string_literal(self._delimiter)})"
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
table = pa_csv.read_csv(
|
|
136
|
+
self._path,
|
|
137
|
+
read_options=pa_csv.ReadOptions(encoding=self._encoding),
|
|
138
|
+
parse_options=pa_csv.ParseOptions(delimiter=self._delimiter),
|
|
139
|
+
)
|
|
140
|
+
self._executor.register("data", table)
|
|
141
|
+
self._view_ready = True
|
|
142
|
+
|
|
143
|
+
def _check_open(self) -> None:
|
|
144
|
+
if self._closed:
|
|
145
|
+
raise ValueError("handle is closed")
|
|
146
|
+
|
|
147
|
+
def schema(self) -> SchemaInfo:
|
|
148
|
+
self._check_open()
|
|
149
|
+
if self._schema_info is None:
|
|
150
|
+
self._ensure_view()
|
|
151
|
+
table = self._executor.execute("DESCRIBE SELECT * FROM data")
|
|
152
|
+
names = table.column(0).to_pylist()
|
|
153
|
+
types = table.column(1).to_pylist()
|
|
154
|
+
self._schema_info = SchemaInfo(
|
|
155
|
+
columns=[
|
|
156
|
+
ColumnInfo(name=str(name), physical_type=str(typ))
|
|
157
|
+
for name, typ in zip(names, types, strict=True)
|
|
158
|
+
]
|
|
159
|
+
)
|
|
160
|
+
return self._schema_info
|
|
161
|
+
|
|
162
|
+
def read_batches(self, batch_size: int = 65536) -> Iterator[FrameBatch]:
|
|
163
|
+
"""流式读取(pyarrow open_csv,block_size 按 batch_size 缩放)。"""
|
|
164
|
+
self._check_open()
|
|
165
|
+
if batch_size < 1:
|
|
166
|
+
raise ValueError("batch_size must be >= 1")
|
|
167
|
+
read_opts = pa_csv.ReadOptions(
|
|
168
|
+
encoding=self._encoding,
|
|
169
|
+
block_size=max(batch_size * 16, 65536),
|
|
170
|
+
)
|
|
171
|
+
parse_opts = pa_csv.ParseOptions(delimiter=self._delimiter)
|
|
172
|
+
row_offset = 0
|
|
173
|
+
with pa_csv.open_csv(
|
|
174
|
+
self._path, read_options=read_opts, parse_options=parse_opts
|
|
175
|
+
) as reader:
|
|
176
|
+
for idx, rb in enumerate(reader):
|
|
177
|
+
table = pa.Table.from_batches([rb])
|
|
178
|
+
yield FrameBatch(
|
|
179
|
+
source=self._spec,
|
|
180
|
+
table=table,
|
|
181
|
+
batch_index=idx,
|
|
182
|
+
row_offset=row_offset,
|
|
183
|
+
)
|
|
184
|
+
row_offset += table.num_rows
|
|
185
|
+
|
|
186
|
+
def read_sample(self, n: int, method: SamplingMethod = "random") -> FrameBatch:
|
|
187
|
+
"""抽样读取:reservoir(REPEATABLE seed) 保证可复现(20.3)。"""
|
|
188
|
+
self._check_open()
|
|
189
|
+
if n < 1:
|
|
190
|
+
raise ValueError("n must be >= 1")
|
|
191
|
+
self._ensure_view()
|
|
192
|
+
seed = int(self._spec.options.get("seed", 42))
|
|
193
|
+
if method == "none":
|
|
194
|
+
sql = "SELECT * FROM data LIMIT ?"
|
|
195
|
+
params: list[object] = [n]
|
|
196
|
+
elif method == "time_based":
|
|
197
|
+
column = self._spec.options.get("time_column")
|
|
198
|
+
if not isinstance(column, str) or not column:
|
|
199
|
+
method = "reservoir"
|
|
200
|
+
sql = f"SELECT * FROM data USING SAMPLE reservoir({n} ROWS) REPEATABLE ({seed})"
|
|
201
|
+
params = []
|
|
202
|
+
else:
|
|
203
|
+
sql = f'SELECT * FROM data ORDER BY "{column}" LIMIT ?'
|
|
204
|
+
params = [n]
|
|
205
|
+
else:
|
|
206
|
+
# SAMPLE 子句不支持预编译参数,n/seed 均为代码内整数(已校验),安全内联
|
|
207
|
+
sql = f"SELECT * FROM data USING SAMPLE reservoir({n} ROWS) REPEATABLE ({seed})"
|
|
208
|
+
params = []
|
|
209
|
+
table = self._executor.execute(sql, params)
|
|
210
|
+
return FrameBatch(source=self._spec, table=table)
|
|
211
|
+
|
|
212
|
+
def sql_aggregate(self, sql: str, params: dict[str, object] | None = None) -> FrameBatch:
|
|
213
|
+
"""只读聚合(DuckDB pushdown)。SQL 必须通过 engine.sql_guard 只读白名单。"""
|
|
214
|
+
self._check_open()
|
|
215
|
+
self._ensure_view()
|
|
216
|
+
table = self._executor.execute(sql, params)
|
|
217
|
+
return FrameBatch(source=self._spec, table=table)
|
|
218
|
+
|
|
219
|
+
def count_rows(self) -> int:
|
|
220
|
+
self._check_open()
|
|
221
|
+
self._ensure_view()
|
|
222
|
+
value = self._executor.execute("SELECT count(*) FROM data").column(0).to_pylist()[0]
|
|
223
|
+
assert isinstance(value, int)
|
|
224
|
+
return value
|
|
225
|
+
|
|
226
|
+
def fingerprint(self, mode: FingerprintMode = "full") -> DatasetFingerprint:
|
|
227
|
+
self._check_open()
|
|
228
|
+
signature = [(col.name, col.physical_type) for col in self.schema().columns]
|
|
229
|
+
row_count = self.count_rows()
|
|
230
|
+
file_sha256: str | None = None
|
|
231
|
+
content_sample_hash: str | None = None
|
|
232
|
+
if mode == "full":
|
|
233
|
+
file_sha256 = _file_sha256(self._path)
|
|
234
|
+
elif mode == "sampled":
|
|
235
|
+
head = self._executor.execute("SELECT * FROM data LIMIT 1000")
|
|
236
|
+
rand = self._executor.execute(
|
|
237
|
+
"SELECT * FROM data USING SAMPLE reservoir(100000) REPEATABLE (42)"
|
|
238
|
+
)
|
|
239
|
+
digest = hashlib.sha256()
|
|
240
|
+
digest.update(str(head.to_pylist()).encode("utf-8"))
|
|
241
|
+
digest.update(str(rand.to_pylist()).encode("utf-8"))
|
|
242
|
+
content_sample_hash = digest.hexdigest()
|
|
243
|
+
return DatasetFingerprint(
|
|
244
|
+
dataset_id=self._spec.options.get("dataset_id", ""),
|
|
245
|
+
fingerprint_type=mode,
|
|
246
|
+
file_sha256=file_sha256,
|
|
247
|
+
schema_hash=_schema_hash(signature),
|
|
248
|
+
row_count=row_count,
|
|
249
|
+
column_count=len(signature),
|
|
250
|
+
column_signature=signature,
|
|
251
|
+
content_sample_hash=content_sample_hash,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def warnings(self) -> list[LoadWarning]:
|
|
255
|
+
"""公式注入标记(11.7):向量化扫描字符串列,前 _WARNING_CAP 条。"""
|
|
256
|
+
self._check_open()
|
|
257
|
+
if self._warnings is not None:
|
|
258
|
+
return list(self._warnings)
|
|
259
|
+
warnings: list[LoadWarning] = []
|
|
260
|
+
for batch in self.read_batches():
|
|
261
|
+
for col_name in batch.table.column_names:
|
|
262
|
+
col = batch.table[col_name]
|
|
263
|
+
if not pa.types.is_string(col.type) or pa.types.is_null(col.type):
|
|
264
|
+
continue
|
|
265
|
+
for prefix in FORMULA_INJECTION_PREFIXES:
|
|
266
|
+
mask = pc.starts_with(col, pattern=prefix)
|
|
267
|
+
indices = pc.indices_nonzero(mask)
|
|
268
|
+
for row in indices.to_pylist():
|
|
269
|
+
value = col[row].as_py() or ""
|
|
270
|
+
warnings.append(
|
|
271
|
+
LoadWarning(
|
|
272
|
+
row=batch.row_offset + int(row),
|
|
273
|
+
column=col_name,
|
|
274
|
+
message=f"value starts with formula-injection prefix {prefix!r}",
|
|
275
|
+
severity=Severity.LOW,
|
|
276
|
+
value_preview=value[:20],
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
if len(warnings) >= _WARNING_CAP:
|
|
280
|
+
self._warnings = warnings
|
|
281
|
+
return warnings
|
|
282
|
+
self._warnings = warnings
|
|
283
|
+
return list(warnings)
|
|
284
|
+
|
|
285
|
+
def close(self) -> None:
|
|
286
|
+
self._executor.close()
|
|
287
|
+
self._closed = True
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _file_sha256(path: Path) -> str:
|
|
291
|
+
"""流式文件 SHA-256(≤1GB 全量,大文件走 sampled 档)。"""
|
|
292
|
+
digest = hashlib.sha256()
|
|
293
|
+
with path.open("rb") as fh:
|
|
294
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
295
|
+
digest.update(chunk)
|
|
296
|
+
return digest.hexdigest()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class CsvConnector:
|
|
300
|
+
"""CSV 连接器(49.3)。"""
|
|
301
|
+
|
|
302
|
+
connector_id = "csv"
|
|
303
|
+
display_name = "CSV"
|
|
304
|
+
|
|
305
|
+
def supports(self, source: DataSourceSpec) -> bool:
|
|
306
|
+
return source.source_type == DataSourceType.CSV and source.path is not None
|
|
307
|
+
|
|
308
|
+
def open(self, source: DataSourceSpec) -> DataHandle:
|
|
309
|
+
return CsvDataHandle(source)
|
|
310
|
+
|
|
311
|
+
def close(self) -> None:
|
|
312
|
+
pass
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""DuckDB 文件连接器(Step 38,V1:数据库型数据源落地)。
|
|
2
|
+
|
|
3
|
+
对 `.duckdb` 文件做 READ_ONLY ATTACH,表经只读视图暴露给
|
|
4
|
+
共享 FileDataHandle 实现(schema/read_sample/sql_aggregate/
|
|
5
|
+
count_rows/fingerprint/warnings)。表名必填(spec.table_name),
|
|
6
|
+
schema 名经 options["schema"] 传入;标识符一律双引号转义。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
|
|
13
|
+
import pyarrow as pa
|
|
14
|
+
|
|
15
|
+
from datasentry_core.connectors.base import FrameBatch
|
|
16
|
+
from datasentry_core.connectors.errors import DataSourceNotFoundError
|
|
17
|
+
from datasentry_core.connectors.file_based import FileDataHandle
|
|
18
|
+
from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
|
|
19
|
+
|
|
20
|
+
#: ATTACH 目标别名(连接器内部名,避开用户数据命名冲突概率极低)
|
|
21
|
+
_DB_ALIAS = "src_db"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sql_string_literal(value: str) -> str:
|
|
25
|
+
return "'" + value.replace("'", "''") + "'"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ident(value: str) -> str:
|
|
29
|
+
"""双引号标识符转义(防表名/schema 名注入)。"""
|
|
30
|
+
return '"' + value.replace('"', '""') + '"'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DuckDBDataHandle(FileDataHandle):
|
|
34
|
+
"""DuckDB 文件表数据句柄。"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, spec: DataSourceSpec) -> None:
|
|
37
|
+
super().__init__(spec)
|
|
38
|
+
if spec.path is None or not spec.path.is_file():
|
|
39
|
+
raise DataSourceNotFoundError(f"duckdb file not found: {spec.path}")
|
|
40
|
+
if spec.table_name is None:
|
|
41
|
+
raise DataSourceNotFoundError("duckdb connector requires table_name")
|
|
42
|
+
self._table_name = spec.table_name
|
|
43
|
+
self._schema_name = spec.options.get("schema")
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def table_name(self) -> str | None:
|
|
47
|
+
return self._table_name
|
|
48
|
+
|
|
49
|
+
def _ensure_view(self) -> None:
|
|
50
|
+
if self._view_ready:
|
|
51
|
+
return
|
|
52
|
+
assert self._path is not None
|
|
53
|
+
self._executor.execute_setup(
|
|
54
|
+
f"ATTACH {_sql_string_literal(str(self._path))} AS {_DB_ALIAS} (READ_ONLY)"
|
|
55
|
+
)
|
|
56
|
+
table_ident = (
|
|
57
|
+
f"{_ident(str(self._schema_name))}.{_ident(self._table_name)}"
|
|
58
|
+
if self._schema_name
|
|
59
|
+
else _ident(self._table_name)
|
|
60
|
+
)
|
|
61
|
+
self._executor.execute_setup(
|
|
62
|
+
f"CREATE OR REPLACE VIEW data AS SELECT * FROM {_DB_ALIAS}.{table_ident}"
|
|
63
|
+
)
|
|
64
|
+
self._view_ready = True
|
|
65
|
+
|
|
66
|
+
def read_batches(self, batch_size: int = 65536) -> Iterator[FrameBatch]:
|
|
67
|
+
self._check_open()
|
|
68
|
+
if batch_size < 1:
|
|
69
|
+
raise ValueError("batch_size must be >= 1")
|
|
70
|
+
self._ensure_view()
|
|
71
|
+
row_offset = 0
|
|
72
|
+
for idx, batch in enumerate(
|
|
73
|
+
self._executor.execute_stream("SELECT * FROM data", batch_size)
|
|
74
|
+
):
|
|
75
|
+
table = pa.Table.from_batches([batch])
|
|
76
|
+
yield FrameBatch(
|
|
77
|
+
source=self._spec,
|
|
78
|
+
table=table,
|
|
79
|
+
batch_index=idx,
|
|
80
|
+
row_offset=row_offset,
|
|
81
|
+
)
|
|
82
|
+
row_offset += table.num_rows
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DuckdbConnector:
|
|
86
|
+
"""DuckDB 文件连接器(7.1 V1 扩展)。"""
|
|
87
|
+
|
|
88
|
+
connector_id = "duckdb"
|
|
89
|
+
display_name = "DuckDB"
|
|
90
|
+
|
|
91
|
+
def supports(self, source: DataSourceSpec) -> bool:
|
|
92
|
+
return source.source_type == DataSourceType.DUCKDB and source.path is not None
|
|
93
|
+
|
|
94
|
+
def open(self, source: DataSourceSpec) -> DuckDBDataHandle:
|
|
95
|
+
if source.table_name is None:
|
|
96
|
+
raise DataSourceNotFoundError(
|
|
97
|
+
"duckdb connector requires table_name (e.g. scan --table <table>)"
|
|
98
|
+
)
|
|
99
|
+
return DuckDBDataHandle(source)
|
|
100
|
+
|
|
101
|
+
def close(self) -> None:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = ["DuckDBDataHandle", "DuckdbConnector"]
|