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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +92 -17
- data/bin/csv-change-eol +4 -8
- data/bin/csv-diff +19 -35
- data/bin/csv-duplicate-finder +17 -14
- data/bin/csv-find-error +7 -5
- data/bin/csv-readline +3 -14
- data/bin/csv-splitter +7 -5
- data/bin/csv-validator +10 -6
- data/lib/csv-utils.rb +3 -1
- data/lib/csv_utils/byte_order_mark.rb +34 -0
- data/lib/csv_utils/csv_compare.rb +89 -65
- data/lib/csv_utils/csv_extender.rb +23 -2
- data/lib/csv_utils/csv_iterator.rb +49 -22
- data/lib/csv_utils/csv_options.rb +65 -27
- data/lib/csv_utils/csv_report.rb +26 -3
- data/lib/csv_utils/csv_row.rb +30 -0
- data/lib/csv_utils/csv_row_matcher.rb +16 -2
- data/lib/csv_utils/csv_sort.rb +44 -18
- data/lib/csv_utils/csv_transformer.rb +79 -35
- data/lib/csv_utils/csv_wrapper.rb +24 -4
- data/lib/csv_utils/version.rb +6 -0
- metadata +27 -23
- data/.github/workflows/ci.yml +0 -53
- data/.gitignore +0 -52
- data/.rubocop.yml +0 -81
- data/.ruby-gemset +0 -1
- data/.ruby-version +0 -1
- data/ARCHITECTURE.md +0 -154
- data/CLAUDE.md +0 -63
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -77
- data/csv-utils.gemspec +0 -19
- data/script/console +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 423eacb18512c595abe83765ccfd4d945f1e2e0d18a6370b6841997eb428ddb7
|
|
4
|
+
data.tar.gz: 3d2df7f1ee62eaebc25d00281401bf49f0fccdb809ec03cbac90e6bf5ba2fd62
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
4
|
-
[](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
|
-
|
|
5
|
+
[](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
|
|
7
|
+
[](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
|
-
|
|
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
|
|
324
|
+
The gem installs command-line utilities for CSV debugging:
|
|
285
325
|
|
|
286
326
|
| Command | Description |
|
|
287
327
|
|---------|-------------|
|
|
288
|
-
| `csv-find-error` | Locate malformed
|
|
289
|
-
| `csv-readline` |
|
|
290
|
-
| `csv-validator` |
|
|
291
|
-
| `csv-diff` | Compare two CSV files |
|
|
292
|
-
| `csv-grep` | Search
|
|
293
|
-
| `csv-splitter` | Split large CSV
|
|
294
|
-
| `csv-explorer` |
|
|
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` |
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
37
|
-
$stderr.puts("headers do not match #{ARGV[0]} headers #{
|
|
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] -
|
|
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] -
|
|
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] = [
|
|
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 =
|
|
64
|
-
|
|
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]
|
|
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
|
-
|
|
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'] +
|
|
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
|
data/bin/csv-duplicate-finder
CHANGED
|
@@ -13,31 +13,34 @@ OptionParser.new do |opts|
|
|
|
13
13
|
exit
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
opts.on('-i', '--ignore HEADERS', '
|
|
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
|
-
|
|
24
|
+
file = ARGV[0] || raise('no CSV file specified')
|
|
26
25
|
|
|
27
|
-
|
|
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
|
-
|
|
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.
|
|
41
|
+
key = Digest::SHA256.hexdigest(row.values.to_csv)
|
|
39
42
|
hashed_rows[key] ||= []
|
|
40
|
-
hashed_rows[key] <<
|
|
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.
|
|
56
|
-
next unless (key = duplicate_rows[
|
|
58
|
+
csv.each do |row|
|
|
59
|
+
next unless (key = duplicate_rows[row.lineno])
|
|
57
60
|
|
|
58
|
-
hashed_rows[key][
|
|
61
|
+
hashed_rows[key][row.lineno] = row
|
|
59
62
|
end
|
|
60
63
|
|
|
61
|
-
CSV.open('duplicates-' + File.basename(
|
|
62
|
-
out << ['duplicate_key', '
|
|
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 |
|
|
66
|
-
out << [key,
|
|
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 '
|
|
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(
|
|
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
|
-
|
|
15
|
-
puts "running #{
|
|
16
|
-
system(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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]}:
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
#
|
|
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
|