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.
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/PKG-INFO +1 -1
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/pyproject.toml +1 -1
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/__init__.py +1 -1
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/__init__.py +3 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/base.py +5 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/csv.py +5 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/file_based.py +34 -5
- datasentry_core-0.6.0/src/datasentry_core/connectors/postgres.py +253 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/registry.py +3 -1
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/base.py +28 -1
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/storage/store.py +15 -1
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/.gitignore +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/README.md +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/duckdb.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/errors.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/jsonl.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/parquet.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/spec.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/sqlite.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/xlsx.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/contracts/__init__.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/contracts/exporters.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/__init__.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/base.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/common.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/cross_field.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/cross_table.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/datetime.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/__init__.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/anomaly_ml.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/categorical.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/formula.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/missing.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/numeric.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/textual.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/uniqueness.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/missingness.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/runner.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/safe_eval.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/textual_variants.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/drift/__init__.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/drift/engine.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/__init__.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/duckdb.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/fusion.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/profiler.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/engine/sql_guard.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/llm/provider.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/__init__.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/audit.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/contract.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/detector.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/drift.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/enums.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/evidence.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/feedback.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/fingerprint.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/issue.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/llm.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/profile.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/quality.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/repair.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/rules.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/scan.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/models/validation.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/plugins.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/privacy/redactor.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/repair/__init__.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/repair/engine.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/__init__.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/html.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/interactive.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/junit.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/markdown.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/sarif.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/rules/engine.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/scoring/__init__.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/scoring/engine.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/scoring/gate.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/scoring/quality.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/scoring/weights.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/storage/__init__.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/storage/paths.py +0 -0
- {datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/storage/schema.py +0 -0
|
@@ -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 档)。"""
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/connectors/file_based.py
RENAMED
|
@@ -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.
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/cross_field.py
RENAMED
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/cross_table.py
RENAMED
|
File without changes
|
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/__init__.py
RENAMED
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/anomaly_ml.py
RENAMED
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/categorical.py
RENAMED
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/formula.py
RENAMED
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/missing.py
RENAMED
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/numeric.py
RENAMED
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/textual.py
RENAMED
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/initial/uniqueness.py
RENAMED
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/missingness.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/detectors/textual_variants.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datasentry_core-0.5.0 → datasentry_core-0.6.0}/src/datasentry_core/reporting/interactive.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|