tableprobe 0.1.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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ __pycache__/
7
+ *.pyc
@@ -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,82 @@
1
+ # tableprobe
2
+
3
+ 上课时用的表探查库。把 CSV、Parquet、SQLite、xlsx 和 MySQL 里的一张表收成同一个对象,然后回答三件事:有多少行、有多少列、每一列是维度、度量、时间还是标识。
4
+
5
+ ```python
6
+ from tableprobe import open_table
7
+
8
+ orders = open_table("examples/orders.csv")
9
+ print(orders.row_count())
10
+ print(orders.column_count())
11
+ for column in orders.profile():
12
+ print(column)
13
+ ```
14
+
15
+ 仓库里的 `python examples/demo.py` 就是这段调用。范围和取舍写在 [docs/spec.md](docs/spec.md)。
16
+
17
+ ## 安装
18
+
19
+ 发布到 PyPI 之后:
20
+
21
+ ```bash
22
+ pip install tableprobe
23
+ ```
24
+
25
+ 在本仓库里试用:
26
+
27
+ ```bash
28
+ pip install -e .
29
+ ```
30
+
31
+ CSV、Parquet 和 SQLite 装完就能打开。xlsx 和 MySQL 第一次使用时,DuckDB 会下载对应扩展,电脑需要能联网。`pip install tableprobe[mysql]` 是预留的安装标记,目前没有额外的 Python 包。
32
+
33
+ CSV 请存成 UTF-8。
34
+
35
+ ## 打开一张表
36
+
37
+ ```python
38
+ from tableprobe import open_table
39
+
40
+ open_table("sales.csv")
41
+ open_table("sales.parquet")
42
+ open_table("sqlite:///app.db", table="users")
43
+ open_table("book.xlsx", sheet="Q1") # 不写 sheet 就用第一张工作表
44
+ open_table("mysql://user:pass@host:3306/shop", table="orders")
45
+ ```
46
+
47
+ SQLite 和 MySQL 必须传入 `table`。一个 `Table` 持有一条连接,用完可以 `close()`,也可以写在 `with` 里。
48
+
49
+ ## 行数、列数和画像
50
+
51
+ `row_count()` 永远是精确的 `COUNT(*)`。`column_count()` 是列数。
52
+
53
+ `profile()` 的每一列有名字、类型、抽样空值率、抽样基数、抽样行数、角色和一句理由。空值率和基数最多只看 10000 行;小文件会看完全部行,结果里仍写着抽样行数,所以它不是另一套全表精确统计。MySQL 系统表里的估计行数不会拿来冒充 `row_count()`。
54
+
55
+ 有列但一行都没有的表,`profile()` 返回空列表。某一列全是空值时,基数是 0,空值率是 1。
56
+
57
+ ## 维度和度量
58
+
59
+ 角色由库自动判断,错了可以接受。理由用来讲「为什么判成这样」。
60
+
61
+ | 角色 | 怎么看出来 |
62
+ | --- | --- |
63
+ | 标识 | 列名是 `id`、以 `_id` 结尾、含 `uuid` 或 `guid`,或者这一列是 SQLite 主键 |
64
+ | 时间 | 日期或时间类型 |
65
+ | 维度 | 文本、布尔、低基数的数字,以及列名像年份、邮编、编码、评分、等级 |
66
+ | 度量 | 其余适合求和或平均的数字 |
67
+ | 未知 | 对不上上面几条,理由里会写出类型 |
68
+
69
+ `dimensions()`、`measures()`、`times()` 和 `identifiers()` 按角色返回列名。课堂上要改一列:
70
+
71
+ ```python
72
+ orders.set_role("金额", "维度")
73
+ orders.dimensions()
74
+ ```
75
+
76
+ 这一改只存在于当前这个对象,理由变成「课堂上手工指定。」
77
+
78
+ ## 第一版不读什么
79
+
80
+ 旧版 `.xls`、带合并单元格的工作表、第一行有空列名的表,都会直接用中文报错。不做查询、写回和画图。
81
+
82
+ 报错里的 MySQL 密码会打成 `***`。
tableprobe-0.1.0/aq.md ADDED
@@ -0,0 +1,7 @@
1
+ ## 我们做的 pypi 是不是可以引用别人的项目
2
+
3
+ 当然能,而且应该
4
+
5
+ > 不用重复发明轮子,别人做了,我们可以直接借鉴。别人做了,我们就不要再做一遍。
6
+
7
+ ##
@@ -0,0 +1,103 @@
1
+ # tableprobe 范围说明
2
+
3
+ 仓库名是 `wanglaoshi-sql`。PyPI 包名是 `tableprobe`。这是一个供上课演示的 Python 库:把 CSV、Parquet、xlsx 的一个工作表、SQLite 的一张表、MySQL 的一张表,变成同一个 `Table` 对象,并回答行数、列数,以及每一列是维度、度量、时间,还是标识。
4
+
5
+ 文档和示例用中文。调用要短,能在课堂上当场敲完。
6
+
7
+ Parquet 与 CSV、SQLite 同为第一版的本地来源,用 DuckDB 自带的 `read_parquet` 读取,不额外安装扩展,也不依赖 pyarrow。
8
+
9
+ ## 这个库做什么
10
+
11
+ 调用方面对的是「一张表」,不是「一个数据库连接」。连接只负责把表打开。
12
+
13
+ ```python
14
+ from tableprobe import open_table
15
+
16
+ orders = open_table("sales.csv")
17
+ orders = open_table("sales.parquet")
18
+ orders = open_table("sqlite:///app.db", table="users")
19
+ orders = open_table("book.xlsx", sheet="Q1")
20
+ orders = open_table("mysql://user:pass@host:3306/shop", table="orders")
21
+
22
+ orders.row_count()
23
+ orders.column_count()
24
+ orders.profile()
25
+ orders.dimensions()
26
+ orders.measures()
27
+ orders.times()
28
+ orders.identifiers()
29
+ ```
30
+
31
+ PyPI 上已有 [`wanglaoshi`](https://pypi.org/project/wanglaoshi/)(数据分析入门与竞赛工具包)。本库使用 `tableprobe`,不沿用那个名字。
32
+
33
+ ## 和现成项目的分工
34
+
35
+ 连接和查询已经有成熟实现,本库不重写驱动。
36
+
37
+ | 已有项目 | 它负责的部分 | 本库不重复的原因 |
38
+ | --- | --- | --- |
39
+ | [DuckDB](https://duckdb.org/) | 读取 CSV、Parquet、xlsx,挂载 SQLite、MySQL,并用同一套 SQL 做 `COUNT(*)`、`DESCRIBE` | 没有「维度 / 度量」这一层,调用方仍要写 SQL |
40
+ | [Ibis](https://github.com/ibis-project/ibis) | 用同一套 Python API 查询 MySQL、SQLite、DuckDB 等二十多个后端 | Excel 不是一等公民;列角色要调用方自己判断 |
41
+ | SQLAlchemy、`records` | 连接关系型数据库并执行 SQL | 不覆盖 CSV、Excel、Parquet |
42
+ | [Frictionless](https://pypi.org/project/frictionless/) | 描述并校验文件和 SQL 表的 schema | 有字段类型,没有分析语义 |
43
+ | [semql-introspect](https://pypi.org/project/semql-introspect/)、[Cantrip](https://pypi.org/project/cantrip/) | 从数仓 schema 推断度量、维度、时间维度 | 面向语义层代码生成,不读 Excel、CSV,也不回答「这张表有多少行」 |
44
+ | [Woodwork](https://woodwork.alteryx.com/) | 给已载入内存的 DataFrame 打 `numeric`、`category` 标签 | 不负责打开数据源 |
45
+
46
+ 执行引擎只用 DuckDB。CSV 用 `read_csv_auto`,Parquet 用 `read_parquet`,xlsx 用 `read_xlsx`,SQLite 和 MySQL 用 `ATTACH`。五类来源最后都落成同一张视图。筛选、分组、聚合交给 Ibis 或 DuckDB。第一版没有 `head`、过滤、分组和画图。
47
+
48
+ ## 第一版范围
49
+
50
+ 五类来源:
51
+
52
+ - CSV
53
+ - Parquet
54
+ - xlsx 的一个工作表,且是第一行为列名的矩形表。没写 `sheet` 时用第一张工作表
55
+ - SQLite 的一张表。路径可以是 `.db`、`.sqlite`、`.sqlite3`,也可以是 `sqlite:///文件路径`,并且必须传入 `table`
56
+ - MySQL 的一张表。连接串是 `mysql://用户:密码@主机:端口/库名`,并且必须传入 `table`
57
+
58
+ 接口:
59
+
60
+ - `row_count()`:精确行数,始终是 `COUNT(*)`
61
+ - `column_count()`:来自 `DESCRIBE` 的列数
62
+ - `profile()`:逐列的名字、DuckDB 类型、抽样空值率、抽样基数、抽样行数、角色和理由
63
+ - `dimensions()`、`measures()`、`times()`、`identifiers()`:按角色返回列名
64
+ - `set_role(column, role)`:只改当前对象。角色可以写 `维度`、`度量`、`时间`、`标识`,或对应的英文。理由改为「课堂上手工指定。」
65
+
66
+ 空表(有列但 0 行)的 `profile()` 返回空列表,`column_count()` 仍返回列数。全空列的基数为 0,空值率为 1。
67
+
68
+ 依赖按来源拆开。默认安装覆盖 CSV、Parquet、SQLite 和 xlsx 的读取代码;`pip install tableprobe[mysql]` 目前是预留标记,没有额外的 Python 包。xlsx 和 MySQL 第一次打开时,由 DuckDB 下载 `excel` 或 `mysql` 扩展,电脑需要能联网。
69
+
70
+ 大表禁止为了分类做全表扫描。空值率和基数来自 `USING SAMPLE 10000 ROWS`,小表会覆盖全表,结果里仍写明抽样行数。行数使用精确 `COUNT(*)`。MySQL 大表上精确计数可能很慢;`information_schema` 中的行数是估算,不作为精确行数返回。
71
+
72
+ 报错用中文。连接串和报错文本里的密码会打码。SQLite 能通过 `DESCRIBE` 看到主键时,该列标为标识。
73
+
74
+ ## 第一版不做
75
+
76
+ - 自研数据库驱动、ORM、SQL 生成器
77
+ - 把整表载入内存再计数
78
+ - 跨表 JOIN、写回、ETL、画图
79
+ - xlsx 的多表头、合并单元格,以及旧版 `.xls`
80
+ - 在文档或介绍中承诺「支持所有数据库」
81
+
82
+ 合并单元格、空列名和 `.xls` 直接给出中文错误。Postgres 可以后加:DuckDB 已经能挂载它,但未验证之前不写入支持列表。
83
+
84
+ ## 列角色
85
+
86
+ 列角色由库自动判断。判断会错,课堂上可以接受;`profile()` 仍给出一句中文理由。老师可以用 `set_role` 当场改一列,再看四个筛选方法的变化。
87
+
88
+ 判断是纯函数,不连接数据库。先命中先停:
89
+
90
+ 1. 列名是 `id`、以 `_id` 结尾,或含 `uuid` / `guid`,标为标识。SQLite 主键同样标为标识。
91
+ 2. 日期或时间类型标为时间。
92
+ 3. 列名像年份、邮编、编码、评分、等级(以及英文 `year`、`zip`、`code`、`rating`、`score`、`level`、`grade`),即使是数字也标为维度。
93
+ 4. 数值列中,不重复值不超过 20,或不重复值占非空值不超过 5% 且非空值至少 20 个,标为维度;其余标为度量。
94
+ 5. 布尔和字符串标为维度。
95
+ 6. 其余标为未知,并写明类型。
96
+
97
+ 年份、邮编、1–5 分的评分、`user_id` 即使是数字,也不标成度量。
98
+
99
+ ## 已定
100
+
101
+ - 用途:上课演示。文档用中文,示例保持能当场敲完的长度。
102
+ - 列角色:自动判断。错了可以接受,理由要能讲出来,并可用 `set_role` 当场改正。
103
+ - 发布名:PyPI 包名 `tableprobe`,导入 `from tableprobe import open_table`。仓库名仍是 `wanglaoshi-sql`。
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+
3
+ from tableprobe import open_table
4
+
5
+ orders = open_table(Path(__file__).with_name("orders.csv"))
6
+ print(orders.row_count())
7
+ print(orders.column_count())
8
+ for column in orders.profile():
9
+ print(column)
@@ -0,0 +1,31 @@
1
+ 金额,地区,下单日期,user_id,年份,评分
2
+ 100,华东,2024-01-01,1000,2022,1
3
+ 117,华北,2024-01-02,1001,2023,2
4
+ 134,华南,2024-01-03,1002,2024,3
5
+ 151,西南,2024-01-04,1003,2025,4
6
+ 168,华东,2024-01-05,1004,2022,5
7
+ 185,华北,2024-01-06,1005,2023,1
8
+ 202,华南,2024-01-07,1006,2024,2
9
+ 219,西南,2024-01-08,1007,2025,3
10
+ 236,华东,2024-01-09,1008,2022,4
11
+ 253,华北,2024-01-10,1009,2023,5
12
+ 270,华南,2024-01-11,1010,2024,1
13
+ 287,西南,2024-01-12,1011,2025,2
14
+ 304,华东,2024-01-13,1012,2022,3
15
+ 321,华北,2024-01-14,1013,2023,4
16
+ 338,华南,2024-01-15,1014,2024,5
17
+ 355,西南,2024-01-16,1015,2025,1
18
+ 372,华东,2024-01-17,1016,2022,2
19
+ 389,华北,2024-01-18,1017,2023,3
20
+ 406,华南,2024-01-19,1018,2024,4
21
+ 423,西南,2024-01-20,1019,2025,5
22
+ 440,华东,2024-01-21,1020,2022,1
23
+ 457,华北,2024-01-22,1021,2023,2
24
+ 474,华南,2024-01-23,1022,2024,3
25
+ 491,西南,2024-01-24,1023,2025,4
26
+ 508,华东,2024-01-25,1024,2022,5
27
+ 525,华北,2024-01-26,1025,2023,1
28
+ 542,华南,2024-01-27,1026,2024,2
29
+ 559,西南,2024-01-28,1027,2025,3
30
+ 576,华东,2024-01-29,1028,2022,4
31
+ 593,华北,2024-01-30,1029,2023,5
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tableprobe"
7
+ version = "0.1.0"
8
+ description = "上课演示用的表探查库:打开 CSV、Parquet、SQLite、xlsx 和 MySQL,回答行数、列数和列角色。"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ dependencies = ["duckdb>=1.1.0"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/WangLaoShi/wanglaoshi-sql"
16
+ Source = "https://github.com/WangLaoShi/wanglaoshi-sql"
17
+
18
+ [project.optional-dependencies]
19
+ mysql = []
20
+ dev = ["pytest>=8"]
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
@@ -0,0 +1,6 @@
1
+ """把一张表收成同一个对象,回答行数、列数和列角色。"""
2
+
3
+ from tableprobe.open import open_table
4
+
5
+ __all__ = ["open_table"]
6
+ __version__ = "0.1.0"
@@ -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
@@ -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}"
@@ -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