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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c07e7e0ae21409f1fcac6d5b150e1577146d79a47ba9e13b8fb28d90d50fb9c6
4
- data.tar.gz: cb27bd0e1edbfc74747c5107f6d740b86e6cedcd547857d0eff2f836cddd4b9d
3
+ metadata.gz: 9bae4a7090c060824d53643d89639336e44da08940ab6e9a629a2ed4fffd7837
4
+ data.tar.gz: 2e4ab9e02540822dbd64cbfd1ca9eebac2ddd24bb66592fb587e3a136e3a50f1
5
5
  SHA512:
6
- metadata.gz: b7402f247e5cead6fba5259df4465836778026e2abdfd2a40281c22ed21679f60e15b23d913d7bb1bb559345557b31d3e024ab181a11c131d9159a7d64794e10
7
- data.tar.gz: 9fd6953261a7e29e2e7676ce770311921b447c899624f105e4d3496ed89018cc515a1b21ecf2f94b36def0a492808105da0953df48680d6beed03776b3d0e6a7
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 `output.csv.part.N` files next to the output, which are merged in pairs. The temporary files are removed when the sort finishes or fails.
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 still read as UTF-8.
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
- Supported column separators: `\x02`, `\t`, `|`, `,` (the first one found in the header line wins)
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
- Headers are parsed as a CSV row, so a quoted header like `"Last, First"` counts as one column. An empty file isn't valid.
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 <<STR
13
- Error: #{bold_string(msg)}
13
+ $stderr.print <<~STR
14
+ Error: #{bold_string(msg)}
14
15
 
15
- #{USAGE}
16
+ #{USAGE}
16
17
 
17
- End of line example: '7C5E7C0A' is '|^|\\n'
18
- - 0A is new line
19
- - 0D is carriage return
18
+ End of line example: '7C5E7C0A' is '|^|\\n'
19
+ - 0A is new line
20
+ - 0D is carriage return
20
21
 
21
- Goto: #{bold_string('http://www.asciitable.com/')} for help with the character sequence
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', 'Comma separated list of headers that generate a unique key per row, uses 1st column by default') do |v|
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
- $stderr.puts('Usage: ' + File.basename(__FILE__) + ' [options] <csv file 1> <csv file 2>')
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
- csv1 = CSVUtils::CSVIterator.new(ARGV[0])
39
- csv2 = CSVUtils::CSVIterator.new(ARGV[1])
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
- $stderr.puts("headers do not match #{ARGV[0]} headers #{headers}, #{ARGV[1]} headers #{csv2.headers}")
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
- $stderr.puts("specified unique headers are unknown #{unknown_unique_headers}")
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
- $stderr.puts("specified headers to ignore are unknown #{unknown_ignore_headers}")
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
- sort_compare_proc = proc do |csv1_row, csv2_row|
67
- result = unique_header_indexes.map { |idx| csv1_row[idx].to_s } <=> unique_header_indexes.map { |idx| csv2_row[idx].to_s }
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.sort(options[:sort_batch_size], &sort_compare_proc)
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.sort(options[:sort_batch_size], &sort_compare_proc)
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
- comparer = CSVUtils::CSVCompare.new(csv1_sorted_file_name, update_comparison_columns) do |csv1_row, csv2_row|
86
- options[:unique_headers].map { |header| csv1_row[header].to_s } <=> options[:unique_headers].map { |header| csv2_row[header].to_s }
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 "files were identical"
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}"
@@ -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! { |key, row_numbers| row_numbers.size <= 1 }
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 << ['duplicate_key', 'line_no'] + csv.headers
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.new(CSV_FILE)
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
- prev_row = nil
9
+ iterator = CSVUtils::CSVIterator.auto_detect(file)
9
10
  begin
10
- CSV.open(file, 'rb') { |csv| csv.each { |row| prev_row = row } }
11
+ iterator.each { |_row| nil }
11
12
  rescue CSV::MalformedCSVError => e
12
- puts e.class.to_s + ': ' + e.message
13
- puts "previous row was #{prev_row}"
14
- if e.message =~ /line (\d+)/
15
- lineno = $1.to_i
16
- # run the csv-readline next to this script, with this gem's lib, rather than whichever is on the PATH
17
- puts "running csv-readline #{file} #{lineno}"
18
- system(RbConfig.ruby, '-I', File.expand_path('../lib', __dir__), File.join(__dir__, 'csv-readline'), file, lineno.to_s)
19
- end
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.new(ARGV[0])
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
- def csv_parse_line(line)
12
- columns = []
13
-
14
- opened_quote = false
15
- pos = -1
16
- last_comma_pos = -1
17
- column = 1
18
-
19
- while pos = line.index(/([",\n])/, pos + 1)
20
- case line[pos]
21
- when '"'
22
- if opened_quote
23
- if line[pos+1] == ',' ||
24
- line[pos+1] == "\r" ||
25
- line[pos+1] == "\n" ||
26
- line[pos+1].nil?
27
- opened_quote = false
28
- elsif line[pos+1] == '"'
29
- pos += 1 # correctly escaped
30
- else
31
- col_end = line.index(/,/, pos + 1)
32
- col_end ||= line.size - 1
33
- # slice out the column value
34
- val = line[last_comma_pos + 1, col_end - last_comma_pos - 1]
35
- columns << [val, :stray_quote]
36
- opened_quote = false
37
- last_comma_pos = col_end
38
- pos = col_end
39
- # return columns
40
- end
41
- else
42
- if (last_comma_pos == -1 && pos != 0) ||
43
- (last_comma_pos != -1 && line[pos-1] != ',')
44
- col_end = line.index(/,/, pos + 1)
45
- col_end ||= line.size - 1
46
- # slice out the column value
47
- val = line[last_comma_pos + 1, col_end - last_comma_pos - 1]
48
- columns << [val, :stray_quote]
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
- columns
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 == 0
90
+ break if number_of_lines.zero?
86
91
  end
87
- csv_parse_line(str)
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 "no lineno specified" unless lineno > 0
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 sprintf(' %-3d %s: %s', cnt, k, v)
127
+ puts format(' %-3d %s: %s', cnt, k, v)
122
128
  else
123
- puts sprintf('* %-3d (%s) %s: %s', cnt, status.to_s.gsub('_', ' '), k, v)
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 = csv_options.col_separator || ','
43
+ csv_options[:col_sep] ||= ','
43
44
 
44
- csv = CSV.open(
45
- file,
46
- 'rb',
47
- col_sep: col_sep,
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.rewind
57
- csv.shift if headers
50
+ CSV.open(file, 'rb', **CSVUtils::EncodingOptions.read(csv_options)) do |csv|
51
+ headers = options[:no_header] ? nil : csv.shift
58
52
 
59
- total_files = total_rows / options[:rows]
60
- total_files += 1 if (total_rows % options[:rows]) > 0
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
- num_rows = 0
63
- num_files = 0
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 << row
76
- num_rows += 1
65
+ ensure
66
+ out&.close
77
67
  end
78
68
 
79
- while (row = csv.shift)
80
- append_row_proc.call(row)
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