csvsmith 0.10.0__tar.gz → 0.11.1__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.
- {csvsmith-0.10.0/src/csvsmith.egg-info → csvsmith-0.11.1}/PKG-INFO +9 -1
- {csvsmith-0.10.0 → csvsmith-0.11.1}/README.rst +8 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/pyproject.toml +1 -1
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/__init__.py +7 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/cli.py +110 -0
- csvsmith-0.11.1/src/csvsmith/tools/csv_viewer.py +168 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1/src/csvsmith.egg-info}/PKG-INFO +9 -1
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith.egg-info/SOURCES.txt +2 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_cli.py +29 -0
- csvsmith-0.11.1/tests/test_csv_viewer.py +124 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/LICENSE +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/setup.cfg +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/__init__.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/classify.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/dense_csv.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/excel2csv.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/filter_rows.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/find_matches_in_csv.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/knapsack_csv.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/move_files.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/row_dedup.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/sample_csv.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/tools/strict_concat.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/utils/__init__.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/utils/clean_numeric.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/utils/distance.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/utils/io.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith/utils/normalize.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith.egg-info/dependency_links.txt +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith.egg-info/entry_points.txt +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith.egg-info/requires.txt +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/src/csvsmith.egg-info/top_level.txt +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_classify.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_clean_numeric.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_dense_csv.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_excel2csv.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_filter_rows.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_find_matches_in_csv.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_knapsack_csv.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_move_files.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_normalize.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_row_dedup.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_sample_csv.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_strict_concat.py +0 -0
- {csvsmith-0.10.0 → csvsmith-0.11.1}/tests/test_string_distance.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: csvsmith
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.1
|
|
4
4
|
Summary: Small CSV utilities: row deduplication, classification, row filtering, and CLI helpers.
|
|
5
5
|
Author-email: Eiichi YAMAMOTO <info@yeiichi.com>
|
|
6
6
|
License: MIT License
|
|
@@ -69,6 +69,7 @@ Small, focused CSV utilities for common data wrangling tasks.
|
|
|
69
69
|
files, including cleaning numeric values, filtering rows, deduplicating
|
|
70
70
|
records, classifying files, converting Excel spreadsheets to CSV, moving
|
|
71
71
|
files by suffix, and finding matches inside CSV content.
|
|
72
|
+
It also includes a lightweight CSV viewer for quick terminal inspection.
|
|
72
73
|
|
|
73
74
|
Documentation
|
|
74
75
|
-------------
|
|
@@ -86,6 +87,7 @@ Features
|
|
|
86
87
|
- Classify CSV files into folders based on headers/signatures
|
|
87
88
|
- Convert Excel workbooks to CSV
|
|
88
89
|
- Generate sample CSV files for testing and examples
|
|
90
|
+
- View selected CSV rows and columns as a text table
|
|
89
91
|
- Mark rows selected by a knapsack-style capacity fit
|
|
90
92
|
- Move files by suffix
|
|
91
93
|
- Find matching values inside CSV files
|
|
@@ -180,6 +182,12 @@ Generate a sample CSV:
|
|
|
180
182
|
|
|
181
183
|
csvsmith sample-csv -o sample.csv --rows 16 --seed 42
|
|
182
184
|
|
|
185
|
+
View a CSV file:
|
|
186
|
+
|
|
187
|
+
.. code-block:: console
|
|
188
|
+
|
|
189
|
+
csvsmith view sample.csv --columns date,value --filter value ">" 700 --head 5
|
|
190
|
+
|
|
183
191
|
Mark rows whose values best fit a capacity:
|
|
184
192
|
|
|
185
193
|
.. code-block:: console
|
|
@@ -23,6 +23,7 @@ Small, focused CSV utilities for common data wrangling tasks.
|
|
|
23
23
|
files, including cleaning numeric values, filtering rows, deduplicating
|
|
24
24
|
records, classifying files, converting Excel spreadsheets to CSV, moving
|
|
25
25
|
files by suffix, and finding matches inside CSV content.
|
|
26
|
+
It also includes a lightweight CSV viewer for quick terminal inspection.
|
|
26
27
|
|
|
27
28
|
Documentation
|
|
28
29
|
-------------
|
|
@@ -40,6 +41,7 @@ Features
|
|
|
40
41
|
- Classify CSV files into folders based on headers/signatures
|
|
41
42
|
- Convert Excel workbooks to CSV
|
|
42
43
|
- Generate sample CSV files for testing and examples
|
|
44
|
+
- View selected CSV rows and columns as a text table
|
|
43
45
|
- Mark rows selected by a knapsack-style capacity fit
|
|
44
46
|
- Move files by suffix
|
|
45
47
|
- Find matching values inside CSV files
|
|
@@ -134,6 +136,12 @@ Generate a sample CSV:
|
|
|
134
136
|
|
|
135
137
|
csvsmith sample-csv -o sample.csv --rows 16 --seed 42
|
|
136
138
|
|
|
139
|
+
View a CSV file:
|
|
140
|
+
|
|
141
|
+
.. code-block:: console
|
|
142
|
+
|
|
143
|
+
csvsmith view sample.csv --columns date,value --filter value ">" 700 --head 5
|
|
144
|
+
|
|
137
145
|
Mark rows whose values best fit a capacity:
|
|
138
146
|
|
|
139
147
|
.. code-block:: console
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "csvsmith"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.1"
|
|
8
8
|
description = "Small CSV utilities: row deduplication, classification, row filtering, and CLI helpers."
|
|
9
9
|
readme = "README.rst"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -21,6 +21,9 @@ Public API:
|
|
|
21
21
|
- concentrate_csv
|
|
22
22
|
- rehydrate_csv
|
|
23
23
|
- create_sample_csv
|
|
24
|
+
- DataFrame
|
|
25
|
+
- build_filter
|
|
26
|
+
- infer_type
|
|
24
27
|
- find_knapsack_indices
|
|
25
28
|
- mark_knapsack_csv
|
|
26
29
|
|
|
@@ -42,6 +45,7 @@ Submodules:
|
|
|
42
45
|
__version__ = "0.8.0"
|
|
43
46
|
|
|
44
47
|
from .tools.classify import CSVClassifier
|
|
48
|
+
from .tools.csv_viewer import DataFrame, build_filter, infer_type
|
|
45
49
|
from .tools.dense_csv import (
|
|
46
50
|
ConcentrateResult,
|
|
47
51
|
RehydrateResult,
|
|
@@ -97,6 +101,9 @@ __all__ = [
|
|
|
97
101
|
"rehydrate_csv",
|
|
98
102
|
"SampleCSVResult",
|
|
99
103
|
"create_sample_csv",
|
|
104
|
+
"DataFrame",
|
|
105
|
+
"build_filter",
|
|
106
|
+
"infer_type",
|
|
100
107
|
"KnapsackCSVResult",
|
|
101
108
|
"find_knapsack_indices",
|
|
102
109
|
"mark_knapsack_csv",
|
|
@@ -8,6 +8,7 @@ from typing import Optional, Sequence
|
|
|
8
8
|
|
|
9
9
|
from . import __version__
|
|
10
10
|
from .tools.classify import CSVClassifier
|
|
11
|
+
from .tools.csv_viewer import DataFrame, build_filter
|
|
11
12
|
from .tools.dense_csv import concentrate_csv, rehydrate_csv
|
|
12
13
|
from .tools.excel2csv import excel_to_csv
|
|
13
14
|
from .utils.clean_numeric import clean_currency_numeric, clean_numeric
|
|
@@ -30,6 +31,20 @@ from .utils.distance import analyze_pair
|
|
|
30
31
|
from .utils.io import read_csv_rows, write_csv_rows
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def _non_negative_int(value: str) -> int:
|
|
35
|
+
number = int(value)
|
|
36
|
+
if number < 0:
|
|
37
|
+
raise argparse.ArgumentTypeError("must be 0 or greater")
|
|
38
|
+
return number
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _positive_int(value: str) -> int:
|
|
42
|
+
number = int(value)
|
|
43
|
+
if number < 1:
|
|
44
|
+
raise argparse.ArgumentTypeError("must be 1 or greater")
|
|
45
|
+
return number
|
|
46
|
+
|
|
47
|
+
|
|
33
48
|
def cmd_row_duplicates(args: argparse.Namespace) -> int:
|
|
34
49
|
rows = read_csv_rows(args.input)
|
|
35
50
|
subset = args.subset.split(",") if args.subset else None
|
|
@@ -287,6 +302,61 @@ def cmd_sample_csv(args: argparse.Namespace) -> int:
|
|
|
287
302
|
return 0
|
|
288
303
|
|
|
289
304
|
|
|
305
|
+
def cmd_view(args: argparse.Namespace) -> int:
|
|
306
|
+
try:
|
|
307
|
+
df = DataFrame.from_csv(
|
|
308
|
+
args.input,
|
|
309
|
+
convert_types=not args.no_convert_types,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if args.filter:
|
|
313
|
+
column, op, value = args.filter
|
|
314
|
+
df = df.filter(build_filter(column, op, value))
|
|
315
|
+
|
|
316
|
+
if args.columns:
|
|
317
|
+
columns = [column.strip() for column in args.columns.split(",") if column.strip()]
|
|
318
|
+
df = df.select(columns)
|
|
319
|
+
|
|
320
|
+
if args.head is not None:
|
|
321
|
+
output = df.head(args.head)
|
|
322
|
+
if output:
|
|
323
|
+
print(output)
|
|
324
|
+
elif sys.stdin.isatty() and sys.stdout.isatty():
|
|
325
|
+
_paginate_dataframe(df, args.page_size)
|
|
326
|
+
else:
|
|
327
|
+
output = df.render()
|
|
328
|
+
if output:
|
|
329
|
+
print(output)
|
|
330
|
+
except (OSError, csv.Error) as e:
|
|
331
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
332
|
+
return 1
|
|
333
|
+
except KeyError as e:
|
|
334
|
+
print(f"Error: unknown column: {e.args[0]}", file=sys.stderr)
|
|
335
|
+
return 1
|
|
336
|
+
except ValueError as e:
|
|
337
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
338
|
+
return 1
|
|
339
|
+
|
|
340
|
+
return 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _paginate_dataframe(df: DataFrame, page_size: int) -> None:
|
|
344
|
+
for start in range(0, len(df), page_size):
|
|
345
|
+
end = min(start + page_size, len(df))
|
|
346
|
+
output = df.render(start=start, end=end)
|
|
347
|
+
if output:
|
|
348
|
+
print(output)
|
|
349
|
+
|
|
350
|
+
if end >= len(df):
|
|
351
|
+
break
|
|
352
|
+
|
|
353
|
+
print(f"\nRows {start + 1}-{end} of {len(df)}")
|
|
354
|
+
response = input("Press Enter for next page, or q to quit: ").strip().lower()
|
|
355
|
+
if response == "q":
|
|
356
|
+
break
|
|
357
|
+
print()
|
|
358
|
+
|
|
359
|
+
|
|
290
360
|
def cmd_knapsack(args: argparse.Namespace) -> int:
|
|
291
361
|
try:
|
|
292
362
|
result = mark_knapsack_csv(
|
|
@@ -403,6 +473,45 @@ def _add_sample_csv_parser(subparsers) -> None:
|
|
|
403
473
|
parser.set_defaults(func=cmd_sample_csv)
|
|
404
474
|
|
|
405
475
|
|
|
476
|
+
def _add_view_parser(subparsers) -> None:
|
|
477
|
+
parser = subparsers.add_parser(
|
|
478
|
+
"view",
|
|
479
|
+
help="View and lightly query CSV files as a text table.",
|
|
480
|
+
)
|
|
481
|
+
parser.add_argument("input", help="Input CSV file.")
|
|
482
|
+
parser.add_argument(
|
|
483
|
+
"-n",
|
|
484
|
+
"--head",
|
|
485
|
+
type=_non_negative_int,
|
|
486
|
+
help="Print only the first N rows.",
|
|
487
|
+
)
|
|
488
|
+
parser.add_argument(
|
|
489
|
+
"-p",
|
|
490
|
+
"--page-size",
|
|
491
|
+
type=_positive_int,
|
|
492
|
+
default=20,
|
|
493
|
+
help="Number of rows per interactive page (default: 20).",
|
|
494
|
+
)
|
|
495
|
+
parser.add_argument(
|
|
496
|
+
"-c",
|
|
497
|
+
"--columns",
|
|
498
|
+
help="Comma-separated columns to display, such as date,amount.",
|
|
499
|
+
)
|
|
500
|
+
parser.add_argument(
|
|
501
|
+
"-f",
|
|
502
|
+
"--filter",
|
|
503
|
+
nargs=3,
|
|
504
|
+
metavar=("COLUMN", "OP", "VALUE"),
|
|
505
|
+
help="Filter rows with a simple expression, such as: value '>' 700.",
|
|
506
|
+
)
|
|
507
|
+
parser.add_argument(
|
|
508
|
+
"--no-convert-types",
|
|
509
|
+
action="store_true",
|
|
510
|
+
help="Keep all CSV values as strings instead of inferring numbers.",
|
|
511
|
+
)
|
|
512
|
+
parser.set_defaults(func=cmd_view)
|
|
513
|
+
|
|
514
|
+
|
|
406
515
|
def _add_knapsack_parser(subparsers) -> None:
|
|
407
516
|
parser = subparsers.add_parser(
|
|
408
517
|
"knapsack",
|
|
@@ -559,6 +668,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
559
668
|
_add_concentrate_parser(subparsers)
|
|
560
669
|
_add_rehydrate_parser(subparsers)
|
|
561
670
|
_add_sample_csv_parser(subparsers)
|
|
671
|
+
_add_view_parser(subparsers)
|
|
562
672
|
_add_knapsack_parser(subparsers)
|
|
563
673
|
_add_dedupe_parser(subparsers)
|
|
564
674
|
_add_classify_parser(subparsers)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import operator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, TextIO
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
OPERATORS: dict[str, Callable[[Any, Any], bool]] = {
|
|
10
|
+
"==": operator.eq,
|
|
11
|
+
"!=": operator.ne,
|
|
12
|
+
">": operator.gt,
|
|
13
|
+
">=": operator.ge,
|
|
14
|
+
"<": operator.lt,
|
|
15
|
+
"<=": operator.le,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DataFrame:
|
|
20
|
+
"""A lightweight in-memory CSV table for inspection and simple filtering."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, data: dict[str, list[Any]]) -> None:
|
|
23
|
+
self._data = data
|
|
24
|
+
self.columns = list(data)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_csv(
|
|
28
|
+
cls,
|
|
29
|
+
filepath: str | Path,
|
|
30
|
+
*,
|
|
31
|
+
convert_types: bool = True,
|
|
32
|
+
) -> DataFrame:
|
|
33
|
+
"""Read a CSV file into a column-oriented ``DataFrame``."""
|
|
34
|
+
with Path(filepath).open("r", newline="", encoding="utf-8-sig") as file:
|
|
35
|
+
reader = csv.DictReader(file)
|
|
36
|
+
fieldnames = reader.fieldnames or []
|
|
37
|
+
data: dict[str, list[Any]] = {column: [] for column in fieldnames}
|
|
38
|
+
|
|
39
|
+
for row in reader:
|
|
40
|
+
for column in fieldnames:
|
|
41
|
+
value = row.get(column)
|
|
42
|
+
if convert_types:
|
|
43
|
+
value = infer_type(value)
|
|
44
|
+
data[column].append(value)
|
|
45
|
+
|
|
46
|
+
return cls(data)
|
|
47
|
+
|
|
48
|
+
def render(self, *, start: int = 0, end: int | None = None) -> str:
|
|
49
|
+
"""Return rows from ``start`` up to ``end`` formatted as a text table."""
|
|
50
|
+
start = max(start, 0)
|
|
51
|
+
end = len(self) if end is None else min(end, len(self))
|
|
52
|
+
if start >= end or not self.columns:
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
row_indexes = range(start, end)
|
|
56
|
+
widths = {column: len(column) for column in self.columns}
|
|
57
|
+
for column in self.columns:
|
|
58
|
+
for index in row_indexes:
|
|
59
|
+
widths[column] = max(widths[column], len(str(self._data[column][index])))
|
|
60
|
+
|
|
61
|
+
lines = [
|
|
62
|
+
" | ".join(f"{column:<{widths[column]}}" for column in self.columns),
|
|
63
|
+
"-+-".join("-" * widths[column] for column in self.columns),
|
|
64
|
+
]
|
|
65
|
+
for index in row_indexes:
|
|
66
|
+
lines.append(
|
|
67
|
+
" | ".join(
|
|
68
|
+
f"{str(self._data[column][index]):<{widths[column]}}"
|
|
69
|
+
for column in self.columns
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return "\n".join(lines)
|
|
74
|
+
|
|
75
|
+
def show(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
start: int = 0,
|
|
79
|
+
end: int | None = None,
|
|
80
|
+
file: TextIO | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Print rows from ``start`` up to ``end`` as a text table."""
|
|
83
|
+
rendered = self.render(start=start, end=end)
|
|
84
|
+
if rendered:
|
|
85
|
+
print(rendered, file=file)
|
|
86
|
+
|
|
87
|
+
def head(self, n: int = 5) -> str:
|
|
88
|
+
"""Return the first ``n`` rows formatted as a text table."""
|
|
89
|
+
return self.render(end=n)
|
|
90
|
+
|
|
91
|
+
def select(self, columns: list[str]) -> DataFrame:
|
|
92
|
+
"""Return a new ``DataFrame`` with the selected columns."""
|
|
93
|
+
missing = [column for column in columns if column not in self._data]
|
|
94
|
+
if missing:
|
|
95
|
+
raise KeyError(", ".join(missing))
|
|
96
|
+
return DataFrame({column: self._data[column] for column in columns})
|
|
97
|
+
|
|
98
|
+
def filter(self, condition_func: Callable[[dict[str, Any]], bool]) -> DataFrame:
|
|
99
|
+
"""Return rows for which ``condition_func`` evaluates to true."""
|
|
100
|
+
filtered_data: dict[str, list[Any]] = {column: [] for column in self.columns}
|
|
101
|
+
|
|
102
|
+
for index in range(len(self)):
|
|
103
|
+
row = {column: self._data[column][index] for column in self.columns}
|
|
104
|
+
if condition_func(row):
|
|
105
|
+
for column in self.columns:
|
|
106
|
+
filtered_data[column].append(self._data[column][index])
|
|
107
|
+
|
|
108
|
+
return DataFrame(filtered_data)
|
|
109
|
+
|
|
110
|
+
def __getitem__(self, column_name: str) -> list[Any]:
|
|
111
|
+
return self._data[column_name]
|
|
112
|
+
|
|
113
|
+
def __setitem__(self, column_name: str, values: list[Any]) -> None:
|
|
114
|
+
if len(values) != len(self):
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Length of values ({len(values)}) does not match "
|
|
117
|
+
f"DataFrame length ({len(self)})"
|
|
118
|
+
)
|
|
119
|
+
self._data[column_name] = list(values)
|
|
120
|
+
if column_name not in self.columns:
|
|
121
|
+
self.columns.append(column_name)
|
|
122
|
+
|
|
123
|
+
def __len__(self) -> int:
|
|
124
|
+
if not self.columns:
|
|
125
|
+
return 0
|
|
126
|
+
return len(self._data[self.columns[0]])
|
|
127
|
+
|
|
128
|
+
def __repr__(self) -> str:
|
|
129
|
+
return f"<DataFrame with {len(self)} rows and {len(self.columns)} columns>"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def infer_type(value: str | None) -> int | float | str | None:
|
|
133
|
+
"""Convert strings to ``int`` or ``float`` when possible."""
|
|
134
|
+
if value is None:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
stripped = value.strip()
|
|
138
|
+
try:
|
|
139
|
+
return int(stripped)
|
|
140
|
+
except ValueError:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
return float(stripped)
|
|
145
|
+
except ValueError:
|
|
146
|
+
return stripped
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_filter(column: str, op: str, raw_value: str) -> Callable[[dict[str, Any]], bool]:
|
|
150
|
+
"""Build a row predicate from a column, operator, and comparison value."""
|
|
151
|
+
if op not in OPERATORS:
|
|
152
|
+
allowed = ", ".join(OPERATORS)
|
|
153
|
+
raise ValueError(f"Unsupported operator {op!r}. Use one of: {allowed}.")
|
|
154
|
+
|
|
155
|
+
expected = infer_type(raw_value)
|
|
156
|
+
compare = OPERATORS[op]
|
|
157
|
+
|
|
158
|
+
def row_matches(row: dict[str, Any]) -> bool:
|
|
159
|
+
if column not in row:
|
|
160
|
+
raise KeyError(column)
|
|
161
|
+
|
|
162
|
+
actual = row[column]
|
|
163
|
+
try:
|
|
164
|
+
return compare(actual, expected)
|
|
165
|
+
except TypeError:
|
|
166
|
+
return compare(str(actual), str(expected))
|
|
167
|
+
|
|
168
|
+
return row_matches
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: csvsmith
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.1
|
|
4
4
|
Summary: Small CSV utilities: row deduplication, classification, row filtering, and CLI helpers.
|
|
5
5
|
Author-email: Eiichi YAMAMOTO <info@yeiichi.com>
|
|
6
6
|
License: MIT License
|
|
@@ -69,6 +69,7 @@ Small, focused CSV utilities for common data wrangling tasks.
|
|
|
69
69
|
files, including cleaning numeric values, filtering rows, deduplicating
|
|
70
70
|
records, classifying files, converting Excel spreadsheets to CSV, moving
|
|
71
71
|
files by suffix, and finding matches inside CSV content.
|
|
72
|
+
It also includes a lightweight CSV viewer for quick terminal inspection.
|
|
72
73
|
|
|
73
74
|
Documentation
|
|
74
75
|
-------------
|
|
@@ -86,6 +87,7 @@ Features
|
|
|
86
87
|
- Classify CSV files into folders based on headers/signatures
|
|
87
88
|
- Convert Excel workbooks to CSV
|
|
88
89
|
- Generate sample CSV files for testing and examples
|
|
90
|
+
- View selected CSV rows and columns as a text table
|
|
89
91
|
- Mark rows selected by a knapsack-style capacity fit
|
|
90
92
|
- Move files by suffix
|
|
91
93
|
- Find matching values inside CSV files
|
|
@@ -180,6 +182,12 @@ Generate a sample CSV:
|
|
|
180
182
|
|
|
181
183
|
csvsmith sample-csv -o sample.csv --rows 16 --seed 42
|
|
182
184
|
|
|
185
|
+
View a CSV file:
|
|
186
|
+
|
|
187
|
+
.. code-block:: console
|
|
188
|
+
|
|
189
|
+
csvsmith view sample.csv --columns date,value --filter value ">" 700 --head 5
|
|
190
|
+
|
|
183
191
|
Mark rows whose values best fit a capacity:
|
|
184
192
|
|
|
185
193
|
.. code-block:: console
|
|
@@ -11,6 +11,7 @@ src/csvsmith.egg-info/requires.txt
|
|
|
11
11
|
src/csvsmith.egg-info/top_level.txt
|
|
12
12
|
src/csvsmith/tools/__init__.py
|
|
13
13
|
src/csvsmith/tools/classify.py
|
|
14
|
+
src/csvsmith/tools/csv_viewer.py
|
|
14
15
|
src/csvsmith/tools/dense_csv.py
|
|
15
16
|
src/csvsmith/tools/excel2csv.py
|
|
16
17
|
src/csvsmith/tools/filter_rows.py
|
|
@@ -28,6 +29,7 @@ src/csvsmith/utils/normalize.py
|
|
|
28
29
|
tests/test_classify.py
|
|
29
30
|
tests/test_clean_numeric.py
|
|
30
31
|
tests/test_cli.py
|
|
32
|
+
tests/test_csv_viewer.py
|
|
31
33
|
tests/test_dense_csv.py
|
|
32
34
|
tests/test_excel2csv.py
|
|
33
35
|
tests/test_filter_rows.py
|
|
@@ -279,6 +279,35 @@ def test_cli_parses_sample_csv_command():
|
|
|
279
279
|
assert args.seed == 42
|
|
280
280
|
|
|
281
281
|
|
|
282
|
+
def test_cli_parses_view_command():
|
|
283
|
+
parser = build_parser()
|
|
284
|
+
args = parser.parse_args(
|
|
285
|
+
[
|
|
286
|
+
"view",
|
|
287
|
+
"sample.csv",
|
|
288
|
+
"--head",
|
|
289
|
+
"3",
|
|
290
|
+
"--page-size",
|
|
291
|
+
"10",
|
|
292
|
+
"--columns",
|
|
293
|
+
"date,value",
|
|
294
|
+
"--filter",
|
|
295
|
+
"value",
|
|
296
|
+
">",
|
|
297
|
+
"700",
|
|
298
|
+
"--no-convert-types",
|
|
299
|
+
]
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
assert args.command == "view"
|
|
303
|
+
assert args.input == "sample.csv"
|
|
304
|
+
assert args.head == 3
|
|
305
|
+
assert args.page_size == 10
|
|
306
|
+
assert args.columns == "date,value"
|
|
307
|
+
assert args.filter == ["value", ">", "700"]
|
|
308
|
+
assert args.no_convert_types is True
|
|
309
|
+
|
|
310
|
+
|
|
282
311
|
def test_cli_parses_knapsack_command():
|
|
283
312
|
parser = build_parser()
|
|
284
313
|
args = parser.parse_args(
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from csvsmith.cli import main
|
|
7
|
+
from csvsmith.tools.csv_viewer import DataFrame, build_filter, infer_type
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write_csv(path: Path, rows: list[list[str]]) -> None:
|
|
11
|
+
with path.open("w", encoding="utf-8", newline="") as outfile:
|
|
12
|
+
csv.writer(outfile).writerows(rows)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_dataframe_reads_csv_and_infers_numeric_types(tmp_path):
|
|
16
|
+
source = tmp_path / "sample.csv"
|
|
17
|
+
write_csv(source, [["name", "value"], ["Alice", "12"], ["Bob", "13.5"]])
|
|
18
|
+
|
|
19
|
+
df = DataFrame.from_csv(source)
|
|
20
|
+
|
|
21
|
+
assert df.columns == ["name", "value"]
|
|
22
|
+
assert df["name"] == ["Alice", "Bob"]
|
|
23
|
+
assert df["value"] == [12, 13.5]
|
|
24
|
+
assert repr(df) == "<DataFrame with 2 rows and 2 columns>"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_dataframe_reads_utf8_bom_csv_headers(tmp_path):
|
|
28
|
+
source = tmp_path / "sample.csv"
|
|
29
|
+
with source.open("w", encoding="utf-8-sig", newline="") as outfile:
|
|
30
|
+
csv.writer(outfile).writerows([["name", "value"], ["Alice", "12"]])
|
|
31
|
+
|
|
32
|
+
df = DataFrame.from_csv(source)
|
|
33
|
+
|
|
34
|
+
assert df.columns == ["name", "value"]
|
|
35
|
+
assert df["name"] == ["Alice"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_dataframe_can_keep_values_as_strings(tmp_path):
|
|
39
|
+
source = tmp_path / "sample.csv"
|
|
40
|
+
write_csv(source, [["id"], ["001"]])
|
|
41
|
+
|
|
42
|
+
df = DataFrame.from_csv(source, convert_types=False)
|
|
43
|
+
|
|
44
|
+
assert df["id"] == ["001"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_dataframe_render_select_and_head(tmp_path):
|
|
48
|
+
source = tmp_path / "sample.csv"
|
|
49
|
+
write_csv(
|
|
50
|
+
source,
|
|
51
|
+
[
|
|
52
|
+
["name", "score", "city"],
|
|
53
|
+
["Alice", "10", "Tokyo"],
|
|
54
|
+
["Bob", "8", "Osaka"],
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
df = DataFrame.from_csv(source).select(["name", "score"])
|
|
59
|
+
|
|
60
|
+
assert df.head(1) == "name | score\n------+------\nAlice | 10 "
|
|
61
|
+
assert df.render(start=1) == "name | score\n-----+------\nBob | 8 "
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_dataframe_filter_supports_numeric_comparisons(tmp_path):
|
|
65
|
+
source = tmp_path / "sample.csv"
|
|
66
|
+
write_csv(source, [["name", "score"], ["Alice", "10"], ["Bob", "8"]])
|
|
67
|
+
|
|
68
|
+
df = DataFrame.from_csv(source).filter(build_filter("score", ">", "8"))
|
|
69
|
+
|
|
70
|
+
assert df["name"] == ["Alice"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_view_command_filters_and_selects_columns(tmp_path, capsys):
|
|
74
|
+
source = tmp_path / "sample.csv"
|
|
75
|
+
write_csv(
|
|
76
|
+
source,
|
|
77
|
+
[
|
|
78
|
+
["name", "score", "city"],
|
|
79
|
+
["Alice", "10", "Tokyo"],
|
|
80
|
+
["Bob", "8", "Osaka"],
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
exit_code = main(
|
|
85
|
+
[
|
|
86
|
+
"view",
|
|
87
|
+
str(source),
|
|
88
|
+
"--columns",
|
|
89
|
+
"name,score",
|
|
90
|
+
"--filter",
|
|
91
|
+
"score",
|
|
92
|
+
">",
|
|
93
|
+
"8",
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
assert exit_code == 0
|
|
98
|
+
assert capsys.readouterr().out == "name | score\n------+------\nAlice | 10 \n"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_dataframe_select_rejects_missing_columns(tmp_path):
|
|
102
|
+
source = tmp_path / "sample.csv"
|
|
103
|
+
write_csv(source, [["name"], ["Alice"]])
|
|
104
|
+
|
|
105
|
+
with pytest.raises(KeyError, match="score"):
|
|
106
|
+
DataFrame.from_csv(source).select(["score"])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_build_filter_rejects_unknown_operator():
|
|
110
|
+
with pytest.raises(ValueError, match="Unsupported operator"):
|
|
111
|
+
build_filter("score", "~", "10")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize(
|
|
115
|
+
("raw_value", "expected"),
|
|
116
|
+
[
|
|
117
|
+
(" 12 ", 12),
|
|
118
|
+
("12.5", 12.5),
|
|
119
|
+
("Alice", "Alice"),
|
|
120
|
+
(None, None),
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
def test_infer_type(raw_value, expected):
|
|
124
|
+
assert infer_type(raw_value) == expected
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|