tableprobe 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tableprobe/__init__.py +6 -0
- tableprobe/errors.py +22 -0
- tableprobe/open.py +192 -0
- tableprobe/profile.py +79 -0
- tableprobe/roles.py +156 -0
- tableprobe/sql.py +11 -0
- tableprobe/table.py +170 -0
- tableprobe/xlsx_check.py +140 -0
- tableprobe-0.1.0.dist-info/METADATA +96 -0
- tableprobe-0.1.0.dist-info/RECORD +11 -0
- tableprobe-0.1.0.dist-info/WHEEL +4 -0
tableprobe/__init__.py
ADDED
tableprobe/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""中文错误,以及连接信息里的密码打码。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_MYSQL_URL = re.compile(r"(mysql://[^:\s/@]+:)([^@/\s]*)(@)")
|
|
8
|
+
_QUOTED_PASSWORD = re.compile(r"(password\s*=\s*')((?:''|[^'])*)(')", re.IGNORECASE)
|
|
9
|
+
_BARE_PASSWORD = re.compile(r"(password\s*=\s*)(\S+)", re.IGNORECASE)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TableProbeError(Exception):
|
|
13
|
+
"""tableprobe 抛出的错误,说明文字是中文。"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def redact(text: str) -> str:
|
|
17
|
+
"""去掉连接串和报错文本里的密码。"""
|
|
18
|
+
|
|
19
|
+
text = _MYSQL_URL.sub(r"\1***\3", text)
|
|
20
|
+
text = _QUOTED_PASSWORD.sub(r"\1***\3", text)
|
|
21
|
+
text = _BARE_PASSWORD.sub(r"\1***", text)
|
|
22
|
+
return text
|
tableprobe/open.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""打开一种数据源,并把它落成同一张 DuckDB 视图。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import unquote, urlparse
|
|
9
|
+
|
|
10
|
+
from tableprobe.errors import TableProbeError, redact
|
|
11
|
+
from tableprobe.sql import quote_ident, sql_literal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class OpenRequest:
|
|
16
|
+
kind: str
|
|
17
|
+
label: str
|
|
18
|
+
path: str | None = None
|
|
19
|
+
table: str | None = None
|
|
20
|
+
sheet: str | None = None
|
|
21
|
+
mysql_host: str | None = None
|
|
22
|
+
mysql_port: int | None = None
|
|
23
|
+
mysql_user: str | None = None
|
|
24
|
+
mysql_password: str | None = None
|
|
25
|
+
mysql_database: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def open_table(
|
|
29
|
+
source: str | os.PathLike[str],
|
|
30
|
+
*,
|
|
31
|
+
table: str | None = None,
|
|
32
|
+
sheet: str | None = None,
|
|
33
|
+
):
|
|
34
|
+
"""打开 CSV、Parquet、SQLite、xlsx 或 MySQL 里的一张表。"""
|
|
35
|
+
|
|
36
|
+
request = parse_source(source, table=table, sheet=sheet)
|
|
37
|
+
from tableprobe.table import Table
|
|
38
|
+
|
|
39
|
+
return Table(request)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_source(
|
|
43
|
+
source: str | os.PathLike[str],
|
|
44
|
+
*,
|
|
45
|
+
table: str | None,
|
|
46
|
+
sheet: str | None,
|
|
47
|
+
) -> OpenRequest:
|
|
48
|
+
raw = os.fspath(source)
|
|
49
|
+
if raw.startswith("mysql://"):
|
|
50
|
+
return _parse_mysql(raw, table=table, sheet=sheet)
|
|
51
|
+
if raw.startswith("sqlite:"):
|
|
52
|
+
return _parse_sqlite_uri(raw, table=table, sheet=sheet)
|
|
53
|
+
|
|
54
|
+
path = Path(raw).expanduser()
|
|
55
|
+
suffix = path.suffix.lower()
|
|
56
|
+
if suffix == ".xls":
|
|
57
|
+
raise TableProbeError("不支持旧版 .xls,请另存为 .xlsx。")
|
|
58
|
+
if suffix in {".csv", ".parquet", ".xlsx", ".db", ".sqlite", ".sqlite3"}:
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
raise TableProbeError(f"找不到文件:{path}")
|
|
61
|
+
resolved = str(path.resolve())
|
|
62
|
+
if suffix == ".csv":
|
|
63
|
+
_reject_table_and_sheet(table, sheet)
|
|
64
|
+
return OpenRequest(kind="csv", label=resolved, path=resolved)
|
|
65
|
+
if suffix == ".parquet":
|
|
66
|
+
_reject_table_and_sheet(table, sheet)
|
|
67
|
+
return OpenRequest(kind="parquet", label=resolved, path=resolved)
|
|
68
|
+
if suffix == ".xlsx":
|
|
69
|
+
if table:
|
|
70
|
+
raise TableProbeError("只有 SQLite 和 MySQL 需要传入 table。")
|
|
71
|
+
return OpenRequest(kind="xlsx", label=resolved, path=resolved, sheet=sheet)
|
|
72
|
+
_require_table(table, "SQLite")
|
|
73
|
+
if sheet is not None:
|
|
74
|
+
raise TableProbeError("只有 xlsx 可以指定 sheet。")
|
|
75
|
+
return OpenRequest(kind="sqlite", label=resolved, path=resolved, table=table)
|
|
76
|
+
raise TableProbeError("无法识别这个数据源。支持 CSV、Parquet、SQLite、xlsx 和 MySQL。")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def mysql_attach_sql(request: OpenRequest) -> str:
|
|
80
|
+
parts = [
|
|
81
|
+
_mysql_token("host", request.mysql_host or ""),
|
|
82
|
+
f"port={request.mysql_port}",
|
|
83
|
+
_mysql_token("user", request.mysql_user or ""),
|
|
84
|
+
_mysql_token("password", request.mysql_password or ""),
|
|
85
|
+
_mysql_token("database", request.mysql_database or ""),
|
|
86
|
+
"connect_timeout=5",
|
|
87
|
+
]
|
|
88
|
+
return "ATTACH " + sql_literal(" ".join(parts)) + " AS src (TYPE mysql)"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def view_sql(request: OpenRequest, sheet_name: str | None = None) -> list[str]:
|
|
92
|
+
"""返回按顺序执行的 SQL。最后一张视图名固定为 probe_table。"""
|
|
93
|
+
|
|
94
|
+
if request.kind == "csv":
|
|
95
|
+
return [
|
|
96
|
+
"CREATE VIEW probe_table AS SELECT * FROM read_csv_auto("
|
|
97
|
+
+ sql_literal(request.path or "")
|
|
98
|
+
+ ")"
|
|
99
|
+
]
|
|
100
|
+
if request.kind == "parquet":
|
|
101
|
+
return [
|
|
102
|
+
"CREATE VIEW probe_table AS SELECT * FROM read_parquet("
|
|
103
|
+
+ sql_literal(request.path or "")
|
|
104
|
+
+ ")"
|
|
105
|
+
]
|
|
106
|
+
if request.kind == "sqlite":
|
|
107
|
+
return [
|
|
108
|
+
"ATTACH "
|
|
109
|
+
+ sql_literal(request.path or "")
|
|
110
|
+
+ " AS src (TYPE sqlite)",
|
|
111
|
+
"CREATE VIEW probe_table AS SELECT * FROM src." + quote_ident(request.table or ""),
|
|
112
|
+
]
|
|
113
|
+
if request.kind == "xlsx":
|
|
114
|
+
options = "header = true"
|
|
115
|
+
if sheet_name:
|
|
116
|
+
options += ", sheet = " + sql_literal(sheet_name)
|
|
117
|
+
return [
|
|
118
|
+
"CREATE VIEW probe_table AS SELECT * FROM read_xlsx("
|
|
119
|
+
+ sql_literal(request.path or "")
|
|
120
|
+
+ ", "
|
|
121
|
+
+ options
|
|
122
|
+
+ ")"
|
|
123
|
+
]
|
|
124
|
+
return [
|
|
125
|
+
mysql_attach_sql(request),
|
|
126
|
+
"CREATE VIEW probe_table AS SELECT * FROM src." + quote_ident(request.table or ""),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_mysql(raw: str, *, table: str | None, sheet: str | None) -> OpenRequest:
|
|
131
|
+
if sheet is not None:
|
|
132
|
+
raise TableProbeError("只有 xlsx 可以指定 sheet。")
|
|
133
|
+
_require_table(table, "MySQL")
|
|
134
|
+
parsed = urlparse(raw)
|
|
135
|
+
database = unquote(parsed.path.lstrip("/"))
|
|
136
|
+
if (
|
|
137
|
+
parsed.scheme != "mysql"
|
|
138
|
+
or not parsed.hostname
|
|
139
|
+
or not database
|
|
140
|
+
or "/" in database
|
|
141
|
+
or parsed.username is None
|
|
142
|
+
):
|
|
143
|
+
raise TableProbeError("MySQL 连接串应为 mysql://用户:密码@主机:端口/库名。")
|
|
144
|
+
password = unquote(parsed.password) if parsed.password is not None else ""
|
|
145
|
+
user = unquote(parsed.username)
|
|
146
|
+
host = parsed.hostname
|
|
147
|
+
port = parsed.port or 3306
|
|
148
|
+
label = f"mysql://{user}:***@{host}:{port}/{database}"
|
|
149
|
+
return OpenRequest(
|
|
150
|
+
kind="mysql",
|
|
151
|
+
label=redact(label),
|
|
152
|
+
table=table,
|
|
153
|
+
mysql_host=host,
|
|
154
|
+
mysql_port=port,
|
|
155
|
+
mysql_user=user,
|
|
156
|
+
mysql_password=password,
|
|
157
|
+
mysql_database=database,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_sqlite_uri(raw: str, *, table: str | None, sheet: str | None) -> OpenRequest:
|
|
162
|
+
if not raw.startswith("sqlite:///"):
|
|
163
|
+
raise TableProbeError("SQLite 连接串应为 sqlite:///文件路径。")
|
|
164
|
+
if sheet is not None:
|
|
165
|
+
raise TableProbeError("只有 xlsx 可以指定 sheet。")
|
|
166
|
+
_require_table(table, "SQLite")
|
|
167
|
+
file_path = Path(raw.removeprefix("sqlite:///")).expanduser()
|
|
168
|
+
if not file_path.is_file():
|
|
169
|
+
raise TableProbeError(f"找不到文件:{file_path}")
|
|
170
|
+
resolved = str(file_path.resolve())
|
|
171
|
+
return OpenRequest(kind="sqlite", label=resolved, path=resolved, table=table)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _require_table(table: str | None, kind: str) -> None:
|
|
175
|
+
if not table:
|
|
176
|
+
raise TableProbeError(f"打开 {kind} 时必须传入 table。")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _reject_table_and_sheet(table: str | None, sheet: str | None) -> None:
|
|
180
|
+
if table:
|
|
181
|
+
raise TableProbeError("只有 SQLite 和 MySQL 需要传入 table。")
|
|
182
|
+
if sheet is not None:
|
|
183
|
+
raise TableProbeError("只有 xlsx 可以指定 sheet。")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _mysql_token(key: str, value: str) -> str:
|
|
187
|
+
if any(character.isspace() or character in "'\"=" for character in value):
|
|
188
|
+
label = {"host": "主机", "user": "用户名", "password": "密码", "database": "库名"}.get(
|
|
189
|
+
key, key
|
|
190
|
+
)
|
|
191
|
+
raise TableProbeError(f"MySQL 的{label}里先不要使用空格、等号或引号。")
|
|
192
|
+
return f"{key}={value}"
|
tableprobe/profile.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""抽样统计。不把整表拉进 Python。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from tableprobe.sql import quote_ident
|
|
8
|
+
|
|
9
|
+
SAMPLE_LIMIT = 10_000
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ColumnStat:
|
|
14
|
+
name: str
|
|
15
|
+
duckdb_type: str
|
|
16
|
+
null_rate: float
|
|
17
|
+
distinct_count: int
|
|
18
|
+
non_null_count: int
|
|
19
|
+
sample_rows: int
|
|
20
|
+
is_primary_key: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ColumnProfile:
|
|
25
|
+
"""一列的画像。role 取维度、度量、时间、标识或未知。"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
duckdb_type: str
|
|
29
|
+
null_rate: float
|
|
30
|
+
distinct_count: int
|
|
31
|
+
sample_rows: int
|
|
32
|
+
role: str
|
|
33
|
+
reason: str
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
return (
|
|
37
|
+
f"{self.name}\t{self.duckdb_type}\t"
|
|
38
|
+
f"空值率 {self.null_rate:.0%}\t"
|
|
39
|
+
f"基数 {self.distinct_count}\t"
|
|
40
|
+
f"抽样 {self.sample_rows} 行\t"
|
|
41
|
+
f"{self.role}\t{self.reason}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def collect_stats(connection, columns: list[tuple[str, str, bool]]) -> list[ColumnStat]:
|
|
46
|
+
"""对最多 10000 行的抽样计算每列的空值率和基数。"""
|
|
47
|
+
|
|
48
|
+
if not columns:
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
pieces = ["COUNT(*) AS sample_rows"]
|
|
52
|
+
for index, (name, _, _) in enumerate(columns):
|
|
53
|
+
ident = quote_ident(name)
|
|
54
|
+
pieces.append(f"COUNT({ident}) AS non_null_{index}")
|
|
55
|
+
pieces.append(f"COUNT(DISTINCT {ident}) AS distinct_{index}")
|
|
56
|
+
sql = (
|
|
57
|
+
"SELECT "
|
|
58
|
+
+ ", ".join(pieces)
|
|
59
|
+
+ f" FROM (SELECT * FROM probe_table USING SAMPLE {SAMPLE_LIMIT} ROWS)"
|
|
60
|
+
)
|
|
61
|
+
row = connection.execute(sql).fetchone()
|
|
62
|
+
sample_rows = int(row[0])
|
|
63
|
+
stats: list[ColumnStat] = []
|
|
64
|
+
for index, (name, duckdb_type, is_primary_key) in enumerate(columns):
|
|
65
|
+
non_null = int(row[1 + index * 2])
|
|
66
|
+
distinct = int(row[2 + index * 2])
|
|
67
|
+
null_rate = 0.0 if sample_rows == 0 else (sample_rows - non_null) / sample_rows
|
|
68
|
+
stats.append(
|
|
69
|
+
ColumnStat(
|
|
70
|
+
name=name,
|
|
71
|
+
duckdb_type=duckdb_type,
|
|
72
|
+
null_rate=null_rate,
|
|
73
|
+
distinct_count=distinct,
|
|
74
|
+
non_null_count=non_null,
|
|
75
|
+
sample_rows=sample_rows,
|
|
76
|
+
is_primary_key=is_primary_key,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return stats
|
tableprobe/roles.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""列角色判断。这个模块不连接数据库。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
# 先命中先停:标识、时间、列名维度、数值基数、布尔或文本、未知。
|
|
9
|
+
_IDENTIFIER_EXACT = {"id"}
|
|
10
|
+
_DIMENSION_TOKENS = {
|
|
11
|
+
"year",
|
|
12
|
+
"zip",
|
|
13
|
+
"postal",
|
|
14
|
+
"postcode",
|
|
15
|
+
"zipcode",
|
|
16
|
+
"code",
|
|
17
|
+
"rating",
|
|
18
|
+
"score",
|
|
19
|
+
"level",
|
|
20
|
+
"grade",
|
|
21
|
+
"rank",
|
|
22
|
+
}
|
|
23
|
+
_DIMENSION_ZH = ("年份", "邮编", "编码", "评分", "等级")
|
|
24
|
+
_NUMERIC = {
|
|
25
|
+
"TINYINT",
|
|
26
|
+
"SMALLINT",
|
|
27
|
+
"INTEGER",
|
|
28
|
+
"INT",
|
|
29
|
+
"BIGINT",
|
|
30
|
+
"HUGEINT",
|
|
31
|
+
"UTINYINT",
|
|
32
|
+
"USMALLINT",
|
|
33
|
+
"UINTEGER",
|
|
34
|
+
"UBIGINT",
|
|
35
|
+
"UHUGEINT",
|
|
36
|
+
"FLOAT",
|
|
37
|
+
"DOUBLE",
|
|
38
|
+
"REAL",
|
|
39
|
+
"DECIMAL",
|
|
40
|
+
"NUMERIC",
|
|
41
|
+
}
|
|
42
|
+
_BOOLEAN = {"BOOLEAN", "BOOL", "LOGICAL"}
|
|
43
|
+
_STRING = {"VARCHAR", "CHAR", "BPCHAR", "TEXT", "STRING"}
|
|
44
|
+
_LOW_DISTINCT = 20
|
|
45
|
+
_LOW_RATIO = 0.05
|
|
46
|
+
_MIN_NON_NULL_FOR_RATIO = 20
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Role(StrEnum):
|
|
50
|
+
DIMENSION = "dimension"
|
|
51
|
+
MEASURE = "measure"
|
|
52
|
+
TIME = "time"
|
|
53
|
+
IDENTIFIER = "identifier"
|
|
54
|
+
UNKNOWN = "unknown"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
ROLE_LABELS = {
|
|
58
|
+
Role.DIMENSION: "维度",
|
|
59
|
+
Role.MEASURE: "度量",
|
|
60
|
+
Role.TIME: "时间",
|
|
61
|
+
Role.IDENTIFIER: "标识",
|
|
62
|
+
Role.UNKNOWN: "未知",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_ROLE_ALIASES = {
|
|
66
|
+
"dimension": Role.DIMENSION,
|
|
67
|
+
"measure": Role.MEASURE,
|
|
68
|
+
"time": Role.TIME,
|
|
69
|
+
"identifier": Role.IDENTIFIER,
|
|
70
|
+
"维度": Role.DIMENSION,
|
|
71
|
+
"度量": Role.MEASURE,
|
|
72
|
+
"时间": Role.TIME,
|
|
73
|
+
"标识": Role.IDENTIFIER,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
MANUAL_REASON = "课堂上手工指定。"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class RoleDecision:
|
|
81
|
+
role: Role
|
|
82
|
+
reason: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_role(value: str) -> Role:
|
|
86
|
+
key = value.strip()
|
|
87
|
+
role = _ROLE_ALIASES.get(key) or _ROLE_ALIASES.get(key.lower())
|
|
88
|
+
if role is None:
|
|
89
|
+
raise ValueError(key)
|
|
90
|
+
return role
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def infer_role(
|
|
94
|
+
name: str,
|
|
95
|
+
duckdb_type: str,
|
|
96
|
+
*,
|
|
97
|
+
distinct_count: int,
|
|
98
|
+
non_null_count: int,
|
|
99
|
+
is_primary_key: bool = False,
|
|
100
|
+
) -> RoleDecision:
|
|
101
|
+
"""按固定顺序判断一列的角色。"""
|
|
102
|
+
|
|
103
|
+
if _is_identifier_name(name):
|
|
104
|
+
return RoleDecision(Role.IDENTIFIER, "列名像标识(id、_id、uuid 或 guid)。")
|
|
105
|
+
if is_primary_key:
|
|
106
|
+
return RoleDecision(Role.IDENTIFIER, "这一列是主键,当作标识。")
|
|
107
|
+
|
|
108
|
+
base = _base_type(duckdb_type)
|
|
109
|
+
if _is_temporal(base):
|
|
110
|
+
return RoleDecision(Role.TIME, "这一列是日期或时间。")
|
|
111
|
+
if _is_dimension_name(name):
|
|
112
|
+
return RoleDecision(Role.DIMENSION, "列名像年份、邮编、编码、评分或等级,当作维度。")
|
|
113
|
+
if base in _NUMERIC:
|
|
114
|
+
if _low_cardinality(distinct_count, non_null_count):
|
|
115
|
+
return RoleDecision(Role.DIMENSION, "数值列的不重复值很少,当作维度。")
|
|
116
|
+
return RoleDecision(Role.MEASURE, "数值列可以做求和或平均,当作度量。")
|
|
117
|
+
if base in _BOOLEAN:
|
|
118
|
+
return RoleDecision(Role.DIMENSION, "布尔列当作维度。")
|
|
119
|
+
if base in _STRING:
|
|
120
|
+
return RoleDecision(Role.DIMENSION, "文本列当作维度。")
|
|
121
|
+
return RoleDecision(Role.UNKNOWN, f"这个类型无法判断角色:{duckdb_type}。")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_identifier_name(name: str) -> bool:
|
|
125
|
+
lowered = name.lower()
|
|
126
|
+
return (
|
|
127
|
+
lowered in _IDENTIFIER_EXACT
|
|
128
|
+
or lowered.endswith("_id")
|
|
129
|
+
or "uuid" in lowered
|
|
130
|
+
or "guid" in lowered
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_dimension_name(name: str) -> bool:
|
|
135
|
+
if any(token in name for token in _DIMENSION_ZH):
|
|
136
|
+
return True
|
|
137
|
+
parts = name.lower().replace("-", "_").split("_")
|
|
138
|
+
return any(part in _DIMENSION_TOKENS for part in parts)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _low_cardinality(distinct_count: int, non_null_count: int) -> bool:
|
|
142
|
+
if distinct_count <= _LOW_DISTINCT:
|
|
143
|
+
return True
|
|
144
|
+
if non_null_count < _MIN_NON_NULL_FOR_RATIO or non_null_count == 0:
|
|
145
|
+
return False
|
|
146
|
+
return distinct_count / non_null_count <= _LOW_RATIO
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _base_type(duckdb_type: str) -> str:
|
|
150
|
+
return duckdb_type.upper().split("(", 1)[0].strip()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_temporal(base: str) -> bool:
|
|
154
|
+
if base.startswith("TIMESTAMP") or base.startswith("DATETIME") or base.startswith("DATE"):
|
|
155
|
+
return True
|
|
156
|
+
return base == "TIME" or base.startswith("TIME ")
|
tableprobe/sql.py
ADDED
tableprobe/table.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""一张表:精确行数、列数,以及抽样后的列角色。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import duckdb
|
|
6
|
+
|
|
7
|
+
from tableprobe.errors import TableProbeError, redact
|
|
8
|
+
from tableprobe.open import OpenRequest, view_sql
|
|
9
|
+
from tableprobe.profile import ColumnProfile, collect_stats
|
|
10
|
+
from tableprobe.roles import MANUAL_REASON, ROLE_LABELS, Role, infer_role, parse_role
|
|
11
|
+
from tableprobe.xlsx_check import prepare_sheet
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Table:
|
|
15
|
+
"""一个数据源里的一张表。一个对象持有一条 DuckDB 连接。"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, request: OpenRequest) -> None:
|
|
18
|
+
self._request = request
|
|
19
|
+
self._con = duckdb.connect()
|
|
20
|
+
self._columns: list[tuple[str, str, bool]] = []
|
|
21
|
+
self._overrides: dict[str, Role] = {}
|
|
22
|
+
self._stats = None
|
|
23
|
+
try:
|
|
24
|
+
self._load()
|
|
25
|
+
except Exception:
|
|
26
|
+
self.close()
|
|
27
|
+
raise
|
|
28
|
+
|
|
29
|
+
def row_count(self) -> int:
|
|
30
|
+
"""精确行数,使用 COUNT(*)。"""
|
|
31
|
+
|
|
32
|
+
row = self._execute("SELECT COUNT(*) FROM probe_table").fetchone()
|
|
33
|
+
return int(row[0])
|
|
34
|
+
|
|
35
|
+
def column_count(self) -> int:
|
|
36
|
+
self._require_open()
|
|
37
|
+
return len(self._columns)
|
|
38
|
+
|
|
39
|
+
def profile(self) -> list[ColumnProfile]:
|
|
40
|
+
"""每列的类型、抽样空值率、抽样基数、抽样行数和角色。空表返回空列表。"""
|
|
41
|
+
|
|
42
|
+
self._require_open()
|
|
43
|
+
if self._stats is None:
|
|
44
|
+
if self.row_count() == 0:
|
|
45
|
+
self._stats = []
|
|
46
|
+
else:
|
|
47
|
+
try:
|
|
48
|
+
self._stats = collect_stats(self._con, self._columns)
|
|
49
|
+
except TableProbeError:
|
|
50
|
+
raise
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
raise TableProbeError("读取数据失败:" + redact(str(exc))) from exc
|
|
53
|
+
profiles: list[ColumnProfile] = []
|
|
54
|
+
for stat in self._stats:
|
|
55
|
+
if stat.name in self._overrides:
|
|
56
|
+
role = self._overrides[stat.name]
|
|
57
|
+
reason = MANUAL_REASON
|
|
58
|
+
else:
|
|
59
|
+
decision = infer_role(
|
|
60
|
+
stat.name,
|
|
61
|
+
stat.duckdb_type,
|
|
62
|
+
distinct_count=stat.distinct_count,
|
|
63
|
+
non_null_count=stat.non_null_count,
|
|
64
|
+
is_primary_key=stat.is_primary_key,
|
|
65
|
+
)
|
|
66
|
+
role = decision.role
|
|
67
|
+
reason = decision.reason
|
|
68
|
+
profiles.append(
|
|
69
|
+
ColumnProfile(
|
|
70
|
+
name=stat.name,
|
|
71
|
+
duckdb_type=stat.duckdb_type,
|
|
72
|
+
null_rate=stat.null_rate,
|
|
73
|
+
distinct_count=stat.distinct_count,
|
|
74
|
+
sample_rows=stat.sample_rows,
|
|
75
|
+
role=ROLE_LABELS[role],
|
|
76
|
+
reason=reason,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return profiles
|
|
80
|
+
|
|
81
|
+
def set_role(self, column: str, role: str) -> None:
|
|
82
|
+
"""只改当前这个表对象上某一列的角色。"""
|
|
83
|
+
|
|
84
|
+
self._require_open()
|
|
85
|
+
if column not in {name for name, _, _ in self._columns}:
|
|
86
|
+
raise TableProbeError(f"没有这一列:{column}")
|
|
87
|
+
try:
|
|
88
|
+
parsed = parse_role(role)
|
|
89
|
+
except ValueError:
|
|
90
|
+
raise TableProbeError("角色只能是维度、度量、时间或标识。") from None
|
|
91
|
+
self._overrides[column] = parsed
|
|
92
|
+
|
|
93
|
+
def dimensions(self) -> list[str]:
|
|
94
|
+
return self._names(Role.DIMENSION)
|
|
95
|
+
|
|
96
|
+
def measures(self) -> list[str]:
|
|
97
|
+
return self._names(Role.MEASURE)
|
|
98
|
+
|
|
99
|
+
def times(self) -> list[str]:
|
|
100
|
+
return self._names(Role.TIME)
|
|
101
|
+
|
|
102
|
+
def identifiers(self) -> list[str]:
|
|
103
|
+
return self._names(Role.IDENTIFIER)
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
if self._con is not None:
|
|
107
|
+
self._con.close()
|
|
108
|
+
self._con = None
|
|
109
|
+
|
|
110
|
+
def __enter__(self) -> Table:
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, *args) -> None:
|
|
114
|
+
self.close()
|
|
115
|
+
|
|
116
|
+
def __del__(self) -> None:
|
|
117
|
+
try:
|
|
118
|
+
self.close()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
def __repr__(self) -> str:
|
|
123
|
+
if self._con is None:
|
|
124
|
+
return "Table(已关闭)"
|
|
125
|
+
return f"Table({self._request.label})"
|
|
126
|
+
|
|
127
|
+
def _load(self) -> None:
|
|
128
|
+
sheet_name = None
|
|
129
|
+
if self._request.kind == "xlsx":
|
|
130
|
+
sheet_name = prepare_sheet(self._request.path or "", self._request.sheet)
|
|
131
|
+
self._ensure_extension("excel")
|
|
132
|
+
elif self._request.kind == "mysql":
|
|
133
|
+
self._ensure_extension("mysql")
|
|
134
|
+
for statement in view_sql(self._request, sheet_name):
|
|
135
|
+
self._execute(statement)
|
|
136
|
+
described = self._execute("DESCRIBE probe_table").fetchall()
|
|
137
|
+
columns: list[tuple[str, str, bool]] = []
|
|
138
|
+
for row in described:
|
|
139
|
+
name = "" if row[0] is None else str(row[0])
|
|
140
|
+
if not name.strip():
|
|
141
|
+
raise TableProbeError("这个表的第一行有空列名,不是矩形表。")
|
|
142
|
+
columns.append((name, str(row[1]), row[3] == "PRI"))
|
|
143
|
+
self._columns = columns
|
|
144
|
+
|
|
145
|
+
def _ensure_extension(self, name: str) -> None:
|
|
146
|
+
try:
|
|
147
|
+
self._con.execute(f"INSTALL {name}")
|
|
148
|
+
self._con.execute(f"LOAD {name}")
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
raise TableProbeError(
|
|
151
|
+
f"加载 DuckDB 的 {name} 扩展失败。上课时请先确认这台电脑能联网。"
|
|
152
|
+
+ redact(str(exc))
|
|
153
|
+
) from exc
|
|
154
|
+
|
|
155
|
+
def _names(self, role: Role) -> list[str]:
|
|
156
|
+
label = ROLE_LABELS[role]
|
|
157
|
+
return [column.name for column in self.profile() if column.role == label]
|
|
158
|
+
|
|
159
|
+
def _execute(self, sql: str):
|
|
160
|
+
self._require_open()
|
|
161
|
+
try:
|
|
162
|
+
return self._con.execute(sql)
|
|
163
|
+
except TableProbeError:
|
|
164
|
+
raise
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
raise TableProbeError("读取数据失败:" + redact(str(exc))) from exc
|
|
167
|
+
|
|
168
|
+
def _require_open(self) -> None:
|
|
169
|
+
if self._con is None:
|
|
170
|
+
raise TableProbeError("这个表已经关闭。")
|
tableprobe/xlsx_check.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""检查 xlsx 是不是第一行为列名的矩形表。只用标准库。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import zipfile
|
|
6
|
+
from xml.etree import ElementTree
|
|
7
|
+
|
|
8
|
+
from tableprobe.errors import TableProbeError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def prepare_sheet(path: str, sheet: str | None) -> str:
|
|
12
|
+
"""确认工作表能读,并返回实际使用的工作表名。"""
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
with zipfile.ZipFile(path) as archive:
|
|
16
|
+
sheet_name, sheet_xml = _read_sheet(archive, sheet)
|
|
17
|
+
shared = _shared_strings(archive)
|
|
18
|
+
except TableProbeError:
|
|
19
|
+
raise
|
|
20
|
+
except zipfile.BadZipFile as exc:
|
|
21
|
+
raise TableProbeError("这个文件不是有效的 xlsx。") from exc
|
|
22
|
+
except KeyError as exc:
|
|
23
|
+
raise TableProbeError("这个文件不是有效的 xlsx。") from exc
|
|
24
|
+
except ElementTree.ParseError as exc:
|
|
25
|
+
raise TableProbeError("这个文件不是有效的 xlsx。") from exc
|
|
26
|
+
|
|
27
|
+
root = ElementTree.fromstring(sheet_xml)
|
|
28
|
+
for element in root.iter():
|
|
29
|
+
if _local(element.tag) == "mergeCell":
|
|
30
|
+
raise TableProbeError("这个工作表有合并单元格,第一版只读取矩形表。")
|
|
31
|
+
_reject_blank_header(root, shared)
|
|
32
|
+
return sheet_name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_sheet(archive: zipfile.ZipFile, sheet: str | None) -> tuple[str, bytes]:
|
|
36
|
+
workbook = ElementTree.fromstring(archive.read("xl/workbook.xml"))
|
|
37
|
+
sheets: list[tuple[str, str]] = []
|
|
38
|
+
for element in workbook.iter():
|
|
39
|
+
if _local(element.tag) != "sheet":
|
|
40
|
+
continue
|
|
41
|
+
name = element.attrib.get("name") or ""
|
|
42
|
+
rel_id = _attr(element, "id")
|
|
43
|
+
if rel_id:
|
|
44
|
+
sheets.append((name, rel_id))
|
|
45
|
+
if not sheets:
|
|
46
|
+
raise TableProbeError("这个 xlsx 里没有工作表。")
|
|
47
|
+
|
|
48
|
+
if sheet is None:
|
|
49
|
+
sheet_name, rel_id = sheets[0]
|
|
50
|
+
else:
|
|
51
|
+
matches = [item for item in sheets if item[0] == sheet]
|
|
52
|
+
if not matches:
|
|
53
|
+
raise TableProbeError(f"这个工作表不存在:{sheet}")
|
|
54
|
+
sheet_name, rel_id = matches[0]
|
|
55
|
+
|
|
56
|
+
rels = ElementTree.fromstring(archive.read("xl/_rels/workbook.xml.rels"))
|
|
57
|
+
target = None
|
|
58
|
+
for element in rels.iter():
|
|
59
|
+
if _local(element.tag) == "Relationship" and element.attrib.get("Id") == rel_id:
|
|
60
|
+
target = element.attrib.get("Target")
|
|
61
|
+
if not target:
|
|
62
|
+
raise TableProbeError("这个文件不是有效的 xlsx。")
|
|
63
|
+
target = target.replace("\\", "/").lstrip("/")
|
|
64
|
+
if target.startswith("./"):
|
|
65
|
+
target = target[2:]
|
|
66
|
+
if not target.startswith("xl/"):
|
|
67
|
+
target = "xl/" + target
|
|
68
|
+
return sheet_name, archive.read(target)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _shared_strings(archive: zipfile.ZipFile) -> list[str]:
|
|
72
|
+
if "xl/sharedStrings.xml" not in archive.namelist():
|
|
73
|
+
return []
|
|
74
|
+
root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml"))
|
|
75
|
+
values: list[str] = []
|
|
76
|
+
for item in list(root):
|
|
77
|
+
if _local(item.tag) != "si":
|
|
78
|
+
continue
|
|
79
|
+
texts = [
|
|
80
|
+
node.text or ""
|
|
81
|
+
for node in item.iter()
|
|
82
|
+
if _local(node.tag) == "t"
|
|
83
|
+
]
|
|
84
|
+
values.append("".join(texts))
|
|
85
|
+
return values
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _reject_blank_header(root: ElementTree.Element, shared: list[str]) -> None:
|
|
89
|
+
first_row = next((element for element in root.iter() if _local(element.tag) == "row"), None)
|
|
90
|
+
if first_row is None:
|
|
91
|
+
raise TableProbeError("这个工作表的第一行有空列名,不是矩形表。")
|
|
92
|
+
|
|
93
|
+
cells: list[tuple[int, str]] = []
|
|
94
|
+
for element in list(first_row):
|
|
95
|
+
if _local(element.tag) != "c":
|
|
96
|
+
continue
|
|
97
|
+
cells.append((_column_index(element.attrib.get("r", "")), _cell_text(element, shared)))
|
|
98
|
+
if not cells:
|
|
99
|
+
raise TableProbeError("这个工作表的第一行有空列名,不是矩形表。")
|
|
100
|
+
|
|
101
|
+
cells.sort(key=lambda item: item[0])
|
|
102
|
+
indexes = [index for index, _ in cells]
|
|
103
|
+
if indexes != list(range(1, len(indexes) + 1)):
|
|
104
|
+
raise TableProbeError("这个工作表的第一行有空列名,不是矩形表。")
|
|
105
|
+
if any(not text.strip() for _, text in cells):
|
|
106
|
+
raise TableProbeError("这个工作表的第一行有空列名,不是矩形表。")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cell_text(cell: ElementTree.Element, shared: list[str]) -> str:
|
|
110
|
+
kind = cell.attrib.get("t")
|
|
111
|
+
value = next((node.text or "" for node in cell.iter() if _local(node.tag) == "v"), "")
|
|
112
|
+
if kind == "s":
|
|
113
|
+
try:
|
|
114
|
+
return shared[int(value)]
|
|
115
|
+
except (ValueError, IndexError):
|
|
116
|
+
return ""
|
|
117
|
+
if kind == "inlineStr":
|
|
118
|
+
return "".join(node.text or "" for node in cell.iter() if _local(node.tag) == "t")
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _column_index(ref: str) -> int:
|
|
123
|
+
letters = "".join(character for character in ref if character.isalpha())
|
|
124
|
+
if not letters:
|
|
125
|
+
return 0
|
|
126
|
+
index = 0
|
|
127
|
+
for character in letters:
|
|
128
|
+
index = index * 26 + (ord(character.upper()) - 64)
|
|
129
|
+
return index
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _local(tag: str) -> str:
|
|
133
|
+
return tag.rsplit("}", 1)[-1]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _attr(element: ElementTree.Element, name: str) -> str | None:
|
|
137
|
+
for key, value in element.attrib.items():
|
|
138
|
+
if _local(key) == name:
|
|
139
|
+
return value
|
|
140
|
+
return None
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tableprobe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 上课演示用的表探查库:打开 CSV、Parquet、SQLite、xlsx 和 MySQL,回答行数、列数和列角色。
|
|
5
|
+
Project-URL: Homepage, https://github.com/WangLaoShi/wanglaoshi-sql
|
|
6
|
+
Project-URL: Source, https://github.com/WangLaoShi/wanglaoshi-sql
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: duckdb>=1.1.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
12
|
+
Provides-Extra: mysql
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# tableprobe
|
|
16
|
+
|
|
17
|
+
上课时用的表探查库。把 CSV、Parquet、SQLite、xlsx 和 MySQL 里的一张表收成同一个对象,然后回答三件事:有多少行、有多少列、每一列是维度、度量、时间还是标识。
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from tableprobe import open_table
|
|
21
|
+
|
|
22
|
+
orders = open_table("examples/orders.csv")
|
|
23
|
+
print(orders.row_count())
|
|
24
|
+
print(orders.column_count())
|
|
25
|
+
for column in orders.profile():
|
|
26
|
+
print(column)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
仓库里的 `python examples/demo.py` 就是这段调用。范围和取舍写在 [docs/spec.md](docs/spec.md)。
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
发布到 PyPI 之后:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install tableprobe
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
在本仓库里试用:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
CSV、Parquet 和 SQLite 装完就能打开。xlsx 和 MySQL 第一次使用时,DuckDB 会下载对应扩展,电脑需要能联网。`pip install tableprobe[mysql]` 是预留的安装标记,目前没有额外的 Python 包。
|
|
46
|
+
|
|
47
|
+
CSV 请存成 UTF-8。
|
|
48
|
+
|
|
49
|
+
## 打开一张表
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from tableprobe import open_table
|
|
53
|
+
|
|
54
|
+
open_table("sales.csv")
|
|
55
|
+
open_table("sales.parquet")
|
|
56
|
+
open_table("sqlite:///app.db", table="users")
|
|
57
|
+
open_table("book.xlsx", sheet="Q1") # 不写 sheet 就用第一张工作表
|
|
58
|
+
open_table("mysql://user:pass@host:3306/shop", table="orders")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
SQLite 和 MySQL 必须传入 `table`。一个 `Table` 持有一条连接,用完可以 `close()`,也可以写在 `with` 里。
|
|
62
|
+
|
|
63
|
+
## 行数、列数和画像
|
|
64
|
+
|
|
65
|
+
`row_count()` 永远是精确的 `COUNT(*)`。`column_count()` 是列数。
|
|
66
|
+
|
|
67
|
+
`profile()` 的每一列有名字、类型、抽样空值率、抽样基数、抽样行数、角色和一句理由。空值率和基数最多只看 10000 行;小文件会看完全部行,结果里仍写着抽样行数,所以它不是另一套全表精确统计。MySQL 系统表里的估计行数不会拿来冒充 `row_count()`。
|
|
68
|
+
|
|
69
|
+
有列但一行都没有的表,`profile()` 返回空列表。某一列全是空值时,基数是 0,空值率是 1。
|
|
70
|
+
|
|
71
|
+
## 维度和度量
|
|
72
|
+
|
|
73
|
+
角色由库自动判断,错了可以接受。理由用来讲「为什么判成这样」。
|
|
74
|
+
|
|
75
|
+
| 角色 | 怎么看出来 |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| 标识 | 列名是 `id`、以 `_id` 结尾、含 `uuid` 或 `guid`,或者这一列是 SQLite 主键 |
|
|
78
|
+
| 时间 | 日期或时间类型 |
|
|
79
|
+
| 维度 | 文本、布尔、低基数的数字,以及列名像年份、邮编、编码、评分、等级 |
|
|
80
|
+
| 度量 | 其余适合求和或平均的数字 |
|
|
81
|
+
| 未知 | 对不上上面几条,理由里会写出类型 |
|
|
82
|
+
|
|
83
|
+
`dimensions()`、`measures()`、`times()` 和 `identifiers()` 按角色返回列名。课堂上要改一列:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
orders.set_role("金额", "维度")
|
|
87
|
+
orders.dimensions()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
这一改只存在于当前这个对象,理由变成「课堂上手工指定。」
|
|
91
|
+
|
|
92
|
+
## 第一版不读什么
|
|
93
|
+
|
|
94
|
+
旧版 `.xls`、带合并单元格的工作表、第一行有空列名的表,都会直接用中文报错。不做查询、写回和画图。
|
|
95
|
+
|
|
96
|
+
报错里的 MySQL 密码会打成 `***`。
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
tableprobe/__init__.py,sha256=-ivhMRFGl3tnVCAr7OnjVSJ3a__Nux3qeOt0qLSAiGY,167
|
|
2
|
+
tableprobe/errors.py,sha256=kYjVPypKXjUJimga6A3WioS3X7_z2XbOxXlgZd04dOE,671
|
|
3
|
+
tableprobe/open.py,sha256=MOf4BrYRKnkrJkyoDYb5XaAYf2uhvf4MFWmIxDkZx_A,6829
|
|
4
|
+
tableprobe/profile.py,sha256=tz9baraokXE4wgymJqYaEhRJ1fFInGi5LG5PN7QVi-Y,2254
|
|
5
|
+
tableprobe/roles.py,sha256=mVf5m8BiuUT9t8pbcDFi8qLuhuUe_sW_ceZg12KTJzk,4167
|
|
6
|
+
tableprobe/sql.py,sha256=0ON4e4RnD2fOrMQSZK0SzsWdyjKNTibeqWlwcDPStVQ,242
|
|
7
|
+
tableprobe/table.py,sha256=ZHoCy-QrkAsH6NrFY8IXmKZ038AWgGz1vXqqLLmjpGE,5949
|
|
8
|
+
tableprobe/xlsx_check.py,sha256=1HpgJfffNgTBb9uTLheN6a7XNCeVvlT6c7tHA4mHTfQ,5082
|
|
9
|
+
tableprobe-0.1.0.dist-info/METADATA,sha256=jFNp6konsFihunWqFzot3UbdiIx2LSbGPnM34a22wUc,3470
|
|
10
|
+
tableprobe-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
tableprobe-0.1.0.dist-info/RECORD,,
|