csv-utils 0.6.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: 423eacb18512c595abe83765ccfd4d945f1e2e0d18a6370b6841997eb428ddb7
4
- data.tar.gz: 3d2df7f1ee62eaebc25d00281401bf49f0fccdb809ec03cbac90e6bf5ba2fd62
3
+ metadata.gz: 9bae4a7090c060824d53643d89639336e44da08940ab6e9a629a2ed4fffd7837
4
+ data.tar.gz: 2e4ab9e02540822dbd64cbfd1ca9eebac2ddd24bb66592fb587e3a136e3a50f1
5
5
  SHA512:
6
- metadata.gz: 2f9856120036ae6a87e2e758186221b7c0b48aa8d94365f7bdf4bffdf8f292868e43cf3c378eff7460902d710de2859d5406ac9ef5384c07f9728f2f1bad4ef4
7
- data.tar.gz: 9e96e672cb1f4da44ae67de48d3e783cf1d2b180c0d8f4a9706ce1ace07b9ac456a689653dfe84537c0cab71e6c58cdb24e5caeec0bcc86bc73a374615ac4e0a
6
+ metadata.gz: 11b8b4ccd4cfaa040f537ebf62c8f835537e9822b253d8ae5af2ee8212a6692e2ad034166b504a9bd7d2845f2b731bb2405e10ba281dc73b82bdf4f37400c1ec
7
+ data.tar.gz: a16ae82b7e3016ab2b1c4e495bd882d083bf808eedd89c19e25b8adda91d6e123832096e3c60a9d28873a52cb65d203b1293f596ab3c1c3c0faa04a24a2bc35e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
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
+
18
+ ## [0.7.0](https://github.com/dougyouch/csv-utils/compare/v0.6.0...v0.7.0) (2026-10-05)
19
+
20
+
21
+ ### ⚠ BREAKING CHANGES
22
+
23
+ * **encoding:** CSVIterator no longer takes a file mode, files are decoded as UTF-8 whatever the locale, and output is written in the decoded encoding. See UPGRADING.md.
24
+
25
+ ### Features
26
+
27
+ * **encoding:** read every file with the encoding option and write the decoded encoding ([1a9ec4f](https://github.com/dougyouch/csv-utils/commit/1a9ec4f98cbaadfff7ef89fec0d586cea828cf91))
28
+ * **iterator:** open files per call, build rows without zip, add auto_detect ([34ab89a](https://github.com/dougyouch/csv-utils/commit/34ab89ae49dad76507371d2bdf9a7cd310c2cf67))
29
+ * **options:** detect windows-1252 and read utf-8 regardless of locale ([10eeca6](https://github.com/dougyouch/csv-utils/commit/10eeca69bf094fb8c2cb5b005bc74687bde61169))
30
+
31
+
32
+ ### Bug Fixes
33
+
34
+ * **iterator:** strip the byte order mark after rewind and from explicit-header rows ([bf8804b](https://github.com/dougyouch/csv-utils/commit/bf8804b1c2ce16738779f3b9ecc7631c3b29a115))
35
+
3
36
  ## [0.6.0](https://github.com/dougyouch/csv-utils/compare/v0.5.0...v0.6.0) (2026-10-05)
4
37
 
5
38
 
data/README.md CHANGED
@@ -6,7 +6,7 @@ Streaming tools for CSV files that are too big or too broken to load into memory
6
6
  [![Coverage](https://raw.githubusercontent.com/dougyouch/csv-utils/badges/coverage.svg)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
7
7
  [![Branch Coverage](https://raw.githubusercontent.com/dougyouch/csv-utils/badges/branches.svg)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
8
8
 
9
- [API reference](https://rubydoc.info/gems/csv-utils) · [Changelog](CHANGELOG.md) · [Architecture](ARCHITECTURE.md)
9
+ [API reference](https://rubydoc.info/gems/csv-utils) · [Changelog](CHANGELOG.md) · [Upgrading](UPGRADING.md) · [Architecture](ARCHITECTURE.md)
10
10
 
11
11
  ## Features
12
12
 
@@ -19,6 +19,7 @@ Streaming tools for CSV files that are too big or too broken to load into memory
19
19
  - **CSV Iteration**: Efficient iteration over CSV files with batch support
20
20
  - **CSV Extension**: Extend CSV files with additional columns
21
21
  - **CSV Options**: Auto-detect CSV file properties (separators, encoding, BOM)
22
+ - **Encodings**: Files are read as UTF-8 whatever the locale, or in the encoding you pass or `CSVOptions` detects
22
23
  - **Byte Order Marks**: UTF-8, UTF-16 and UTF-32 byte order marks are stripped from the first header
23
24
  - **CLI Tools**: Command-line utilities for CSV debugging and manipulation
24
25
 
@@ -75,7 +76,12 @@ CSVUtils::CSVCompare.new('primary.tsv', ['updated_at'], col_sep: "\t") { |src, d
75
76
 
76
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.
77
78
 
78
- 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.
79
85
 
80
86
  ### Sorting CSV Files
81
87
 
@@ -87,11 +93,18 @@ require 'csv-utils'
87
93
  sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true) # true = has headers
88
94
  sorter.sort(100_000) { |a, b| a.first.to_i <=> b.first.to_i } # batch size, comparison block
89
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
+
90
100
  # without a block, rows are compared as arrays of strings
91
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 }
92
105
  ```
93
106
 
94
- 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.
95
108
 
96
109
  ### Transforming CSV Data
97
110
 
@@ -230,7 +243,20 @@ lookup = iterator.to_hash('id', 'name') # { 'id_value' => 'name_value', ... }
230
243
  iterator.each.with_index { |row, idx| puts "#{idx}: #{row['name']}" }
231
244
  ```
232
245
 
233
- 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`).
251
+
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.
253
+
254
+ Let `CSVOptions` work out the separators and encoding:
255
+
256
+ ```ruby
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
259
+ ```
234
260
 
235
261
  ### Matching CSV Rows
236
262
 
@@ -295,27 +321,44 @@ options = CSVUtils::CSVOptions.new('data.csv')
295
321
  options.valid? # true if separators detected
296
322
  options.col_separator # detected column separator
297
323
  options.row_separator # detected row separator
298
- options.encoding # detected encoding (UTF-8, UTF-16, UTF-32)
324
+ options.encoding # detected encoding (UTF-8, UTF-16, UTF-32, Windows-1252, ISO-8859-1)
299
325
  options.columns # number of columns
300
326
  options.byte_order_mark # BOM if present
327
+
328
+ # Open the file with what was detected, with CSV or any class in this gem
329
+ CSV.open('data.csv', 'rb', **options.to_csv_options)
330
+ CSVUtils::CSVSort.new('data.csv', 'sorted.csv', true, options.to_csv_options).sort
301
331
  ```
302
332
 
303
- Supported column separators: `\x02`, `\t`, `|`, `,` (the first one found in the header line wins)
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.
334
+
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:
336
+
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.
304
342
  Supported row separators: `\r\n`, `\n`, `\r`
305
343
 
306
- 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.
307
345
 
308
346
  ### Encodings and Byte Order Marks
309
347
 
310
- Files are opened with mode `'rb'`. With csv 3.3 and later, when Ruby's default external encoding is UTF-8 (the usual case), CSV reads `'rb'` files as UTF-8 and raises `CSV::InvalidEncodingError` on bytes that aren't valid UTF-8. To read a file's bytes as is, pass an explicit encoding:
348
+ Every class handles encodings the same way:
349
+
350
+ - **Reading** uses the `encoding:` CSV option, `'bom|utf-8'` by default, so values are UTF-8 strings whatever the locale (including `LANG=C` in containers and cron jobs). Bytes that aren't valid UTF-8 raise `CSV::InvalidEncodingError`.
351
+ - **Writing** (`CSVExtender`, `CSVTransformer`, `CSVSort`) uses the encoding values were decoded to, so a file read with `'Windows-1252:UTF-8'` is written as UTF-8. `CSVReport` writes UTF-8 unless you pass `encoding:`.
352
+ - **File modes never carry an encoding.** CSV raises `ArgumentError: encoding specified twice` when the mode and the `encoding:` option both have one.
311
353
 
312
354
  ```ruby
313
- iterator = CSVUtils::CSVIterator.new('latin1.csv', {}, 'rb:BINARY')
355
+ iterator = CSVUtils::CSVIterator.new('export.csv', encoding: 'Windows-1252:UTF-8')
356
+ iterator = CSVUtils::CSVIterator.new('latin1.csv', encoding: 'BINARY') # raw bytes
314
357
  comparator = CSVUtils::CSVCompare.new('primary.csv', ['updated_at'], encoding: 'BINARY') { |src, dest| src['id'] <=> dest['id'] }
315
- sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true, encoding: 'BINARY')
358
+ sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true, CSVUtils::CSVOptions.new('input.csv').to_csv_options)
316
359
  ```
317
360
 
318
- `CSVSort`, `CSVExtender` and `CSVTransformer` take CSV options as their last argument and `CSVIterator` takes them second; they're passed to `CSV.open` for file paths.
361
+ `CSVSort`, `CSVExtender`, `CSVTransformer`, `CSVCompare` and `CSVReport` take CSV options as their last argument and `CSVIterator` takes them second; they're passed to `CSV.open` for file paths. `CSVUtils::EncodingOptions` holds the rules above. Upgrading from 0.6? See [UPGRADING.md](UPGRADING.md).
319
362
 
320
363
  `CSVUtils::ByteOrderMark` detects and strips UTF-8, UTF-16 and UTF-32 byte order marks; `CSVIterator`, `CSVCompare` and `CSVOptions` use it to clean the first header.
321
364
 
@@ -360,6 +403,17 @@ csv-change-eol data.csv 7C5E7C0A
360
403
 
361
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`.
362
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
+
363
417
  ## Development
364
418
 
365
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, {}, 'rb: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