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 +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +115 -18
- 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 +5 -1
- data/lib/csv_utils/byte_order_mark.rb +34 -0
- data/lib/csv_utils/character_encoding.rb +23 -0
- data/lib/csv_utils/csv_compare.rb +89 -65
- data/lib/csv_utils/csv_extender.rb +26 -4
- data/lib/csv_utils/csv_iterator.rb +111 -44
- data/lib/csv_utils/csv_options.rb +117 -30
- data/lib/csv_utils/csv_report.rb +28 -4
- 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 +63 -24
- data/lib/csv_utils/csv_transformer.rb +82 -37
- data/lib/csv_utils/csv_wrapper.rb +24 -4
- data/lib/csv_utils/encoding_options.rb +40 -0
- data/lib/csv_utils/version.rb +6 -0
- metadata +29 -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: c07e7e0ae21409f1fcac6d5b150e1577146d79a47ba9e13b8fb28d90d50fb9c6
|
|
4
|
+
data.tar.gz: cb27bd0e1edbfc74747c5107f6d740b86e6cedcd547857d0eff2f836cddd4b9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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) · [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
|
-
|
|
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
|
-
|
|
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
|
|
346
|
+
The gem installs command-line utilities for CSV debugging:
|
|
285
347
|
|
|
286
348
|
| Command | Description |
|
|
287
349
|
|---------|-------------|
|
|
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` |
|
|
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` |
|
|
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
|
|
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
|
|
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, 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
|
-
|
|
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,12 @@
|
|
|
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'
|
|
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'
|