csv-utils 0.7.0 → 0.8.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +38 -6
- data/bin/csv-change-eol +11 -10
- data/bin/csv-diff +33 -21
- data/bin/csv-duplicate-finder +7 -8
- data/bin/csv-explorer +2 -1
- data/bin/csv-find-error +12 -11
- data/bin/csv-grep +2 -1
- data/bin/csv-readline +64 -58
- data/bin/csv-splitter +23 -35
- data/bin/csv-validator +12 -14
- data/lib/csv-utils.rb +5 -0
- data/lib/csv_utils/character_encoding.rb +58 -8
- data/lib/csv_utils/csv_compare.rb +37 -21
- data/lib/csv_utils/csv_iterator.rb +22 -31
- data/lib/csv_utils/csv_options.rb +57 -28
- data/lib/csv_utils/csv_sort.rb +97 -97
- data/lib/csv_utils/csv_wrapper.rb +10 -0
- data/lib/csv_utils/error.rb +8 -0
- data/lib/csv_utils/header_not_found_error.rb +21 -0
- data/lib/csv_utils/malformed_row_error.rb +20 -0
- data/lib/csv_utils/row_reader.rb +51 -0
- data/lib/csv_utils/unsorted_file_error.rb +21 -0
- data/lib/csv_utils/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9bae4a7090c060824d53643d89639336e44da08940ab6e9a629a2ed4fffd7837
|
|
4
|
+
data.tar.gz: 2e4ab9e02540822dbd64cbfd1ca9eebac2ddd24bb66592fb587e3a136e3a50f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11b8b4ccd4cfaa040f537ebf62c8f835537e9822b253d8ae5af2ee8212a6692e2ad034166b504a9bd7d2845f2b731bb2405e10ba281dc73b82bdf4f37400c1ec
|
|
7
|
+
data.tar.gz: a16ae82b7e3016ab2b1c4e495bd882d083bf808eedd89c19e25b8adda91d6e123832096e3c60a9d28873a52cb65d203b1293f596ab3c1c3c0faa04a24a2bc35e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0](https://github.com/dougyouch/csv-utils/compare/v0.7.0...v0.8.0) (2026-10-05)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **cli:** detect separators and encodings, one-pass splitter, lint scripts ([5bfc98b](https://github.com/dougyouch/csv-utils/commit/5bfc98b843c01fcc4a86f3516248942eb4e96b53))
|
|
9
|
+
* **compare:** raise on unsorted input and missing update columns ([1ba7151](https://github.com/dougyouch/csv-utils/commit/1ba715178432570cdb0c3490d5f5f8e67ce895dc))
|
|
10
|
+
* **iterator:** report the physical line rows start on and add CSVUtils errors ([242d747](https://github.com/dougyouch/csv-utils/commit/242d747c2e9a5a6bcb14a682751860142028c4aa))
|
|
11
|
+
* **options:** detect separators by count, support semicolons, add full_scan ([1492864](https://github.com/dougyouch/csv-utils/commit/1492864075570065c108190a700edf6154a2dd48))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Performance Improvements
|
|
15
|
+
|
|
16
|
+
* **sort:** merge up to 64 part files per pass and add sort_by and tmp_dir ([3245788](https://github.com/dougyouch/csv-utils/commit/3245788afabe2dbdf21b534065a54c3547f1183a))
|
|
17
|
+
|
|
3
18
|
## [0.7.0](https://github.com/dougyouch/csv-utils/compare/v0.6.0...v0.7.0) (2026-10-05)
|
|
4
19
|
|
|
5
20
|
|
data/README.md
CHANGED
|
@@ -76,7 +76,12 @@ CSVUtils::CSVCompare.new('primary.tsv', ['updated_at'], col_sep: "\t") { |src, d
|
|
|
76
76
|
|
|
77
77
|
**Note**: Both CSV files must be sorted by the same key columns for comparison to work correctly, and the block must compare them in that same order.
|
|
78
78
|
|
|
79
|
-
The block compares the key columns of a primary and a secondary record like `<=>`. Records only in the primary file are yielded as `:create`, records only in the secondary file as `:delete`, and matching records as `:update` when any of the update comparison columns differ. Without update comparison columns, no updates are yielded.
|
|
79
|
+
The block compares the key columns of a primary and a secondary record like `<=>`. Records only in the primary file are yielded as `:create`, records only in the secondary file as `:delete`, and matching records as `:update` when any of the update comparison columns differ. Without update comparison columns, no updates are yielded. Records are hashes of header to value that also know the line they start on (`record.lineno`).
|
|
80
|
+
|
|
81
|
+
`compare` checks its inputs instead of returning wrong results:
|
|
82
|
+
|
|
83
|
+
- A file that isn't sorted the way the block expects raises `CSVUtils::UnsortedFileError` with the file and the line of the first record out of order. Each record is compared with the one before it in the same file, so the check only runs for a file whose first record the block compares equal to itself; a block written for different headers in each file (`src['id'] <=> dest['ID']`) skips it. Pass `check_order: false` to turn it off.
|
|
84
|
+
- An update comparison column missing from either file raises `CSVUtils::HeaderNotFoundError`, rather than never reporting updates.
|
|
80
85
|
|
|
81
86
|
### Sorting CSV Files
|
|
82
87
|
|
|
@@ -88,11 +93,18 @@ require 'csv-utils'
|
|
|
88
93
|
sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true) # true = has headers
|
|
89
94
|
sorter.sort(100_000) { |a, b| a.first.to_i <=> b.first.to_i } # batch size, comparison block
|
|
90
95
|
|
|
96
|
+
# faster: compute a key once per row instead of converting values on every comparison
|
|
97
|
+
sorter.sort_by(100_000) { |row| row.first.to_i }
|
|
98
|
+
sorter.sort_by { |row| [row[2], row[0].to_i] } # several columns
|
|
99
|
+
|
|
91
100
|
# without a block, rows are compared as arrays of strings
|
|
92
101
|
sorter.sort
|
|
102
|
+
|
|
103
|
+
# write the temporary files somewhere other than next to output.csv
|
|
104
|
+
sorter.sort_by(100_000, tmp_dir: '/mnt/scratch') { |row| row.first.to_i }
|
|
93
105
|
```
|
|
94
106
|
|
|
95
|
-
Batches are sorted into temporary `
|
|
107
|
+
Batches are sorted into temporary files, which are then merged up to 64 at a time (`CSVSort::MERGE_WIDTH`), so files of up to 64 batches are merged in a single pass and every row is read and written about twice. Rows with equal keys keep the order of the batches they came from. The temporary files go next to the output unless you pass `tmp_dir:`, and are removed when the sort finishes or fails.
|
|
96
108
|
|
|
97
109
|
### Transforming CSV Data
|
|
98
110
|
|
|
@@ -231,7 +243,11 @@ lookup = iterator.to_hash('id', 'name') # { 'id_value' => 'name_value', ... }
|
|
|
231
243
|
iterator.each.with_index { |row, idx| puts "#{idx}: #{row['name']}" }
|
|
232
244
|
```
|
|
233
245
|
|
|
234
|
-
Line numbers count the header row as line 1, so they match what an editor shows. `headers` returns `[]` for an empty file.
|
|
246
|
+
Line numbers count the header row as line 1 and are the line each row starts on, so they match what an editor shows even when a quoted value has a line break in it (CSV's own line numbers count rows). `headers` returns `[]` for an empty file.
|
|
247
|
+
|
|
248
|
+
A row CSV can't parse raises `CSVUtils::MalformedRowError`, a `CSV::MalformedCSVError` whose `line_number` is the line the row starts on and whose `prev_row` is the last row read. Bytes that don't match the encoding raise CSV's own `CSV::InvalidEncodingError`, which already has the right line.
|
|
249
|
+
|
|
250
|
+
`to_hash` raises `CSVUtils::HeaderNotFoundError` for a header the file doesn't have. Every error this gem raises includes `CSVUtils::Error`, so `rescue CSVUtils::Error` catches them all, and also subclasses the error raised before it existed (`RuntimeError` or `CSV::MalformedCSVError`).
|
|
235
251
|
|
|
236
252
|
Given a path, the iterator opens the file for each call (`each`, `headers`, `size`, ...) and closes it when the call returns, even when the block raises or stops early, so an idle iterator holds no file handle. A CSV object passed in is rewound, never closed.
|
|
237
253
|
|
|
@@ -239,6 +255,7 @@ Let `CSVOptions` work out the separators and encoding:
|
|
|
239
255
|
|
|
240
256
|
```ruby
|
|
241
257
|
iterator = CSVUtils::CSVIterator.auto_detect('export.tsv')
|
|
258
|
+
iterator = CSVUtils::CSVIterator.auto_detect('export.csv', full_scan: true) # check the encoding of the whole file
|
|
242
259
|
```
|
|
243
260
|
|
|
244
261
|
### Matching CSV Rows
|
|
@@ -315,12 +332,16 @@ CSVUtils::CSVSort.new('data.csv', 'sorted.csv', true, options.to_csv_options).so
|
|
|
315
332
|
|
|
316
333
|
`to_csv_options` returns the detected `col_sep` and `row_sep`, leaving out any that weren't found, and an `encoding` that reads values as UTF-8 strings: `'bom|utf-8'` for UTF-8 files, `'BOM|UTF-16LE:UTF-8'` and the like for files with a UTF-16 or UTF-32 byte order mark (CSV detects the row separator for those), and `'Windows-1252:UTF-8'` for files that aren't valid UTF-8.
|
|
317
334
|
|
|
318
|
-
Without a byte order mark, the encoding comes from the first megabyte (`CSVOptions::SAMPLE_SIZE`): valid UTF-8 is `UTF-8`, anything else is `Windows-1252`, the usual encoding of Excel exports, or `ISO-8859-1` when the sample has one of the five bytes Windows-1252 leaves undefined. A file whose first non-UTF-8 byte comes after the sample is
|
|
335
|
+
Without a byte order mark, the encoding comes from the first megabyte (`CSVOptions::SAMPLE_SIZE`): valid UTF-8 is `UTF-8`, anything else is `Windows-1252`, the usual encoding of Excel exports, or `ISO-8859-1` when the sample has one of the five bytes Windows-1252 leaves undefined. A file whose first non-UTF-8 byte comes after the sample is read as UTF-8 unless you pass `full_scan: true`, which checks the whole file a megabyte at a time:
|
|
319
336
|
|
|
320
|
-
|
|
337
|
+
```ruby
|
|
338
|
+
CSVUtils::CSVOptions.new('data.csv', full_scan: true).encoding # => "Windows-1252"
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Supported column separators: `\x02`, `\t`, `|`, `,`, `;`. The one found most often outside quotes in the header row wins, and a tie goes to the one listed first, so `cost|usd,name,id` is comma separated.
|
|
321
342
|
Supported row separators: `\r\n`, `\n`, `\r`
|
|
322
343
|
|
|
323
|
-
|
|
344
|
+
The header row runs to the first line break outside quotes and is parsed as a CSV row, so a quoted header like `"Last, First"` counts as one column. An empty file isn't valid.
|
|
324
345
|
|
|
325
346
|
### Encodings and Byte Order Marks
|
|
326
347
|
|
|
@@ -382,6 +403,17 @@ csv-change-eol data.csv 7C5E7C0A
|
|
|
382
403
|
|
|
383
404
|
`csv-validator` needs the `rchardet` gem (`gem install rchardet`) to guess the encoding of values that aren't UTF-8. It writes the converted values to `utf8-correction.csv`.
|
|
384
405
|
|
|
406
|
+
`csv-find-error`, `csv-grep`, `csv-diff`, `csv-splitter`, `csv-explorer` and `csv-duplicate-finder` detect each file's separators, and all but `csv-duplicate-finder` its encoding too, with `CSVOptions`. Line numbers are the line a row starts on, so `csv-find-error` hands `csv-readline` the right line even after values with line breaks. `csv-splitter` reads the file once and writes UTF-8 parts. `csv-diff` needs both files to use the same column separator.
|
|
407
|
+
|
|
408
|
+
## Benchmarks
|
|
409
|
+
|
|
410
|
+
`script/benchmark.rb` times the iterator, sort and compare on a generated file and counts the objects they allocate. Set `LIB` to another checkout's `lib` to compare versions:
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
ROWS=500000 ruby script/benchmark.rb
|
|
414
|
+
LIB=../csv-utils-0.6/lib ruby script/benchmark.rb
|
|
415
|
+
```
|
|
416
|
+
|
|
385
417
|
## Development
|
|
386
418
|
|
|
387
419
|
After checking out the repo, run `bundle install` to install dependencies. Then:
|
data/bin/csv-change-eol
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'csv'
|
|
4
5
|
|
|
5
6
|
def bold_string(str)
|
|
6
7
|
"\033[1m#{str}\033[0m"
|
|
7
8
|
end
|
|
8
|
-
|
|
9
|
-
USAGE = "Usage: #{bold_string('csv-change-eol')} <csv_file> <end of line character sequence in hex>"
|
|
9
|
+
|
|
10
|
+
USAGE = "Usage: #{bold_string('csv-change-eol')} <csv_file> <end of line character sequence in hex>".freeze
|
|
10
11
|
|
|
11
12
|
def exit_on_error(msg)
|
|
12
|
-
$stderr.print
|
|
13
|
-
Error: #{bold_string(msg)}
|
|
13
|
+
$stderr.print <<~STR
|
|
14
|
+
Error: #{bold_string(msg)}
|
|
14
15
|
|
|
15
|
-
#{USAGE}
|
|
16
|
+
#{USAGE}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
End of line example: '7C5E7C0A' is '|^|\\n'
|
|
19
|
+
- 0A is new line
|
|
20
|
+
- 0D is carriage return
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
Goto: #{bold_string('http://www.asciitable.com/')} for help with the character sequence
|
|
22
23
|
|
|
23
|
-
STR
|
|
24
|
+
STR
|
|
24
25
|
exit 1
|
|
25
26
|
end
|
|
26
27
|
|
data/bin/csv-diff
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
|
|
@@ -15,7 +16,8 @@ OptionParser.new do |opts|
|
|
|
15
16
|
exit
|
|
16
17
|
end
|
|
17
18
|
|
|
18
|
-
opts.on('-u', '--unique HEADERS',
|
|
19
|
+
opts.on('-u', '--unique HEADERS',
|
|
20
|
+
'Comma separated list of headers that generate a unique key per row, uses 1st column by default') do |v|
|
|
19
21
|
options[:unique_headers] = v.split(',')
|
|
20
22
|
end
|
|
21
23
|
|
|
@@ -29,30 +31,38 @@ OptionParser.new do |opts|
|
|
|
29
31
|
end.parse!
|
|
30
32
|
|
|
31
33
|
unless ARGV.size == 2
|
|
32
|
-
|
|
34
|
+
warn('Usage: ' + File.basename(__FILE__) + ' [options] <csv file 1> <csv file 2>')
|
|
33
35
|
exit 1
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
require 'csv-utils'
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
csv1_options = CSVUtils::CSVOptions.new(ARGV[0]).to_csv_options
|
|
41
|
+
csv2_options = CSVUtils::CSVOptions.new(ARGV[1]).to_csv_options
|
|
42
|
+
unless csv1_options[:col_sep] == csv2_options[:col_sep]
|
|
43
|
+
warn("column separators do not match #{ARGV[0]} #{csv1_options[:col_sep].inspect}, " \
|
|
44
|
+
"#{ARGV[1]} #{csv2_options[:col_sep].inspect}")
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
csv1 = CSVUtils::CSVIterator.new(ARGV[0], csv1_options)
|
|
49
|
+
csv2 = CSVUtils::CSVIterator.new(ARGV[1], csv2_options)
|
|
40
50
|
headers = csv1.headers
|
|
41
51
|
|
|
42
52
|
unless headers == csv2.headers
|
|
43
|
-
|
|
53
|
+
warn("headers do not match #{ARGV[0]} headers #{headers}, #{ARGV[1]} headers #{csv2.headers}")
|
|
44
54
|
exit 1
|
|
45
55
|
end
|
|
46
56
|
|
|
47
57
|
unknown_unique_headers = options[:unique_headers] - headers
|
|
48
58
|
unless unknown_unique_headers.empty?
|
|
49
|
-
|
|
59
|
+
warn("specified unique headers are unknown #{unknown_unique_headers}")
|
|
50
60
|
exit 1
|
|
51
61
|
end
|
|
52
62
|
|
|
53
63
|
unknown_ignore_headers = options[:ignore_headers] - headers
|
|
54
64
|
unless unknown_ignore_headers.empty?
|
|
55
|
-
|
|
65
|
+
warn("specified headers to ignore are unknown #{unknown_ignore_headers}")
|
|
56
66
|
exit 1
|
|
57
67
|
end
|
|
58
68
|
|
|
@@ -62,38 +72,40 @@ puts "uniqueness header(s) are #{options[:unique_headers].join(', ')}"
|
|
|
62
72
|
|
|
63
73
|
unique_header_indexes = options[:unique_headers].map { |header| headers.index(header) }
|
|
64
74
|
|
|
65
|
-
# empty cells are nil, so values are compared as strings
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
result = csv1_row.map(&:to_s) <=> csv2_row.map(&:to_s) if result.zero?
|
|
69
|
-
result
|
|
75
|
+
# empty cells are nil, so values are compared as strings; the key is computed once per row
|
|
76
|
+
sort_key_proc = proc do |row|
|
|
77
|
+
[unique_header_indexes.map { |idx| row[idx].to_s }, row.map(&:to_s)]
|
|
70
78
|
end
|
|
71
79
|
|
|
72
80
|
csv1_sorted_file_name = ARGV[0] + '.sorted'
|
|
73
81
|
csv2_sorted_file_name = ARGV[1] + '.sorted'
|
|
74
82
|
|
|
75
83
|
puts "sorting #{ARGV[0]}"
|
|
76
|
-
sorter = CSVUtils::CSVSort.new(ARGV[0], csv1_sorted_file_name)
|
|
77
|
-
sorter.
|
|
84
|
+
sorter = CSVUtils::CSVSort.new(ARGV[0], csv1_sorted_file_name, true, csv1_options)
|
|
85
|
+
sorter.sort_by(options[:sort_batch_size], &sort_key_proc)
|
|
78
86
|
|
|
79
87
|
puts "sorting #{ARGV[1]}"
|
|
80
|
-
sorter = CSVUtils::CSVSort.new(ARGV[1], csv2_sorted_file_name)
|
|
81
|
-
sorter.
|
|
88
|
+
sorter = CSVUtils::CSVSort.new(ARGV[1], csv2_sorted_file_name, true, csv2_options)
|
|
89
|
+
sorter.sort_by(options[:sort_batch_size], &sort_key_proc)
|
|
82
90
|
|
|
83
91
|
update_comparison_columns = headers - options[:unique_headers] - options[:ignore_headers]
|
|
84
92
|
|
|
85
|
-
|
|
86
|
-
|
|
93
|
+
# the sorted files are written in the decoded encoding, with the same separators
|
|
94
|
+
sorted_options = CSVUtils::EncodingOptions.write(csv1_options)
|
|
95
|
+
comparer = CSVUtils::CSVCompare.new(csv1_sorted_file_name, update_comparison_columns, sorted_options) do |csv1_row, csv2_row|
|
|
96
|
+
options[:unique_headers].map { |header| csv1_row[header].to_s } <=> options[:unique_headers].map do |header|
|
|
97
|
+
csv2_row[header].to_s
|
|
98
|
+
end
|
|
87
99
|
end
|
|
88
100
|
|
|
89
101
|
stats = Hash.new(0)
|
|
90
102
|
puts "comparing #{ARGV[0]} with #{ARGV[1]}"
|
|
91
103
|
diff_file_name = 'diff-results-' + File.basename(ARGV[0])
|
|
92
104
|
CSV.open(diff_file_name, 'wb') do |out|
|
|
93
|
-
out << ['Result'] + headers
|
|
105
|
+
out << (['Result'] + headers)
|
|
94
106
|
comparer.compare(csv2_sorted_file_name) do |action, record|
|
|
95
107
|
stats[action] += 1
|
|
96
|
-
out << [action] + record.values
|
|
108
|
+
out << ([action] + record.values)
|
|
97
109
|
end
|
|
98
110
|
end
|
|
99
111
|
puts "differences found #{stats}"
|
|
@@ -102,7 +114,7 @@ File.unlink(csv1_sorted_file_name)
|
|
|
102
114
|
File.unlink(csv2_sorted_file_name)
|
|
103
115
|
|
|
104
116
|
if stats.empty?
|
|
105
|
-
puts
|
|
117
|
+
puts 'files were identical'
|
|
106
118
|
File.unlink(diff_file_name)
|
|
107
119
|
else
|
|
108
120
|
puts "results can be found in #{diff_file_name}"
|
data/bin/csv-duplicate-finder
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
|
|
@@ -23,13 +24,11 @@ require 'csv-utils'
|
|
|
23
24
|
|
|
24
25
|
file = ARGV[0] || raise('no CSV file specified')
|
|
25
26
|
|
|
26
|
-
# read bytes as is, so values that aren't valid UTF-8 can still be compared
|
|
27
|
-
csv = CSVUtils::CSVIterator.new(file, encoding: 'BINARY')
|
|
27
|
+
# detect the separators, but read bytes as is, so values that aren't valid UTF-8 can still be compared
|
|
28
|
+
csv = CSVUtils::CSVIterator.new(file, CSVUtils::CSVOptions.new(file).to_csv_options.merge(encoding: 'BINARY'))
|
|
28
29
|
|
|
29
30
|
missing_headers = options[:ignore_columns] - csv.headers
|
|
30
|
-
unless missing_headers.empty?
|
|
31
|
-
raise("unknown headers #{missing_headers.join(', ')} in configured ignore headers")
|
|
32
|
-
end
|
|
31
|
+
raise("unknown headers #{missing_headers.join(', ')} in configured ignore headers") unless missing_headers.empty?
|
|
33
32
|
|
|
34
33
|
hashed_rows = {}
|
|
35
34
|
|
|
@@ -45,7 +44,7 @@ end
|
|
|
45
44
|
|
|
46
45
|
duplicate_rows = {}
|
|
47
46
|
|
|
48
|
-
hashed_rows.reject! { |
|
|
47
|
+
hashed_rows.reject! { |_key, row_numbers| row_numbers.size <= 1 }
|
|
49
48
|
|
|
50
49
|
hashed_rows.each do |key, row_numbers|
|
|
51
50
|
hashed_rows[key] = {}
|
|
@@ -62,11 +61,11 @@ csv.each do |row|
|
|
|
62
61
|
end
|
|
63
62
|
|
|
64
63
|
CSV.open('duplicates-' + File.basename(file), 'wb:BINARY') do |out|
|
|
65
|
-
out << [
|
|
64
|
+
out << (%w[duplicate_key line_no] + csv.headers)
|
|
66
65
|
|
|
67
66
|
hashed_rows.each do |key, rows|
|
|
68
67
|
rows.each do |lineno, row|
|
|
69
|
-
out << [key, lineno] + row.values
|
|
68
|
+
out << ([key, lineno] + row.values)
|
|
70
69
|
end
|
|
71
70
|
end
|
|
72
71
|
end
|
data/bin/csv-explorer
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'csv-utils'
|
|
4
5
|
require 'irb'
|
|
@@ -6,7 +7,7 @@ require 'irb'
|
|
|
6
7
|
CSV_FILE = ARGV.shift
|
|
7
8
|
|
|
8
9
|
def csv
|
|
9
|
-
@csv ||= CSVUtils::CSVIterator.
|
|
10
|
+
@csv ||= CSVUtils::CSVIterator.auto_detect(CSV_FILE)
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
IRB.start
|
data/bin/csv-find-error
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
|
-
require 'csv'
|
|
4
|
+
require 'csv-utils'
|
|
4
5
|
require 'rbconfig'
|
|
5
6
|
|
|
6
7
|
file = ARGV[0] || abort('Usage: csv-find-error <csv file>')
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
iterator = CSVUtils::CSVIterator.auto_detect(file)
|
|
9
10
|
begin
|
|
10
|
-
|
|
11
|
+
iterator.each { |_row| nil }
|
|
11
12
|
rescue CSV::MalformedCSVError => e
|
|
12
|
-
puts e.class
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
puts "#{e.class}: #{e.message}"
|
|
14
|
+
# CSV raises encoding errors ahead of the rows before them, so only a malformed row has a reliable previous row
|
|
15
|
+
puts "previous row was #{e.prev_row}" if e.is_a?(CSVUtils::MalformedRowError)
|
|
16
|
+
# line_number is the physical line the bad row starts on, which is what csv-readline reads
|
|
17
|
+
puts "running csv-readline #{file} #{e.line_number}"
|
|
18
|
+
# run the csv-readline next to this script, with this gem's lib, rather than whichever is on the PATH
|
|
19
|
+
system(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), File.join(__dir__, 'csv-readline'), file,
|
|
20
|
+
e.line_number.to_s)
|
|
20
21
|
exit 1
|
|
21
22
|
end
|
|
22
23
|
|
data/bin/csv-grep
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
|
|
@@ -43,7 +44,7 @@ raise('no search specified') unless options[:search]
|
|
|
43
44
|
|
|
44
45
|
require 'csv-utils'
|
|
45
46
|
|
|
46
|
-
csv = CSVUtils::CSVIterator.
|
|
47
|
+
csv = CSVUtils::CSVIterator.auto_detect(ARGV[0])
|
|
47
48
|
|
|
48
49
|
search_regex =
|
|
49
50
|
if options[:exact_match]
|
data/bin/csv-readline
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
|
|
@@ -8,62 +9,65 @@ def strip_byte_order_mark(line)
|
|
|
8
9
|
CSVUtils::ByteOrderMark.strip(line)
|
|
9
10
|
end
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
opened_quote = false
|
|
50
|
-
last_comma_pos = col_end
|
|
51
|
-
pos = col_end
|
|
52
|
-
else
|
|
53
|
-
opened_quote = true
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
when ',',
|
|
57
|
-
"\n"
|
|
58
|
-
if ! opened_quote
|
|
59
|
-
column += 1
|
|
60
|
-
columns << [line[last_comma_pos + 1, pos - last_comma_pos - 1], :ok]
|
|
61
|
-
last_comma_pos = pos
|
|
62
|
-
end
|
|
12
|
+
# Splits a raw line on commas, flagging columns with a quote CSV would reject, so a malformed line can be read.
|
|
13
|
+
class LineParser
|
|
14
|
+
def initialize(line)
|
|
15
|
+
@line = line
|
|
16
|
+
@columns = []
|
|
17
|
+
@opened_quote = false
|
|
18
|
+
@pos = -1
|
|
19
|
+
@last_comma_pos = -1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Array<Array(String, Symbol)>] each column's raw value and :ok or :stray_quote
|
|
23
|
+
def parse
|
|
24
|
+
while (@pos = @line.index(/[",\n]/, @pos + 1))
|
|
25
|
+
@line[@pos] == '"' ? quote : separator
|
|
26
|
+
end
|
|
27
|
+
@columns
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def quote
|
|
33
|
+
if @opened_quote
|
|
34
|
+
closing_quote
|
|
35
|
+
elsif column_start?
|
|
36
|
+
@opened_quote = true
|
|
37
|
+
else
|
|
38
|
+
stray_quote
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def closing_quote
|
|
43
|
+
following = @line[@pos + 1]
|
|
44
|
+
if following.nil? || [',', "\r", "\n"].include?(following)
|
|
45
|
+
@opened_quote = false
|
|
46
|
+
elsif following == '"'
|
|
47
|
+
@pos += 1 # correctly escaped
|
|
48
|
+
else
|
|
49
|
+
stray_quote
|
|
63
50
|
end
|
|
64
51
|
end
|
|
65
52
|
|
|
66
|
-
|
|
53
|
+
def column_start?
|
|
54
|
+
@last_comma_pos == -1 ? @pos.zero? : @line[@pos - 1] == ','
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# the column runs to the next comma, quotes and all
|
|
58
|
+
def stray_quote
|
|
59
|
+
col_end = @line.index(',', @pos + 1) || (@line.size - 1)
|
|
60
|
+
@columns << [@line[@last_comma_pos + 1, col_end - @last_comma_pos - 1], :stray_quote]
|
|
61
|
+
@opened_quote = false
|
|
62
|
+
@last_comma_pos = @pos = col_end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def separator
|
|
66
|
+
return if @opened_quote
|
|
67
|
+
|
|
68
|
+
@columns << [@line[@last_comma_pos + 1, @pos - @last_comma_pos - 1], :ok]
|
|
69
|
+
@last_comma_pos = @pos
|
|
70
|
+
end
|
|
67
71
|
end
|
|
68
72
|
|
|
69
73
|
def empty_column?(col)
|
|
@@ -80,11 +84,12 @@ def parse_csv_row(file, lineno, number_of_lines)
|
|
|
80
84
|
file.each do |line|
|
|
81
85
|
cnt += 1
|
|
82
86
|
next if cnt < lineno
|
|
87
|
+
|
|
83
88
|
str << line
|
|
84
89
|
number_of_lines -= 1
|
|
85
|
-
break if number_of_lines
|
|
90
|
+
break if number_of_lines.zero?
|
|
86
91
|
end
|
|
87
|
-
|
|
92
|
+
LineParser.new(str).parse
|
|
88
93
|
end
|
|
89
94
|
|
|
90
95
|
options = {
|
|
@@ -106,7 +111,7 @@ end.parse!
|
|
|
106
111
|
lineno = ARGV[1].to_i
|
|
107
112
|
number_of_lines = (ARGV[2] || 1).to_i
|
|
108
113
|
|
|
109
|
-
raise
|
|
114
|
+
raise 'no lineno specified' unless lineno.positive?
|
|
110
115
|
|
|
111
116
|
data = File.open(ARGV[0], 'rb') do |file|
|
|
112
117
|
headers = strip_byte_order_mark(file.readline.strip).split(',')
|
|
@@ -117,9 +122,10 @@ cnt = 0
|
|
|
117
122
|
data.each do |k, (v, status)|
|
|
118
123
|
cnt += 1
|
|
119
124
|
next if !options[:all_columns] && empty_column?(v)
|
|
125
|
+
|
|
120
126
|
if status == :ok
|
|
121
|
-
puts
|
|
127
|
+
puts format(' %-3d %s: %s', cnt, k, v)
|
|
122
128
|
else
|
|
123
|
-
puts
|
|
129
|
+
puts format('* %-3d (%s) %s: %s', cnt, status.to_s.gsub('_', ' '), k, v)
|
|
124
130
|
end
|
|
125
131
|
end
|
data/bin/csv-splitter
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
|
|
@@ -37,47 +38,34 @@ def get_split_file_name(file, num_files, total_files)
|
|
|
37
38
|
end
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
csv_options = CSVUtils::CSVOptions.new(file)
|
|
41
|
+
csv_options = CSVUtils::CSVOptions.new(file).to_csv_options.merge(liberal_parsing: true)
|
|
41
42
|
# a single column file has no separator to detect
|
|
42
|
-
col_sep
|
|
43
|
+
csv_options[:col_sep] ||= ','
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
liberal_parsing: true
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
headers = options[:no_header] ? nil : csv.shift
|
|
52
|
-
|
|
53
|
-
total_rows = 0
|
|
54
|
-
total_rows += 1 while csv.shift
|
|
45
|
+
# one pass: parts are written under temporary names, then renamed once the number of parts is known
|
|
46
|
+
part_files = []
|
|
47
|
+
out = nil
|
|
48
|
+
num_rows = 0
|
|
55
49
|
|
|
56
|
-
csv
|
|
57
|
-
csv.shift
|
|
50
|
+
CSV.open(file, 'rb', **CSVUtils::EncodingOptions.read(csv_options)) do |csv|
|
|
51
|
+
headers = options[:no_header] ? nil : csv.shift
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
while (row = csv.shift)
|
|
54
|
+
if out.nil? || num_rows >= options[:rows]
|
|
55
|
+
out&.close
|
|
56
|
+
num_rows = 0
|
|
57
|
+
part_files << "#{file}.part-#{part_files.size + 1}.tmp"
|
|
58
|
+
out = CSV.open(part_files.last, 'wb', **CSVUtils::EncodingOptions.write(csv_options))
|
|
59
|
+
out << headers if headers
|
|
60
|
+
end
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
out = nil
|
|
65
|
-
|
|
66
|
-
append_row_proc = proc do |row|
|
|
67
|
-
if out.nil? || num_rows >= options[:rows]
|
|
68
|
-
out.close if out
|
|
69
|
-
num_rows = 0
|
|
70
|
-
num_files += 1
|
|
71
|
-
out = CSV.open(get_split_file_name(file, num_files, total_files), 'wb', col_sep: col_sep)
|
|
72
|
-
out << headers if headers
|
|
62
|
+
out << row
|
|
63
|
+
num_rows += 1
|
|
73
64
|
end
|
|
74
|
-
|
|
75
|
-
out
|
|
76
|
-
num_rows += 1
|
|
65
|
+
ensure
|
|
66
|
+
out&.close
|
|
77
67
|
end
|
|
78
68
|
|
|
79
|
-
|
|
80
|
-
|
|
69
|
+
part_files.each_with_index do |part_file, idx|
|
|
70
|
+
File.rename(part_file, get_split_file_name(file, idx + 1, part_files.size))
|
|
81
71
|
end
|
|
82
|
-
|
|
83
|
-
out&.close
|