duckview 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.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: duckview
3
+ Version: 0.1.0
4
+ Author-email: laq12345 <laq12345@stu.zzu.edu.cn>
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: build<2,>=1.5.0
7
+ Provides-Extra: vd
8
+ Requires-Dist: visidata; extra == 'vd'
@@ -0,0 +1,9 @@
1
+ dv/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ dv/display.py,sha256=IqdAMJNSNj0oljSvrRAkO6pYfACw_cYNGZRwj9WFvek,5502
3
+ dv/main.py,sha256=e-IeXvc8ARkZucsC6IirxzbqqV4x46eqm4TjAxUUlS8,26917
4
+ dv/reader.py,sha256=U7PtrkDViDv35sbPL5ku9-jPyyDMjDtqy7jn59YG2Bg,3184
5
+ dv/utils.py,sha256=pVvAXW3p8y08TItIebRjbCHvNMP8ZvqjcuQ_xqTx-0I,599
6
+ duckview-0.1.0.dist-info/METADATA,sha256=qeEca2JBQNCOh0ZwlqdaJ69zjxrQH2QaJCTL2dtbYcs,214
7
+ duckview-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ duckview-0.1.0.dist-info/entry_points.txt,sha256=83QZEcuNHO-VyYn01932bVLklrdX6utqDgB0Hiq4XMg,40
9
+ duckview-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dv = dv.main:main_cli
dv/__init__.py ADDED
File without changes
dv/display.py ADDED
@@ -0,0 +1,181 @@
1
+ """Rich-based display functions for dv output."""
2
+
3
+ from typing import Any
4
+ import duckdb
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from loguru import logger
9
+
10
+
11
+ def _create_console() -> Console:
12
+ """Create a Rich Console instance."""
13
+ return Console()
14
+
15
+
16
+ def _results_to_table(
17
+ columns: list[str],
18
+ rows: list[tuple[Any, ...]],
19
+ title: str = "",
20
+ column_types: list[str] | None = None,
21
+ ) -> Table:
22
+ """Convert query results to a Rich Table.
23
+
24
+ Args:
25
+ columns: Column names.
26
+ rows: Row data as tuples.
27
+ title: Optional table title.
28
+ column_types: Optional column type strings, shown below column names.
29
+ """
30
+ table = Table(title=title, expand=False)
31
+
32
+ if column_types and len(column_types) == len(columns):
33
+ for name, dtype in zip(columns, column_types):
34
+ header = f"{name}\n[dim italic]{dtype}[/dim italic]"
35
+ table.add_column(header, overflow="ellipsis", no_wrap=False)
36
+ else:
37
+ for col_name in columns:
38
+ table.add_column(str(col_name), overflow="ellipsis", no_wrap=False)
39
+
40
+ for row in rows:
41
+ table.add_row(*[str(v) if v is not None else "" for v in row])
42
+
43
+ return table
44
+
45
+
46
+ def display_structure(
47
+ con: duckdb.DuckDBPyConnection,
48
+ file_name: str,
49
+ format_name: str,
50
+ file_size: str,
51
+ ) -> None:
52
+ """Display file structure overview: columns, types, row count.
53
+
54
+ Args:
55
+ con: Active DuckDB connection with 'data' table registered.
56
+ file_name: Name of the source file.
57
+ format_name: Detected format (csv, parquet, etc.).
58
+ file_size: Human-readable file size string.
59
+ """
60
+ console = _create_console()
61
+
62
+ # Get column info via DESCRIBE
63
+ desc_result = con.sql("DESCRIBE SELECT * FROM data").fetchall()
64
+ # Get row count
65
+ count_result = con.sql("SELECT COUNT(*) AS cnt FROM data").fetchone()
66
+ row_count = count_result[0] if count_result else 0
67
+
68
+ # Build column info table
69
+ col_table = Table(expand=False, show_header=True, header_style="bold cyan")
70
+ col_table.add_column("Column", style="cyan")
71
+ col_table.add_column("Type")
72
+ col_table.add_column("Nullable")
73
+ col_table.add_column("NULLs")
74
+
75
+ # Build single null-count query for all columns
76
+ null_parts = [
77
+ f"COUNT(CASE WHEN \"{row[0]}\" IS NULL THEN 1 END)" for row in desc_result
78
+ ]
79
+ null_counts = con.sql(
80
+ f"SELECT {', '.join(null_parts)} FROM data"
81
+ ).fetchone() if null_parts else []
82
+
83
+ for i, row in enumerate(desc_result):
84
+ col_name, col_type, nullable = row[0], row[1], row[2]
85
+ null_count = null_counts[i] if null_counts else 0
86
+ null_pct = (null_count / row_count * 100) if row_count > 0 else 0
87
+ null_str = f"{null_count} ({null_pct:.1f}%)" if null_count > 0 else "0"
88
+ col_table.add_row(str(col_name), str(col_type), str(nullable), null_str)
89
+
90
+ # Summary stats in subtitle
91
+ col_count = len(desc_result)
92
+
93
+ # Build panel
94
+ panel = Panel(
95
+ col_table,
96
+ title=f"[bold]{file_name}[/bold]",
97
+ subtitle=(
98
+ f"{format_name.upper()} | {file_size} | "
99
+ f"{row_count} rows × {col_count} columns"
100
+ ),
101
+ border_style="green",
102
+ expand=False,
103
+ )
104
+
105
+ console.print(panel)
106
+ logger.debug("Displayed structure: {} cols, {} rows", col_count, row_count)
107
+
108
+
109
+ def display_preview(
110
+ con: duckdb.DuckDBPyConnection,
111
+ n_rows: int = 10,
112
+ ) -> None:
113
+ """Display first N rows of the registered table.
114
+
115
+ Args:
116
+ con: Active DuckDB connection with 'data' table registered.
117
+ n_rows: Number of rows to display.
118
+ """
119
+ console = _create_console()
120
+
121
+ result = con.sql(f"SELECT * FROM data LIMIT {n_rows}")
122
+ columns = [desc[0] for desc in result.description]
123
+ types = [desc[1] for desc in result.description]
124
+ rows = result.fetchall()
125
+
126
+ table = _results_to_table(
127
+ columns, rows,
128
+ title=f"预览(前 {len(rows)} 行)",
129
+ column_types=types,
130
+ )
131
+ console.print(table)
132
+ logger.debug("Displayed preview: {} rows", len(rows))
133
+
134
+
135
+ def display_stats(con: duckdb.DuckDBPyConnection) -> None:
136
+ """Display column statistics using DuckDB SUMMARIZE.
137
+
138
+ Args:
139
+ con: Active DuckDB connection with 'data' table registered.
140
+ """
141
+ console = _create_console()
142
+
143
+ try:
144
+ result = con.sql("SUMMARIZE SELECT * FROM data")
145
+ except Exception:
146
+ # SUMMARIZE may fail for some formats; fall back to DESCRIBE
147
+ logger.warning("SUMMARIZE not supported; falling back to DESCRIBE")
148
+ result = con.sql("DESCRIBE SELECT * FROM data")
149
+
150
+ columns = [desc[0] for desc in result.description]
151
+ rows = result.fetchall()
152
+
153
+ table = _results_to_table(columns, rows, title="列统计")
154
+ console.print(table)
155
+ logger.debug("Displayed stats: {} columns", len(columns))
156
+
157
+
158
+ def display_query_result(
159
+ con: duckdb.DuckDBPyConnection,
160
+ query: str,
161
+ ) -> None:
162
+ """Execute a SQL query and display results.
163
+
164
+ Args:
165
+ con: Active DuckDB connection.
166
+ query: SQL query string.
167
+ """
168
+ console = _create_console()
169
+
170
+ result = con.sql(query)
171
+ columns = [desc[0] for desc in result.description]
172
+ types = [desc[1] for desc in result.description]
173
+ rows = result.fetchall()
174
+
175
+ table = _results_to_table(
176
+ columns, rows,
177
+ title="查询结果",
178
+ column_types=types,
179
+ )
180
+ console.print(table)
181
+ logger.debug("Displayed query result: {} rows", len(rows))
dv/main.py ADDED
@@ -0,0 +1,747 @@
1
+ """dv - Terminal data file preview, exploration, and conversion tool."""
2
+
3
+ import re
4
+ import sys
5
+ from pathlib import Path
6
+ import duckdb
7
+ import typer
8
+ from loguru import logger
9
+
10
+ from dv.utils import setup_logging
11
+ from dv.reader import register_file, init_excel_support
12
+ from dv.display import (
13
+ display_structure,
14
+ display_preview,
15
+ display_stats,
16
+ display_query_result,
17
+ )
18
+
19
+ app = typer.Typer(
20
+ name="dv",
21
+ help="在终端快速预览、探查、转换表格数据文件。",
22
+ )
23
+
24
+ COPY_FORMATS: dict[str, str] = {
25
+ ".csv": "FORMAT csv, HEADER true",
26
+ ".tsv": "FORMAT csv, HEADER true, DELIMITER '\\t'",
27
+ ".txt": "FORMAT csv, HEADER true, DELIMITER '\\t'",
28
+ ".tab": "FORMAT csv, HEADER true, DELIMITER '\\t'",
29
+ ".parquet": "FORMAT parquet",
30
+ ".json": "FORMAT json",
31
+ ".jsonl": "FORMAT json, ARRAY false",
32
+ ".xls": "FORMAT xlsx, HEADER true",
33
+ ".xlsx": "FORMAT xlsx, HEADER true",
34
+ }
35
+
36
+ FILE_PATH_PATTERN = re.compile(
37
+ r"""['"]([^'"]+\.(?:csv|tsv|txt|tab|parquet|jsonl?|xlsx?))['"]""",
38
+ re.IGNORECASE,
39
+ )
40
+
41
+ VALID_JOIN_HOW = {"inner", "left", "right", "outer"}
42
+
43
+
44
+ def _format_size(size_bytes: int) -> str:
45
+ for unit in ("B", "KB", "MB", "GB"):
46
+ if size_bytes < 1024:
47
+ return f"{size_bytes:.1f} {unit}"
48
+ size_bytes /= 1024
49
+ return f"{size_bytes:.1f} TB"
50
+
51
+
52
+ def _resolve_output(path: str, con: duckdb.DuckDBPyConnection | None = None, force: bool = False) -> tuple[Path, str]:
53
+ """Validate output path and return (resolved_path, copy_opts_string)."""
54
+ dst_path = Path(path)
55
+ dst_ext = dst_path.suffix.lower()
56
+ copy_opts = COPY_FORMATS.get(dst_ext)
57
+ if copy_opts is None:
58
+ logger.error(
59
+ "Unsupported output format: '{}'. Supported: {}",
60
+ dst_ext,
61
+ ", ".join(sorted(COPY_FORMATS.keys())),
62
+ )
63
+ raise typer.Exit(code=1)
64
+ if con and dst_ext in (".xls", ".xlsx"):
65
+ init_excel_support(con)
66
+ if dst_path.exists() and not force:
67
+ response = input(f"Overwrite '{dst_path.name}'? [y/N] ")
68
+ if response.lower() not in ("y", "yes"):
69
+ logger.info("Aborted.")
70
+ raise typer.Exit(code=0)
71
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
72
+ return dst_path, copy_opts
73
+
74
+
75
+ def _find_and_register_files(con: duckdb.DuckDBPyConnection, query: str) -> None:
76
+ seen = set()
77
+ for match in FILE_PATH_PATTERN.finditer(query):
78
+ file_str = match.group(1)
79
+ if file_str in seen:
80
+ continue
81
+ seen.add(file_str)
82
+ file_path = Path(file_str)
83
+ if file_path.exists():
84
+ table_name = file_path.stem.replace(".", "_")
85
+ register_file(con, file_path, table_name)
86
+
87
+
88
+ @app.callback(invoke_without_command=True)
89
+ def main(
90
+ ctx: typer.Context,
91
+ verbose: bool = typer.Option(False, "-v", "--verbose", help="启用调试日志"),
92
+ preview: int = typer.Option(10, "-p", "--preview", help="显示前 N 行"),
93
+ stats: bool = typer.Option(False, "-s", "--stats", help="显示列统计"),
94
+ columns: str = typer.Option(None, "-c", "--columns", help="逗号分隔的列名"),
95
+ sort: str = typer.Option(None, "--sort", help="按列排序(ASC/DESC)"),
96
+ info: bool = typer.Option(False, "-I", "--info", help="显示结构概览,替代默认预览"),
97
+ ):
98
+ """dv - 终端表格数据工具,基于 DuckDB。
99
+
100
+ 示例:
101
+ dv data.csv 查看文件结构(自动 peek)
102
+ dv peek data.csv -p 10 显示前 10 行
103
+ dv convert a.csv b.parquet CSV 转 Parquet
104
+ dv sql "FROM 'data.csv' SELECT * LIMIT 5"
105
+ cat data.csv | dv 管道数据自动预览
106
+ cat data.csv | dv -p 10 管道 + 预览
107
+ """
108
+ setup_logging(verbose)
109
+ if ctx.invoked_subcommand is None and not sys.stdin.isatty():
110
+ ctx.invoke(peek, file=None, preview=preview, stats=stats, columns=columns, sort=sort, info=info)
111
+
112
+
113
+ @app.command()
114
+ def peek(
115
+ file: str = typer.Argument(None, help="数据文件路径(省略时从 stdin 读取)"),
116
+ preview: int = typer.Option(10, "-p", "--preview", help="显示前 N 行"),
117
+ stats: bool = typer.Option(False, "-s", "--stats", help="显示列统计"),
118
+ columns: str = typer.Option(None, "-c", "--columns", help="逗号分隔的列名"),
119
+ sort: str = typer.Option(None, "--sort", help="按列排序(可加 ASC/DESC)"),
120
+ info: bool = typer.Option(False, "-I", "--info", help="显示结构概览(列名、类型、NULL 数)"),
121
+ ):
122
+ """预览和探查数据文件。
123
+
124
+ 默认显示前 10 行。用 -I/--info 查看结构概览。
125
+ 用 -p/--preview 指定行数(0 = 显示结构)。
126
+ 用 -s/--stats 查看列统计。
127
+ 用 -c/--columns 选列显示。
128
+ 用 --sort 排序。
129
+
130
+ 省略文件路径时从 stdin 读取。
131
+ """
132
+ import tempfile
133
+
134
+ tmp_file = None
135
+ if file is None:
136
+ if sys.stdin.isatty():
137
+ logger.error("No file specified and stdin is a terminal. Pipe data or provide a file.")
138
+ raise typer.Exit(code=1)
139
+ data = sys.stdin.buffer.read()
140
+ tmp_file = tempfile.NamedTemporaryFile(suffix=".csv", delete=False)
141
+ tmp_file.write(data)
142
+ tmp_file.close()
143
+ file_path = Path(tmp_file.name)
144
+ file_size = _format_size(len(data))
145
+ display_name = "stdin"
146
+ else:
147
+ file_path = Path(file)
148
+ display_name = file_path.name
149
+ try:
150
+ file_size = _format_size(file_path.stat().st_size)
151
+ except FileNotFoundError:
152
+ logger.error("File not found: {}", file_path)
153
+ raise typer.Exit(code=1)
154
+
155
+ con = duckdb.connect()
156
+ try:
157
+ if preview < 0:
158
+ logger.error("Preview count must be >= 0, got {}", preview)
159
+ raise typer.Exit(code=1)
160
+ fmt = register_file(con, file_path, "_src")
161
+ if sort or columns:
162
+ select = "SELECT *" if not columns else f"SELECT {', '.join(c.strip() for c in columns.split(','))}"
163
+ order = f" ORDER BY {sort}" if sort else ""
164
+ con.sql(f"CREATE OR REPLACE VIEW data AS {select} FROM _src{order}")
165
+ else:
166
+ con.sql("CREATE OR REPLACE VIEW data AS SELECT * FROM _src")
167
+ if info or preview == 0:
168
+ display_structure(con, display_name, fmt, file_size)
169
+ elif stats:
170
+ display_stats(con)
171
+ else:
172
+ display_preview(con, n_rows=preview)
173
+ except ValueError as e:
174
+ logger.error(str(e))
175
+ raise typer.Exit(code=1)
176
+ except typer.Exit:
177
+ raise
178
+ except Exception as e:
179
+ logger.error("Failed to process file: {}", e)
180
+ raise typer.Exit(code=1)
181
+ finally:
182
+ con.close()
183
+ if tmp_file:
184
+ Path(tmp_file.name).unlink(missing_ok=True)
185
+
186
+
187
+ @app.command()
188
+ def convert(
189
+ src: str = typer.Argument(..., help="源文件路径"),
190
+ dst: str = typer.Argument(..., help="目标文件路径"),
191
+ where: str = typer.Option(None, "--where", help="SQL 过滤条件(WHERE 子句)"),
192
+ yes: bool = typer.Option(False, "-y", "--yes", help="跳过覆盖确认"),
193
+ ):
194
+ """转换数据文件格式。
195
+
196
+ 输出格式由目标文件扩展名自动推断。
197
+ 支持的格式:.csv、.tsv、.parquet、.json、.jsonl、.xlsx
198
+ """
199
+ src_path = Path(src)
200
+
201
+ if not src_path.exists():
202
+ logger.error("Source file not found: {}", src_path)
203
+ raise typer.Exit(code=1)
204
+
205
+ con = duckdb.connect()
206
+ try:
207
+ dst_path, copy_opts = _resolve_output(dst, con, force=yes)
208
+ register_file(con, src_path)
209
+ dst_abs = str(dst_path.resolve())
210
+ query = (
211
+ f"COPY (SELECT * FROM data WHERE {where}) TO '{dst_abs}' ({copy_opts})"
212
+ if where
213
+ else f"COPY (SELECT * FROM data) TO '{dst_abs}' ({copy_opts})"
214
+ )
215
+ con.sql(query)
216
+ logger.info("Converted {} -> {}", src_path.name, dst_path.name)
217
+ except typer.Exit:
218
+ raise
219
+ except Exception as e:
220
+ logger.error("Conversion failed: {}", e)
221
+ raise typer.Exit(code=1)
222
+ finally:
223
+ con.close()
224
+
225
+
226
+ @app.command()
227
+ def sql(
228
+ query: str = typer.Argument(
229
+ ..., help="SQL 查询语句(用单引号引用文件路径)"
230
+ ),
231
+ output: str = typer.Option(
232
+ None, "-o", "--output", help="保存结果到文件(格式由扩展名推断)"
233
+ ),
234
+ yes: bool = typer.Option(False, "-y", "--yes", help="跳过覆盖确认"),
235
+ ):
236
+ """对数据文件执行 SQL 查询。
237
+
238
+ 查询中引用的文件(如 'data.csv')会自动注册为临时表。
239
+
240
+ 不加 -o 时显示结果,加 -o 时保存到文件。
241
+
242
+ 示例:
243
+ dv sql "FROM 'data.csv' SELECT * WHERE age > 30"
244
+ dv sql "SELECT * FROM 'data.csv'" -o result.parquet
245
+ """
246
+ con = duckdb.connect()
247
+ tmp_path = None
248
+ try:
249
+ if not sys.stdin.isatty():
250
+ data = sys.stdin.buffer.read()
251
+ if data:
252
+ import tempfile
253
+ tmp = tempfile.NamedTemporaryFile(suffix=".csv", delete=False)
254
+ tmp.write(data)
255
+ tmp.close()
256
+ tmp_path = tmp.name
257
+ register_file(con, Path(tmp_path), "stdin")
258
+ _find_and_register_files(con, query)
259
+ if output:
260
+ dst_path, copy_opts = _resolve_output(output, con, force=yes)
261
+ dst_abs = str(dst_path.resolve())
262
+ con.sql(f"CREATE OR REPLACE TABLE _copy_tmp AS {query}")
263
+ con.sql(f"COPY (SELECT * FROM _copy_tmp) TO '{dst_abs}' ({copy_opts})")
264
+ con.sql("DROP TABLE IF EXISTS _copy_tmp")
265
+ logger.info("Saved to {}", dst_path.name)
266
+ else:
267
+ display_query_result(con, query)
268
+ except typer.Exit:
269
+ raise
270
+ except Exception as e:
271
+ logger.error("SQL execution failed: {}", e)
272
+ raise typer.Exit(code=1)
273
+ finally:
274
+ con.close()
275
+ if tmp_path:
276
+ Path(tmp_path).unlink(missing_ok=True)
277
+
278
+
279
+ @app.command()
280
+ def cat(
281
+ files: list[str] = typer.Argument(..., help="待拼接的文件路径(至少 2 个)"),
282
+ preview: int = typer.Option(10, "-p", "--preview", help="显示前 N 行"),
283
+ stats: bool = typer.Option(False, "-s", "--stats", help="显示列统计"),
284
+ output: str = typer.Option(
285
+ None, "-o", "--output", help="保存到文件(格式由扩展名推断)"
286
+ ),
287
+ yes: bool = typer.Option(False, "-y", "--yes", help="跳过覆盖确认"),
288
+ ):
289
+ """按行拼接多个数据文件(UNION ALL BY NAME)。
290
+
291
+ 按列名自动对齐,各文件列数不同时缺失列自动填 NULL。
292
+
293
+ 示例:
294
+ dv cat a.csv b.csv 拼接并预览结构
295
+ dv cat a.csv b.csv -p 10 拼接后预览 10 行
296
+ dv cat part*.csv -o merged.parquet
297
+ """
298
+ if len(files) < 2:
299
+ logger.error("Need at least 2 files to concatenate, got {}", len(files))
300
+ raise typer.Exit(code=1)
301
+
302
+ con = duckdb.connect()
303
+ try:
304
+ if preview < 0:
305
+ logger.error("Preview count must be >= 0, got {}", preview)
306
+ raise typer.Exit(code=1)
307
+ for i, f in enumerate(files):
308
+ fp = Path(f)
309
+ if not fp.exists():
310
+ logger.error("File not found: {}", fp)
311
+ raise typer.Exit(code=1)
312
+ register_file(con, fp, f"src_{i}")
313
+
314
+ unions = "\n UNION ALL BY NAME\n".join(
315
+ f"SELECT * FROM src_{i}" for i in range(len(files))
316
+ )
317
+ con.sql(f"CREATE OR REPLACE VIEW data AS {unions}")
318
+
319
+ first_name = Path(files[0]).name
320
+ total_size = _format_size(sum(Path(f).stat().st_size for f in files))
321
+
322
+ if output:
323
+ dst_path, copy_opts = _resolve_output(output, con, force=yes)
324
+ dst_abs = str(dst_path.resolve())
325
+ con.sql(f"COPY (SELECT * FROM data) TO '{dst_abs}' ({copy_opts})")
326
+ logger.info("Saved to {}", dst_path.name)
327
+ elif preview == 0:
328
+ count = con.sql("SELECT COUNT(*) FROM data").fetchone()[0]
329
+ display_structure(
330
+ con, f"{first_name} + {len(files) - 1} more", "CONCAT", total_size
331
+ )
332
+ logger.debug("Concatenated {} files: {} rows", len(files), count)
333
+ elif stats:
334
+ display_stats(con)
335
+ else:
336
+ display_preview(con, n_rows=preview)
337
+ except typer.Exit:
338
+ raise
339
+ except Exception as e:
340
+ logger.error("Concatenation failed: {}", e)
341
+ raise typer.Exit(code=1)
342
+ finally:
343
+ con.close()
344
+
345
+
346
+ @app.command()
347
+ def join(
348
+ a: str = typer.Argument(..., help="左表文件路径"),
349
+ b: str = typer.Argument(..., help="右表文件路径"),
350
+ on: str = typer.Option(
351
+ None, "--on", help="JOIN 列名。同名列用 'gene';不同名用 'probe=gene'。省略时自动检测。"
352
+ ),
353
+ how: str = typer.Option(
354
+ "inner", "--how", help="JOIN 方式:inner、left、right、outer"
355
+ ),
356
+ preview: int = typer.Option(10, "-p", "--preview", help="显示前 N 行"),
357
+ stats: bool = typer.Option(False, "-s", "--stats", help="显示列统计"),
358
+ output: str = typer.Option(
359
+ None, "-o", "--output", help="保存到文件(格式由扩展名推断)"
360
+ ),
361
+ yes: bool = typer.Option(False, "-y", "--yes", help="跳过覆盖确认"),
362
+ ):
363
+ """按公共列合并两个数据文件。
364
+
365
+ 不指定 --on 时自动检测唯一共有列。
366
+ 用 --how 选择合并方式(默认 inner)。
367
+
368
+ 示例:
369
+ dv join deg.csv meta.csv 自动检测合并列
370
+ dv join deg.csv meta.csv --on gene 同名列合并
371
+ dv join expr.csv meta.csv --on probe=gene 不同名列合并
372
+ dv join a.csv b.csv --on id --how left -o joined.parquet
373
+ """
374
+ a_path, b_path = Path(a), Path(b)
375
+
376
+ for p, label in [(a_path, "Left"), (b_path, "Right")]:
377
+ if not p.exists():
378
+ logger.error("{} file not found: {}", label, p)
379
+ raise typer.Exit(code=1)
380
+
381
+ how_lower = how.lower()
382
+ if how_lower not in VALID_JOIN_HOW:
383
+ logger.error(
384
+ "Invalid --how '{}'. Must be one of: {}",
385
+ how,
386
+ ", ".join(sorted(VALID_JOIN_HOW)),
387
+ )
388
+ raise typer.Exit(code=1)
389
+
390
+ how_sql = {"inner": "INNER", "left": "LEFT", "right": "RIGHT", "outer": "FULL"}[how_lower]
391
+
392
+ con = duckdb.connect()
393
+ try:
394
+ if preview < 0:
395
+ logger.error("Preview count must be >= 0, got {}", preview)
396
+ raise typer.Exit(code=1)
397
+ register_file(con, a_path, "a")
398
+ register_file(con, b_path, "b")
399
+
400
+ on_clause = _resolve_join_on(con, on)
401
+ a_cols = _get_column_names(con, "a")
402
+ b_cols = _get_column_names(con, "b")
403
+ join_col_raw = _get_join_column(on, a_cols, b_cols)
404
+
405
+ # Build explicit column list to avoid duplicate column errors
406
+ def _q(col, tbl):
407
+ """Quote col with tbl prefix, handling special chars."""
408
+ c = f'"{col}"' if "." in col else col
409
+ return f"{tbl}.{c}"
410
+
411
+ select_cols = [_q(c, "a") for c in a_cols]
412
+ for c in b_cols:
413
+ if c != join_col_raw:
414
+ select_cols.append(f"{_q(c, 'b')} AS \"{c}_2\"" if c in a_cols else _q(c, "b"))
415
+
416
+ col_list = ", ".join(select_cols)
417
+ con.sql(
418
+ f"CREATE OR REPLACE VIEW data AS "
419
+ f"SELECT {col_list} FROM a {how_sql} JOIN b {on_clause}"
420
+ )
421
+
422
+ total_size = _format_size(a_path.stat().st_size + b_path.stat().st_size)
423
+ count = con.sql("SELECT COUNT(*) FROM data").fetchone()[0]
424
+
425
+ if output:
426
+ dst_path, copy_opts = _resolve_output(output, con, force=yes)
427
+ dst_abs = str(dst_path.resolve())
428
+ con.sql(f"COPY (SELECT * FROM data) TO '{dst_abs}' ({copy_opts})")
429
+ logger.info("Saved to {}", dst_path.name)
430
+ elif preview == 0:
431
+ display_structure(
432
+ con,
433
+ f"{a_path.name} ⋈ {b_path.name}",
434
+ f"JOIN ({how_lower})",
435
+ total_size,
436
+ )
437
+ logger.debug("Joined: {} rows ({} JOIN)", count, how_lower)
438
+ elif stats:
439
+ display_stats(con)
440
+ else:
441
+ display_preview(con, n_rows=preview)
442
+ except typer.Exit:
443
+ raise
444
+ except Exception as e:
445
+ logger.error("Join failed: {}", e)
446
+ raise typer.Exit(code=1)
447
+ finally:
448
+ con.close()
449
+
450
+
451
+ @app.command()
452
+ def search(
453
+ pattern: str = typer.Argument(..., help="正则表达式"),
454
+ file: str = typer.Argument(..., help="数据文件路径"),
455
+ column: str = typer.Option(None, "--in", help="限定搜索某一列"),
456
+ ignore_case: bool = typer.Option(False, "-i", "--ignore-case", help="忽略大小写"),
457
+ literal: bool = typer.Option(False, "-l", "--literal", help="字面量搜索(非正则)"),
458
+ preview: int = typer.Option(10, "-p", "--preview", help="显示前 N 条匹配行"),
459
+ output: str = typer.Option(None, "-o", "--output", help="保存结果到文件"),
460
+ yes: bool = typer.Option(False, "-y", "--yes", help="跳过覆盖确认"),
461
+ ):
462
+ """正则搜索数据行。
463
+
464
+ 搜索所有文本列,除非用 --in 指定某列。
465
+ 用 -i 忽略大小写。
466
+
467
+ 示例:
468
+ dv search "TP53" data.csv
469
+ dv search "TP53|BRCA1" data.csv --in gene -i
470
+ """
471
+ file_path = Path(file)
472
+ if not file_path.exists():
473
+ logger.error("File not found: {}", file_path)
474
+ raise typer.Exit(code=1)
475
+
476
+ con = duckdb.connect()
477
+ try:
478
+ if preview < 0:
479
+ logger.error("Preview count must be >= 0, got {}", preview)
480
+ raise typer.Exit(code=1)
481
+ register_file(con, file_path, "_src")
482
+ flags = "'i'" if ignore_case else ""
483
+ safe_pattern = pattern.replace("'", "''")
484
+ if literal:
485
+ safe_pattern = re.escape(safe_pattern)
486
+ col_info = con.sql("DESCRIBE SELECT * FROM _src").fetchall()
487
+ col_names = [r[0] for r in col_info]
488
+ col_types = [r[1] for r in col_info]
489
+
490
+ if column:
491
+ if column not in col_names:
492
+ logger.error("Column '{}' not found. Available: {}", column, ", ".join(col_names))
493
+ raise typer.Exit(code=1)
494
+ col_type = col_types[col_names.index(column)]
495
+ if "VARCHAR" not in str(col_type).upper():
496
+ logger.error(
497
+ "Column '{}' is type {}, not text — cannot regex search.",
498
+ column, col_type
499
+ )
500
+ raise typer.Exit(code=1)
501
+ where = f"WHERE regexp_matches(\"{column}\", '{safe_pattern}'{',' + flags if flags else ''})"
502
+ else:
503
+ str_cols = [c for c, t in zip(col_names, col_types) if "VARCHAR" in str(t).upper()]
504
+ if not str_cols:
505
+ logger.error("No text columns found to search in.")
506
+ raise typer.Exit(code=1)
507
+ conditions = " OR ".join(
508
+ f"regexp_matches(\"{c}\", '{safe_pattern}'{',' + flags if flags else ''})"
509
+ for c in str_cols
510
+ )
511
+ where = f"WHERE {conditions}"
512
+
513
+ con.sql(f"CREATE OR REPLACE VIEW data AS SELECT * FROM _src {where}")
514
+
515
+ if output:
516
+ dst_path, copy_opts = _resolve_output(output, con, force=yes)
517
+ dst_abs = str(dst_path.resolve())
518
+ con.sql(f"COPY (SELECT * FROM data) TO '{dst_abs}' ({copy_opts})")
519
+ logger.info("Saved to {}", dst_path.name)
520
+ elif preview > 0:
521
+ display_preview(con, n_rows=preview)
522
+ else:
523
+ display_structure(con, file_path.name, "FILTERED", _format_size(file_path.stat().st_size))
524
+ except typer.Exit:
525
+ raise
526
+ except Exception as e:
527
+ logger.error("Search failed: {}", e)
528
+ raise typer.Exit(code=1)
529
+ finally:
530
+ con.close()
531
+
532
+
533
+ @app.command()
534
+ def rename(
535
+ file: str = typer.Argument(..., help="数据文件路径"),
536
+ mapping: str = typer.Argument(..., help="重命名规则:old=new 或 old1=new1,old2=new2"),
537
+ output: str = typer.Option(None, "-o", "--output", help="保存到文件"),
538
+ info: bool = typer.Option(False, "-I", "--info", help="显示结构概览,替代默认预览"),
539
+ yes: bool = typer.Option(False, "-y", "--yes", help="跳过覆盖确认"),
540
+ ):
541
+ """重命名数据文件中的列名。
542
+
543
+ 示例:
544
+ dv rename data.csv column0=gene
545
+ dv rename data.csv "id=gene_id,name=symbol"
546
+ dv rename data.csv p_val_adj=padj -o cleaned.csv
547
+ """
548
+ file_path = Path(file)
549
+ if not file_path.exists():
550
+ logger.error("File not found: {}", file_path)
551
+ raise typer.Exit(code=1)
552
+
553
+ pairs = []
554
+ for part in mapping.split(","):
555
+ if "=" not in part:
556
+ logger.error("Invalid mapping '{}'. Use old=new format.", part.strip())
557
+ raise typer.Exit(code=1)
558
+ old, new = part.split("=", 1)
559
+ pairs.append((old.strip(), new.strip()))
560
+
561
+ con = duckdb.connect()
562
+ try:
563
+ fmt = register_file(con, file_path, "_src")
564
+ col_names = [r[0] for r in con.sql("DESCRIBE SELECT * FROM _src").fetchall()]
565
+
566
+ select_parts = []
567
+ for old, new in pairs:
568
+ if old not in col_names:
569
+ logger.error("Column '{}' not found. Available: {}", old, ", ".join(col_names))
570
+ raise typer.Exit(code=1)
571
+ select_parts.append(f'"{old}" AS "{new}"')
572
+
573
+ renamed = set(old for old, _ in pairs)
574
+ for c in col_names:
575
+ if c not in renamed:
576
+ select_parts.append(f'"{c}"')
577
+
578
+ select_str = ", ".join(select_parts)
579
+ con.sql(f"CREATE OR REPLACE VIEW data AS SELECT {select_str} FROM _src")
580
+
581
+ if output:
582
+ dst_path, copy_opts = _resolve_output(output, con, force=yes)
583
+ dst_abs = str(dst_path.resolve())
584
+ con.sql(f"COPY (SELECT * FROM data) TO '{dst_abs}' ({copy_opts})")
585
+ logger.info("Saved to {}", dst_path.name)
586
+ else:
587
+ display_structure(con, file_path.name, fmt.upper(), _format_size(file_path.stat().st_size))
588
+ except typer.Exit:
589
+ raise
590
+ except Exception as e:
591
+ logger.error("Rename failed: {}", e)
592
+ raise typer.Exit(code=1)
593
+ finally:
594
+ con.close()
595
+
596
+
597
+ @app.command()
598
+ def vd(
599
+ file: str = typer.Argument(..., help="数据文件路径"),
600
+ where: str = typer.Option(None, "--where", help="过滤条件"),
601
+ columns: str = typer.Option(None, "-c", "--columns", help="逗号分隔的列名"),
602
+ sort: str = typer.Option(None, "--sort", help="排序列(ASC/DESC)"),
603
+ preview: int = typer.Option(0, "-p", "--preview", help="限制行数,0=不限"),
604
+ ):
605
+ """用 VisiData 交互式探索数据文件。
606
+
607
+ 可先用 --where/-c/--sort/-p 过滤数据,再自动打开 VisiData。
608
+ """
609
+ import shutil
610
+ import subprocess
611
+ import tempfile
612
+
613
+ if not shutil.which("vd"):
614
+ logger.error("vd not found. Install: pip install \"dv[vd]\" or pip install visidata")
615
+ raise typer.Exit(code=1)
616
+
617
+ file_path = Path(file)
618
+ if not file_path.exists():
619
+ logger.error("File not found: {}", file_path)
620
+ raise typer.Exit(code=1)
621
+
622
+ # 无过滤参数:直接打开
623
+ if not (where or columns or sort or preview):
624
+ subprocess.run(["vd", str(file_path)])
625
+ return
626
+
627
+ # 有过滤参数:DuckDB 过滤 → 临时文件 → vd
628
+ con = duckdb.connect()
629
+ tmp_path = None
630
+ try:
631
+ register_file(con, file_path, "_src")
632
+
633
+ select = "SELECT *"
634
+ if columns:
635
+ col_list = ", ".join(c.strip() for c in columns.split(","))
636
+ select = f"SELECT {col_list}"
637
+
638
+ clauses = []
639
+ if where:
640
+ clauses.append(f"WHERE {where}")
641
+ if sort:
642
+ clauses.append(f"ORDER BY {sort}")
643
+ if preview > 0:
644
+ clauses.append(f"LIMIT {preview}")
645
+
646
+ sql = f"{select} FROM _src {' '.join(clauses)}"
647
+ tmp = tempfile.NamedTemporaryFile(suffix=".csv", delete=False)
648
+ tmp.close()
649
+ tmp_path = tmp.name
650
+ con.sql(f"COPY ({sql}) TO '{tmp_path}' (FORMAT csv, HEADER true)")
651
+ subprocess.run(["vd", tmp_path])
652
+ except typer.Exit:
653
+ raise
654
+ except Exception as e:
655
+ logger.error("vd open failed: {}", e)
656
+ raise typer.Exit(code=1)
657
+ finally:
658
+ con.close()
659
+ if tmp_path:
660
+ Path(tmp_path).unlink(missing_ok=True)
661
+
662
+
663
+ def _get_column_names(con: duckdb.DuckDBPyConnection, table: str) -> list[str]:
664
+ return [
665
+ r[0]
666
+ for r in con.sql(
667
+ f"SELECT column_name FROM information_schema.columns WHERE table_name='{table}'"
668
+ ).fetchall()
669
+ ]
670
+
671
+
672
+ def _get_join_column(on: str | None, a_cols: list[str], b_cols: list[str]) -> str | None:
673
+ if on is None:
674
+ common = sorted(set(a_cols) & set(b_cols))
675
+ return common[0] if len(common) == 1 else None
676
+ if "=" in on:
677
+ return on.split("=", 1)[0].strip()
678
+ return on
679
+
680
+
681
+ def _resolve_join_on(con: duckdb.DuckDBPyConnection, on: str | None) -> str:
682
+ """Resolve the ON/USING clause for a JOIN.
683
+
684
+ Returns a SQL fragment like 'USING ("col.1")' or 'ON a."col.1" = b."gene"'.
685
+ """
686
+ def qc(col: str) -> str:
687
+ return f'"{col}"' if "." in col else col
688
+
689
+ if on is not None and "=" in on:
690
+ parts = on.split("=", 1)
691
+ left_col, right_col = parts[0].strip(), parts[1].strip()
692
+ return f"ON a.{qc(left_col)} = b.{qc(right_col)}"
693
+
694
+ if on is not None:
695
+ return f"USING ({qc(on)})"
696
+
697
+ a_cols = {
698
+ r[0]
699
+ for r in con.sql(
700
+ "SELECT column_name FROM information_schema.columns WHERE table_name='a'"
701
+ ).fetchall()
702
+ }
703
+ b_cols = {
704
+ r[0]
705
+ for r in con.sql(
706
+ "SELECT column_name FROM information_schema.columns WHERE table_name='b'"
707
+ ).fetchall()
708
+ }
709
+ common = sorted(a_cols & b_cols)
710
+
711
+ if len(common) == 0:
712
+ logger.error("No common columns found between the two files. Use --on to specify the join columns.")
713
+ raise typer.Exit(code=1)
714
+ if len(common) == 1:
715
+ return f"USING ({qc(common[0])})"
716
+
717
+ logger.error(
718
+ "Multiple common columns found: {}. Use --on to specify which one to join on.",
719
+ ", ".join(common),
720
+ )
721
+ raise typer.Exit(code=1)
722
+
723
+
724
+ def _patch_default_peek():
725
+ """If the first non-flag argument looks like a file path, insert 'peek' at position 1."""
726
+ known = {
727
+ "peek", "convert", "sql", "cat", "join", "search", "rename", "vd",
728
+ "--help", "-h", "--show-completion", "--install-completion",
729
+ }
730
+ if any(a in known for a in sys.argv[1:]):
731
+ return
732
+ for i, arg in enumerate(sys.argv[1:], start=1):
733
+ if arg.startswith("-") or "=" in arg:
734
+ continue
735
+ if "/" in arg or "\\" in arg or "." in arg:
736
+ sys.argv.insert(1, "peek")
737
+ return
738
+
739
+
740
+ def main_cli():
741
+ """Entry point for console_scripts and __main__."""
742
+ _patch_default_peek()
743
+ app()
744
+
745
+
746
+ if __name__ == "__main__":
747
+ main_cli()
dv/reader.py ADDED
@@ -0,0 +1,110 @@
1
+ """Format detection and DuckDB file registration for dv."""
2
+
3
+ from pathlib import Path
4
+ import duckdb
5
+ from loguru import logger
6
+
7
+
8
+ # Extension -> format name mapping for DuckDB read functions
9
+ FORMAT_MAP: dict[str, str] = {
10
+ ".csv": "csv",
11
+ ".tsv": "tsv",
12
+ ".txt": "tsv",
13
+ ".tab": "tsv",
14
+ ".parquet": "parquet",
15
+ ".json": "json",
16
+ ".jsonl": "json",
17
+ ".xlsx": "excel",
18
+ ".xls": "excel",
19
+ }
20
+
21
+ # Extension -> SQL read function template
22
+ READ_SQL_TEMPLATES: dict[str, str] = {
23
+ "csv": "read_csv_auto('{path}')",
24
+ "tsv": "read_csv_auto('{path}')",
25
+ "parquet": "read_parquet('{path}')",
26
+ "json": "read_json_auto('{path}')",
27
+ }
28
+
29
+
30
+ def detect_format(file_path: Path) -> str:
31
+ """Detect data format from file extension.
32
+
33
+ Args:
34
+ file_path: Path to the data file.
35
+
36
+ Returns:
37
+ Format name string (e.g. 'csv', 'parquet', 'excel').
38
+
39
+ Raises:
40
+ ValueError: If the file extension is not supported.
41
+ """
42
+ ext = file_path.suffix.lower()
43
+ if not ext:
44
+ raise ValueError(
45
+ f"Cannot detect format: '{file_path}' has no file extension. "
46
+ f"Supported extensions: {', '.join(sorted(set(FORMAT_MAP.values())))}\n"
47
+ f"Supported file types: {', '.join(sorted(FORMAT_MAP.keys()))}"
48
+ )
49
+
50
+ fmt = FORMAT_MAP.get(ext)
51
+ if fmt is None:
52
+ raise ValueError(
53
+ f"Unsupported file format: '{ext}'. "
54
+ f"Supported extensions: {', '.join(sorted(FORMAT_MAP.keys()))}"
55
+ )
56
+ return fmt
57
+
58
+
59
+ def init_excel_support(con: duckdb.DuckDBPyConnection) -> None:
60
+ """Install and load DuckDB excel and spatial extensions."""
61
+ for ext in ("excel", "spatial"):
62
+ try:
63
+ con.sql(f"LOAD {ext}")
64
+ except Exception:
65
+ logger.debug("Installing {} extension...", ext)
66
+ con.sql(f"INSTALL {ext}")
67
+ con.sql(f"LOAD {ext}")
68
+ logger.debug("{} extension installed and loaded", ext)
69
+
70
+
71
+ def register_file(
72
+ con: duckdb.DuckDBPyConnection,
73
+ file_path: Path,
74
+ table_name: str = "data",
75
+ ) -> str:
76
+ """Register a data file as a DuckDB view.
77
+
78
+ Args:
79
+ con: Active DuckDB connection.
80
+ file_path: Path to the data file.
81
+ table_name: Name for the registered view (default: 'data').
82
+
83
+ Returns:
84
+ The format name used to read the file.
85
+
86
+ Raises:
87
+ FileNotFoundError: If the file does not exist.
88
+ ValueError: If the format is not supported.
89
+ """
90
+ if not file_path.exists():
91
+ raise FileNotFoundError(f"File not found: {file_path}")
92
+
93
+ fmt = detect_format(file_path)
94
+ path_str = str(file_path.resolve())
95
+ logger.debug("Registering {} as format={} table={}", path_str, fmt, table_name)
96
+
97
+ if fmt == "excel":
98
+ init_excel_support(con)
99
+ con.sql(
100
+ f"CREATE OR REPLACE VIEW {table_name} AS "
101
+ f"SELECT * FROM st_read('{path_str}')"
102
+ )
103
+ else:
104
+ read_fn = READ_SQL_TEMPLATES[fmt].format(path=path_str)
105
+ con.sql(f"CREATE OR REPLACE VIEW {table_name} AS SELECT * FROM {read_fn}")
106
+
107
+ logger.info(
108
+ "Registered '{}' as table '{}' (format: {})", file_path.name, table_name, fmt
109
+ )
110
+ return fmt
dv/utils.py ADDED
@@ -0,0 +1,24 @@
1
+ """Logging and utility functions for dv."""
2
+
3
+ import sys
4
+ from loguru import logger
5
+
6
+
7
+ def setup_logging(verbose: bool = False) -> None:
8
+ """Configure loguru logging.
9
+
10
+ Args:
11
+ verbose: If True, set log level to DEBUG. Otherwise WARNING.
12
+ """
13
+ logger.remove()
14
+ level = "DEBUG" if verbose else "WARNING"
15
+ logger.add(
16
+ sys.stderr,
17
+ level=level,
18
+ format=(
19
+ "<level>{level: <8}</level> | "
20
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
21
+ "<level>{message}</level>"
22
+ ),
23
+ colorize=True,
24
+ )