datasentry-core 0.6.0__tar.gz → 0.7.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.6.0 → datasentry_core-0.7.0}/PKG-INFO +1 -1
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/pyproject.toml +1 -1
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/__init__.py +1 -1
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/__init__.py +6 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/base.py +11 -1
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/csv.py +19 -6
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/duckdb.py +1 -1
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/file_based.py +17 -4
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/jsonl.py +5 -1
- datasentry_core-0.7.0/src/datasentry_core/connectors/mysql.py +266 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/parquet.py +5 -1
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/postgres.py +8 -5
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/registry.py +4 -0
- datasentry_core-0.7.0/src/datasentry_core/connectors/remote_file.py +263 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/spec.py +19 -1
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/sqlite.py +1 -1
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/repair/engine.py +1 -1
- datasentry_core-0.7.0/src/datasentry_core/secrets.py +116 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/.gitignore +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/README.md +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/errors.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/xlsx.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/contracts/__init__.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/contracts/exporters.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/__init__.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/base.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/common.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/cross_field.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/cross_table.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/datetime.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/__init__.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/anomaly_ml.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/categorical.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/formula.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/missing.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/numeric.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/textual.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/uniqueness.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/missingness.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/runner.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/safe_eval.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/textual_variants.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/drift/__init__.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/drift/engine.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/engine/__init__.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/engine/base.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/engine/duckdb.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/engine/fusion.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/engine/profiler.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/engine/sql_guard.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/llm/provider.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/__init__.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/audit.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/contract.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/detector.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/drift.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/enums.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/evidence.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/feedback.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/fingerprint.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/issue.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/llm.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/profile.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/quality.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/repair.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/rules.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/scan.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/models/validation.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/plugins.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/privacy/redactor.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/repair/__init__.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/reporting/__init__.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/reporting/html.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/reporting/interactive.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/reporting/junit.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/reporting/markdown.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/reporting/sarif.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/rules/engine.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/scoring/__init__.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/scoring/engine.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/scoring/gate.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/scoring/quality.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/scoring/weights.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/storage/__init__.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/storage/paths.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/storage/schema.py +0 -0
- {datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/storage/store.py +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,7 +27,7 @@ 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
|
|
@@ -91,8 +91,8 @@ class CsvDataHandle:
|
|
|
91
91
|
"""CSV 数据句柄:pyarrow 流式读取 + duckdb 只读聚合。"""
|
|
92
92
|
|
|
93
93
|
def __init__(self, spec: DataSourceSpec) -> None:
|
|
94
|
-
if spec.path is None:
|
|
95
|
-
raise DataSourceNotFoundError("csv source requires a path")
|
|
94
|
+
if spec.path is None or isinstance(spec.path, str):
|
|
95
|
+
raise DataSourceNotFoundError("csv source requires a local file path")
|
|
96
96
|
self._spec = spec
|
|
97
97
|
self._path = spec.path
|
|
98
98
|
self._encoding = _detect_encoding(spec.path, override=spec.options.get("encoding"))
|
|
@@ -108,7 +108,7 @@ class CsvDataHandle:
|
|
|
108
108
|
return self._spec.source_type
|
|
109
109
|
|
|
110
110
|
@property
|
|
111
|
-
def source_path(self) -> Path | None:
|
|
111
|
+
def source_path(self) -> Path | str | None:
|
|
112
112
|
return self._path
|
|
113
113
|
|
|
114
114
|
@property
|
|
@@ -288,9 +288,17 @@ class CsvDataHandle:
|
|
|
288
288
|
|
|
289
289
|
def content_fingerprint(self) -> str:
|
|
290
290
|
"""内容指纹(Step 55):文件源 = 文件 SHA-256(Step 53 调度哈希语义)。"""
|
|
291
|
-
|
|
291
|
+
if not isinstance(self._path, Path):
|
|
292
|
+
raise ConnectorError("csv fingerprint requires a local file path")
|
|
292
293
|
return _file_sha256(self._path)
|
|
293
294
|
|
|
295
|
+
def stats_fingerprint(self) -> str:
|
|
296
|
+
"""统计层指纹(Step 58):本地文件源不参与两层指纹——调度器仅对
|
|
297
|
+
远程源(PG/MySQL/云文件)调用,本地文件沿用 Step 53 单层语义。"""
|
|
298
|
+
raise NotImplementedError(
|
|
299
|
+
"local file handles do not participate in two-layer fingerprinting"
|
|
300
|
+
)
|
|
301
|
+
|
|
294
302
|
|
|
295
303
|
def _file_sha256(path: Path) -> str:
|
|
296
304
|
"""流式文件 SHA-256(≤1GB 全量,大文件走 sampled 档)。"""
|
|
@@ -308,7 +316,12 @@ class CsvConnector:
|
|
|
308
316
|
display_name = "CSV"
|
|
309
317
|
|
|
310
318
|
def supports(self, source: DataSourceSpec) -> bool:
|
|
311
|
-
|
|
319
|
+
# Step 57:本地文件连接器排除云 URI(s3:// gs:// az:// 归 RemoteFileConnector)
|
|
320
|
+
return (
|
|
321
|
+
source.source_type == DataSourceType.CSV
|
|
322
|
+
and source.path is not None
|
|
323
|
+
and not isinstance(source.path, str)
|
|
324
|
+
)
|
|
312
325
|
|
|
313
326
|
def open(self, source: DataSourceSpec) -> DataHandle:
|
|
314
327
|
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")
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/connectors/file_based.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
78
|
+
dsn = lookup_secret(ref)
|
|
78
79
|
if dsn:
|
|
79
80
|
return dsn
|
|
80
|
-
raise DataSourceNotFoundError(
|
|
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
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""云存储文件源连接器(Step 57,V5:云存储文件源,ADR-057)。
|
|
2
|
+
|
|
3
|
+
经 DuckDB httpfs 扩展只读直读对象存储:s3://、gs://、az:// 前缀的
|
|
4
|
+
CSV/Parquet/JSONL 文件,注册只读 data 视图后复用 FileDataHandle
|
|
5
|
+
共享实现(schema/read_sample/sql_aggregate/count_rows/warnings)。
|
|
6
|
+
零新依赖(httpfs 随 DuckDB 分发,离线需预装)。
|
|
7
|
+
|
|
8
|
+
凭据红线(沿用 Step 55/56 全套语义):
|
|
9
|
+
- 凭据只走进程环境变量(AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY 等
|
|
10
|
+
httpfs 原生读取)或 spec.options 内存态覆盖;不落库、不进日志/
|
|
11
|
+
evidence/报告
|
|
12
|
+
- 非机密会话配置(endpoint/region/url_style/use_ssl)可经 options
|
|
13
|
+
传入(如 MinIO:options["s3_endpoint"]),有自定义 endpoint 时
|
|
14
|
+
自动 path-style + 非 SSL(MinIO 必需);无 endpoint 走 AWS 默认
|
|
15
|
+
(vhost-style + SSL)
|
|
16
|
+
- httpfs 错误文本可能回显完整 URL(az:// 可含 SAS token 查询参数):
|
|
17
|
+
_RemoteRedactingExecutor 将 URI 整体净化后转 ConnectorError 传播
|
|
18
|
+
|
|
19
|
+
变更感知(快速失效层):
|
|
20
|
+
- httpfs 不暴露对象 ETag(glob 仅 file 列)→ `content_fingerprint()`
|
|
21
|
+
用 size + last_modified 元数据组合哈希(read_blob 元数据列,
|
|
22
|
+
HEAD 级开销 ~0.004s,免下载)作为调度跳过判定基准
|
|
23
|
+
- 语义:同内容 → size/mtime 不变 → 指纹相同(跳过);变更(重新
|
|
24
|
+
上传)→ Last-Modified 必更新 → 指纹变(重扫)。已知局限(ADR-057):
|
|
25
|
+
同秒内同 size 覆盖的极限窗口会漏判;元数据抖动会多扫一次(无害)
|
|
26
|
+
- 源不可达(对象删除/断网)→ DataSourceNotFoundError,调度回退
|
|
27
|
+
正常失败路径,不误跳过
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import os
|
|
34
|
+
from collections.abc import Iterator
|
|
35
|
+
from typing import ClassVar
|
|
36
|
+
|
|
37
|
+
import duckdb
|
|
38
|
+
import pyarrow as pa
|
|
39
|
+
|
|
40
|
+
from datasentry_core.connectors.base import FingerprintMode, FrameBatch
|
|
41
|
+
from datasentry_core.connectors.errors import ConnectorError, DataSourceNotFoundError
|
|
42
|
+
from datasentry_core.connectors.file_based import FileDataHandle, _schema_hash
|
|
43
|
+
from datasentry_core.connectors.spec import DataSourceSpec, DataSourceType
|
|
44
|
+
from datasentry_core.engine import DuckDBExecutor
|
|
45
|
+
from datasentry_core.engine.base import SqlParams
|
|
46
|
+
from datasentry_core.models.fingerprint import DatasetFingerprint
|
|
47
|
+
|
|
48
|
+
#: 云存储 URI 前缀(httpfs 原生支持的三种对象存储)
|
|
49
|
+
_CLOUD_PREFIXES: tuple[str, ...] = ("s3://", "gs://", "az://")
|
|
50
|
+
|
|
51
|
+
#: 扩展加载失败提示(缺网络/离线/发行版未带扩展)
|
|
52
|
+
_HTTPFS_EXTENSION_HINT = (
|
|
53
|
+
"failed to load duckdb httpfs extension: network access is required on first use "
|
|
54
|
+
"(or the extension must be pre-provisioned for offline environments); "
|
|
55
|
+
"original error: {}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
#: read_* 函数按格式分派
|
|
59
|
+
_READ_FUNCS = {
|
|
60
|
+
DataSourceType.CSV: "read_csv_auto",
|
|
61
|
+
DataSourceType.PARQUET: "read_parquet",
|
|
62
|
+
DataSourceType.JSONL: "read_json_auto",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _sql_string_literal(value: str) -> str:
|
|
67
|
+
return "'" + value.replace("'", "''") + "'"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def redact_uri(text: str, uri: str) -> str:
|
|
71
|
+
"""净化错误文本:完整 URI(含 az:// SAS token 等查询参数)不对外。"""
|
|
72
|
+
return text.replace(uri, "<remote-uri>")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_cloud_uri(path: str) -> bool:
|
|
76
|
+
return path.startswith(_CLOUD_PREFIXES)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _s3_session_options(spec: DataSourceSpec) -> list[str]:
|
|
80
|
+
"""S3 会话配置(仅 s3://):endpoint 经 options 或标准 env;凭据只走 env。
|
|
81
|
+
|
|
82
|
+
有自定义 endpoint(MinIO 等)→ 自动 path-style + 非 SSL(MinIO 必需);
|
|
83
|
+
无 endpoint → AWS 默认(vhost-style + SSL),零配置。
|
|
84
|
+
"""
|
|
85
|
+
options = spec.options
|
|
86
|
+
endpoint = (
|
|
87
|
+
options.get("s3_endpoint")
|
|
88
|
+
or os.environ.get("AWS_ENDPOINT_URL_S3")
|
|
89
|
+
or os.environ.get("AWS_ENDPOINT_URL")
|
|
90
|
+
)
|
|
91
|
+
if not endpoint:
|
|
92
|
+
return []
|
|
93
|
+
statements = [
|
|
94
|
+
f"SET s3_endpoint = {_sql_string_literal(str(endpoint))}",
|
|
95
|
+
"SET s3_region = "
|
|
96
|
+
f"{_sql_string_literal(str(options.get('s3_region') or os.environ.get('AWS_REGION') or 'us-east-1'))}", # noqa: E501
|
|
97
|
+
f"SET s3_url_style = {_sql_string_literal(str(options.get('s3_url_style') or 'path'))}",
|
|
98
|
+
f"SET s3_use_ssl = {_sql_string_literal(str(options.get('s3_use_ssl', 'false')).lower())}",
|
|
99
|
+
]
|
|
100
|
+
return statements
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _RemoteRedactingExecutor:
|
|
104
|
+
"""云文件句柄专用执行器包装:DuckDB 异常净化(URI 打码)后转 ConnectorError。
|
|
105
|
+
|
|
106
|
+
只读守卫(sql_guard)在 DuckDBExecutor 内先于执行触发,其异常
|
|
107
|
+
(UnsafeSqlError 等连接器族)原样透传;httpfs 错误文本可能回显
|
|
108
|
+
完整 URL(az:// SAS token),净化后传播避免凭据出现在落库面。
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, inner: DuckDBExecutor, uri: str) -> None:
|
|
112
|
+
self._inner = inner
|
|
113
|
+
self._uri = uri
|
|
114
|
+
|
|
115
|
+
def execute_setup(self, sql: str) -> None:
|
|
116
|
+
try:
|
|
117
|
+
self._inner.execute_setup(sql)
|
|
118
|
+
except duckdb.Error as exc:
|
|
119
|
+
raise ConnectorError(redact_uri(str(exc), self._uri)) from exc
|
|
120
|
+
|
|
121
|
+
def register(self, name: str, obj: object) -> None:
|
|
122
|
+
self._inner.register(name, obj)
|
|
123
|
+
|
|
124
|
+
def execute(self, sql: str, params: SqlParams = None) -> pa.Table:
|
|
125
|
+
try:
|
|
126
|
+
return self._inner.execute(sql, params)
|
|
127
|
+
except duckdb.Error as exc:
|
|
128
|
+
raise ConnectorError(redact_uri(str(exc), self._uri)) from exc
|
|
129
|
+
|
|
130
|
+
def execute_stream(self, sql: str, batch_size: int = 65536) -> Iterator[pa.RecordBatch]:
|
|
131
|
+
try:
|
|
132
|
+
yield from self._inner.execute_stream(sql, batch_size)
|
|
133
|
+
except duckdb.Error as exc:
|
|
134
|
+
raise ConnectorError(redact_uri(str(exc), self._uri)) from exc
|
|
135
|
+
|
|
136
|
+
def close(self) -> None:
|
|
137
|
+
self._inner.close()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class RemoteFileDataHandle(FileDataHandle):
|
|
141
|
+
"""云存储文件数据句柄(只读,s3:// gs:// az:// 前缀)。"""
|
|
142
|
+
|
|
143
|
+
requires_path: ClassVar[bool] = True
|
|
144
|
+
|
|
145
|
+
def __init__(self, spec: DataSourceSpec) -> None:
|
|
146
|
+
super().__init__(spec)
|
|
147
|
+
if not isinstance(self._path, str) or not _is_cloud_uri(self._path):
|
|
148
|
+
raise DataSourceNotFoundError(
|
|
149
|
+
f"remote file connector requires a cloud uri (s3:// gs:// az://), got: {self._path}"
|
|
150
|
+
)
|
|
151
|
+
self._uri = self._path
|
|
152
|
+
assert isinstance(self._executor, DuckDBExecutor)
|
|
153
|
+
self._executor = _RemoteRedactingExecutor(self._executor, self._uri)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def source_path(self) -> str | None:
|
|
157
|
+
return self._uri
|
|
158
|
+
|
|
159
|
+
def _ensure_view(self) -> None:
|
|
160
|
+
if self._view_ready:
|
|
161
|
+
return
|
|
162
|
+
try:
|
|
163
|
+
self._executor.execute_setup("LOAD httpfs")
|
|
164
|
+
except ConnectorError as exc:
|
|
165
|
+
raise ConnectorError(_HTTPFS_EXTENSION_HINT.format(exc)) from exc
|
|
166
|
+
for statement in _s3_session_options(self._spec):
|
|
167
|
+
self._executor.execute_setup(statement)
|
|
168
|
+
read_func = _READ_FUNCS[self._spec.source_type]
|
|
169
|
+
try:
|
|
170
|
+
self._executor.execute(
|
|
171
|
+
f"SELECT 1 FROM {read_func}({_sql_string_literal(self._uri)}) LIMIT 0"
|
|
172
|
+
)
|
|
173
|
+
except ConnectorError as exc:
|
|
174
|
+
raise DataSourceNotFoundError(
|
|
175
|
+
f"remote file not found or unreadable: {read_func}: {exc}"
|
|
176
|
+
) from exc
|
|
177
|
+
self._executor.execute_setup(
|
|
178
|
+
"CREATE OR REPLACE VIEW data AS "
|
|
179
|
+
f"SELECT * FROM {read_func}({_sql_string_literal(self._uri)})"
|
|
180
|
+
)
|
|
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 57):size + last_modified 元数据组合(快速失效层)。
|
|
203
|
+
|
|
204
|
+
read_blob 元数据列(HEAD 级开销 ~0.004s)免下载;对象重新上传
|
|
205
|
+
Last-Modified 必更新 → 指纹变。极限窗口(同秒同 size 覆盖)与
|
|
206
|
+
元数据抖动行为记录在 ADR-057 已知局限。
|
|
207
|
+
"""
|
|
208
|
+
self._check_open()
|
|
209
|
+
self._ensure_view()
|
|
210
|
+
try:
|
|
211
|
+
meta = self._executor.execute(
|
|
212
|
+
f"SELECT size, last_modified FROM read_blob({_sql_string_literal(self._uri)})"
|
|
213
|
+
)
|
|
214
|
+
except ConnectorError as exc:
|
|
215
|
+
raise DataSourceNotFoundError(f"remote file metadata unavailable: {exc}") from exc
|
|
216
|
+
rows = meta.to_pylist()
|
|
217
|
+
if not rows:
|
|
218
|
+
raise DataSourceNotFoundError(f"remote file not found: {self._uri}")
|
|
219
|
+
size = rows[0]["size"]
|
|
220
|
+
last_modified = str(rows[0]["last_modified"])
|
|
221
|
+
assert isinstance(size, int)
|
|
222
|
+
return hashlib.sha256(f"{self._uri}|{size}|{last_modified}".encode()).hexdigest()
|
|
223
|
+
|
|
224
|
+
def fingerprint(self, mode: FingerprintMode = "full") -> DatasetFingerprint:
|
|
225
|
+
"""云文件无本地字节:file_sha256=None,内容指纹(快速失效层)放
|
|
226
|
+
content_sample_hash(与 PG/MySQL 语义一致)。"""
|
|
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 RemoteFileConnector:
|
|
244
|
+
"""云存储文件源连接器(Step 57,V5,ADR-057):CSV/Parquet/JSONL。"""
|
|
245
|
+
|
|
246
|
+
connector_id = "remote"
|
|
247
|
+
display_name = "Cloud Storage"
|
|
248
|
+
|
|
249
|
+
def supports(self, source: DataSourceSpec) -> bool:
|
|
250
|
+
return (
|
|
251
|
+
source.source_type in _READ_FUNCS
|
|
252
|
+
and isinstance(source.path, str)
|
|
253
|
+
and _is_cloud_uri(source.path)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def open(self, source: DataSourceSpec) -> RemoteFileDataHandle:
|
|
257
|
+
return RemoteFileDataHandle(source)
|
|
258
|
+
|
|
259
|
+
def close(self) -> None:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
__all__ = ["RemoteFileConnector", "RemoteFileDataHandle", "_is_cloud_uri", "redact_uri"]
|
|
@@ -21,16 +21,34 @@ class DataSourceType(StrEnum):
|
|
|
21
21
|
XLSX = "xlsx"
|
|
22
22
|
SQLITE = "sqlite"
|
|
23
23
|
POSTGRESQL = "postgresql"
|
|
24
|
+
MYSQL = "mysql"
|
|
24
25
|
DUCKDB = "duckdb"
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
#: 文件扩展名 → 数据源类型(Step 18/57:scan_file 与调度器按后缀推断,
|
|
29
|
+
#: 云存储文件源仅限 CSV/Parquet/JSONL,见 RemoteFileConnector)
|
|
30
|
+
EXT_TO_SOURCE_TYPE: dict[str, DataSourceType] = {
|
|
31
|
+
".csv": DataSourceType.CSV,
|
|
32
|
+
".tsv": DataSourceType.CSV,
|
|
33
|
+
".parquet": DataSourceType.PARQUET,
|
|
34
|
+
".pq": DataSourceType.PARQUET,
|
|
35
|
+
".jsonl": DataSourceType.JSONL,
|
|
36
|
+
".ndjson": DataSourceType.JSONL,
|
|
37
|
+
".xlsx": DataSourceType.XLSX,
|
|
38
|
+
".duckdb": DataSourceType.DUCKDB,
|
|
39
|
+
".db": DataSourceType.SQLITE,
|
|
40
|
+
".sqlite": DataSourceType.SQLITE,
|
|
41
|
+
".sqlite3": DataSourceType.SQLITE,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
27
45
|
class DataSourceSpec(BaseModel):
|
|
28
46
|
"""数据源规格:描述"从哪读、怎么读",不含凭据原文。"""
|
|
29
47
|
|
|
30
48
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
31
49
|
|
|
32
50
|
source_type: DataSourceType
|
|
33
|
-
path: Path | None = None
|
|
51
|
+
path: Path | str | None = None
|
|
34
52
|
table_name: str | None = None
|
|
35
53
|
options: dict[str, Any] = Field(default_factory=dict)
|
|
36
54
|
connection_ref: str | None = None
|
|
@@ -28,7 +28,7 @@ class SQLiteDataHandle(FileDataHandle):
|
|
|
28
28
|
|
|
29
29
|
def __init__(self, spec: DataSourceSpec) -> None:
|
|
30
30
|
super().__init__(spec)
|
|
31
|
-
if spec.path is None or not spec.path.is_file():
|
|
31
|
+
if spec.path is None or isinstance(spec.path, str) or not spec.path.is_file():
|
|
32
32
|
raise DataSourceNotFoundError(f"sqlite file not found: {spec.path}")
|
|
33
33
|
if spec.table_name is None:
|
|
34
34
|
raise DataSourceNotFoundError("sqlite connector requires table_name")
|
|
@@ -251,7 +251,7 @@ class RepairEngine:
|
|
|
251
251
|
repairs_dir.mkdir(parents=True, exist_ok=True)
|
|
252
252
|
run_id = f"rep_{uuid.uuid4().hex[:12]}"
|
|
253
253
|
source_path = context.handle.source_path
|
|
254
|
-
if source_path is None or not source_path.exists():
|
|
254
|
+
if source_path is None or isinstance(source_path, str) or not source_path.exists():
|
|
255
255
|
raise FileNotFoundError("repair requires an on-disk source file")
|
|
256
256
|
suffix = self._suffix(context)
|
|
257
257
|
artifact_path = repairs_dir / f"{run_id}.before{suffix}"
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""凭据文件管理(Step 59,ADR-059):~/.config/datasentry/secrets.env。
|
|
2
|
+
|
|
3
|
+
- 行格式 `KEY=VALUE`(空行/`#` 注释忽略),键名必须匹配环境变量命名
|
|
4
|
+
形态 `[A-Z][A-Z0-9_]*`(连接器 connection_ref 直接按键名引用);
|
|
5
|
+
- 文件权限强制 600(读取与写入双向校验,过松给可操作错误);
|
|
6
|
+
- 统一解析链(CLI 参数 > 进程环境变量 > secrets.env 自动加载):
|
|
7
|
+
`lookup_secret` 先查进程 env 再查文件,均无返回 None;
|
|
8
|
+
- 值不落库/日志/报告(连接器层仅内存态流转,沿用 Step 55/56 红线)。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import stat
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_KEY_PATTERN = re.compile(r"[A-Z][A-Z0-9_]*")
|
|
19
|
+
_FILE_NAME = "secrets.env"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SecretsFileError(ValueError):
|
|
23
|
+
"""secrets.env 解析或权限错误;消息可操作(含文件路径与行号)。"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def secrets_path() -> Path:
|
|
27
|
+
"""secrets.env 路径:DATASENTRY_CONFIG_HOME > XDG_CONFIG_HOME > ~/.config。"""
|
|
28
|
+
base = os.environ.get("DATASENTRY_CONFIG_HOME") or os.environ.get("XDG_CONFIG_HOME")
|
|
29
|
+
if base:
|
|
30
|
+
return Path(base) / "datasentry" / _FILE_NAME
|
|
31
|
+
return Path.home() / ".config" / "datasentry" / _FILE_NAME
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_permissions(path: Path) -> None:
|
|
35
|
+
"""权限校验(读取侧):组/他位可读即拒绝——凭据 600 是强制语义。"""
|
|
36
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
37
|
+
if mode & 0o077:
|
|
38
|
+
raise SecretsFileError(
|
|
39
|
+
f"secrets file permissions too open: {path} is {oct(mode)}; "
|
|
40
|
+
"run `chmod 600 <file>` (datasentry secrets set 会自动修正)"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_secrets(path: Path) -> dict[str, str]:
|
|
45
|
+
"""解析 secrets.env(不校验权限——写入路径自会重写为 600)。"""
|
|
46
|
+
secrets: dict[str, str] = {}
|
|
47
|
+
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
48
|
+
stripped = line.strip()
|
|
49
|
+
if not stripped or stripped.startswith("#"):
|
|
50
|
+
continue
|
|
51
|
+
if "=" not in stripped:
|
|
52
|
+
raise SecretsFileError(f"malformed secrets line {path}:{lineno} (expected KEY=VALUE)")
|
|
53
|
+
name, value = stripped.split("=", 1)
|
|
54
|
+
if not _KEY_PATTERN.fullmatch(name):
|
|
55
|
+
raise SecretsFileError(
|
|
56
|
+
f"invalid secret key {path}:{lineno}: {name!r} (keys must match [A-Z][A-Z0-9_]*)"
|
|
57
|
+
)
|
|
58
|
+
secrets[name] = value
|
|
59
|
+
return secrets
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_secrets(path: Path | None = None) -> dict[str, str]:
|
|
63
|
+
"""解析 secrets.env 为 dict;文件缺失返回 {},权限过松/格式错误抛错。"""
|
|
64
|
+
secrets_file = path or secrets_path()
|
|
65
|
+
if not secrets_file.is_file():
|
|
66
|
+
return {}
|
|
67
|
+
_check_permissions(secrets_file)
|
|
68
|
+
return _read_secrets(secrets_file)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def lookup_secret(name: str, path: Path | None = None) -> str | None:
|
|
72
|
+
"""统一解析链:进程环境变量优先,回落 secrets.env;均无返回 None。"""
|
|
73
|
+
value = os.environ.get(name)
|
|
74
|
+
if value:
|
|
75
|
+
return value
|
|
76
|
+
return load_secrets(path).get(name)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def write_secrets(secrets: dict[str, str], path: Path | None = None) -> Path:
|
|
80
|
+
"""原子写回 secrets.env:父目录 700、文件 600(set/rm 共用)。"""
|
|
81
|
+
secrets_file = path or secrets_path()
|
|
82
|
+
secrets_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
83
|
+
tmp = secrets_file.with_name(f".{secrets_file.name}.tmp")
|
|
84
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
85
|
+
os.fchmod(fh.fileno(), 0o600)
|
|
86
|
+
for name in sorted(secrets):
|
|
87
|
+
fh.write(f"{name}={secrets[name]}\n")
|
|
88
|
+
os.chmod(tmp, 0o600)
|
|
89
|
+
os.replace(tmp, secrets_file)
|
|
90
|
+
os.chmod(secrets_file, 0o600)
|
|
91
|
+
return secrets_file
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def set_secret(name: str, value: str, path: Path | None = None) -> Path:
|
|
95
|
+
"""写入/更新单个凭据(CLI `secrets set` 入口)。
|
|
96
|
+
|
|
97
|
+
读取侧绕过权限检查:本操作随即将文件整体重写为 600——过松权限
|
|
98
|
+
在此被自动修正(而非拒绝写入)。
|
|
99
|
+
"""
|
|
100
|
+
secrets_file = path or secrets_path()
|
|
101
|
+
secrets = _read_secrets(secrets_file) if secrets_file.is_file() else {}
|
|
102
|
+
secrets[name] = value
|
|
103
|
+
return write_secrets(secrets, secrets_file)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def remove_secret(name: str, path: Path | None = None) -> bool:
|
|
107
|
+
"""删除单个凭据;键不存在返回 False(CLI `secrets rm` 入口)。"""
|
|
108
|
+
secrets_file = path or secrets_path()
|
|
109
|
+
if not secrets_file.is_file():
|
|
110
|
+
return False
|
|
111
|
+
secrets = _read_secrets(secrets_file)
|
|
112
|
+
if name not in secrets:
|
|
113
|
+
return False
|
|
114
|
+
del secrets[name]
|
|
115
|
+
write_secrets(secrets, secrets_file)
|
|
116
|
+
return True
|
|
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.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/cross_field.py
RENAMED
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/cross_table.py
RENAMED
|
File without changes
|
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/__init__.py
RENAMED
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/anomaly_ml.py
RENAMED
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/categorical.py
RENAMED
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/formula.py
RENAMED
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/missing.py
RENAMED
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/numeric.py
RENAMED
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/textual.py
RENAMED
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/initial/uniqueness.py
RENAMED
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.0}/src/datasentry_core/detectors/missingness.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datasentry_core-0.6.0 → datasentry_core-0.7.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.6.0 → datasentry_core-0.7.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
|
|
File without changes
|