csvsmith 0.7.3__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {csvsmith-0.7.3/src/csvsmith.egg-info → csvsmith-0.8.0}/PKG-INFO +9 -2
  2. {csvsmith-0.7.3 → csvsmith-0.8.0}/README.rst +8 -1
  3. {csvsmith-0.7.3 → csvsmith-0.8.0}/pyproject.toml +1 -1
  4. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/__init__.py +8 -2
  5. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/cli.py +29 -0
  6. csvsmith-0.8.0/src/csvsmith/tools/strict_concat.py +101 -0
  7. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/utils/clean_numeric.py +1 -1
  8. {csvsmith-0.7.3 → csvsmith-0.8.0/src/csvsmith.egg-info}/PKG-INFO +9 -2
  9. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith.egg-info/SOURCES.txt +2 -0
  10. {csvsmith-0.7.3 → csvsmith-0.8.0}/tests/test_cli.py +10 -1
  11. csvsmith-0.8.0/tests/test_strict_concat.py +102 -0
  12. {csvsmith-0.7.3 → csvsmith-0.8.0}/LICENSE +0 -0
  13. {csvsmith-0.7.3 → csvsmith-0.8.0}/setup.cfg +0 -0
  14. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/tools/__init__.py +0 -0
  15. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/tools/classify.py +0 -0
  16. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/tools/excel2csv.py +0 -0
  17. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/tools/filter_rows.py +0 -0
  18. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/tools/find_matches_in_csv.py +0 -0
  19. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/tools/move_files.py +0 -0
  20. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/tools/row_dedup.py +0 -0
  21. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/utils/__init__.py +0 -0
  22. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/utils/distance.py +0 -0
  23. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/utils/io.py +0 -0
  24. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith/utils/normalize.py +0 -0
  25. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith.egg-info/dependency_links.txt +0 -0
  26. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith.egg-info/entry_points.txt +0 -0
  27. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith.egg-info/requires.txt +0 -0
  28. {csvsmith-0.7.3 → csvsmith-0.8.0}/src/csvsmith.egg-info/top_level.txt +0 -0
  29. {csvsmith-0.7.3 → csvsmith-0.8.0}/tests/test_classify.py +0 -0
  30. {csvsmith-0.7.3 → csvsmith-0.8.0}/tests/test_clean_numeric.py +0 -0
  31. {csvsmith-0.7.3 → csvsmith-0.8.0}/tests/test_excel2csv.py +0 -0
  32. {csvsmith-0.7.3 → csvsmith-0.8.0}/tests/test_filter_rows.py +0 -0
  33. {csvsmith-0.7.3 → csvsmith-0.8.0}/tests/test_find_matches_in_csv.py +0 -0
  34. {csvsmith-0.7.3 → csvsmith-0.8.0}/tests/test_move_files.py +0 -0
  35. {csvsmith-0.7.3 → csvsmith-0.8.0}/tests/test_normalize.py +0 -0
  36. {csvsmith-0.7.3 → csvsmith-0.8.0}/tests/test_row_dedup.py +0 -0
  37. {csvsmith-0.7.3 → csvsmith-0.8.0}/tests/test_string_distance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csvsmith
3
- Version: 0.7.3
3
+ Version: 0.8.0
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
@@ -87,6 +87,7 @@ Features
87
87
  - Convert Excel workbooks to CSV
88
88
  - Move files by suffix
89
89
  - Find matching values inside CSV files
90
+ - Concatenate CSV files with identical headers
90
91
  - Use the tools either from Python or from the command line
91
92
 
92
93
  Installation
@@ -182,6 +183,12 @@ Find matches in a CSV:
182
183
 
183
184
  csvsmith find-matches input.csv target --ignore-case --ignore-whitespace
184
185
 
186
+ Concatenate CSV files:
187
+
188
+ .. code-block:: console
189
+
190
+ csvsmith strict-concat file1.csv file2.csv -o combined.csv
191
+
185
192
  Find matches in a CSV
186
193
  ---------------------
187
194
 
@@ -241,7 +248,7 @@ File and conversion helpers:
241
248
 
242
249
  .. code-block:: python
243
250
 
244
- from csvsmith import excel_to_csv, move_by_suffix
251
+ from csvsmith import excel_to_csv, move_by_suffix, strict_concat_rows, save_csv
245
252
 
246
253
  String comparison utilities:
247
254
 
@@ -41,6 +41,7 @@ Features
41
41
  - Convert Excel workbooks to CSV
