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 ADDED
@@ -0,0 +1,6 @@
1
+ """把一张表收成同一个对象,回答行数、列数和列角色。"""
2
+
3
+ from tableprobe.open import open_table
4
+
5
+ __all__ = ["open_table"]
6
+ __version__ = "0.1.0"
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
@@ -0,0 +1,11 @@
1
+ """拼 SQL 时用到的转义。"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def quote_ident(name: str) -> str:
7
+ return '"' + name.replace('"', '""') + '"'
8
+
9
+
10
+ def sql_literal(value: str) -> str:
11
+ return "'" + value.replace("'", "''") + "'"
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("这个表已经关闭。")
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any