csv-utils 0.5.0 → 0.7.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: c07e7e0ae21409f1fcac6d5b150e1577146d79a47ba9e13b8fb28d90d50fb9c6
4
+ data.tar.gz: cb27bd0e1edbfc74747c5107f6d740b86e6cedcd547857d0eff2f836cddd4b9d
5
5
  SHA512:
6
- metadata.gz: 7afee881db16b4afce9cceb812d4994ee66bcf172db147eaf73911961983cd0d73f9c553ca549880793e1c9a2bb2904e8444f2618ff30d040808d1b4e5383cf7
7
- data.tar.gz: 4811b6ff72153107bf029f36feef65ec68646e0365d3b3987bd3231b9842b20c8c27c72216ca25350bd469887315b139737eedeba3b7c7fa17d240c729ae9a5a
6
+ metadata.gz: b7402f247e5cead6fba5259df4465836778026e2abdfd2a40281c22ed21679f60e15b23d913d7bb1bb559345557b31d3e024ab181a11c131d9159a7d64794e10
7
+ data.tar.gz: 9fd6953261a7e29e2e7676ce770311921b447c899624f105e4d3496ed89018cc515a1b21ecf2f94b36def0a492808105da0953df48680d6beed03776b3d0e6a7
data/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ ## [0.7.0](https://github.com/dougyouch/csv-utils/compare/v0.6.0...v0.7.0) (2026-10-05)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **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.
9
+
10
+ ### Features
11
+
12
+ * **encoding:** read every file with the encoding option and write the decoded encoding ([1a9ec4f](https://github.com/dougyouch/csv-utils/commit/1a9ec4f98cbaadfff7ef89fec0d586cea828cf91))
13
+ * **iterator:** open files per call, build rows without zip, add auto_detect ([34ab89a](https://github.com/dougyouch/csv-utils/commit/34ab89ae49dad76507371d2bdf9a7cd310c2cf67))
14
+ * **options:** detect windows-1252 and read utf-8 regardless of locale ([10eeca6](https://github.com/dougyouch/csv-utils/commit/10eeca69bf094fb8c2cb5b005bc74687bde61169))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **iterator:** strip the byte order mark after rewind and from explicit-header rows ([bf8804b](https://github.com/dougyouch/csv-utils/commit/bf8804b1c2ce16738779f3b9ecc7631c3b29a115))
20
+
21
+ ## [0.6.0](https://github.com/dougyouch/csv-utils/compare/v0.5.0...v0.6.0) (2026-10-05)
22
+
23
+
24
+ ### ⚠ BREAKING CHANGES
25
+
26
+ * **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.
27
+
28
+ ### Features
29
+
30
+ * **compare:** accept csv options for both files ([4336edf](https://github.com/dougyouch/csv-utils/commit/4336edf8ca928ebc73c7b58a533ee7280267907e))
31
+
32
+
33
+ ### Bug Fixes
34
+
35
+ * **bom:** detect utf-32 le and strip marks from text in any encoding ([7c68517](https://github.com/dougyouch/csv-utils/commit/7c6851755b9ddfb6ca49b459d9ada578c35a634d))
36
+ * **cli:** keep trailing whitespace and raw bytes in csv-change-eol ([a61e53a](https://github.com/dougyouch/csv-utils/commit/a61e53a3add068ebc49c1f6025f06185e8b8a702))
37
+ * **cli:** match csv-diff rows on the --unique headers and allow empty cells ([3e0d1ae](https://github.com/dougyouch/csv-utils/commit/3e0d1ae3763090ade46068e056ee909711a2d4df))
38
+ * **cli:** read raw bytes in csv-validator ([80c5002](https://github.com/dougyouch/csv-utils/commit/80c5002eb91474d58d7999f41385743c4f719c78))
39
+ * **cli:** report line numbers and handle any bytes in csv-duplicate-finder ([f87144d](https://github.com/dougyouch/csv-utils/commit/f87144d07727c9a418c0a37a6e82ceedf55ecf59))
40
+ * **cli:** run the bundled csv-readline from csv-find-error ([e6736c6](https://github.com/dougyouch/csv-utils/commit/e6736c6b2682b7611079a1bb5d10ea5df85f3f92))
41
+ * **cli:** split single column files with csv-splitter ([ee00da6](https://github.com/dougyouch/csv-utils/commit/ee00da6f9128a744345583541850348490cabdfc))
42
+ * **compare:** yield the last record when the other file ends first ([947d2b7](https://github.com/dougyouch/csv-utils/commit/947d2b73d4aa404c52b147782f5bc3c02f5afd42))
43
+ * **io:** close files when a block raises ([5ffa159](https://github.com/dougyouch/csv-utils/commit/5ffa159255e6a134721075536f7b70de9f7c1244))
44
+ * **iterator:** return an enumerator from each without a block ([751d034](https://github.com/dougyouch/csv-utils/commit/751d034d325279f4eaeca76b726e4a63a6443042))
45
+ * **options:** count quoted headers correctly and handle empty files ([abbd084](https://github.com/dougyouch/csv-utils/commit/abbd084a4a2cf84bf27410d616c63e3679f07a14))
46
+ * **row:** stop csv_column from changing the options hash passed in ([6a9f916](https://github.com/dougyouch/csv-utils/commit/6a9f916d1e3c9b0f27410a4cae795f80e5968cec))
47
+ * **sort:** sort without a block and clean up temporary files on failure ([bc2d21a](https://github.com/dougyouch/csv-utils/commit/bc2d21ae63973323d12084de2710d07e06e70dbb))
48
+ * **transformer:** allow append when headers were not read ([9d1e042](https://github.com/dougyouch/csv-utils/commit/9d1e04259e5819eaf5865895674b8aafc8d51c8f))
49
+
50
+
51
+ ### Build System
52
+
53
+ * **gem:** require ruby 3.3 and add gem metadata ([e34a89a](https://github.com/dougyouch/csv-utils/commit/e34a89a473159fa4c70f5279d63ca95499a43c90))
54
+
55
+ ## 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) · [Upgrading](UPGRADING.md) · [Architecture](ARCHITECTURE.md)
7
10
 
8
11
  ## Features
9
12
 
@@ -16,11 +19,13 @@ 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
+ - **Encodings**: Files are read as UTF-8 whatever the locale, or in the encoding you pass or `CSVOptions` detects
23
+ - **Byte Order Marks**: UTF-8, UTF-16 and UTF-32 byte order marks are stripped from the first header
19
24
  - **CLI Tools**: Command-line utilities for CSV debugging and manipulation
20
25
 
21
26
  ## Installation
22
27
 
23
- Add this line to your application's Gemfile:
28
+ Requires Ruby 3.3 or newer. Add this line to your application's Gemfile:
24
29
 
25
30
  ```ruby
26
31
  gem 'csv-utils'
@@ -63,7 +68,15 @@ comparator.compare('secondary.csv') do |action, record|
63
68
  end
64
69
  ```
65
70
 
66
- **Note**: Both CSV files must be sorted by the same key columns for comparison to work correctly.
71
+ Pass CSV options as the third argument to read both files with them, e.g. tab separated files:
72
+
73
+ ```ruby
74
+ CSVUtils::CSVCompare.new('primary.tsv', ['updated_at'], col_sep: "\t") { |src, dest| src['id'] <=> dest['id'] }
75
+ ```
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.
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.
67
80
 
68
81
  ### Sorting CSV Files
69
82
 
@@ -74,8 +87,13 @@ require 'csv-utils'
74
87
 
75
88
  sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true) # true = has headers
76
89
  sorter.sort(100_000) { |a, b| a.first.to_i <=> b.first.to_i } # batch size, comparison block
90
+
91
+ # without a block, rows are compared as arrays of strings
92
+ sorter.sort
77
93
  ```
78
94
 
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.
96
+
79
97
  ### Transforming CSV Data
80
98
 
81
99
  Transform CSV data using a chainable pipeline:
@@ -100,6 +118,8 @@ Available pipeline methods:
100
118
  - `each { |row, headers, additional_data| }` - Side effects without modification
101
119
  - `set_headers(headers)` - Override output headers
102
120
 
121
+ 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.
122
+
103
123
  ### CSV Row and Report
104
124
 
105
125
  Define CSV-serializable objects and generate reports:
@@ -206,6 +226,19 @@ end
206
226
 
207
227
  # Build a lookup hash
208
228
  lookup = iterator.to_hash('id', 'name') # { 'id_value' => 'name_value', ... }
229
+
230
+ # Without a block, each returns an Enumerator
231
+ iterator.each.with_index { |row, idx| puts "#{idx}: #{row['name']}" }
232
+ ```
233
+
234
+ Line numbers count the header row as line 1, so they match what an editor shows. `headers` returns `[]` for an empty file.
235
+
236
+ 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
+
238
+ Let `CSVOptions` work out the separators and encoding:
239
+
240
+ ```ruby
241
+ iterator = CSVUtils::CSVIterator.auto_detect('export.tsv')
209
242
  ```
210
243
 
211
244
  ### Matching CSV Rows
@@ -271,33 +304,97 @@ options = CSVUtils::CSVOptions.new('data.csv')
271
304
  options.valid? # true if separators detected
272
305
  options.col_separator # detected column separator
273
306
  options.row_separator # detected row separator
274
- options.encoding # detected encoding (UTF-8, UTF-16, UTF-32)
307
+ options.encoding # detected encoding (UTF-8, UTF-16, UTF-32, Windows-1252, ISO-8859-1)
275
308
  options.columns # number of columns
276
309
  options.byte_order_mark # BOM if present
310
+
311
+ # Open the file with what was detected, with CSV or any class in this gem
312
+ CSV.open('data.csv', 'rb', **options.to_csv_options)
313
+ CSVUtils::CSVSort.new('data.csv', 'sorted.csv', true, options.to_csv_options).sort
277
314
  ```
278
315
 
279
- Supported column separators: `\x02`, `\t`, `|`, `,`
316
+ `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
+
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.
319
+
320
+ Supported column separators: `\x02`, `\t`, `|`, `,` (the first one found in the header line wins)
280
321
  Supported row separators: `\r\n`, `\n`, `\r`
281
322
 
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.
324
+
325
+ ### Encodings and Byte Order Marks
326
+
327
+ Every class handles encodings the same way:
328
+
329
+ - **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`.
330
+ - **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:`.
331
+ - **File modes never carry an encoding.** CSV raises `ArgumentError: encoding specified twice` when the mode and the `encoding:` option both have one.
332
+
333
+ ```ruby
334
+ iterator = CSVUtils::CSVIterator.new('export.csv', encoding: 'Windows-1252:UTF-8')
335
+ iterator = CSVUtils::CSVIterator.new('latin1.csv', encoding: 'BINARY') # raw bytes
336
+ comparator = CSVUtils::CSVCompare.new('primary.csv', ['updated_at'], encoding: 'BINARY') { |src, dest| src['id'] <=> dest['id'] }
337
+ sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true, CSVUtils::CSVOptions.new('input.csv').to_csv_options)
338
+ ```
339
+
340
+ `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).
341
+
342
+ `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.
343
+
282
344
  ## CLI Tools
283
345
 
284
- The gem includes command-line utilities for CSV debugging:
346
+ The gem installs command-line utilities for CSV debugging:
285
347
 
286
348
  | Command | Description |
287
349
  |---------|-------------|
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 |
350
+ | `csv-find-error` | Locate the first malformed row and show it with `csv-readline` |
351
+ | `csv-readline` | Print the columns of a line, flagging stray quotes |
352
+ | `csv-validator` | Report rows with the wrong number of columns and values that aren't UTF-8 |
353
+ | `csv-diff` | Compare two CSV files by a unique key |
354
+ | `csv-grep` | Search columns for a pattern |
355
+ | `csv-splitter` | Split a large CSV file into parts |
356
+ | `csv-explorer` | Open an IRB session with the file loaded as a `CSVIterator` |
295
357
  | `csv-duplicate-finder` | Find duplicate rows |
296
- | `csv-change-eol` | Convert line endings |
358
+ | `csv-change-eol` | Rewrite a file with a different line ending |
359
+
360
+ ```bash
361
+ # find the first malformed row
362
+ csv-find-error data.csv
363
+
364
+ # print the row on line 1042, read as 3 lines for values with embedded newlines, including empty columns
365
+ csv-readline --all data.csv 1042 3
366
+
367
+ # compare by the id column, ignoring updated_at; writes diff-results-old.csv
368
+ csv-diff -u id -i updated_at old.csv new.csv
369
+
370
+ # case-insensitive search of the email and name columns, first 10 matches
371
+ csv-grep -s 'smith' -c email,name -i -l 10 data.csv
372
+
373
+ # split into files of 100,000 rows, each with the header
374
+ csv-splitter -r 100000 data.csv
375
+
376
+ # find rows that are duplicates apart from id; writes duplicates-data.csv
377
+ csv-duplicate-finder -i id data.csv
378
+
379
+ # end every row with |^| and a newline
380
+ csv-change-eol data.csv 7C5E7C0A
381
+ ```
382
+
383
+ `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
384
 
298
385
  ## Development
299
386
 
300
- After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests.
387
+ After checking out the repo, run `bundle install` to install dependencies. Then:
388
+
389
+ ```bash
390
+ bundle exec rspec # run the tests
391
+ bundle exec rubocop # run the linter
392
+ bundle exec yard stats --list-undoc # check the API docs
393
+ ```
394
+
395
+ 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).
396
+
397
+ 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
398
 
302
399
  ## Contributing
303
400
 
@@ -305,4 +402,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/dougyo
305
402
 
306
403
  ## License
307
404
 
308
- The gem is available as open source under the terms of the MIT License.
405
+ 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, encoding: '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,12 @@
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'
9
+ autoload :CharacterEncoding, 'csv_utils/character_encoding'
10
+ autoload :EncodingOptions, 'csv_utils/encoding_options'
7
11
  autoload :CSVCompare, 'csv_utils/csv_compare'
8
12
  autoload :CSVExtender, 'csv_utils/csv_extender'
9
13
  autoload :CSVIterator, 'csv_utils/csv_iterator'