42
42
  - Move files by suffix
43
43
  - Find matching values inside CSV files
44
+ - Concatenate CSV files with identical headers
44
45
  - Use the tools either from Python or from the command line
45
46
 
46
47
  Installation
@@ -136,6 +137,12 @@ Find matches in a CSV:
136
137
 
137
138
  csvsmith find-matches input.csv target --ignore-case --ignore-whitespace
138
139
 
140
+ Concatenate CSV files:
141
+
142
+ .. code-block:: console
143
+
144
+ csvsmith strict-concat file1.csv file2.csv -o combined.csv
145
+
139
146
  Find matches in a CSV
140
147
  ---------------------
141
148
 
@@ -195,7 +202,7 @@ File and conversion helpers:
195
202
 
196
203
  .. code-block:: python
197
204
 
198
- from csvsmith import excel_to_csv, move_by_suffix
205
+ from csvsmith import excel_to_csv, move_by_suffix, strict_concat_rows, save_csv
199
206
 
200
207
  String comparison utilities:
201
208
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "csvsmith"
7
- version = "0.7.3"
7
+ version = "0.8.0"
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"
@@ -16,6 +16,8 @@ Public API:
16
16
  - Relation
17
17
  - Result
18
18
  - analyze_pair
19
+ - strict_concat_rows
20
+ - save_csv
19
21
 
20
22
  Compatibility aliases:
21
23
  - CSVCleaner
@@ -28,10 +30,11 @@ Submodules:
28
30
  - csvsmith.filter_rows
29
31
  - csvsmith.excel2csv
30
32
  - csvsmith.move_files
33
+ - csvsmith.strict_concat
31
34
  - csvsmith.cli (CLI entrypoint)
