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,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"]