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.
- duckview-0.1.0.dist-info/METADATA +8 -0
- duckview-0.1.0.dist-info/RECORD +9 -0
- duckview-0.1.0.dist-info/WHEEL +4 -0
- duckview-0.1.0.dist-info/entry_points.txt +2 -0
- dv/__init__.py +0 -0
- dv/display.py +181 -0
- dv/main.py +747 -0
- dv/reader.py +110 -0
- dv/utils.py +24 -0
|
@@ -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,,
|
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
|
+
)
|