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