datasentry-core 0.6.0__tar.gz → 0.8.0__tar.gz

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 (98) hide show
  1. datasentry_core-0.8.0/.gitignore +43 -0
  2. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/PKG-INFO +1 -1
  3. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/pyproject.toml +1 -1
  4. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/__init__.py +1 -1
  5. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/__init__.py +6 -0
  6. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/base.py +11 -1
  7. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/csv.py +38 -6
  8. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/duckdb.py +1 -1
  9. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/file_based.py +17 -4
  10. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/jsonl.py +5 -1
  11. datasentry_core-0.8.0/src/datasentry_core/connectors/mysql.py +266 -0
  12. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/parquet.py +5 -1
  13. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/postgres.py +8 -5
  14. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/registry.py +4 -0
  15. datasentry_core-0.8.0/src/datasentry_core/connectors/remote_file.py +263 -0
  16. datasentry_core-0.8.0/src/datasentry_core/connectors/sampling.py +141 -0
  17. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/spec.py +19 -1
  18. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/sqlite.py +1 -1
  19. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/xlsx.py +12 -1
  20. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/base.py +13 -0
  21. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/common.py +9 -0
  22. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/datetime.py +31 -12
  23. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/anomaly_ml.py +4 -1
  24. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/categorical.py +27 -13
  25. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/formula.py +6 -2
  26. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/missing.py +18 -9
  27. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/numeric.py +44 -21
  28. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/textual.py +42 -16
  29. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/uniqueness.py +12 -4
  30. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/runner.py +74 -10
  31. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/textual_variants.py +11 -4
  32. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/profiler.py +27 -2
  33. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/repair.py +14 -1
  34. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/scan.py +1 -0
  35. datasentry_core-0.8.0/src/datasentry_core/plugin_fixtures.py +156 -0
  36. datasentry_core-0.8.0/src/datasentry_core/plugin_locks.py +187 -0
  37. datasentry_core-0.8.0/src/datasentry_core/plugins.py +402 -0
  38. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/repair/engine.py +44 -1
  39. datasentry_core-0.8.0/src/datasentry_core/reporting/column_profiles.py +204 -0
  40. datasentry_core-0.8.0/src/datasentry_core/reporting/evidence_desc.py +72 -0
  41. datasentry_core-0.8.0/src/datasentry_core/reporting/html.py +623 -0
  42. datasentry_core-0.8.0/src/datasentry_core/reporting/i18n.py +725 -0
  43. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/reporting/interactive.py +140 -34
  44. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/reporting/markdown.py +38 -21
  45. datasentry_core-0.8.0/src/datasentry_core/reporting/suggestions.py +101 -0
  46. datasentry_core-0.8.0/src/datasentry_core/reporting/translate.py +72 -0
  47. datasentry_core-0.8.0/src/datasentry_core/secrets.py +116 -0
  48. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/storage/paths.py +9 -0
  49. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/storage/schema.py +99 -7
  50. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/storage/store.py +13 -5
  51. datasentry_core-0.6.0/.gitignore +0 -32
  52. datasentry_core-0.6.0/src/datasentry_core/plugins.py +0 -177
  53. datasentry_core-0.6.0/src/datasentry_core/reporting/html.py +0 -267
  54. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/README.md +0 -0
  55. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/connectors/errors.py +0 -0
  56. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/contracts/__init__.py +0 -0
  57. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/contracts/exporters.py +0 -0
  58. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/__init__.py +0 -0
  59. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/cross_field.py +0 -0
  60. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/cross_table.py +0 -0
  61. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/initial/__init__.py +0 -0
  62. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/missingness.py +0 -0
  63. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/detectors/safe_eval.py +0 -0
  64. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/drift/__init__.py +0 -0
  65. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/drift/engine.py +0 -0
  66. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/__init__.py +0 -0
  67. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/base.py +0 -0
  68. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/duckdb.py +0 -0
  69. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/fusion.py +0 -0
  70. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/engine/sql_guard.py +0 -0
  71. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/llm/provider.py +0 -0
  72. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/__init__.py +0 -0
  73. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/audit.py +0 -0
  74. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/contract.py +0 -0
  75. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/detector.py +0 -0
  76. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/drift.py +0 -0
  77. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/enums.py +0 -0
  78. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/evidence.py +0 -0
  79. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/feedback.py +0 -0
  80. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/fingerprint.py +0 -0
  81. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/issue.py +0 -0
  82. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/llm.py +0 -0
  83. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/profile.py +0 -0
  84. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/quality.py +0 -0
  85. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/rules.py +0 -0
  86. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/models/validation.py +0 -0
  87. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/privacy/redactor.py +0 -0
  88. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/repair/__init__.py +0 -0
  89. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/reporting/__init__.py +0 -0
  90. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/reporting/junit.py +0 -0
  91. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/reporting/sarif.py +0 -0
  92. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/rules/engine.py +0 -0
  93. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/scoring/__init__.py +0 -0
  94. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/scoring/engine.py +0 -0
  95. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/scoring/gate.py +0 -0
  96. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/scoring/quality.py +0 -0
  97. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/scoring/weights.py +0 -0
  98. {datasentry_core-0.6.0 → datasentry_core-0.8.0}/src/datasentry_core/storage/__init__.py +0 -0
