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,19 @@
|
|
|
1
|
+
"""连接器错误类型。SDK 层的 DataSentryError 体系在 SDK 包建立时映射(47.2)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConnectorError(Exception):
|
|
7
|
+
"""连接器基类错误。"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnsupportedFormatError(ConnectorError):
|
|
11
|
+
"""数据源格式不被任何已注册连接器支持。"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DataSourceNotFoundError(ConnectorError):
|
|
15
|
+
"""数据源路径/资源不存在或不可读。"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnsafeSqlError(ConnectorError):
|
|
19
|
+
"""聚合查询未通过只读白名单校验(11.10/49.2)。"""
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""文件型数据句柄基类(Step 18):Parquet/JSONL/XLSX 连接器的共享实现。
|
|
2
|
+
|
|
3
|
+
与 CSV 连接器保持独立(CSV 有编码探测/分隔符嗅探等专属逻辑,不重构,ADR-019)。
|
|
4
|
+
共享部分:schema(DESCRIBE 视图)/read_sample/sql_aggregate/count_rows/
|
|
5
|
+
fingerprint/warnings(公式注入扫描)/close。
|
|
6
|
+
子类需实现:_ensure_view()(注册只读 data 视图)与 read_batches()(流式)。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
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
|
+
|
|
19
|
+
from datasentry_core.connectors.base import (
|
|
20
|
+
ColumnInfo,
|
|
21
|
+
FingerprintMode,
|
|
22
|
+
FrameBatch,
|
|
23
|
+
LoadWarning,
|
|
24
|
+
SamplingMethod,
|
|
25
|
+
SchemaInfo,
|
|
26
|
+
)
|
|
27
|
+
from datasentry_core.connectors.errors import DataSourceNotFoundError
|
|
28
|
+
from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
|
|
29
|
+
from datasentry_core.engine import DuckDBExecutor
|
|
30
|
+
from datasentry_core.models.enums import Severity
|
|
31
|
+
from datasentry_core.models.fingerprint import DatasetFingerprint
|
|
32
|
+
|
|
33
|
+
FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
|
|
34
|
+
_WARNING_CAP = 100
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _schema_hash(column_signature: list[tuple[str, str]]) -> str:
|
|
38
|
+
"""schema_hash = 列签名规范 JSON 的 SHA-256(19.1)。"""
|
|
39
|
+
canonical = json.dumps(column_signature, sort_keys=True, ensure_ascii=True)
|
|
40
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _file_sha256(path: Path) -> str:
|
|
44
|
+
"""流式文件 SHA-256(≤1GB 全量,大文件走 sampled 档)。"""
|
|
45
|
+
digest = hashlib.sha256()
|
|
46
|
+
with path.open("rb") as fh:
|
|
47
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
48
|
+
digest.update(chunk)
|
|
49
|
+
return digest.hexdigest()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FileDataHandle:
|
|
53
|
+
"""文件型数据句柄基类(DataHandle 协议共享实现)。"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, spec: DataSourceSpec) -> None:
|
|
56
|
+
if spec.path is None:
|
|
57
|
+
raise DataSourceNotFoundError(f"{type(self).__name__} requires a path")
|
|
58
|
+
self._spec = spec
|
|
59
|
+
self._path = spec.path
|
|
60
|
+
self._executor = DuckDBExecutor()
|
|
61
|
+
self._view_ready = False
|
|
62
|
+
self._schema_info: SchemaInfo | None = None
|
|
63
|
+
self._warnings: list[LoadWarning] | None = None
|
|
64
|
+
self._closed = False
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def source_type(self) -> DataSourceType:
|
|
68
|
+
return self._spec.source_type
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def source_path(self) -> Path | None:
|
|
72
|
+
return self._path
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def table_name(self) -> str | None:
|
|
76
|
+
"""DuckDB 等需要表名的数据源返回表名,其他返回 None(Step 40)。"""
|
|
77
|
+
return self._spec.table_name
|
|
78
|
+
|
|
79
|
+
# ---- 子类契约 ------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def _ensure_view(self) -> None:
|
|
82
|
+
raise NotImplementedError
|
|
83
|
+
|
|
84
|
+
def read_batches(self, batch_size: int = 65536) -> Iterator[FrameBatch]:
|
|
85
|
+
raise NotImplementedError
|
|
86
|
+
|
|
87
|
+
# ---- 共享实现 ------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def _check_open(self) -> None:
|
|
90
|
+
if self._closed:
|
|
91
|
+
raise ValueError("handle is closed")
|
|
92
|
+
|
|
93
|
+
def schema(self) -> SchemaInfo:
|
|
94
|
+
self._check_open()
|
|
95
|
+
if self._schema_info is None:
|
|
96
|
+
self._ensure_view()
|
|
97
|
+
table = self._executor.execute("DESCRIBE SELECT * FROM data")
|
|
98
|
+
names = table.column(0).to_pylist()
|
|
99
|
+
types = table.column(1).to_pylist()
|
|
100
|
+
self._schema_info = SchemaInfo(
|
|
101
|
+
columns=[
|
|
102
|
+
ColumnInfo(name=str(name), physical_type=str(typ))
|
|
103
|
+
for name, typ in zip(names, types, strict=True)
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
return self._schema_info
|
|
107
|
+
|
|
108
|
+
def read_sample(self, n: int, method: SamplingMethod = "random") -> FrameBatch:
|
|
109
|
+
"""抽样读取:reservoir(REPEATABLE seed) 保证可复现(20.3)。"""
|
|
110
|
+
self._check_open()
|
|
111
|
+
if n < 1:
|
|
112
|
+
raise ValueError("n must be >= 1")
|
|
113
|
+
self._ensure_view()
|
|
114
|
+
seed = int(self._spec.options.get("seed", 42))
|
|
115
|
+
if method == "none":
|
|
116
|
+
sql = "SELECT * FROM data LIMIT ?"
|
|
117
|
+
params: list[object] = [n]
|
|
118
|
+
elif method == "time_based":
|
|
119
|
+
column = self._spec.options.get("time_column")
|
|
120
|
+
if not isinstance(column, str) or not column:
|
|
121
|
+
method = "reservoir"
|
|
122
|
+
sql = f"SELECT * FROM data USING SAMPLE reservoir({n} ROWS) REPEATABLE ({seed})"
|
|
123
|
+
params = []
|
|
124
|
+
else:
|
|
125
|
+
sql = f'SELECT * FROM data ORDER BY "{column}" LIMIT ?'
|
|
126
|
+
params = [n]
|
|
127
|
+
else:
|
|
128
|
+
# SAMPLE 子句不支持预编译参数,n/seed 均为代码内整数(已校验),安全内联
|
|
129
|
+
sql = f"SELECT * FROM data USING SAMPLE reservoir({n} ROWS) REPEATABLE ({seed})"
|
|
130
|
+
params = []
|
|
131
|
+
table = self._executor.execute(sql, params)
|
|
132
|
+
return FrameBatch(source=self._spec, table=table)
|
|
133
|
+
|
|
134
|
+
def sql_aggregate(self, sql: str, params: dict[str, object] | None = None) -> FrameBatch:
|
|
135
|
+
"""只读聚合(DuckDB pushdown)。SQL 必须通过 engine.sql_guard 只读白名单。"""
|
|
136
|
+
self._check_open()
|
|
137
|
+
self._ensure_view()
|
|
138
|
+
table = self._executor.execute(sql, params)
|
|
139
|
+
return FrameBatch(source=self._spec, table=table)
|
|
140
|
+
|
|
141
|
+
def count_rows(self) -> int:
|
|
142
|
+
self._check_open()
|
|
143
|
+
self._ensure_view()
|
|
144
|
+
value = self._executor.execute("SELECT count(*) FROM data").column(0).to_pylist()[0]
|
|
145
|
+
assert isinstance(value, int)
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
def fingerprint(self, mode: FingerprintMode = "full") -> DatasetFingerprint:
|
|
149
|
+
self._check_open()
|
|
150
|
+
signature = [(col.name, col.physical_type) for col in self.schema().columns]
|
|
151
|
+
row_count = self.count_rows()
|
|
152
|
+
file_sha256: str | None = None
|
|
153
|
+
content_sample_hash: str | None = None
|
|
154
|
+
if mode == "full":
|
|
155
|
+
file_sha256 = _file_sha256(self._path)
|
|
156
|
+
elif mode == "sampled":
|
|
157
|
+
head = self._executor.execute("SELECT * FROM data LIMIT 1000")
|
|
158
|
+
rand = self._executor.execute(
|
|
159
|
+
"SELECT * FROM data USING SAMPLE reservoir(100000) REPEATABLE (42)"
|
|
160
|
+
)
|
|
161
|
+
digest = hashlib.sha256()
|
|
162
|
+
digest.update(str(head.to_pylist()).encode("utf-8"))
|
|
163
|
+
digest.update(str(rand.to_pylist()).encode("utf-8"))
|
|
164
|
+
content_sample_hash = digest.hexdigest()
|
|
165
|
+
return DatasetFingerprint(
|
|
166
|
+
dataset_id=self._spec.options.get("dataset_id", ""),
|
|
167
|
+
fingerprint_type=mode,
|
|
168
|
+
file_sha256=file_sha256,
|
|
169
|
+
schema_hash=_schema_hash(signature),
|
|
170
|
+
row_count=row_count,
|
|
171
|
+
column_count=len(signature),
|
|
172
|
+
column_signature=signature,
|
|
173
|
+
content_sample_hash=content_sample_hash,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def warnings(self) -> list[LoadWarning]:
|
|
177
|
+
"""公式注入标记(11.7):遍历批处理扫描字符串列,前 _WARNING_CAP 条。"""
|
|
178
|
+
self._check_open()
|
|
179
|
+
if self._warnings is not None:
|
|
180
|
+
return list(self._warnings)
|
|
181
|
+
warnings: list[LoadWarning] = []
|
|
182
|
+
for batch in self.read_batches():
|
|
183
|
+
for col_name in batch.table.column_names:
|
|
184
|
+
col = batch.table[col_name]
|
|
185
|
+
if not pa.types.is_string(col.type) or pa.types.is_null(col.type):
|
|
186
|
+
continue
|
|
187
|
+
for prefix in FORMULA_INJECTION_PREFIXES:
|
|
188
|
+
mask = pc.starts_with(col, pattern=prefix)
|
|
189
|
+
indices = pc.indices_nonzero(mask)
|
|
190
|
+
for row in indices.to_pylist():
|
|
191
|
+
value = col[row].as_py() or ""
|
|
192
|
+
warnings.append(
|
|
193
|
+
LoadWarning(
|
|
194
|
+
row=batch.row_offset + int(row),
|
|
195
|
+
column=col_name,
|
|
196
|
+
message=f"value starts with formula-injection prefix {prefix!r}",
|
|
197
|
+
severity=Severity.LOW,
|
|
198
|
+
value_preview=value[:20],
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
if len(warnings) >= _WARNING_CAP:
|
|
202
|
+
self._warnings = warnings
|
|
203
|
+
return warnings
|
|
204
|
+
self._warnings = warnings
|
|
205
|
+
return list(warnings)
|
|
206
|
+
|
|
207
|
+
def close(self) -> None:
|
|
208
|
+
self._executor.close()
|
|
209
|
+
self._closed = True
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""JSONL 连接器(Step 18):duckdb read_json_auto(newline_delimited) 视图。
|
|
2
|
+
|
|
3
|
+
read_batches 用 SQL LIMIT/OFFSET 分页(duckdb 无 JSONL 流式读接口;
|
|
4
|
+
1e6 行预算内可接受,ADR-019)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
|
|
11
|
+
from datasentry_core.connectors.base import FrameBatch
|
|
12
|
+
from datasentry_core.connectors.file_based import FileDataHandle
|
|
13
|
+
from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _sql_string_literal(value: str) -> str:
|
|
17
|
+
return "'" + value.replace("'", "''") + "'"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class JsonlDataHandle(FileDataHandle):
|
|
21
|
+
"""JSONL 数据句柄。"""
|
|
22
|
+
|
|
23
|
+
def _ensure_view(self) -> None:
|
|
24
|
+
if self._view_ready:
|
|
25
|
+
return
|
|
26
|
+
self._executor.execute_setup(
|
|
27
|
+
"CREATE OR REPLACE VIEW data AS "
|
|
28
|
+
"SELECT * FROM read_json_auto("
|
|
29
|
+
f"{_sql_string_literal(str(self._path))}, "
|
|
30
|
+
"format='newline_delimited')"
|
|
31
|
+
)
|
|
32
|
+
self._view_ready = True
|
|
33
|
+
|
|
34
|
+
def read_batches(self, batch_size: int = 65536) -> Iterator[FrameBatch]:
|
|
35
|
+
self._check_open()
|
|
36
|
+
if batch_size < 1:
|
|
37
|
+
raise ValueError("batch_size must be >= 1")
|
|
38
|
+
self._ensure_view()
|
|
39
|
+
offset = 0
|
|
40
|
+
idx = 0
|
|
41
|
+
while True:
|
|
42
|
+
table = self._executor.execute(f"SELECT * FROM data LIMIT {batch_size} OFFSET {offset}")
|
|
43
|
+
if table.num_rows == 0:
|
|
44
|
+
return
|
|
45
|
+
yield FrameBatch(
|
|
46
|
+
source=self._spec,
|
|
47
|
+
table=table,
|
|
48
|
+
batch_index=idx,
|
|
49
|
+
row_offset=offset,
|
|
50
|
+
)
|
|
51
|
+
offset += table.num_rows
|
|
52
|
+
idx += 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class JsonlConnector:
|
|
56
|
+
"""JSONL 连接器(7.1)。"""
|
|
57
|
+
|
|
58
|
+
connector_id = "jsonl"
|
|
59
|
+
display_name = "JSONL"
|
|
60
|
+
|
|
61
|
+
def supports(self, source: DataSourceSpec) -> bool:
|
|
62
|
+
return source.source_type == DataSourceType.JSONL and source.path is not None
|
|
63
|
+
|
|
64
|
+
def open(self, source: DataSourceSpec) -> JsonlDataHandle:
|
|
65
|
+
return JsonlDataHandle(source)
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__all__ = ["JsonlConnector", "JsonlDataHandle"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Parquet 连接器(Step 18):duckdb read_parquet 视图 + pyarrow 流式批读。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
import pyarrow as pa
|
|
8
|
+
import pyarrow.parquet as pq
|
|
9
|
+
|
|
10
|
+
from datasentry_core.connectors.base import FrameBatch
|
|
11
|
+
from datasentry_core.connectors.file_based import FileDataHandle
|
|
12
|
+
from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _sql_string_literal(value: str) -> str:
|
|
16
|
+
return "'" + value.replace("'", "''") + "'"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ParquetDataHandle(FileDataHandle):
|
|
20
|
+
"""Parquet 数据句柄。"""
|
|
21
|
+
|
|
22
|
+
def _ensure_view(self) -> None:
|
|
23
|
+
if self._view_ready:
|
|
24
|
+
return
|
|
25
|
+
self._executor.execute_setup(
|
|
26
|
+
"CREATE OR REPLACE VIEW data AS "
|
|
27
|
+
f"SELECT * FROM read_parquet({_sql_string_literal(str(self._path))})"
|
|
28
|
+
)
|
|
29
|
+
self._view_ready = True
|
|
30
|
+
|
|
31
|
+
def read_batches(self, batch_size: int = 65536) -> Iterator[FrameBatch]:
|
|
32
|
+
self._check_open()
|
|
33
|
+
if batch_size < 1:
|
|
34
|
+
raise ValueError("batch_size must be >= 1")
|
|
35
|
+
row_offset = 0
|
|
36
|
+
with pq.ParquetFile(self._path) as reader:
|
|
37
|
+
for idx, batch in enumerate(reader.iter_batches(batch_size=batch_size)):
|
|
38
|
+
table = pa.Table.from_batches([batch])
|
|
39
|
+
yield FrameBatch(
|
|
40
|
+
source=self._spec,
|
|
41
|
+
table=table,
|
|
42
|
+
batch_index=idx,
|
|
43
|
+
row_offset=row_offset,
|
|
44
|
+
)
|
|
45
|
+
row_offset += table.num_rows
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ParquetConnector:
|
|
49
|
+
"""Parquet 连接器(7.1)。"""
|
|
50
|
+
|
|
51
|
+
connector_id = "parquet"
|
|
52
|
+
display_name = "Parquet"
|
|
53
|
+
|
|
54
|
+
def supports(self, source: DataSourceSpec) -> bool:
|
|
55
|
+
return source.source_type == DataSourceType.PARQUET and source.path is not None
|
|
56
|
+
|
|
57
|
+
def open(self, source: DataSourceSpec) -> ParquetDataHandle:
|
|
58
|
+
return ParquetDataHandle(source)
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["ParquetConnector", "ParquetDataHandle"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""连接器注册表(49.1):按 source_type 分发。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datasentry_core.connectors.base import DataConnector, DataHandle
|
|
6
|
+
from datasentry_core.connectors.csv import CsvConnector
|
|
7
|
+
from datasentry_core.connectors.duckdb import DuckdbConnector
|
|
8
|
+
from datasentry_core.connectors.errors import UnsupportedFormatError
|
|
9
|
+
from datasentry_core.connectors.jsonl import JsonlConnector
|
|
10
|
+
from datasentry_core.connectors.parquet import ParquetConnector
|
|
11
|
+
from datasentry_core.connectors.spec import DataSourceSpec
|
|
12
|
+
from datasentry_core.connectors.xlsx import XlsxConnector
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConnectorRegistry:
|
|
16
|
+
"""连接器注册表:register/get/list/get_for/open。"""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._connectors: dict[str, DataConnector] = {}
|
|
20
|
+
|
|
21
|
+
def register(self, connector: DataConnector) -> None:
|
|
22
|
+
"""注册连接器,重复注册抛 ValueError。"""
|
|
23
|
+
if connector.connector_id in self._connectors:
|
|
24
|
+
raise ValueError(f"connector already registered: {connector.connector_id}")
|
|
25
|
+
self._connectors[connector.connector_id] = connector
|
|
26
|
+
|
|
27
|
+
def get(self, connector_id: str) -> DataConnector | None:
|
|
28
|
+
"""按 id 获取连接器。"""
|
|
29
|
+
return self._connectors.get(connector_id)
|
|
30
|
+
|
|
31
|
+
def list(self) -> list[DataConnector]:
|
|
32
|
+
"""全部已注册连接器。"""
|
|
33
|
+
return list(self._connectors.values())
|
|
34
|
+
|
|
35
|
+
def get_for(self, source: DataSourceSpec) -> DataConnector:
|
|
36
|
+
"""找到支持该数据源的连接器,找不到抛 UnsupportedFormatError。"""
|
|
37
|
+
for connector in self._connectors.values():
|
|
38
|
+
if connector.supports(source):
|
|
39
|
+
return connector
|
|
40
|
+
raise UnsupportedFormatError(f"no connector supports source type={source.source_type}")
|
|
41
|
+
|
|
42
|
+
def open(self, source: DataSourceSpec) -> DataHandle:
|
|
43
|
+
"""打开数据源,返回 DataHandle。"""
|
|
44
|
+
return self.get_for(source).open(source)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_registry() -> ConnectorRegistry:
|
|
48
|
+
"""默认注册表:文件型连接器 + DuckDB 文件连接器(Step 38,V1 数据库型落地)。"""
|
|
49
|
+
registry = ConnectorRegistry()
|
|
50
|
+
registry.register(CsvConnector())
|
|
51
|
+
registry.register(ParquetConnector())
|
|
52
|
+
registry.register(JsonlConnector())
|
|
53
|
+
registry.register(XlsxConnector())
|
|
54
|
+
registry.register(DuckdbConnector())
|
|
55
|
+
return registry
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""数据源规格(49.1/四十三第 10 项:凭据不入库,只存引用)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from datasentry_core.models.evidence import utcnow
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DataSourceType(StrEnum):
|
|
16
|
+
"""数据源类型(7.1 MVP 六个 + V1 扩展)。"""
|
|
17
|
+
|
|
18
|
+
CSV = "csv"
|
|
19
|
+
PARQUET = "parquet"
|
|
20
|
+
JSONL = "jsonl"
|
|
21
|
+
XLSX = "xlsx"
|
|
22
|
+
SQLITE = "sqlite"
|
|
23
|
+
POSTGRESQL = "postgresql"
|
|
24
|
+
DUCKDB = "duckdb"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DataSourceSpec(BaseModel):
|
|
28
|
+
"""数据源规格:描述"从哪读、怎么读",不含凭据原文。"""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
31
|
+
|
|
32
|
+
source_type: DataSourceType
|
|
33
|
+
path: Path | None = None
|
|
34
|
+
table_name: str | None = None
|
|
35
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
36
|
+
connection_ref: str | None = None
|
|
37
|
+
created_at: datetime = Field(default_factory=utcnow)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""XLSX 连接器(Step 18):openpyxl 读取 → pyarrow 表注册。
|
|
2
|
+
|
|
3
|
+
公式注入扫描价值最高(电子表格场景);混合类型列自动推断失败时回退全字符串
|
|
4
|
+
(ADR-019)。read_batches 内存切片(MVP 预算 1e6 行内,ADR-019)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
|
|
11
|
+
import pyarrow as pa
|
|
12
|
+
from openpyxl import load_workbook
|
|
13
|
+
|
|
14
|
+
from datasentry_core.connectors.base import FrameBatch
|
|
15
|
+
from datasentry_core.connectors.file_based import FileDataHandle
|
|
16
|
+
from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _rows_to_table(names: list[str], rows: list[list[object]]) -> pa.Table:
|
|
20
|
+
"""行值列表 → pyarrow 表;混合类型列自动推断失败时回退全字符串(ADR-019)。"""
|
|
21
|
+
pylist = [dict(zip(names, r, strict=False)) for r in rows]
|
|
22
|
+
try:
|
|
23
|
+
return pa.Table.from_pylist(pylist)
|
|
24
|
+
except (pa.ArrowInvalid, pa.ArrowTypeError):
|
|
25
|
+
stringified = [
|
|
26
|
+
{name: (str(value) if value is not None else None) for name, value in r.items()}
|
|
27
|
+
for r in pylist
|
|
28
|
+
]
|
|
29
|
+
return pa.Table.from_pylist(
|
|
30
|
+
stringified,
|
|
31
|
+
schema=pa.schema([(name, pa.string()) for name in names]),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class XlsxDataHandle(FileDataHandle):
|
|
36
|
+
"""XLSX 数据句柄。"""
|
|
37
|
+
|
|
38
|
+
def _load_rows(self) -> tuple[list[str], list[list[object]]]:
|
|
39
|
+
"""读取指定 sheet 为 (列名, 行值)(header_row 可配,默认第 0 行)。
|
|
40
|
+
|
|
41
|
+
data_only=False:公式单元格返回公式文本(公式注入可检测,ADR-019);
|
|
42
|
+
计算结果缓存读取归 V1。
|
|
43
|
+
"""
|
|
44
|
+
sheet_ref = self._spec.options.get("sheet")
|
|
45
|
+
header_row = int(self._spec.options.get("header_row", 0))
|
|
46
|
+
workbook = load_workbook(self._path, read_only=True, data_only=False)
|
|
47
|
+
try:
|
|
48
|
+
if sheet_ref is None:
|
|
49
|
+
sheet = workbook.worksheets[0]
|
|
50
|
+
elif isinstance(sheet_ref, int):
|
|
51
|
+
sheet = workbook.worksheets[sheet_ref]
|
|
52
|
+
else:
|
|
53
|
+
sheet = workbook[str(sheet_ref)]
|
|
54
|
+
rows = list(sheet.iter_rows(values_only=True))
|
|
55
|
+
finally:
|
|
56
|
+
workbook.close()
|
|
57
|
+
if header_row >= len(rows):
|
|
58
|
+
return [], []
|
|
59
|
+
names = [str(c) if c is not None else f"col_{i}" for i, c in enumerate(rows[header_row])]
|
|
60
|
+
data = [list(r) for r in rows[header_row + 1 :]]
|
|
61
|
+
return names, data
|
|
62
|
+
|
|
63
|
+
def _ensure_view(self) -> None:
|
|
64
|
+
if self._view_ready:
|
|
65
|
+
return
|
|
66
|
+
names, rows = self._load_rows()
|
|
67
|
+
if not names:
|
|
68
|
+
raise ValueError("xlsx sheet is empty")
|
|
69
|
+
if not rows:
|
|
70
|
+
table = pa.table({name: pa.array([], type=pa.null()) for name in names})
|
|
71
|
+
else:
|
|
72
|
+
table = _rows_to_table(names, rows)
|
|
73
|
+
self._executor.register("data", table)
|
|
74
|
+
self._view_ready = True
|
|
75
|
+
|
|
76
|
+
def read_batches(self, batch_size: int = 65536) -> Iterator[FrameBatch]:
|
|
77
|
+
self._check_open()
|
|
78
|
+
if batch_size < 1:
|
|
79
|
+
raise ValueError("batch_size must be >= 1")
|
|
80
|
+
names, rows = self._load_rows()
|
|
81
|
+
if not names:
|
|
82
|
+
return
|
|
83
|
+
for idx in range(0, len(rows), batch_size):
|
|
84
|
+
table = _rows_to_table(names, rows[idx : idx + batch_size])
|
|
85
|
+
yield FrameBatch(
|
|
86
|
+
source=self._spec,
|
|
87
|
+
table=table,
|
|
88
|
+
batch_index=idx // batch_size,
|
|
89
|
+
row_offset=idx,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class XlsxConnector:
|
|
94
|
+
"""XLSX 连接器(7.1)。"""
|
|
95
|
+
|
|
96
|
+
connector_id = "xlsx"
|
|
97
|
+
display_name = "XLSX"
|
|
98
|
+
|
|
99
|
+
def supports(self, source: DataSourceSpec) -> bool:
|
|
100
|
+
return source.source_type == DataSourceType.XLSX and source.path is not None
|
|
101
|
+
|
|
102
|
+
def open(self, source: DataSourceSpec) -> XlsxDataHandle:
|
|
103
|
+
return XlsxDataHandle(source)
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = ["XlsxConnector", "XlsxDataHandle"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""契约导出(V1:Pandera / Great Expectations)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from datasentry_core.models.contract import Contract
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_pandera(contract: Contract) -> str:
|
|
11
|
+
"""契约 → Pandera DataFrameSchema 代码字符串。"""
|
|
12
|
+
from datasentry_core.contracts.exporters import to_pandera as _to
|
|
13
|
+
|
|
14
|
+
return _to(contract)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_great_expectations(contract: Contract) -> dict[str, Any]:
|
|
18
|
+
"""契约 → Great Expectations ExpectationSuite 字典。"""
|
|
19
|
+
from datasentry_core.contracts.exporters import to_great_expectations as _to
|
|
20
|
+
|
|
21
|
+
return _to(contract)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["to_great_expectations", "to_pandera"]
|