csv-utils 0.5.0 → 0.6.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: 151a25f6d4d171b169ac194665f217151a96ef43aede166c6a62ec9f2b259765
4
- data.tar.gz: b223b26ea97a29f58f532e6b737d481c4f144ce2e202592f8057300790156ed5
3
+ metadata.gz: 423eacb18512c595abe83765ccfd4d945f1e2e0d18a6370b6841997eb428ddb7
4
+ data.tar.gz: 3d2df7f1ee62eaebc25d00281401bf49f0fccdb809ec03cbac90e6bf5ba2fd62
5
5
  SHA512:
6
- metadata.gz: 7afee881db16b4afce9cceb812d4994ee66bcf172db147eaf73911961983cd0d73f9c553ca549880793e1c9a2bb2904e8444f2618ff30d040808d1b4e5383cf7
7
- data.tar.gz: 4811b6ff72153107bf029f36feef65ec68646e0365d3b3987bd3231b9842b20c8c27c72216ca25350bd469887315b139737eedeba3b7c7fa17d240c729ae9a5a
6
+ metadata.gz: 2f9856120036ae6a87e2e758186221b7c0b48aa8d94365f7bdf4bffdf8f292868e43cf3c378eff7460902d710de2859d5406ac9ef5384c07f9728f2f1bad4ef4
7
+ data.tar.gz: 9e96e672cb1f4da44ae67de48d3e783cf1d2b180c0d8f4a9706ce1ace07b9ac456a689653dfe84537c0cab71e6c58cdb24e5caeec0bcc86bc73a374615ac4e0a
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## [0.6.0](https://github.com/dougyouch/csv-utils/compare/v0.5.0...v0.6.0) (2026-10-05)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **gem:** Ruby 3.2 reached end of life in March 2026 and is no longer supported. The gemspec now declares required_ruby_version >= 3.3.
9
+
10
+ ### Features
11
+
12
+ * **compare:** accept csv options for both files ([4336edf](https://github.com/dougyouch/csv-utils/commit/4336edf8ca928ebc73c7b58a533ee7280267907e))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **bom:** detect utf-32 le and strip marks from text in any encoding ([7c68517](https://github.com/dougyouch/csv-utils/commit/7c6851755b9ddfb6ca49b459d9ada578c35a634d))
18
+ * **cli:** keep trailing whitespace and raw bytes in csv-change-eol ([a61e53a](https://github.com/dougyouch/csv-utils/commit/a61e53a3add068ebc49c1f6025f06185e8b8a702))
19
+ * **cli:** match csv-diff rows on the --unique headers and allow empty cells ([3e0d1ae](https://github.com/dougyouch/csv-utils/commit/3e0d1ae3763090ade46068e056ee909711a2d4df))
20
+ * **cli:** read raw bytes in csv-validator ([80c5002](https://github.com/dougyouch/csv-utils/commit/80c5002eb91474d58d7999f41385743c4f719c78))
21
+ * **cli:** report line numbers and handle any bytes in csv-duplicate-finder ([f87144d](https://github.com/dougyouch/csv-utils/commit/f87144d07727c9a418c0a37a6e82ceedf55ecf59))
22
+ * **cli:** run the bundled csv-readline from csv-find-error ([e6736c6](https://github.com/dougyouch/csv-utils/commit/e6736c6b2682b7611079a1bb5d10ea5df85f3f92))
23
+ * **cli:** split single column files with csv-splitter ([ee00da6](https://github.com/dougyouch/csv-utils/commit/ee00da6f9128a744345583541850348490cabdfc))
24
+ * **compare:** yield the last record when the other file ends first ([947d2b7](https://github.com/dougyouch/csv-utils/commit/947d2b73d4aa404c52b147782f5bc3c02f5afd42))
25
+ * **io:** close files when a block raises ([5ffa159](https://github.com/dougyouch/csv-utils/commit/5ffa159255e6a134721075536f7b70de9f7c1244))
26
+ * **iterator:** return an enumerator from each without a block ([751d034](https://github.com/dougyouch/csv-utils/commit/751d034d325279f4eaeca76b726e4a63a6443042))
27
+ * **options:** count quoted headers correctly and handle empty files ([abbd084](https://github.com/dougyouch/csv-utils/commit/abbd084a4a2cf84bf27410d616c63e3679f07a14))
28
+ * **row:** stop csv_column from changing the options hash passed in ([6a9f916](https://github.com/dougyouch/csv-utils/commit/6a9f916d1e3c9b0f27410a4cae795f80e5968cec))
29
+ * **sort:** sort without a block and clean up temporary files on failure ([bc2d21a](https://github.com/dougyouch/csv-utils/commit/bc2d21ae63973323d12084de2710d07e06e70dbb))
30
+ * **transformer:** allow append when headers were not read ([9d1e042](https://github.com/dougyouch/csv-utils/commit/9d1e04259e5819eaf5865895674b8aafc8d51c8f))
31
+
32
+
33
+ ### Build System
34
+
35
+ * **gem:** require ruby 3.3 and add gem metadata ([e34a89a](https://github.com/dougyouch/csv-utils/commit/e34a89a473159fa4c70f5279d63ca95499a43c90))
36
+
37
+ ## Changelog
data/README.md CHANGED
@@ -1,9 +1,12 @@
1
1
  # CSV Utils
2
2
 
3
- [![CI](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml/badge.svg)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
4
- [![codecov](https://codecov.io/gh/dougyouch/csv-utils/graph/badge.svg)](https://codecov.io/gh/dougyouch/csv-utils)
3
+ Streaming tools for CSV files that are too big or too broken to load into memory. Compare two sorted files into create, update and delete actions, sort with an external merge sort, transform and extend rows in batches, and build reports from Ruby objects. Command line tools pinpoint malformed rows and diff, grep, split and validate CSV files.
5
4
 
6
- A Ruby library providing a comprehensive set of utilities for manipulating and processing CSV files. This library offers a robust set of tools for comparing, transforming, sorting, and managing CSV data efficiently.
5
+ [![CI](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
6
+ [![Coverage](https://raw.githubusercontent.com/dougyouch/csv-utils/badges/coverage.svg)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
7
+ [![Branch Coverage](https://raw.githubusercontent.com/dougyouch/csv-utils/badges/branches.svg)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
8
+
9
+ [API reference](https://rubydoc.info/gems/csv-utils) · [Changelog](CHANGELOG.md) · [Architecture](ARCHITECTURE.md)
7
10
 
8
11
  ## Features
9
12
 
@@ -16,11 +19,12 @@ A Ruby library providing a comprehensive set of utilities for manipulating and p
16
19
  - **CSV Iteration**: Efficient iteration over CSV files with batch support
17
20
  - **CSV Extension**: Extend CSV files with additional columns
18
21
  - **CSV Options**: Auto-detect CSV file properties (separators, encoding, BOM)
22
+ - **Byte Order Marks**: UTF-8, UTF-16 and UTF-32 byte order marks are stripped from the first header
19
23
  - **CLI Tools**: Command-line utilities for CSV debugging and manipulation
20
24
 
21
25
  ## Installation
22
26
 
23
- Add this line to your application's Gemfile:
27
+ Requires Ruby 3.3 or newer. Add this line to your application's Gemfile:
24
28
 
25
29
  ```ruby
26
30
  gem 'csv-utils'
@@ -63,7 +67,15 @@ comparator.compare('secondary.csv') do |action, record|
63
67
  end
64
68
  ```
65
69
 
66
- **Note**: Both CSV files must be sorted by the same key columns for comparison to work correctly.
70
+ Pass CSV options as the third argument to read both files with them, e.g. tab separated files:
71
+
72
+ ```ruby
73
+ CSVUtils::CSVCompare.new('primary.tsv', ['updated_at'], col_sep: "\t") { |src, dest| src['id'] <=> dest['id'] }
74
+ ```
75
+
76
+ **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
+ 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.
67
79
 
68
80
  ### Sorting CSV Files
69
81
 
@@ -74,8 +86,13 @@ require 'csv-utils'
74
86
 
75
87
  sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true) # true = has headers
76
88
  sorter.sort(100_000) { |a, b| a.first.to_i <=> b.first.to_i } # batch size, comparison block
89
+
90
+ # without a block, rows are compared as arrays of strings
91
+ sorter.sort
77
92
  ```
78
93
 
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.
95
+
79
96
  ### Transforming CSV Data
80
97
 
81
98
  Transform CSV data using a chainable pipeline:
@@ -100,6 +117,8 @@ Available pipeline methods:
100
117
  - `each { |row, headers, additional_data| }` - Side effects without modification
101
118
  - `set_headers(headers)` - Override output headers
102
119
 
120
+ Without `read_headers`, every row is treated as data and the output has no header row. `process` closes the files it opened, even when a step raises.
121
+
103
122
  ### CSV Row and Report
104
123
 
105
124
  Define CSV-serializable objects and generate reports:
@@ -206,8 +225,13 @@ end
206
225
 
207
226
  # Build a lookup hash
208
227
  lookup = iterator.to_hash('id', 'name') # { 'id_value' => 'name_value', ... }
228
+
229
+ # Without a block, each returns an Enumerator
230
+ iterator.each.with_index { |row, idx| puts "#{idx}: #{row['name']}" }
209
231
  ```
210
232
 
233
+ Line numbers count the header row as line 1, so they match what an editor shows. `headers` returns `[]` for an empty file.
234
+
211
235
  ### Matching CSV Rows
212
236
 
213
237
  Filter CSV rows using regex patterns:
@@ -276,28 +300,79 @@ options.columns # number of columns
276
300
  options.byte_order_mark # BOM if present
277
301
  ```
278
302
 
279
- Supported column separators: `\x02`, `\t`, `|`, `,`
303
+ Supported column separators: `\x02`, `\t`, `|`, `,` (the first one found in the header line wins)
280
304
  Supported row separators: `\r\n`, `\n`, `\r`
281
305
 
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.
307
+
308
+ ### Encodings and Byte Order Marks
309
+
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:
311
+
312
+ ```ruby
313
+ iterator = CSVUtils::CSVIterator.new('latin1.csv', {}, 'rb:BINARY')
314
+ 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')
316
+ ```
317
+
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.
319
+
320
+ `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
+
282
322
  ## CLI Tools
283
323
 
284
- The gem includes command-line utilities for CSV debugging:
324
+ The gem installs command-line utilities for CSV debugging:
285
325
 
286
326
  | Command | Description |
287
327
  |---------|-------------|
288
- | `csv-find-error` | Locate malformed CSV errors with context |
289
- | `csv-readline` | Read specific lines from a CSV file |
290
- | `csv-validator` | Validate CSV structure |
291
- | `csv-diff` | Compare two CSV files |
292
- | `csv-grep` | Search within CSV content |
293
- | `csv-splitter` | Split large CSV files into parts |
294
- | `csv-explorer` | Interactive CSV exploration |
328
+ | `csv-find-error` | Locate the first malformed row and show it with `csv-readline` |
329
+ | `csv-readline` | Print the columns of a line, flagging stray quotes |
330
+ | `csv-validator` | Report rows with the wrong number of columns and values that aren't UTF-8 |
331
+ | `csv-diff` | Compare two CSV files by a unique key |
332
+ | `csv-grep` | Search columns for a pattern |
333
+ | `csv-splitter` | Split a large CSV file into parts |
334
+ | `csv-explorer` | Open an IRB session with the file loaded as a `CSVIterator` |
295
335
  | `csv-duplicate-finder` | Find duplicate rows |
296
- | `csv-change-eol` | Convert line endings |
336
+ | `csv-change-eol` | Rewrite a file with a different line ending |
337
+
338
+ ```bash
339
+ # find the first malformed row
340
+ csv-find-error data.csv
341
+
342
+ # print the row on line 1042, read as 3 lines for values with embedded newlines, including empty columns
343
+ csv-readline --all data.csv 1042 3
344
+
345
+ # compare by the id column, ignoring updated_at; writes diff-results-old.csv
346
+ csv-diff -u id -i updated_at old.csv new.csv
347
+
348
+ # case-insensitive search of the email and name columns, first 10 matches
349
+ csv-grep -s 'smith' -c email,name -i -l 10 data.csv
350
+
351
+ # split into files of 100,000 rows, each with the header
352
+ csv-splitter -r 100000 data.csv
353
+
354
+ # find rows that are duplicates apart from id; writes duplicates-data.csv
355
+ csv-duplicate-finder -i id data.csv
356
+
357
+ # end every row with |^| and a newline
358
+ csv-change-eol data.csv 7C5E7C0A
359
+ ```
360
+
361
+ `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`.
297
362
 
298
363
  ## Development
299
364
 
300
- After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests.
365
+ After checking out the repo, run `bundle install` to install dependencies. Then:
366
+
367
+ ```bash
368
+ bundle exec rspec # run the tests
369
+ bundle exec rubocop # run the linter
370
+ bundle exec yard stats --list-undoc # check the API docs
371
+ ```
372
+
373
+ CI runs RuboCop, requires every public class, module, constant and method to have a YARD doc comment, and runs the specs on Ruby 3.3 and the `.ruby-version` Ruby with 100% line and branch coverage (`CI=1 bundle exec rspec` enforces it locally).
374
+
375
+ Releases are automated by [release-please](https://github.com/googleapis/release-please) from [conventional commits](https://www.conventionalcommits.org/): merging its release PR tags the version and publishes the gem.
301
376
 
302
377
  ## Contributing
303
378
 
@@ -305,4 +380,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/dougyo
305
380
 
306
381
  ## License
307
382
 
308
- The gem is available as open source under the terms of the MIT License.
383
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/bin/csv-change-eol CHANGED
@@ -28,12 +28,11 @@ csv_file = ARGV.shift || exit_on_error('no csv file specified')
28
28
  eol_sequence = ARGV.shift || exit_on_error('no EOL character sequence specified')
29
29
 
30
30
  exit_on_error("file #{csv_file} not found") unless File.exist?(csv_file)
31
- exit_on_error("not a HEX sequece (#{eol_sequence})") unless eol_sequence =~ /\A[0-9a-f]+\z/i
31
+ exit_on_error("not a HEX sequence (#{eol_sequence})") unless eol_sequence =~ /\A[0-9a-f]+\z/i
32
32
  exit_on_error("incorrect number of characters in (#{eol_sequence}), should be even") unless eol_sequence.size.even?
33
33
 
34
34
  eol_sequence = [eol_sequence].pack('H*')
35
35
 
36
-
37
36
  escaped_csv_file =
38
37
  if csv_file =~ /\.csv$/i
39
38
  csv_file.sub(/(\.csv)$/i, '.escaped-eol\1')
@@ -41,13 +40,10 @@ escaped_csv_file =
41
40
  csv_file + '.escaped-eol'
42
41
  end
43
42
 
44
-
43
+ # read and write bytes as is, so values in any encoding pass through unchanged
45
44
  File.open(escaped_csv_file, 'wb') do |out|
46
- CSV.foreach(csv_file) do |row|
47
- line = row.to_csv
48
- line.rstrip!
49
- line.concat(eol_sequence)
50
- out.write line
45
+ CSV.foreach(csv_file, 'rb:BINARY') do |row|
46
+ out.write row.to_csv(row_sep: eol_sequence)
51
47
  end
52
48
  end
53
49
 
data/bin/csv-diff CHANGED
@@ -8,7 +8,7 @@ options = {
8
8
  sort_batch_size: 1_000_000
9
9
  }
10
10
  OptionParser.new do |opts|
11
- opts.banner = 'Usage: ' + File.basename(__FILE__) + ' [options] <csv file>'
11
+ opts.banner = 'Usage: ' + File.basename(__FILE__) + ' [options] <csv file 1> <csv file 2>'
12
12
 
13
13
  opts.on('-h', '--help', 'Prints this help') do
14
14
  puts opts
@@ -28,52 +28,44 @@ OptionParser.new do |opts|
28
28
  end
29
29
  end.parse!
30
30
 
31
+ unless ARGV.size == 2
32
+ $stderr.puts('Usage: ' + File.basename(__FILE__) + ' [options] <csv file 1> <csv file 2>')
33
+ exit 1
34
+ end
35
+
31
36
  require 'csv-utils'
32
37
 
33
38
  csv1 = CSVUtils::CSVIterator.new(ARGV[0])
34
39
  csv2 = CSVUtils::CSVIterator.new(ARGV[1])
40
+ headers = csv1.headers
35
41
 
36
- unless csv1.first.keys == csv2.first.keys
37
- $stderr.puts("headers do not match #{ARGV[0]} headers #{csv1.first.keys}, #{ARGV[1]} headers #{csv2.first.keys}")
42
+ unless headers == csv2.headers
43
+ $stderr.puts("headers do not match #{ARGV[0]} headers #{headers}, #{ARGV[1]} headers #{csv2.headers}")
38
44
  exit 1
39
45
  end
40
46
 
41
- unknown_unique_headers = options[:unique_headers] - csv1.first.keys
47
+ unknown_unique_headers = options[:unique_headers] - headers
42
48
  unless unknown_unique_headers.empty?
43
49
  $stderr.puts("specified unique headers are unknown #{unknown_unique_headers}")
44
50
  exit 1
45
51
  end
46
52
 
47
- unknown_ignore_headers = options[:ignore_headers] - csv1.first.keys
53
+ unknown_ignore_headers = options[:ignore_headers] - headers
48
54
  unless unknown_ignore_headers.empty?
49
55
  $stderr.puts("specified headers to ignore are unknown #{unknown_ignore_headers}")
50
56
  exit 1
51
57
  end
52
58
 
53
- options[:unique_headers] = [csv1.first.keys.first] if options[:unique_headers].empty?
59
+ options[:unique_headers] = [headers.first] if options[:unique_headers].empty?
54
60
 
55
61
  puts "uniqueness header(s) are #{options[:unique_headers].join(', ')}"
56
62
 
57
- unique_header_indexes = []
58
- csv1.first.keys.each_with_index do |header, idx|
59
- unique_header_indexes << idx if options[:unique_headers].include?(header)
60
- end
63
+ unique_header_indexes = options[:unique_headers].map { |header| headers.index(header) }
61
64
 
65
+ # empty cells are nil, so values are compared as strings
62
66
  sort_compare_proc = proc do |csv1_row, csv2_row|
63
- result = 0
64
- unique_header_indexes.each do |idx|
65
- result = csv1_row[idx] <=> csv2_row[idx]
66
- break unless result == 0
67
- end
68
-
69
- if result == 0
70
- csv1_row.each_with_index do |csv1_col, idx|
71
- csv2_col = csv2_row[idx]
72
- result = csv1_col <=> csv2_col
73
- break unless result == 0
74
- end
75
- end
76
-
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?
77
69
  result
78
70
  end
79
71
 
@@ -88,25 +80,17 @@ puts "sorting #{ARGV[1]}"
88
80
  sorter = CSVUtils::CSVSort.new(ARGV[1], csv2_sorted_file_name)
89
81
  sorter.sort(options[:sort_batch_size], &sort_compare_proc)
90
82
 
91
- options[:unique_headers] = [csv1.first.keys.first]
92
- update_comparison_columns = csv1.first.keys - options[:unique_headers]
93
- update_comparison_columns -= options[:ignore_headers]
83
+ update_comparison_columns = headers - options[:unique_headers] - options[:ignore_headers]
94
84
 
95
85
  comparer = CSVUtils::CSVCompare.new(csv1_sorted_file_name, update_comparison_columns) do |csv1_row, csv2_row|
96
- result = 0
97
- options[:unique_headers].each do |header|
98
- result = csv1_row[header] <=> csv2_row[header]
99
- break unless result == 0
100
- end
101
-
102
- result
86
+ options[:unique_headers].map { |header| csv1_row[header].to_s } <=> options[:unique_headers].map { |header| csv2_row[header].to_s }
103
87
  end
104
88
 
105
89
  stats = Hash.new(0)
106
90
  puts "comparing #{ARGV[0]} with #{ARGV[1]}"
107
91
  diff_file_name = 'diff-results-' + File.basename(ARGV[0])
108
92
  CSV.open(diff_file_name, 'wb') do |out|
109
- out << ['Result'] + csv1.first.keys
93
+ out << ['Result'] + headers
110
94
  comparer.compare(csv2_sorted_file_name) do |action, record|
111
95
  stats[action] += 1
112
96
  out << [action] + record.values
@@ -13,31 +13,34 @@ OptionParser.new do |opts|
13
13
  exit
14
14
  end
15
15
 
16
- opts.on('-i', '--ignore HEADERS', 'Comman separated list of headers to ignore') do |v|
16
+ opts.on('-i', '--ignore HEADERS', 'Comma separated list of headers to ignore') do |v|
17
17
  options[:ignore_columns] = v.split(',')
18
18
  end
19
19
  end.parse!
20
20
 
21
21
  require 'digest/sha2'
22
- require 'json'
23
22
  require 'csv-utils'
24
23
 
25
- csv = CSVUtils::CSVIterator.new(ARGV[0])
24
+ file = ARGV[0] || raise('no CSV file specified')
26
25
 
27
- missing_headers = options[:ignore_columns] - csv.first.keys
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')
28
+
29
+ missing_headers = options[:ignore_columns] - csv.headers
28
30
  unless missing_headers.empty?
29
31
  raise("unknown headers #{missing_headers.join(', ')} in configured ignore headers")
30
32
  end
31
33
 
32
34
  hashed_rows = {}
33
35
 
34
- csv.each_with_index do |row, idx|
36
+ # rows are keyed by their line number in the file
37
+ csv.each do |row|
35
38
  options[:ignore_columns].each do |ignore_column|
36
39
  row[ignore_column] = ''
37
40
  end
38
- key = Digest::SHA256.hexdigest(row.to_json)
41
+ key = Digest::SHA256.hexdigest(row.values.to_csv)
39
42
  hashed_rows[key] ||= []
40
- hashed_rows[key] << idx
43
+ hashed_rows[key] << row.lineno
41
44
  end
42
45
 
43
46
  duplicate_rows = {}
@@ -52,18 +55,18 @@ hashed_rows.each do |key, row_numbers|
52
55
  end
53
56
  end
54
57
 
55
- csv.each_with_index do |row, idx|
56
- next unless (key = duplicate_rows[idx])
58
+ csv.each do |row|
59
+ next unless (key = duplicate_rows[row.lineno])
57
60
 
58
- hashed_rows[key][idx] = row
61
+ hashed_rows[key][row.lineno] = row
59
62
  end
60
63
 
61
- CSV.open('duplicates-' + File.basename(ARGV[0]), 'wb') do |out|
62
- out << ['duplicate_key', 'row_no'] + csv.first.keys
64
+ CSV.open('duplicates-' + File.basename(file), 'wb:BINARY') do |out|
65
+ out << ['duplicate_key', 'line_no'] + csv.headers
63
66
 
64
67
  hashed_rows.each do |key, rows|
65
- rows.each do |idx, row|
66
- out << [key, idx] + row.values
68
+ rows.each do |lineno, row|
69
+ out << [key, lineno] + row.values
67
70
  end
68
71
  end
69
72
  end
data/bin/csv-find-error CHANGED
@@ -1,19 +1,21 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require 'csv'
4
- require 'shellwords'
4
+ require 'rbconfig'
5
+
6
+ file = ARGV[0] || abort('Usage: csv-find-error <csv file>')
5
7
 
6
8
  prev_row = nil
7
9
  begin
8
- CSV.open(ARGV[0], 'rb').each { |row| prev_row = row }
10
+ CSV.open(file, 'rb') { |csv| csv.each { |row| prev_row = row } }
9
11
  rescue CSV::MalformedCSVError => e
10
12
  puts e.class.to_s + ': ' + e.message
11
13
  puts "previous row was #{prev_row}"
12
14
  if e.message =~ /line (\d+)/
13
15
  lineno = $1.to_i
14
- cmd = "csv-readline #{Shellwords.escape(ARGV[0])} #{lineno}"
15
- puts "running #{cmd}"
16
- system(cmd)
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)
17
19
  end
18
20
  exit 1
19
21
  end
data/bin/csv-readline CHANGED
@@ -2,21 +2,10 @@
2
2
 
3
3
  require 'optparse'
4
4
 
5
- BYTE_ORDER_MARKS = {
6
- "\xEF\xBB\xBF".force_encoding('ASCII-8BIT') => 'UTF-8',
7
- "\xFE\xFF".force_encoding('ASCII-8BIT') => 'UTF-16',
8
- "\xFF\xFE".force_encoding('ASCII-8BIT') => 'UTF-16',
9
- "\x00\x00\xFE\xFF".force_encoding('ASCII-8BIT') => 'UTF-32',
10
- "\xFF\xFE\x00\x00".force_encoding('ASCII-8BIT') => 'UTF-32'
11
- }
5
+ require 'csv-utils'
12
6
 
13
7
  def strip_byte_order_mark(line)
14
- BYTE_ORDER_MARKS.keys.detect do |bom|
15
- if line =~ /\A#{bom}/
16
- return line.sub(/\A#{bom}/, '')
17
- end
18
- end
19
- line
8
+ CSVUtils::ByteOrderMark.strip(line)
20
9
  end
21
10
 
22
11
  def csv_parse_line(line)
@@ -87,7 +76,7 @@ end
87
76
 
88
77
  def parse_csv_row(file, lineno, number_of_lines)
89
78
  cnt = 1
90
- str = ''
79
+ str = +''
91
80
  file.each do |line|
92
81
  cnt += 1
93
82
  next if cnt < lineno
data/bin/csv-splitter CHANGED
@@ -15,6 +15,8 @@ OptionParser.new do |opts|
15
15
  end
16
16
 
17
17
  opts.on('-r', '--rows ROWS', Integer, 'Max number of rows per a CSV') do |v|
18
+ raise OptionParser::InvalidArgument, "--rows must be positive (#{v})" unless v.positive?
19
+
18
20
  options[:rows] = v
19
21
  end
20
22
 
@@ -36,20 +38,20 @@ def get_split_file_name(file, num_files, total_files)
36
38
  end
37
39
 
38
40
  csv_options = CSVUtils::CSVOptions.new(file)
41
+ # a single column file has no separator to detect
42
+ col_sep = csv_options.col_separator || ','
39
43
 
40
44
  csv = CSV.open(
41
45
  file,
42
46
  'rb',
43
- col_sep: csv_options.col_separator,
47
+ col_sep: col_sep,
44
48
  liberal_parsing: true
45
49
  )
46
50
 
47
51
  headers = options[:no_header] ? nil : csv.shift
48
52
 
49
53
  total_rows = 0
50
- while csv.shift
51
- total_rows +=1
52
- end
54
+ total_rows += 1 while csv.shift
53
55
 
54
56
  csv.rewind
55
57
  csv.shift if headers
@@ -66,7 +68,7 @@ append_row_proc = proc do |row|
66
68
  out.close if out
67
69
  num_rows = 0
68
70
  num_files += 1
69
- out = CSV.open(get_split_file_name(file, num_files, total_files), 'wb', col_sep: csv_options.col_separator)
71
+ out = CSV.open(get_split_file_name(file, num_files, total_files), 'wb', col_sep: col_sep)
70
72
  out << headers if headers
71
73
  end
72
74
 
data/bin/csv-validator CHANGED
@@ -15,6 +15,8 @@ def utf8?(str)
15
15
  end
16
16
 
17
17
  def convert_to_utf8(str, current_encoding)
18
+ return nil unless current_encoding
19
+
18
20
  str.force_encoding(current_encoding)
19
21
  return nil unless str.valid_encoding?
20
22
 
@@ -30,12 +32,13 @@ def strip_bom!(col)
30
32
  end
31
33
 
32
34
  id_column_name = ARGV[1]
33
- csv = CSV.open(ARGV[0], 'rb')
35
+ # read bytes as is; reading as UTF-8 would raise on the values this script looks for
36
+ csv = CSV.open(ARGV[0] || abort('Usage: csv-validator <csv file> [<id header>]'), 'rb:BINARY')
34
37
  out = nil
35
38
 
36
39
  begin
37
- headers = csv.shift
38
- strip_bom!(headers[0])
40
+ headers = csv.shift || []
41
+ strip_bom!(headers[0]) if headers[0]
39
42
 
40
43
  id_column_name ||= headers[0]
41
44
  unless headers.include?(id_column_name)
@@ -68,9 +71,10 @@ begin
68
71
  row.each_with_index do |col, idx|
69
72
  next if col.nil? || utf8?(col)
70
73
 
71
- $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: none UTF-8 characters found in \"#{col}\""
72
- if (col_utf8_encoded = convert_to_utf8(col, detect_encoding(col)))
73
- puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: converted to UTF-8 from #{detect_encoding(col)} \"#{col_utf8_encoded}\""
74
+ $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: non UTF-8 characters found in #{col.inspect}"
75
+ encoding = detect_encoding(col)
76
+ if (col_utf8_encoded = convert_to_utf8(col, encoding))
77
+ puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: converted to UTF-8 from #{encoding} \"#{col_utf8_encoded}\""
74
78
  out_proc.call [row[id_column_num], csv_lineno, (idx + 1), headers[idx], col_utf8_encoded]
75
79
  else
76
80
  $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: unknown character encoding"
data/lib/csv-utils.rb CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  require 'csv'
4
4
 
5
- # Collection of tools for working with CSV files.
5
+ # Tools for comparing, sorting, transforming and debugging CSV files, including ones too large to load
6
+ # into memory. Each class is autoloaded on first use.
6
7
  module CSVUtils
8
+ autoload :ByteOrderMark, 'csv_utils/byte_order_mark'
7
9
  autoload :CSVCompare, 'csv_utils/csv_compare'
8
10
  autoload :CSVExtender, 'csv_utils/csv_extender'
9
11
  autoload :CSVIterator, 'csv_utils/csv_iterator'
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Detects and strips the byte order mark at the start of a CSV file.
5
+ module ByteOrderMark
6
+ # Byte order marks, as binary strings, to the encoding they indicate.
7
+ # This list is from https://en.wikipedia.org/wiki/Byte_order_mark.
8
+ # UTF-32 LE starts with the UTF-16 LE mark, so longer marks come first.
9
+ # @return [Hash{String => String}]
10
+ ENCODINGS = {
11
+ (+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'UTF-32',
12
+ (+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT').freeze => 'UTF-32',
13
+ (+"\xEF\xBB\xBF").force_encoding('ASCII-8BIT').freeze => 'UTF-8',
14
+ (+"\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'UTF-16',
15
+ (+"\xFF\xFE").force_encoding('ASCII-8BIT').freeze => 'UTF-16'
16
+ }.freeze
17
+
18
+ # The byte order mark the string starts with. Compares bytes, so it works whatever the string's encoding is.
19
+ # @param str [String]
20
+ # @return [String, nil] the mark as a binary string
21
+ def self.detect(str)
22
+ prefix = str.byteslice(0, 4).b
23
+ ENCODINGS.keys.detect { |bom| prefix.start_with?(bom) }
24
+ end
25
+
26
+ # The string without its leading byte order mark, in its original encoding.
27
+ # @param str [String]
28
+ # @return [String]
29
+ def self.strip(str)
30
+ bom = detect(str)
31
+ bom ? str.byteslice(bom.bytesize..) : str
32
+ end
33
+ end
34
+ end