@@ -0,0 +1,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ .coverage
9
+ htmlcov/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+ hypothesis/
14
+ .hypothesis/
15
+
16
+ # Project data
17
+ .datasentry/
18
+ *.db
19
+ *.sqlite3
20
+
21
+ # Env / secrets
22
+ .env
23
+ credentials.json
24
+
25
+ # OS
26
+ .DS_Store
27
+
28
+ # Editors
29
+ .idea/
30
+ .vscode/
31
+ ~$*
32
+ workspace/
33
+
34
+ # Debug scratch (agent sessions)
35
+ p/
36
+
37
+ # 开发任务书(prompt)——本地保留,不进仓库(V22 起约定)
38
+ docs/V*_DEV_PROMPT.md
39
+
40
+ # 完整产品设计与开发 Prompt(本地保留,不进仓库)
41
+ DataSentry AI 产品设计与开发 Prompt*.md
42
+ DataSentry AI 产品设计与开发 Prompt*.docx
43
+ DataSentry AI 增长与推广 Prompt.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datasentry-core
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: DataSentry AI core domain models and engine (detectors, fusion, scoring, repairs).
5
5
  License: Apache-2.0
6
6
  Keywords: data-observability,data-quality,duckdb
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datasentry-core"
3
- version = "0.6.0"
3
+ version = "0.8.0"
4
4
  description = "DataSentry AI core domain models and engine (detectors, fusion, scoring, repairs)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -108,4 +108,4 @@ __all__ = [
108
108
  "ValidationResult",
109
109
  ]
110
110
 
111
- __version__ = "0.5.1"
111
+ __version__ = "1.0.0"
@@ -19,9 +19,11 @@ from datasentry_core.connectors.errors import (
19
19
  UnsupportedFormatError,
20
20
  )
21
21
  from datasentry_core.connectors.jsonl import JsonlConnector
22
+ from datasentry_core.connectors.mysql import MySQLConnector, MySQLDataHandle
22
23
  from datasentry_core.connectors.parquet import ParquetConnector
23
24
  from datasentry_core.connectors.postgres import PostgresConnector, PostgresDataHandle
24
25
  from datasentry_core.connectors.registry import ConnectorRegistry, default_registry
26
+ from datasentry_core.connectors.remote_file import RemoteFileConnector, RemoteFileDataHandle
25
27
  from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
26
28
  from datasentry_core.connectors.sqlite import SqliteConnector
27
29
  from datasentry_core.connectors.xlsx import XlsxConnector