32
35
  """
33
36
 
34
- __version__ = "0.7.3"
37
+ __version__ = "0.8.0"
35
38
 
36
39
  from .tools.classify import CSVClassifier
37
40
  from .tools.excel2csv import excel_to_csv
@@ -43,6 +46,7 @@ from .tools.row_dedup import (
43
46
  find_duplicate_rows,
44
47
  dedupe_with_report,
45
48
  )
49
+ from .tools.strict_concat import save_csv, strict_concat_rows
46
50
  from .utils.clean_numeric import clean_numeric
47
51
  from .utils.distance import StringDistance, Relation, Result, analyze_pair
48
52
  from .utils.io import (
@@ -68,4 +72,6 @@ __all__ = [
68
72
  "Result",
69
73
  "analyze_pair",
70
74
  "clean_numeric",
71
- ]
75
+ "strict_concat_rows",
76
+ "save_csv",
77
+ ]
@@ -16,6 +16,7 @@ from .tools.row_dedup import (
16
16
  dedupe_with_report,
17
17
  find_duplicate_rows,
18
18
  )
19
+ from .tools.strict_concat import save_csv, strict_concat_rows
19
20
  from .tools.find_matches_in_csv import find_matches_in_csv
20
21
  from .utils.distance import analyze_pair
21
22
  from .utils.io import read_csv_rows, write_csv_rows
@@ -180,6 +181,23 @@ def cmd_find_matches(args: argparse.Namespace) -> int:
180
181
  return 0
181
182
 
182
183
 
184
+ def cmd_strict_concat(args: argparse.Namespace) -> int:
185
+ input_dir = Path(args.input_dir)
186
+ if not input_dir.is_dir():
187
+ print(f"Error: input directory not found: {input_dir}", file=sys.stderr)
188
+ return 1
189
+
190
+ try:
191
+ rows = strict_concat_rows(input_dir)
192
+ save_csv(rows, args.output)
193
+ except Exception as e:
194
+ print(f"Error: {e}", file=sys.stderr)
195
+ return 1
196
+
197
+ print(f"Wrote concatenated CSV to: {args.output}")
198
+ return 0
199
+
200
+
183
201
  def _add_find_matches_parser(subparsers) -> None:
184
202
  parser = subparsers.add_parser("find-matches", help="Find matches in a CSV file.")
185
203
  parser.add_argument("input", help="Input CSV file.")
@@ -190,6 +208,16 @@ def _add_find_matches_parser(subparsers) -> None:
190
208
  parser.set_defaults(func=cmd_find_matches)
191
209
 
192
210
 
211
+ def _add_strict_concat_parser(subparsers) -> None:
212
+ parser = subparsers.add_parser(
213
+ "strict-concat",
214
+ help="Concatenate CSVs in a directory only when all headers match exactly.",
215
+ )
216
+ parser.add_argument("input_dir", help="Directory containing CSV files to concatenate.")
217
+ parser.add_argument("-o", "--output", required=True, help="Output CSV file path.")
218
+ parser.set_defaults(func=cmd_strict_concat)
219
+
220
+
193
221
  def _add_row_duplicates_parser(subparsers) -> None:
194
222
  parser = subparsers.add_parser("row-duplicates", help="Find duplicate rows in a CSV.")
195
223
  parser.add_argument("input", help="Input CSV file.")
@@ -317,6 +345,7 @@ def build_parser() -> argparse.ArgumentParser:
317
345
 
318
346
  _add_row_duplicates_parser(subparsers)
319
347
  _add_find_matches_parser(subparsers)
348
+ _add_strict_concat_parser(subparsers)
320
349
  _add_dedupe_parser(subparsers)
321
350
  _add_classify_parser(subparsers)
322
351
  _add_move_files_parser(subparsers)
@@ -0,0 +1,101 @@
1
+ import csv
2
+ from collections.abc import Iterable
3
+ from pathlib import Path
4
+
5
+
6
+ def find_csvs(csv_dir: Path | str) -> list[Path]:
7
+ """
8
+ Find all CSV files in the specified directory.
9
+
10
+ This function searches for all files with a ``.csv`` extension in the given
11
+ directory and returns a sorted list of their paths.
12
+
13
+ :param csv_dir: The directory to search for CSV files. This can be provided
14
+ as either a ``Path`` object or a string representing the path to the
15
+ directory.
16
+ :type csv_dir: Path | str
17
+ :return: Sorted list of paths to all ``.csv`` files found in the specified
18
+ directory.
19
+ :rtype: list[Path]
20
+ """
21
+ return sorted(Path(csv_dir).glob("*.csv"))
22
+
23
+
24
+ def read_header(csv_path: Path) -> list[str]:
25
+ """
26
+ Reads the header row of a given CSV file.
27
+
28
+ This function opens a CSV file located at the specified path, reads its first
29
+ row, and returns it as a list of strings. The file is assumed to be encoded
30
+ in UTF-8 with optional BOM (Byte Order Mark). If the CSV file is empty, a
31
+ ValueError is raised indicating the problem. The CSV file is expected to be
32
+ opened in read mode with no newline translation.
33
+
34
+ :param csv_path: The path to the CSV file to read the header from.
35
+ :type csv_path: Path
36
+ :return: A list of strings representing the header row of the CSV file.
37
+ :rtype: list[str]
38
+ :raises ValueError: If the CSV file is empty and no header can be retrieved.
39
+ """
40
+ with csv_path.open(encoding="utf-8-sig", newline="") as f:
41
+ reader = csv.reader(f)
42
+ try:
43
+ return next(reader)
44
+ except StopIteration as e:
45
+ raise ValueError(f"Empty CSV: {csv_path}") from e
46
+
47
+
48
+ def _validate_headers_match(csv_paths: list[Path]) -> list[str]:
49
+ """
50
+ Validates that the headers of all provided CSV files match each other. The function compares the
51
+ header of each CSV file in the input list against the header of the first file. If a mismatch
52
+ is encountered, a ValueError is raised indicating the problematic file. The function returns
53
+ the header of the first file if all headers are consistent.
54
+
55
+ :param csv_paths: A list of Path objects representing the file paths to the CSV files to validate.
56
+ :type csv_paths: list[Path]
57
+ :return: A list of strings representing the matched header of the first CSV file.
58
+ :rtype: list[str]
59
+ """
60
+ expected_header = read_header(csv_paths[0])
61
+ for csv_path in csv_paths[1:]:
62
+ if read_header(csv_path) != expected_header:
63
+ raise ValueError(f"Header mismatch: {csv_path}")
64
+ return expected_header
65
+
66
+
67
+ def strict_concat_rows(csv_dir: Path | str) -> list[list[str]]:
68
+ """
69
+ Concatenates rows from multiple CSV files into a list of lists of strings, ensuring
70
+ the headers across all CSV files match. The output includes a new column indicating
71
+ the file stem.
72
+
73
+ :param csv_dir: Directory containing the CSV files or a specific path to a CSV file.
74
+ :type csv_dir: Path | str
75
+ :return: A list of lists, where each inner list represents a row from the concatenated
76
+ CSV files. The first row contains the headers, including a "file_stem" column.
77
+ :rtype: list[list[str]]
78
+ :raises FileNotFoundError: If no CSV files are found in the provided directory.
79
+ """
80
+ csv_paths = find_csvs(csv_dir)
81
+ if not csv_paths:
82
+ raise FileNotFoundError(f"No CSV files found in: {csv_dir}")
83
+
84
+ expected_header = _validate_headers_match(csv_paths)
85
+ out_rows: list[list[str]] = [["file_stem", *expected_header]]
86
+
87
+ for csv_path in csv_paths:
88
+ with csv_path.open(encoding="utf-8-sig", newline="") as f:
89
+ reader = csv.reader(f)
90
+ next(reader) # skip header
91
+ for row in reader:
92
+ out_rows.append([csv_path.stem, *row])
93
+ return out_rows
94
+
95
+
96
+ def save_csv(rows: Iterable[list[str]], out_path: Path | str) -> None:
97
+ """Write rows to out_path."""
98
+ out_path = Path(out_path)
99
+ with out_path.open("w", encoding="utf-8", newline="") as f:
100
+ writer = csv.writer(f)
101
+ writer.writerows(rows)
@@ -5,7 +5,7 @@ NON_BREAKING_SPACE = "\xa0"
5
5
  SEPARATOR_PATTERN = re.compile(r"[ _\xa0]")
6
6
  NUMBER_PATTERN = re.compile(r"^-?(?:\d+|\d*\.\d+)$")
7
7
  INVALID_NUMBER_MESSAGE = "Could not convert {value!r} to a valid number."
8
- CURRENCY_PREFIX_PATTERN = re.compile(r"^[\$€£¥]")
8
+ CURRENCY_PREFIX_PATTERN = re.compile(r"^[\$€£¥₹]")
9
9
 
10
10
 
11
11
  def strip_currency_prefix(value: Any) -> Any:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csvsmith
3
- Version: 0.7.3
3
+ Version: 0.8.0
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
@@ -87,6 +87,7 @@ Features
87
87
  - Convert Excel workbooks to CSV
88
88
  - Move files by suffix
89
89
  - Find matching values inside CSV files
90
+ - Concatenate CSV files with identical headers
90
91
  - Use the tools either from Python or from the command line
91
92
 
92
93
  Installation
@@ -182,6 +183,12 @@ Find matches in a CSV:
182
183
 
183
184
  csvsmith find-matches input.csv target --ignore-case --ignore-whitespace
184
185
 
186
+ Concatenate CSV files:
187
+
188
+ .. code-block:: console
189
+
190
+ csvsmith strict-concat file1.csv file2.csv -o combined.csv
191
+
185
192
  Find matches in a CSV
186
193
  ---------------------
187
194
 
@@ -241,7 +248,7 @@ File and conversion helpers:
241
248
 
242
249
  .. code-block:: python
243
250
 
244
- from csvsmith import excel_to_csv, move_by_suffix
251
+ from csvsmith import excel_to_csv, move_by_suffix, strict_concat_rows, save_csv
245
252
 
246
253
  String comparison utilities:
247
254
 
@@ -16,6 +16,7 @@ src/csvsmith/tools/filter_rows.py
16
16
  src/csvsmith/tools/find_matches_in_csv.py
17
17
  src/csvsmith/tools/move_files.py
18
18
  src/csvsmith/tools/row_dedup.py
19
+ src/csvsmith/tools/strict_concat.py
19
20
  src/csvsmith/utils/__init__.py
20
21
  src/csvsmith/utils/clean_numeric.py
21
22
  src/csvsmith/utils/distance.py
@@ -30,4 +31,5 @@ tests/test_find_matches_in_csv.py
30
31
  tests/test_move_files.py
31
32
  tests/test_normalize.py
32
33
  tests/test_row_dedup.py
34
+ tests/test_strict_concat.py
33
35
  tests/test_string_distance.py
@@ -187,4 +187,13 @@ def test_cli_parses_find_matches_command():
187
187
  assert args.target == "target"
188
188
  assert args.ignore_case is True
189
189
  assert args.ignore_whitespace is True
190
- assert args.no_nfkc is True
190
+ assert args.no_nfkc is True
191
+
192
+
193
+ def test_cli_parses_strict_concat_command():
194
+ parser = build_parser()
195
+ args = parser.parse_args(["strict-concat", "input_dir", "-o", "output.csv"])
196
+
197
+ assert args.command == "strict-concat"
198
+ assert args.input_dir == "input_dir"
199
+ assert args.output == "output.csv"
@@ -0,0 +1,102 @@
1
+ import csv
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from csvsmith.tools.strict_concat import strict_concat_rows
7
+
8
+
9
+ def write_csv(path: Path, rows: list[list[str]]):
10
+ with path.open("w", newline="", encoding="utf-8") as f:
11
+ writer = csv.writer(f)
12
+ writer.writerows(rows)
13
+
14
+
15
+ def test_strict_concat_happy(tmp_path):
16
+ f1 = tmp_path / "a.csv"
17
+ f2 = tmp_path / "b.csv"
18
+
19
+ write_csv(f1, [["id", "name"], ["1", "Alice"]])
20
+ write_csv(f2, [["id", "name"], ["2", "Bob"]])
21
+
22
+ rows = strict_concat_rows(tmp_path)
23
+
24
+ assert rows == [
25
+ ["file_stem", "id", "name"],
26
+ ["a", "1", "Alice"],
27
+ ["b", "2", "Bob"],
28
+ ]
29
+
30
+
31
+ def test_strict_concat_header_mismatch(tmp_path):
32
+ write_csv(tmp_path / "a.csv", [["id", "name"], ["1", "Alice"]])
33
+ write_csv(tmp_path / "b.csv", [["id", "age"], ["2", "30"]])
34
+
35
+ with pytest.raises(ValueError, match="Header mismatch"):
36
+ strict_concat_rows(tmp_path)
37
+
38
+
39
+ def test_strict_concat_empty_dir(tmp_path):
40
+ with pytest.raises(FileNotFoundError):
41
+ strict_concat_rows(tmp_path)
42
+
43
+ def test_strict_concat_with_non_csv_files(tmp_path):
44
+ # Create sample CSV files.
45
+ write_csv(tmp_path / "a.csv", [["id", "name"], ["1", "Alice"]])
46
+ write_csv(tmp_path / "b.csv", [["id", "name"], ["2", "Bob"]])
47
+
48
+ # Create some non-CSV files in the same directory.
49
+ (tmp_path / "file1.txt").write_text("This is a text file.", encoding="utf-8")
50
+ (tmp_path / "file2.doc").write_text("This is a Word document.", encoding="utf-8")
51
+
52
+ # Call function and check results ignore non-CSV files.
53
+ rows = strict_concat_rows(tmp_path)
54
+ assert rows == [
55
+ ["file_stem", "id", "name"],
56
+ ["a", "1", "Alice"],
57
+ ["b", "2", "Bob"],
58
+ ]
59
+
60
+
61
+ def test_strict_concat_empty_csv(tmp_path):
62
+ (tmp_path / "a.csv").write_text("", encoding="utf-8")
63
+
64
+ with pytest.raises(ValueError, match="Empty CSV"):
65
+ strict_concat_rows(tmp_path)
66
+
67
+
68
+ def test_header_only_files(tmp_path):
69
+ write_csv(tmp_path / "a.csv", [["id", "name"]])
70
+ write_csv(tmp_path / "b.csv", [["id", "name"]])
71
+
72
+ rows = strict_concat_rows(tmp_path)
73
+
74
+ assert rows == [["file_stem", "id", "name"]]
75
+
76
+
77
+ def test_strict_concat_cli(tmp_path, capsys):
78
+ from csvsmith.cli import main
79
+ f1 = tmp_path / "a.csv"
80
+ f2 = tmp_path / "b.csv"
81
+ out = tmp_path / "out.csv"
82
+
83
+ write_csv(f1, [["id", "name"], ["1", "Alice"]])
84
+ write_csv(f2, [["id", "name"], ["2", "Bob"]])
85
+
86
+ exit_code = main(["strict-concat", str(tmp_path), "-o", str(out)])
87
+
88
+ assert exit_code == 0
89
+ assert out.exists()
90
+
91
+ with out.open(encoding="utf-8") as f:
92
+ reader = csv.reader(f)
93
+ rows = list(reader)
94
+
95
+ assert rows == [
96
+ ["file_stem", "id", "name"],
97
+ ["a", "1", "Alice"],
98
+ ["b", "2", "Bob"],
99
+ ]
100
+
101
+ captured = capsys.readouterr()
102
+ assert "Wrote concatenated CSV to:" in captured.out
File without changes
File without changes