datasentry-core 0.5.0__tar.gz → 0.6.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 (84) hide show
  1. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/PKG-INFO +1 -1
  2. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/pyproject.toml +1 -1
  3. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/__init__.py +1 -1
  4. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/__init__.py +3 -0
  5. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/base.py +5 -0
  6. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/csv.py +5 -0
  7. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/file_based.py +34 -5
  8. datasentry_core-0.6.0/src/datasentry_core/connectors/postgres.py +253 -0
  9. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/registry.py +3 -1
  10. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/base.py +28 -1
  11. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/storage/store.py +15 -1
  12. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/.gitignore +0 -0
  13. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/README.md +0 -0
  14. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/duckdb.py +0 -0
  15. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/errors.py +0 -0
  16. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/jsonl.py +0 -0
  17. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/parquet.py +0 -0
  18. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/spec.py +0 -0
  19. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/sqlite.py +0 -0
  20. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/xlsx.py +0 -0
  21. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/contracts/__init__.py +0 -0
  22. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/contracts/exporters.py +0 -0
  23. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/__init__.py +0 -0
  24. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/base.py +0 -0
  25. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/common.py +0 -0
  26. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/cross_field.py +0 -0
  27. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/cross_table.py +0 -0
  28. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/datetime.py +0 -0
  29. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/__init__.py +0 -0
  30. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/anomaly_ml.py +0 -0
  31. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/categorical.py +0 -0
  32. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/formula.py +0 -0
  33. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/missing.py +0 -0
  34. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/numeric.py +0 -0
  35. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/textual.py +0 -0
  36. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/uniqueness.py +0 -0
  37. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/missingness.py +0 -0
  38. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/runner.py +0 -0
  39. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/safe_eval.py +0 -0
  40. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/textual_variants.py +0 -0
  41. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/drift/__init__.py +0 -0
  42. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/drift/engine.py +0 -0
  43. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/__init__.py +0 -0
  44. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/duckdb.py +0 -0
  45. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/fusion.py +0 -0
  46. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/profiler.py +0 -0
  47. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/sql_guard.py +0 -0
  48. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/llm/provider.py +0 -0
  49. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/__init__.py +0 -0
  50. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/audit.py +0 -0
  51. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/contract.py +0 -0
  52. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/detector.py +0 -0
  53. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/drift.py +0 -0
  54. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/enums.py +0 -0
  55. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/evidence.py +0 -0
  56. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/feedback.py +0 -0
  57. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/fingerprint.py +0 -0
  58. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/issue.py +0 -0
  59. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/llm.py +0 -0
  60. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/profile.py +0 -0
  61. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/quality.py +0 -0
  62. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/repair.py +0 -0
  63. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/rules.py +0 -0
  64. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/scan.py +0 -0
  65. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/validation.py +0 -0
  66. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/plugins.py +0 -0
  67. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/privacy/redactor.py +0 -0
  68. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/repair/__init__.py +0 -0
  69. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/repair/engine.py +0 -0
  70. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/__init__.py +0 -0
  71. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/html.py +0 -0
  72. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/interactive.py +0 -0
  73. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/junit.py +0 -0
  74. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/markdown.py +0 -0
  75. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/sarif.py +0 -0
  76. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/rules/engine.py +0 -0
  77. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/scoring/__init__.py +0 -0
  78. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/scoring/engine.py +0 -0
  79. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/scoring/gate.py +0 -0
  80. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/scoring/quality.py +0 -0
  81. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/scoring/weights.py +0 -0
  82. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/storage/__init__.py +0 -0
  83. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/storage/paths.py +0 -0
  84. {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/storage/schema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datasentry-core
3
- Version: 0.5.0
3
+ Version: 0.6.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.5.0"
3
+ version = "0.6.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.0"
111
+ __version__ = "0.5.1"
@@ -20,6 +20,7 @@ from datasentry_core.connectors.errors import (
20
20
  )
21
21
  from datasentry_core.connectors.jsonl import JsonlConnector
22
22
  from datasentry_core.connectors.parquet import ParquetConnector
23
+ from datasentry_core.connectors.postgres import PostgresConnector, PostgresDataHandle
23
24
  from datasentry_core.connectors.registry import ConnectorRegistry, default_registry
24
25
  from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
25
26
  from datasentry_core.connectors.sqlite import SqliteConnector
@@ -41,6 +42,8 @@ __all__ = [
41
42
  "JsonlConnector",
42
43
  "LoadWarning",
43
44
  "ParquetConnector",
45
+ "PostgresConnector",
46
+ "PostgresDataHandle",
44
47
  "SamplingMethod",
45
48
  "SchemaInfo",
46
49
  "SqliteConnector",
@@ -93,6 +93,11 @@ class DataHandle(Protocol):
93
93
  def sql_aggregate(self, sql: str, params: dict[str, Any] | None = None) -> FrameBatch: ...
94
94
  def count_rows(self) -> int: ...
95
95
  def fingerprint(self, mode: FingerprintMode = "full") -> DatasetFingerprint: ...
96
+ def content_fingerprint(self) -> str:
97
+ """内容指纹(Step 55):文件源=文件 SHA-256;无文件字节源(如 PostgreSQL)
98
+ 由子类实现内容哈希——调度器变更感知(Step 53/55)的跳过判定基准。"""
99
+ ...
100
+
96
101
  def warnings(self) -> list[LoadWarning]: ...
97
102
  def close(self) -> None: ...
98
103
 
@@ -286,6 +286,11 @@ class CsvDataHandle:
286
286
  self._executor.close()
287
287
  self._closed = True
288
288
 
289
+ def content_fingerprint(self) -> str:
290
+ """内容指纹(Step 55):文件源 = 文件 SHA-256(Step 53 调度哈希语义)。"""
291
+ assert self._path is not None
292
+ return _file_sha256(self._path)
293
+
289
294
 
290
295
  def _file_sha256(path: Path) -> str:
291
296
  """流式文件 SHA-256(≤1GB 全量,大文件走 sampled 档)。"""
@@ -12,6 +12,7 @@ import hashlib
12
12
  import json
13
13
  from collections.abc import Iterator
14
14
  from pathlib import Path
15
+ from typing import ClassVar
15
16
 
16
17
  import pyarrow as pa
17
18
  import pyarrow.compute as pc
@@ -24,15 +25,33 @@ from datasentry_core.connectors.base import (
24
25
  SamplingMethod,
25
26
  SchemaInfo,
26
27
  )
27
- from datasentry_core.connectors.errors import DataSourceNotFoundError
28
+ from datasentry_core.connectors.errors import ConnectorError, DataSourceNotFoundError
28
29
  from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
29
30
  from datasentry_core.engine import DuckDBExecutor
31
+ from datasentry_core.engine.base import SetupExecutor
30
32
  from datasentry_core.models.enums import Severity
31
33
  from datasentry_core.models.fingerprint import DatasetFingerprint
32
34
 
33
35
  FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
34
36
  _WARNING_CAP = 100
35
37
 
38
+ #: DuckDB DESCRIBE 物理类型 → 规范名(Step 55:检测器/画像的类型集合按规范名精确匹配)
39
+ _TYPE_ALIASES = {
40
+ "TIMESTAMP WITH TIME ZONE": "TIMESTAMPTZ",
41
+ "TIMESTAMP WITHOUT TIME ZONE": "TIMESTAMP",
42
+ }
43
+
44
+
45
+ def _normalize_physical_type(typ: str) -> str:
46
+ """物理类型归一化:DECIMAL(n,p) → DECIMAL;别名时间类型 → 规范名。
47
+
48
+ PG/Parquet 等源的 DECIMAL(10,2) 与 DuckDB 原生 TIMESTAMP WITH TIME ZONE
49
+ 直接进 schema 时无法命中检测器/画像的精确类型集合(Step 55 实测发现)。
50
+ """
51
+ if typ.startswith("DECIMAL(") and typ.endswith(")"):
52
+ return "DECIMAL"
53
+ return _TYPE_ALIASES.get(typ, typ)
54
+
36
55
 
37
56
  def _schema_hash(column_signature: list[tuple[str, str]]) -> str:
38
57
  """schema_hash = 列签名规范 JSON 的 SHA-256(19.1)。"""
@@ -52,12 +71,15 @@ def _file_sha256(path: Path) -> str:
52
71
  class FileDataHandle:
53
72
  """文件型数据句柄基类(DataHandle 协议共享实现)。"""
54
73
 
74
+ #: 需要文件路径的子类默认 True;无文件源的子类(如 PostgreSQL)置 False
75
+ requires_path: ClassVar[bool] = True
76
+
55
77
  def __init__(self, spec: DataSourceSpec) -> None:
56
- if spec.path is None:
57
- raise DataSourceNotFoundError(f"{type(self).__name__} requires a path")
58
78
  self._spec = spec
59
79
  self._path = spec.path
60
- self._executor = DuckDBExecutor()
80
+ if self.requires_path and spec.path is None:
81
+ raise DataSourceNotFoundError(f"{type(self).__name__} requires a path")
82
+ self._executor: SetupExecutor = DuckDBExecutor()
61
83
  self._view_ready = False
62
84
  self._schema_info: SchemaInfo | None = None
63
85
  self._warnings: list[LoadWarning] | None = None
@@ -99,7 +121,7 @@ class FileDataHandle:
99
121
  types = table.column(1).to_pylist()
100
122
  self._schema_info = SchemaInfo(
101
123
  columns=[
102
- ColumnInfo(name=str(name), physical_type=str(typ))
124
+ ColumnInfo(name=str(name), physical_type=_normalize_physical_type(str(typ)))
103
125
  for name, typ in zip(names, types, strict=True)
104
126
  ]
105
127
  )
@@ -152,6 +174,8 @@ class FileDataHandle:
152
174
  file_sha256: str | None = None
153
175
  content_sample_hash: str | None = None
154
176
  if mode == "full":
177
+ if self._path is None:
178
+ raise ConnectorError("file fingerprint requires a path")
155
179
  file_sha256 = _file_sha256(self._path)
156
180
  elif mode == "sampled":
157
181
  head = self._executor.execute("SELECT * FROM data LIMIT 1000")
@@ -173,6 +197,11 @@ class FileDataHandle:
173
197
  content_sample_hash=content_sample_hash,
174
198
  )
175
199
 
200
+ def content_fingerprint(self) -> str:
201
+ """内容指纹(Step 55):文件源 = 文件 SHA-256(Step 53 调度哈希语义)。"""
202
+ assert self._path is not None
203
+ return _file_sha256(self._path)
204
+
176
205
  def warnings(self) -> list[LoadWarning]:
177
206
  """公式注入标记(11.7):遍历批处理扫描字符串列,前 _WARNING_CAP 条。"""
178
207
  self._check_open()
@@ -0,0 +1,253 @@
1
+ """PostgreSQL 数据源连接器(Step 55,V4:多数据源第二落点,ADR-055)。
2
+
3
+ 经 DuckDB postgres 扩展(LOAD postgres)只读 ATTACH 远程库
4
+ (TYPE postgres, READ_ONLY),注册只读 data 视图后复用 FileDataHandle
5
+ 共享实现(schema/read_sample/sql_aggregate/count_rows/warnings)。
6
+ 表名必填;schema 名经 options["schema"] 传入,默认 public。
7
+
8
+ 凭据红线(V4 硬性约束):
9
+ - DSN 只存在于内存态(spec.options["dsn"])或经 connection_ref
10
+ 指向的环境变量(如 DATASENTRY_PG_DSN),不落库、不进日志/evidence/报告
11
+ - DuckDB 错误文本会原样包含 DSN 与密码:_RedactingExecutor 将
12
+ DuckDB 异常净化(DSN → postgresql://***、密码 → ***)后转
13
+ ConnectorError 传播,REST 400 / CLI 错误面只见净化文本
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import os
20
+ from collections.abc import Iterator
21
+ from typing import ClassVar, cast
22
+ from urllib.parse import urlparse
23
+
24
+ import duckdb
25
+ import pyarrow as pa
26
+
27
+ from datasentry_core.connectors.base import FingerprintMode, FrameBatch
28
+ from datasentry_core.connectors.errors import ConnectorError, DataSourceNotFoundError
29
+ from datasentry_core.connectors.file_based import FileDataHandle, _schema_hash
30
+ from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
31
+ from datasentry_core.engine import DuckDBExecutor
32
+ from datasentry_core.engine.base import SqlParams
33
+ from datasentry_core.models.fingerprint import DatasetFingerprint
34
+
35
+ #: ATTACH 目标别名(连接器内部名,避开用户数据命名冲突概率极低)
36
+ _DB_ALIAS = "pg"
37
+
38
+ #: 加载 postgres 扩展失败的提示(缺网络/离线/发行版未带扩展)
39
+ _PG_EXTENSION_HINT = (
40
+ "failed to load duckdb postgres extension: network access is required on first use "
41
+ "(or the extension must be pre-provisioned for offline environments); "
42
+ "original error: {}"
43
+ )
44
+
45
+ #: 连接失败提示(凭据已净化)
46
+ _PG_CONNECT_HINT = "postgres connection failed (check host/port/database/credentials): {}"
47
+
48
+
49
+ def _sql_string_literal(value: str) -> str:
50
+ return "'" + value.replace("'", "''") + "'"
51
+
52
+
53
+ def _ident(value: str) -> str:
54
+ """双引号标识符转义(防表名/schema 名注入)。"""
55
+ return '"' + value.replace('"', '""') + '"'
56
+
57
+
58
+ def redact_credentials(text: str, dsn: str) -> str:
59
+ """净化错误文本:DSN 整体与密码不对外(凭据红线,可单测)。"""
60
+ redacted = text.replace(dsn, "postgresql://***")
61
+ try:
62
+ parsed = urlparse(dsn)
63
+ except ValueError:
64
+ return redacted
65
+ if parsed.password:
66
+ redacted = redacted.replace(parsed.password, "***")
67
+ return redacted
68
+
69
+
70
+ def _resolve_dsn(spec: DataSourceSpec) -> str:
71
+ """DSN 解析:spec.options["dsn"](内存态)或 connection_ref 环境变量引用。"""
72
+ dsn = spec.options.get("dsn")
73
+ if isinstance(dsn, str) and dsn:
74
+ return dsn
75
+ if spec.connection_ref:
76
+ ref = spec.connection_ref
77
+ dsn = os.environ.get(ref)
78
+ if dsn:
79
+ return dsn
80
+ raise DataSourceNotFoundError(f"postgres connection_ref env not set: {ref}")
81
+ raise DataSourceNotFoundError(
82
+ "postgres connector requires a dsn (options['dsn'] or connection_ref env)"
83
+ )
84
+
85
+
86
+ class _RedactingExecutor:
87
+ """PG 句柄专用执行器包装:DuckDB 异常净化后转 ConnectorError 传播。
88
+
89
+ 只读守卫(sql_guard)在 DuckDBExecutor 内先于执行触发,其异常
90
+ (UnsafeSqlError 等连接器族)原样透传;查询期远程错误(断连等)
91
+ 同样净化,避免 DSN 出现在 DetectorRun.error / 调度 summary 等
92
+ 落库面。
93
+ """
94
+
95
+ def __init__(self, inner: DuckDBExecutor, dsn: str) -> None:
96
+ self._inner = inner
97
+ self._dsn = dsn
98
+
99
+ def execute_setup(self, sql: str) -> None:
100
+ try:
101
+ self._inner.execute_setup(sql)
102
+ except duckdb.Error as exc:
103
+ raise ConnectorError(redact_credentials(str(exc), self._dsn)) from exc
104
+
105
+ def register(self, name: str, obj: object) -> None:
106
+ self._inner.register(name, obj)
107
+
108
+ def execute(self, sql: str, params: SqlParams = None) -> pa.Table:
109
+ try:
110
+ return self._inner.execute(sql, params)
111
+ except duckdb.Error as exc:
112
+ raise ConnectorError(redact_credentials(str(exc), self._dsn)) from exc
113
+
114
+ def execute_stream(self, sql: str, batch_size: int = 65536) -> Iterator[pa.RecordBatch]:
115
+ try:
116
+ yield from self._inner.execute_stream(sql, batch_size)
117
+ except duckdb.Error as exc:
118
+ raise ConnectorError(redact_credentials(str(exc), self._dsn)) from exc
119
+
120
+ def close(self) -> None:
121
+ self._inner.close()
122
+
123
+
124
+ class PostgresDataHandle(FileDataHandle):
125
+ """PostgreSQL 远程表数据句柄(只读,表名必填,无文件字节)。"""
126
+
127
+ requires_path: ClassVar[bool] = False
128
+
129
+ def __init__(self, spec: DataSourceSpec) -> None:
130
+ super().__init__(spec)
131
+ if spec.table_name is None:
132
+ raise DataSourceNotFoundError("postgres connector requires table_name")
133
+ self._dsn = _resolve_dsn(spec)
134
+ self._table_name = spec.table_name
135
+ self._schema_name = spec.options.get("schema")
136
+ assert isinstance(self._executor, DuckDBExecutor)
137
+ self._executor = _RedactingExecutor(self._executor, self._dsn)
138
+
139
+ @property
140
+ def table_name(self) -> str | None:
141
+ return self._table_name
142
+
143
+ def _ensure_view(self) -> None:
144
+ if self._view_ready:
145
+ return
146
+ try:
147
+ self._executor.execute_setup("LOAD postgres")
148
+ except ConnectorError as exc:
149
+ raise ConnectorError(_PG_EXTENSION_HINT.format(exc)) from exc
150
+ try:
151
+ self._executor.execute_setup(
152
+ f"ATTACH {_sql_string_literal(self._dsn)} AS {_DB_ALIAS} (TYPE postgres, READ_ONLY)"
153
+ )
154
+ except ConnectorError as exc:
155
+ raise ConnectorError(_PG_CONNECT_HINT.format(exc)) from exc
156
+ schema_ident = _ident(cast(str, self._schema_name) or "public")
157
+ table_ident = f"{_DB_ALIAS}.{schema_ident}.{_ident(self._table_name)}"
158
+ try:
159
+ self._executor.execute(f"SELECT 1 FROM {table_ident} LIMIT 0")
160
+ except ConnectorError as exc:
161
+ raise DataSourceNotFoundError(
162
+ f"postgres table not found: {self._schema_name or 'public'}.{self._table_name}"
163
+ ) from exc
164
+ self._executor.execute_setup(f"CREATE OR REPLACE VIEW data AS SELECT * FROM {table_ident}")
165
+ self._view_ready = True
166
+
167
+ def read_batches(self, batch_size: int = 65536) -> Iterator[FrameBatch]:
168
+ self._check_open()
169
+ if batch_size < 1:
170
+ raise ValueError("batch_size must be >= 1")
171
+ self._ensure_view()
172
+ row_offset = 0
173
+ for idx, batch in enumerate(
174
+ self._executor.execute_stream("SELECT * FROM data", batch_size)
175
+ ):
176
+ table = pa.Table.from_batches([batch])
177
+ yield FrameBatch(
178
+ source=self._spec,
179
+ table=table,
180
+ batch_index=idx,
181
+ row_offset=row_offset,
182
+ )
183
+ row_offset += table.num_rows
184
+
185
+ def content_fingerprint(self) -> str:
186
+ """内容指纹(Step 55):单查询全表哈希(行序无关、NULL 不折叠)。
187
+
188
+ 逐行哈希 = md5(concat_ws(chr(31), coalesce(col::VARCHAR, chr(0))...)),
189
+ 排序聚合后并入 schema_hash 与行数 —— 类型/数量/取值任一变化
190
+ 指纹即变;相同内容指纹恒定(供调度变更感知作跳过基准)。
191
+ """
192
+ self._check_open()
193
+ self._ensure_view()
194
+ signature = [(c.name, c.physical_type) for c in self.schema().columns]
195
+ schema_hash = _schema_hash(signature)
196
+ exprs = ", ".join(
197
+ f"coalesce(cast({_ident(name)} AS VARCHAR), chr(0))" for name, _typ in signature
198
+ )
199
+ table = self._executor.execute(
200
+ "SELECT count(*) AS n, md5(string_agg(rh, '' ORDER BY rh)) AS h FROM "
201
+ f"(SELECT md5(concat_ws(chr(31), {exprs})) AS rh FROM data) t"
202
+ )
203
+ row_count = table.column(0).to_pylist()[0]
204
+ rows_hash = table.column(1).to_pylist()[0]
205
+ assert isinstance(row_count, int)
206
+ digest = hashlib.sha256(f"{schema_hash}|{row_count}|{rows_hash or ''}".encode()).hexdigest()
207
+ return digest
208
+
209
+ def fingerprint(self, mode: FingerprintMode = "full") -> DatasetFingerprint:
210
+ """PG 无文件字节:file_sha256=None,内容指纹放 content_sample_hash。
211
+
212
+ sampled 档 MVP 与 full 同(远程源暂不做增量采样优化,ADR-055)。
213
+ """
214
+ self._check_open()
215
+ signature = [(c.name, c.physical_type) for c in self.schema().columns]
216
+ row_count = self.count_rows()
217
+ content_hash = self.content_fingerprint() if mode != "metadata_only" else None
218
+ return DatasetFingerprint(
219
+ dataset_id=self._spec.options.get("dataset_id", ""),
220
+ fingerprint_type=mode,
221
+ file_sha256=None,
222
+ schema_hash=_schema_hash(signature),
223
+ row_count=row_count,
224
+ column_count=len(signature),
225
+ column_signature=signature,
226
+ content_sample_hash=content_hash,
227
+ )
228
+
229
+
230
+ class PostgresConnector:
231
+ """PostgreSQL 数据源连接器(Step 55,V4,ADR-055)。"""
232
+
233
+ connector_id = "postgres"
234
+ display_name = "PostgreSQL"
235
+
236
+ def supports(self, source: DataSourceSpec) -> bool:
237
+ if source.source_type != DataSourceType.POSTGRESQL:
238
+ return False
239
+ dsn = source.options.get("dsn")
240
+ return (isinstance(dsn, str) and bool(dsn)) or source.connection_ref is not None
241
+
242
+ def open(self, source: DataSourceSpec) -> PostgresDataHandle:
243
+ if source.table_name is None:
244
+ raise DataSourceNotFoundError(
245
+ "postgres connector requires table_name (e.g. scan --table <table>)"
246
+ )
247
+ return PostgresDataHandle(source)
248
+
249
+ def close(self) -> None:
250
+ pass
251
+
252
+
253
+ __all__ = ["PostgresConnector", "PostgresDataHandle", "redact_credentials"]
@@ -8,6 +8,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
10
  from datasentry_core.connectors.parquet import ParquetConnector
11
+ from datasentry_core.connectors.postgres import PostgresConnector
11
12
  from datasentry_core.connectors.spec import DataSourceSpec
12
13
  from datasentry_core.connectors.sqlite import SqliteConnector
13
14
  from datasentry_core.connectors.xlsx import XlsxConnector
@@ -46,7 +47,7 @@ class ConnectorRegistry:
46
47
 
47
48
 
48
49
  def default_registry() -> ConnectorRegistry:
49
- """默认注册表:文件型连接器 + DuckDB/SQLite 文件连接器(Step 38/54)。"""
50
+ """默认注册表:文件型连接器 + DuckDB/SQLite 文件连接器(Step 38/54)+ PostgreSQL(Step 55)。"""
50
51
  registry = ConnectorRegistry()
51
52
  registry.register(CsvConnector())
52
53
  registry.register(ParquetConnector())
@@ -54,4 +55,5 @@ def default_registry() -> ConnectorRegistry:
54
55
  registry.register(XlsxConnector())
55
56
  registry.register(DuckdbConnector())
56
57
  registry.register(SqliteConnector())
58
+ registry.register(PostgresConnector())
57
59
  return registry
@@ -5,7 +5,7 @@ Polars 引擎(ADR-005 归 V1)将实现同一协议,消费方无需感知
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from collections.abc import Mapping, Sequence
8
+ from collections.abc import Iterator, Mapping, Sequence
9
9
  from typing import Protocol, runtime_checkable
10
10
 
11
11
  import pyarrow as pa
@@ -32,3 +32,30 @@ class SqlExecutor(Protocol):
32
32
  def close(self) -> None:
33
33
  """释放底层连接。关闭后 execute 抛错误。"""
34
34
  ...
35
+
36
+
37
+ @runtime_checkable
38
+ class SetupExecutor(Protocol):
39
+ """连接器内部执行器协议:只读面 + 受信 setup 面(视图/ATTACH/register)。
40
+
41
+ FileDataHandle 等连接器基类以此类型持有执行器,便于包装器
42
+ (如 Step 55 的凭据净化包装)在类型安全的前提下替换实现。
43
+ """
44
+
45
+ def execute_setup(self, sql: str) -> None: ...
46
+
47
+ def register(self, name: str, obj: object) -> None: ...
48
+
49
+ def execute(
50
+ self,
51
+ sql: str,
52
+ params: SqlParams = None,
53
+ ) -> pa.Table: ...
54
+
55
+ def execute_stream(
56
+ self,
57
+ sql: str,
58
+ batch_size: int = 65536,
59
+ ) -> Iterator[pa.RecordBatch]: ...
60
+
61
+ def close(self) -> None: ...
@@ -9,6 +9,7 @@
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import decimal
12
13
  import json
13
14
  import sqlite3
14
15
  import threading
@@ -39,6 +40,19 @@ def _iso(dt: datetime) -> str:
39
40
  return dt.isoformat()
40
41
 
41
42
 
43
+ def _json_default(obj: Any) -> Any:
44
+ """evidence/参数 JSON 兜底(Step 55:PG 源聚合结果可能带 Decimal)。
45
+
46
+ 检测器 evidence.data 契约是 JSON 可序列化;DuckDB postgres 扩展对
47
+ numeric 聚合(avg/sum 等)返回 Decimal,此处统一收敛为 float。
48
+ """
49
+ if isinstance(obj, decimal.Decimal):
50
+ return float(obj)
51
+ if isinstance(obj, (datetime,)):
52
+ return obj.isoformat()
53
+ raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable")
54
+
55
+
42
56
  class MetadataStore:
43
57
  """项目元数据库门面。open 即迁移;close 后不可用。"""
44
58
 
@@ -184,7 +198,7 @@ class MetadataStore:
184
198
  evidence.detector_id,
185
199
  evidence.detector_version,
186
200
  evidence.description,
187
- json.dumps(evidence.data),
201
+ json.dumps(evidence.data, default=_json_default),
188
202
  evidence.confidence,
189
203
  (
190
204
  evidence.provenance.model_dump_json()