@@ -41,9 +43,13 @@ __all__ = [
41
43
  "FrameBatch",
42
44
  "JsonlConnector",
43
45
  "LoadWarning",
46
+ "MySQLConnector",
47
+ "MySQLDataHandle",
44
48
  "ParquetConnector",
45
49
  "PostgresConnector",
46
50
  "PostgresDataHandle",
51
+ "RemoteFileConnector",
52
+ "RemoteFileDataHandle",
47
53
  "SamplingMethod",
48
54
  "SchemaInfo",
49
55
  "SqliteConnector",
@@ -80,7 +80,7 @@ class DataHandle(Protocol):
80
80
  @property
81
81
  def source_type(self) -> DataSourceType: ...
82
82
  @property
83
- def source_path(self) -> Path | None: ...
83
+ def source_path(self) -> Path | str | None: ...
84
84
 
85
85
  @property
86
86
  def table_name(self) -> str | None:
@@ -98,6 +98,16 @@ class DataHandle(Protocol):
98
98
  由子类实现内容哈希——调度器变更感知(Step 53/55)的跳过判定基准。"""
99
99
  ...
100
100
 
101
+ def stats_fingerprint(self) -> str:
102
+ """统计层指纹(Step 58,ADR-058):schema_hash + row_count 组合,零内容读取。
103
+
104
+ 调度两层快速失效第一层:统计层一致才计算内容指纹;统计层变化
105
+ 立即判定变更。远程源(PG/MySQL/云文件)由 FileDataHandle 默认
106
+ 实现(DESCRIBE 目录查询 + count),文件源不参与(沿用 Step 53
107
+ 单层文件 SHA-256)。协议默认体:本地文件句柄不实现(调度器不会
108
+ 对本地文件调用),实现者不覆盖即视为不适用。"""
109
+ raise NotImplementedError("stats_fingerprint is not supported by this handle")
110
+
101
111
  def warnings(self) -> list[LoadWarning]: ...
102
112
  def close(self) -> None: ...
103
113
 
@@ -27,12 +27,15 @@ from datasentry_core.connectors.base import (
27
27
  SamplingMethod,
28
28
  SchemaInfo,
29
29
  )
30
- from datasentry_core.connectors.errors import DataSourceNotFoundError
30
+ from datasentry_core.connectors.errors import ConnectorError, DataSourceNotFoundError
31
31
  from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
32
32
  from datasentry_core.engine import DuckDBExecutor
33
33
  from datasentry_core.models.enums import Severity
34
34
  from datasentry_core.models.fingerprint import DatasetFingerprint
35
35
 
36
+ #: 非 utf-8 全文件入内存的警告阈值(Step 73/ADR-073)
37
+ _NON_UTF8_FULL_READ_WARN_BYTES = 512 * 1024 * 1024
38
+
36
39
  FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
37
40
  _WARNING_CAP = 100
38
41
 
@@ -91,8 +94,8 @@ class CsvDataHandle:
91
94
  """CSV 数据句柄:pyarrow 流式读取 + duckdb 只读聚合。"""
92
95
 
93
96
  def __init__(self, spec: DataSourceSpec) -> None:
94
- if spec.path is None:
95
- raise DataSourceNotFoundError("csv source requires a path")
97
+ if spec.path is None or isinstance(spec.path, str):
98
+ raise DataSourceNotFoundError("csv source requires a local file path")
96
99
  self._spec = spec
97
100
  self._path = spec.path
98
101
  self._encoding = _detect_encoding(spec.path, override=spec.options.get("encoding"))
@@ -108,7 +111,7 @@ class CsvDataHandle:
108
111
  return self._spec.source_type
109
112
 
110
113
  @property
111
- def source_path(self) -> Path | None:
114
+ def source_path(self) -> Path | str | None:
112
115
  return self._path
113
116
 
114
117
  @property
@@ -132,6 +135,22 @@ class CsvDataHandle:
132
135
  f"delim={_sql_string_literal(self._delimiter)})"
133
136
  )
134
137
  else:
138
+ # Step 73/ADR-073:非 utf-8 全文件入内存(pyarrow 无流式编码读取),
139
+ # 超阈值时预置提示 warning,引导 --sampling 或转码
140
+ if self._path.stat().st_size > _NON_UTF8_FULL_READ_WARN_BYTES:
141
+ self._warnings = [
142
+ LoadWarning(
143
+ row=0,
144
+ column="",
145
+ message=(
146
+ "non-utf-8 csv is fully loaded into memory "
147
+ f"({self._path.stat().st_size} bytes > "
148
+ f"{_NON_UTF8_FULL_READ_WARN_BYTES}); "
149
+ "use --sampling or re-encode as utf-8"
150
+ ),
151
+ severity=Severity.LOW,
152
+ )
153
+ ]
135
154
  table = pa_csv.read_csv(
136
155
  self._path,
137
156
  read_options=pa_csv.ReadOptions(encoding=self._encoding),
@@ -288,9 +307,17 @@ class CsvDataHandle:
288
307
 
289
308
  def content_fingerprint(self) -> str:
290
309
  """内容指纹(Step 55):文件源 = 文件 SHA-256(Step 53 调度哈希语义)。"""
291
- assert self._path is not None
310
+ if not isinstance(self._path, Path):
311
+ raise ConnectorError("csv fingerprint requires a local file path")
292
312
  return _file_sha256(self._path)
293
313
 
314
+ def stats_fingerprint(self) -> str:
315
+ """统计层指纹(Step 58):本地文件源不参与两层指纹——调度器仅对
316
+ 远程源(PG/MySQL/云文件)调用,本地文件沿用 Step 53 单层语义。"""
317
+ raise NotImplementedError(
318
+ "local file handles do not participate in two-layer fingerprinting"
319
+ )
320
+
294
321
 
295
322
  def _file_sha256(path: Path) -> str:
296
323
  """流式文件 SHA-256(≤1GB 全量,大文件走 sampled 档)。"""
@@ -308,7 +335,12 @@ class CsvConnector:
308
335
  display_name = "CSV"
309
336
 
310
337
  def supports(self, source: DataSourceSpec) -> bool:
311
- return source.source_type == DataSourceType.CSV and source.path is not None
338
+ # Step 57:本地文件连接器排除云 URI(s3:// gs:// az:// 归 RemoteFileConnector)
339
+ return (
340
+ source.source_type == DataSourceType.CSV
341
+ and source.path is not None
342
+ and not isinstance(source.path, str)
343
+ )
312
344
 
313
345
  def open(self, source: DataSourceSpec) -> DataHandle:
314
346
  return CsvDataHandle(source)
@@ -35,7 +35,7 @@ class DuckDBDataHandle(FileDataHandle):
35
35
 
36
36
  def __init__(self, spec: DataSourceSpec) -> None:
37
37
  super().__init__(spec)
38
- if spec.path is None or not spec.path.is_file():
38
+ if spec.path is None or isinstance(spec.path, str) or not spec.path.is_file():
39
39
  raise DataSourceNotFoundError(f"duckdb file not found: {spec.path}")
40
40
  if spec.table_name is None:
41
41
  raise DataSourceNotFoundError("duckdb connector requires table_name")
@@ -90,7 +90,7 @@ class FileDataHandle:
90
90
  return self._spec.source_type
91
91
 
92
92
  @property
93
- def source_path(self) -> Path | None:
93
+ def source_path(self) -> Path | str | None:
94
94
  return self._path
95
95
 
96
96
  @property
@@ -174,8 +174,8 @@ class FileDataHandle:
174
174
  file_sha256: str | None = None
175
175
  content_sample_hash: str | None = None
176
176
  if mode == "full":
177
- if self._path is None:
178
- raise ConnectorError("file fingerprint requires a path")
177
+ if not isinstance(self._path, Path):
178
+ raise ConnectorError("file fingerprint requires a local file path")
179
179
  file_sha256 = _file_sha256(self._path)
180
180
  elif mode == "sampled":
181
181
  head = self._executor.execute("SELECT * FROM data LIMIT 1000")
@@ -199,9 +199,22 @@ class FileDataHandle:
199
199
 
200
200
  def content_fingerprint(self) -> str:
201
201
  """内容指纹(Step 55):文件源 = 文件 SHA-256(Step 53 调度哈希语义)。"""
202
- assert self._path is not None
202
+ if not isinstance(self._path, Path):
203
+ raise ConnectorError("file fingerprint requires a local file path")
203
204
  return _file_sha256(self._path)
204
205
 
206
+ def stats_fingerprint(self) -> str:
207
+ """统计层指纹(Step 58,ADR-058):schema_hash + row_count 组合。
208
+
209
+ 零内容读取:DESCRIBE(目录查询,PG/MySQL 为扩展目录、云文件为
210
+ parquet footer/CSV 元数据)+ count(远程库为 count 扫描,云文件
211
+ 为 footer 行数)。调度两层快速失效第一层:统计层一致才进内容层。
212
+ """
213
+ self._check_open()
214
+ signature = [(c.name, c.physical_type) for c in self.schema().columns]
215
+ row_count = self.count_rows()
216
+ return hashlib.sha256(f"{_schema_hash(signature)}|{row_count}".encode()).hexdigest()
217
+
205
218
  def warnings(self) -> list[LoadWarning]:
206
219
  """公式注入标记(11.7):遍历批处理扫描字符串列,前 _WARNING_CAP 条。"""
207
220
  self._check_open()
@@ -59,7 +59,11 @@ class JsonlConnector:
59
59
  display_name = "JSONL"
60
60
 
61
61
  def supports(self, source: DataSourceSpec) -> bool:
62
- return source.source_type == DataSourceType.JSONL and source.path is not None
62
+ return (
63
+ source.source_type == DataSourceType.JSONL
64
+ and source.path is not None
65
+ and not isinstance(source.path, str)
66
+ )
63
67
 
64
68
  def open(self, source: DataSourceSpec) -> JsonlDataHandle:
65
69
  return JsonlDataHandle(source)
@@ -0,0 +1,266 @@
1
+ """MySQL 数据源连接器(Step 56,V5:多数据源第三落点,ADR-056)。
2
+
3
+ 经 DuckDB mysql 扩展(LOAD mysql)只读 ATTACH 远程库
4
+ (TYPE mysql, READ_ONLY),注册只读 data 视图后复用 FileDataHandle
5
+ 共享实现(schema/read_sample/sql_aggregate/count_rows/warnings)。
6
+ 表名必填;MySQL 无独立 schema 层(database 已在 DSN 内)。
7
+
8
+ 凭据红线(沿用 Step 55 全套):
9
+ - DSN 只存在于内存态(spec.options["dsn"])或经 connection_ref
10
+ 指向的环境变量(如 DATASENTRY_MYSQL_DSN),不落库、不进日志/evidence/报告
11
+ - mysql 扩展连接失败时错误文本会原样回显 DSN 参数串
12
+ ("Failed to connect to MySQL database with parameters ..."):
13
+ _RedactingExecutor 净化(DSN 整体 → mysql://***、密码 → ***)后转
14
+ ConnectorError 传播,CLI 错误面 / REST 400 只见净化文本
15
+ - DuckDB 1.5.x mysql 扩展已知 bug:视图之上的聚合(count/groupby)触发
16
+ 聚合下推时报内部绑定错误(直连 attach 正常,PG 扩展无此问题)。连接器
17
+ _ensure_view 统一 SET mysql_aggregate_pushdown_enabled = false 绕过:
18
+ 聚合改在 DuckDB 本地执行,语义不变(ADR-056)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import re
25
+ from collections.abc import Iterator
26
+ from typing import ClassVar
27
+ from urllib.parse import urlparse
28
+
29
+ import duckdb
30
+ import pyarrow as pa
31
+
32
+ from datasentry_core.connectors.base import FingerprintMode, FrameBatch
33
+ from datasentry_core.connectors.errors import ConnectorError, DataSourceNotFoundError
34
+ from datasentry_core.connectors.file_based import FileDataHandle, _schema_hash
35
+ from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
36
+ from datasentry_core.engine import DuckDBExecutor
37
+ from datasentry_core.engine.base import SqlParams
38
+ from datasentry_core.models.fingerprint import DatasetFingerprint
39
+ from datasentry_core.secrets import lookup_secret
40
+
41
+ #: ATTACH 目标别名(连接器内部名,避开用户数据命名冲突概率极低)
42
+ _DB_ALIAS = "my"
43
+
44
+ #: 加载 mysql 扩展失败的提示(缺网络/离线/发行版未带扩展)
45
+ _MYSQL_EXTENSION_HINT = (
46
+ "failed to load duckdb mysql extension: network access is required on first use "
47
+ "(or the extension must be pre-provisioned for offline environments); "
48
+ "original error: {}"
49
+ )
50
+
51
+ #: 连接失败提示(凭据已净化)
52
+ _MYSQL_CONNECT_HINT = "mysql connection failed (check host/port/database/credentials): {}"
53
+
54
+ #: KV 形式 DSN 的密码键(mysql 扩展用 passwd 而非 password)
55
+ _KV_PASSWORD_PATTERN = re.compile(r"(passwd=\S+)")
56
+
57
+
58
+ def _sql_string_literal(value: str) -> str:
59
+ return "'" + value.replace("'", "''") + "'"
60
+
61
+
62
+ def _ident(value: str) -> str:
63
+ """双引号标识符转义(防表名注入)。"""
64
+ return '"' + value.replace('"', '""') + '"'
65
+
66
+
67
+ def redact_credentials(text: str, dsn: str) -> str:
68
+ """净化错误文本:DSN 整体与密码不对外(凭据红线,可单测)。
69
+
70
+ mysql 扩展错误文本回显两种形式:URL 形式(mysql://user:pass@host/db)
71
+ 与 KV 形式(host=... passwd=...)——两种都要打码。
72
+ """
73
+ redacted = text.replace(dsn, "mysql://***")
74
+ redacted = _KV_PASSWORD_PATTERN.sub("passwd=***", redacted)
75
+ try:
76
+ parsed = urlparse(dsn)
77
+ except ValueError:
78
+ return redacted
79
+ if parsed.password:
80
+ redacted = redacted.replace(parsed.password, "***")
81
+ return redacted
82
+
83
+
84
+ def _resolve_dsn(spec: DataSourceSpec) -> str:
85
+ """DSN 解析(Step 59,ADR-059):options["dsn"] 内存态 > connection_ref
86
+ 统一解析链(进程环境变量 > secrets.env,`lookup_secret`)。"""
87
+ dsn = spec.options.get("dsn")
88
+ if isinstance(dsn, str) and dsn:
89
+ return dsn
90
+ if spec.connection_ref:
91
+ ref = spec.connection_ref
92
+ dsn = lookup_secret(ref)
93
+ if dsn:
94
+ return dsn
95
+ raise DataSourceNotFoundError(f"mysql connection_ref not set (env or secrets.env): {ref}")
96
+ raise DataSourceNotFoundError(
97
+ "mysql connector requires a dsn (options['dsn'] or connection_ref env/secrets)"
98
+ )
99
+
100
+
101
+ class _RedactingExecutor:
102
+ """MySQL 句柄专用执行器包装:DuckDB 异常净化后转 ConnectorError 传播。
103
+
104
+ 只读守卫(sql_guard)在 DuckDBExecutor 内先于执行触发,其异常
105
+ (UnsafeSqlError 等连接器族)原样透传;查询期远程错误(断连等)
106
+ 同样净化,避免 DSN 出现在 DetectorRun.error / 调度 summary 等
107
+ 落库面。
108
+ """
109
+
110
+ def __init__(self, inner: DuckDBExecutor, dsn: str) -> None:
111
+ self._inner = inner
112
+ self._dsn = dsn
113
+
114
+ def execute_setup(self, sql: str) -> None:
115
+ try:
116
+ self._inner.execute_setup(sql)
117
+ except duckdb.Error as exc:
118
+ raise ConnectorError(redact_credentials(str(exc), self._dsn)) from exc
119
+
120
+ def register(self, name: str, obj: object) -> None:
121
+ self._inner.register(name, obj)
122
+
123
+ def execute(self, sql: str, params: SqlParams = None) -> pa.Table:
124
+ try:
125
+ return self._inner.execute(sql, params)
126
+ except duckdb.Error as exc:
127
+ raise ConnectorError(redact_credentials(str(exc), self._dsn)) from exc
128
+
129
+ def execute_stream(self, sql: str, batch_size: int = 65536) -> Iterator[pa.RecordBatch]:
130
+ try:
131
+ yield from self._inner.execute_stream(sql, batch_size)
132
+ except duckdb.Error as exc:
133
+ raise ConnectorError(redact_credentials(str(exc), self._dsn)) from exc
134
+
135
+ def close(self) -> None:
136
+ self._inner.close()
137
+
138
+
139
+ class MySQLDataHandle(FileDataHandle):
140
+ """MySQL 远程表数据句柄(只读,表名必填,无文件字节)。"""
141
+
142
+ requires_path: ClassVar[bool] = False
143
+
144
+ def __init__(self, spec: DataSourceSpec) -> None:
145
+ super().__init__(spec)
146
+ if spec.table_name is None:
147
+ raise DataSourceNotFoundError("mysql connector requires table_name")
148
+ self._dsn = _resolve_dsn(spec)
149
+ self._table_name = spec.table_name
150
+ assert isinstance(self._executor, DuckDBExecutor)
151
+ self._executor = _RedactingExecutor(self._executor, self._dsn)
152
+
153
+ @property
154
+ def table_name(self) -> str | None:
155
+ return self._table_name
156
+
157
+ def _ensure_view(self) -> None:
158
+ if self._view_ready:
159
+ return
160
+ try:
161
+ # DuckDB 1.5.x mysql 扩展绑定 bug:SQL 经「mysql-attach 之上的视图」触发
162
+ # 聚合下推时报 "Failed to bind column reference"(直连 attach 无此问题)。
163
+ # 关闭聚合下推后聚合在 DuckDB 本地计算(结果语义不变,仅少一次远端
164
+ # 聚合执行),视图 + count/groupby 全部正常(ADR-056 已知问题记录)。
165
+ self._executor.execute_setup("SET mysql_aggregate_pushdown_enabled = false")
166
+ self._executor.execute_setup("LOAD mysql")
167
+ except ConnectorError as exc:
168
+ raise ConnectorError(_MYSQL_EXTENSION_HINT.format(exc)) from exc
169
+ try:
170
+ self._executor.execute_setup(
171
+ f"ATTACH {_sql_string_literal(self._dsn)} AS {_DB_ALIAS} (TYPE mysql, READ_ONLY)"
172
+ )
173
+ except ConnectorError as exc:
174
+ raise ConnectorError(_MYSQL_CONNECT_HINT.format(exc)) from exc
175
+ table_ident = f"{_DB_ALIAS}.{_ident(self._table_name)}"
176
+ try:
177
+ self._executor.execute(f"SELECT 1 FROM {table_ident} LIMIT 0")
178
+ except ConnectorError as exc:
179
+ raise DataSourceNotFoundError(f"mysql table not found: {self._table_name}") from exc
180
+ self._executor.execute_setup(f"CREATE OR REPLACE VIEW data AS SELECT * FROM {table_ident}")
181
+ self._view_ready = True
182
+
183
+ def read_batches(self, batch_size: int = 65536) -> Iterator[FrameBatch]:
184
+ self._check_open()
185
+ if batch_size < 1:
186
+ raise ValueError("batch_size must be >= 1")
187
+ self._ensure_view()
188
+ row_offset = 0
189
+ for idx, batch in enumerate(
190
+ self._executor.execute_stream("SELECT * FROM data", batch_size)
191
+ ):
192
+ table = pa.Table.from_batches([batch])
193
+ yield FrameBatch(
194
+ source=self._spec,
195
+ table=table,
196
+ batch_index=idx,
197
+ row_offset=row_offset,
198
+ )
199
+ row_offset += table.num_rows
200
+
201
+ def content_fingerprint(self) -> str:
202
+ """内容指纹(Step 56):单查询全表哈希(行序无关、NULL 不折叠)。
203
+
204
+ 语义与 PostgresDataHandle 一致(Step 55 模式):逐行
205
+ md5(concat_ws(chr(31), coalesce(col::VARCHAR, chr(0))...)) +
206
+ 排序聚合 string_agg;并入 schema_hash 与行数。
207
+ """
208
+ self._check_open()
209
+ self._ensure_view()
210
+ signature = [(c.name, c.physical_type) for c in self.schema().columns]
211
+ schema_hash = _schema_hash(signature)
212
+ exprs = ", ".join(
213
+ f"coalesce(cast({_ident(name)} AS VARCHAR), chr(0))" for name, _typ in signature
214
+ )
215
+ table = self._executor.execute(
216
+ "SELECT count(*) AS n, md5(string_agg(rh, '' ORDER BY rh)) AS h FROM "
217
+ f"(SELECT md5(concat_ws(chr(31), {exprs})) AS rh FROM data) t"
218
+ )
219
+ row_count = table.column(0).to_pylist()[0]
220
+ rows_hash = table.column(1).to_pylist()[0]
221
+ assert isinstance(row_count, int)
222
+ digest = hashlib.sha256(f"{schema_hash}|{row_count}|{rows_hash or ''}".encode()).hexdigest()
223
+ return digest
224
+
225
+ def fingerprint(self, mode: FingerprintMode = "full") -> DatasetFingerprint:
226
+ """MySQL 无文件字节:file_sha256=None,内容指纹放 content_sample_hash。"""
227
+ self._check_open()
228
+ signature = [(c.name, c.physical_type) for c in self.schema().columns]
229
+ row_count = self.count_rows()
230
+ content_hash = self.content_fingerprint() if mode != "metadata_only" else None
231
+ return DatasetFingerprint(
232
+ dataset_id=self._spec.options.get("dataset_id", ""),
233
+ fingerprint_type=mode,
234
+ file_sha256=None,
235
+ schema_hash=_schema_hash(signature),
236
+ row_count=row_count,
237
+ column_count=len(signature),
238
+ column_signature=signature,
239
+ content_sample_hash=content_hash,
240
+ )
241
+
242
+
243
+ class MySQLConnector:
244
+ """MySQL 数据源连接器(Step 56,V5,ADR-056)。"""
245
+
246
+ connector_id = "mysql"
247
+ display_name = "MySQL"
248
+
249
+ def supports(self, source: DataSourceSpec) -> bool:
250
+ if source.source_type != DataSourceType.MYSQL:
251
+ return False
252
+ dsn = source.options.get("dsn")
253
+ return (isinstance(dsn, str) and bool(dsn)) or source.connection_ref is not None
254
+
255
+ def open(self, source: DataSourceSpec) -> MySQLDataHandle:
256
+ if source.table_name is None:
257
+ raise DataSourceNotFoundError(
258
+ "mysql connector requires table_name (e.g. scan --table <table>)"
259
+ )
260
+ return MySQLDataHandle(source)
261
+
262
+ def close(self) -> None:
263
+ pass
264
+
265
+
266
+ __all__ = ["MySQLConnector", "MySQLDataHandle", "redact_credentials"]
@@ -52,7 +52,11 @@ class ParquetConnector:
52
52
  display_name = "Parquet"
53
53
 
54
54
  def supports(self, source: DataSourceSpec) -> bool:
55
- return source.source_type == DataSourceType.PARQUET and source.path is not None
55
+ return (
56
+ source.source_type == DataSourceType.PARQUET
57
+ and source.path is not None
58
+ and not isinstance(source.path, str)
59
+ )
56
60
 
57
61
  def open(self, source: DataSourceSpec) -> ParquetDataHandle:
58
62
  return ParquetDataHandle(source)
@@ -16,7 +16,6 @@
16
16
  from __future__ import annotations
17
17
 
18
18
  import hashlib
19
- import os
20
19
  from collections.abc import Iterator
21
20
  from typing import ClassVar, cast
22
21
  from urllib.parse import urlparse
@@ -31,6 +30,7 @@ from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
31
30
  from datasentry_core.engine import DuckDBExecutor
32
31
  from datasentry_core.engine.base import SqlParams
33
32
  from datasentry_core.models.fingerprint import DatasetFingerprint
33
+ from datasentry_core.secrets import lookup_secret
34
34
 
35
35
  #: ATTACH 目标别名(连接器内部名,避开用户数据命名冲突概率极低)
36
36
  _DB_ALIAS = "pg"
@@ -68,18 +68,21 @@ def redact_credentials(text: str, dsn: str) -> str:
68
68
 
69
69
 
70
70
  def _resolve_dsn(spec: DataSourceSpec) -> str:
71
- """DSN 解析:spec.options["dsn"](内存态)或 connection_ref 环境变量引用。"""
71
+ """DSN 解析(Step 59,ADR-059):options["dsn"] 内存态 > connection_ref
72
+ 统一解析链(进程环境变量 > secrets.env,`lookup_secret`)。"""
72
73
  dsn = spec.options.get("dsn")
73
74
  if isinstance(dsn, str) and dsn:
74
75
  return dsn
75
76
  if spec.connection_ref:
76
77
  ref = spec.connection_ref
77
- dsn = os.environ.get(ref)
78
+ dsn = lookup_secret(ref)
78
79
  if dsn:
79
80
  return dsn
80
- raise DataSourceNotFoundError(f"postgres connection_ref env not set: {ref}")
81
+ raise DataSourceNotFoundError(
82
+ f"postgres connection_ref not set (env or secrets.env): {ref}"
83
+ )
81
84
  raise DataSourceNotFoundError(
82
- "postgres connector requires a dsn (options['dsn'] or connection_ref env)"
85
+ "postgres connector requires a dsn (options['dsn'] or connection_ref env/secrets)"
83
86
  )
84
87
 
85
88
 
@@ -7,8 +7,10 @@ from datasentry_core.connectors.csv import CsvConnector
7
7
  from datasentry_core.connectors.duckdb import DuckdbConnector
8
8
  from datasentry_core.connectors.errors import UnsupportedFormatError
9
9
  from datasentry_core.connectors.jsonl import JsonlConnector
10
+ from datasentry_core.connectors.mysql import MySQLConnector
10
11
  from datasentry_core.connectors.parquet import ParquetConnector
11
12
  from datasentry_core.connectors.postgres import PostgresConnector
13
+ from datasentry_core.connectors.remote_file import RemoteFileConnector
12
14
  from datasentry_core.connectors.spec import DataSourceSpec
13
15
  from datasentry_core.connectors.sqlite import SqliteConnector
14
16
  from datasentry_core.connectors.xlsx import XlsxConnector
@@ -56,4 +58,6 @@ def default_registry() -> ConnectorRegistry:
56
58
  registry.register(DuckdbConnector())
57
59
  registry.register(SqliteConnector())
58
60
  registry.register(PostgresConnector())
61
+ registry.register(MySQLConnector())
62
+ registry.register(RemoteFileConnector())
59
63
  return registry