csv-utils 0.6.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 +18 -0
- data/README.md +28 -6
- data/bin/csv-duplicate-finder +1 -1
- data/lib/csv-utils.rb +2 -0
- data/lib/csv_utils/character_encoding.rb +23 -0
- data/lib/csv_utils/csv_compare.rb +2 -2
- data/lib/csv_utils/csv_extender.rb +4 -3
- data/lib/csv_utils/csv_iterator.rb +74 -34
- data/lib/csv_utils/csv_options.rb +59 -10
- data/lib/csv_utils/csv_report.rb +3 -2
- data/lib/csv_utils/csv_sort.rb +20 -7
- data/lib/csv_utils/csv_transformer.rb +4 -3
- data/lib/csv_utils/encoding_options.rb +40 -0
- data/lib/csv_utils/version.rb +1 -1
- metadata +3 -1
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
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
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
|
+
|
|
3
21
|
## [0.6.0](https://github.com/dougyouch/csv-utils/compare/v0.5.0...v0.6.0) (2026-10-05)
|
|
4
22
|
|
|
5
23
|
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Streaming tools for CSV files that are too big or too broken to load into memory
|
|
|
6
6
|
[](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
|
|
7
7
|
[](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
|
|
8
8
|
|
|
9
|
-
[API reference](https://rubydoc.info/gems/csv-utils) · [Changelog](CHANGELOG.md) · [Architecture](ARCHITECTURE.md)
|
|
9
|
+
[API reference](https://rubydoc.info/gems/csv-utils) · [Changelog](CHANGELOG.md) · [Upgrading](UPGRADING.md) · [Architecture](ARCHITECTURE.md)
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
@@ -19,6 +19,7 @@ Streaming tools for CSV files that are too big or too broken to load into memory
|
|
|
19
19
|
- **CSV Iteration**: Efficient iteration over CSV files with batch support
|
|
20
20
|
- **CSV Extension**: Extend CSV files with additional columns
|
|
21
21
|
- **CSV Options**: Auto-detect CSV file properties (separators, encoding, BOM)
|
|
22
|
+
- **Encodings**: Files are read as UTF-8 whatever the locale, or in the encoding you pass or `CSVOptions` detects
|
|
22
23
|
- **Byte Order Marks**: UTF-8, UTF-16 and UTF-32 byte order marks are stripped from the first header
|
|
23
24
|
- **CLI Tools**: Command-line utilities for CSV debugging and manipulation
|
|
24
25
|
|
|
@@ -232,6 +233,14 @@ iterator.each.with_index { |row, idx| puts "#{idx}: #{row['name']}" }
|
|
|
232
233
|
|
|
233
234
|
Line numbers count the header row as line 1, so they match what an editor shows. `headers` returns `[]` for an empty file.
|
|
234
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')
|
|
242
|
+
```
|
|
243
|
+
|
|
235
244
|
### Matching CSV Rows
|
|
236
245
|
|
|
237
246
|
Filter CSV rows using regex patterns:
|
|
@@ -295,11 +304,19 @@ options = CSVUtils::CSVOptions.new('data.csv')
|
|
|
295
304
|
options.valid? # true if separators detected
|
|
296
305
|
options.col_separator # detected column separator
|
|
297
306
|
options.row_separator # detected row separator
|
|
298
|
-
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)
|
|
299
308
|
options.columns # number of columns
|
|
300
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
|
|
301
314
|
```
|
|
302
315
|
|
|
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
|
+
|
|
303
320
|
Supported column separators: `\x02`, `\t`, `|`, `,` (the first one found in the header line wins)
|
|
304
321
|
Supported row separators: `\r\n`, `\n`, `\r`
|
|
305
322
|
|
|
@@ -307,15 +324,20 @@ Headers are parsed as a CSV row, so a quoted header like `"Last, First"` counts
|
|
|
307
324
|
|
|
308
325
|
### Encodings and Byte Order Marks
|
|
309
326
|
|
|
310
|
-
|
|
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.
|
|
311
332
|
|
|
312
333
|
```ruby
|
|
313
|
-
iterator = CSVUtils::CSVIterator.new('
|
|
334
|
+
iterator = CSVUtils::CSVIterator.new('export.csv', encoding: 'Windows-1252:UTF-8')
|
|
335
|
+
iterator = CSVUtils::CSVIterator.new('latin1.csv', encoding: 'BINARY') # raw bytes
|
|
314
336
|
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,
|
|
337
|
+
sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true, CSVUtils::CSVOptions.new('input.csv').to_csv_options)
|
|
316
338
|
```
|
|
317
339
|
|
|
318
|
-
`CSVSort`, `CSVExtender` and `
|
|
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).
|
|
319
341
|
|
|
320
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.
|
|
321
343
|
|
data/bin/csv-duplicate-finder
CHANGED
|
@@ -24,7 +24,7 @@ require 'csv-utils'
|
|
|
24
24
|
file = ARGV[0] || raise('no CSV file specified')
|
|
25
25
|
|
|
26
26
|
# read bytes as is, so values that aren't valid UTF-8 can still be compared
|
|
27
|
-
csv = CSVUtils::CSVIterator.new(file,
|
|
27
|
+
csv = CSVUtils::CSVIterator.new(file, encoding: 'BINARY')
|
|
28
28
|
|
|
29
29
|
missing_headers = options[:ignore_columns] - csv.headers
|
|
30
30
|
unless missing_headers.empty?
|
data/lib/csv-utils.rb
CHANGED
|
@@ -6,6 +6,8 @@ require 'csv'
|
|
|
6
6
|
# into memory. Each class is autoloaded on first use.
|
|
7
7
|
module CSVUtils
|
|
8
8
|
autoload :ByteOrderMark, 'csv_utils/byte_order_mark'
|
|
9
|
+
autoload :CharacterEncoding, 'csv_utils/character_encoding'
|
|
10
|
+
autoload :EncodingOptions, 'csv_utils/encoding_options'
|
|
9
11
|
autoload :CSVCompare, 'csv_utils/csv_compare'
|
|
10
12
|
autoload :CSVExtender, 'csv_utils/csv_extender'
|
|
11
13
|
autoload :CSVIterator, 'csv_utils/csv_iterator'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CSVUtils
|
|
4
|
+
# Guesses the character encoding of a file without a byte order mark from a sample of its bytes.
|
|
5
|
+
module CharacterEncoding
|
|
6
|
+
# Bytes Windows-1252 leaves undefined; converting them to UTF-8 raises.
|
|
7
|
+
# @return [Regexp]
|
|
8
|
+
WINDOWS_1252_UNDEFINED = /[\x81\x8D\x8F\x90\x9D]/n
|
|
9
|
+
|
|
10
|
+
# 'UTF-8' when the sample is valid UTF-8. Otherwise 'Windows-1252', the usual encoding of Excel exports,
|
|
11
|
+
# or 'ISO-8859-1' when the sample has a byte Windows-1252 leaves undefined, since every ISO-8859-1 byte
|
|
12
|
+
# converts to UTF-8.
|
|
13
|
+
# @param sample [String] bytes from the start of the file, ending on a whole character
|
|
14
|
+
# @return [String]
|
|
15
|
+
def self.detect(sample)
|
|
16
|
+
bytes = sample.b
|
|
17
|
+
return 'UTF-8' if bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
18
|
+
return 'ISO-8859-1' if bytes.match?(WINDOWS_1252_UNDEFINED)
|
|
19
|
+
|
|
20
|
+
'Windows-1252'
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -24,7 +24,7 @@ module CSVUtils
|
|
|
24
24
|
# @param update_comparison_columns [Array<String>, nil] columns compared on matching records;
|
|
25
25
|
# without them no updates are yielded
|
|
26
26
|
# @param csv_options [Hash] options passed to CSV.open for both files, ex: col_sep: "\t",
|
|
27
|
-
# or encoding: 'BINARY' to read bytes that aren't valid UTF-8
|
|
27
|
+
# or encoding: 'BINARY' to read bytes that aren't valid UTF-8; the :encoding defaults to 'bom|utf-8'
|
|
28
28
|
# @yieldparam src [Hash{String => String}] record from the primary file
|
|
29
29
|
# @yieldparam dest [Hash{String => String}] record from the secondary file
|
|
30
30
|
# @yieldreturn [Integer] negative, 0 or positive like <=>, using the same order both files are sorted by
|
|
@@ -88,7 +88,7 @@ module CSVUtils
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
def open_csv(file)
|
|
91
|
-
csv = CSV.open(file, 'rb', **csv_options)
|
|
91
|
+
csv = CSV.open(file, 'rb', **EncodingOptions.read(csv_options))
|
|
92
92
|
yield csv, read_headers(csv)
|
|
93
93
|
ensure
|
|
94
94
|
csv&.close
|
|
@@ -9,10 +9,11 @@ module CSVUtils
|
|
|
9
9
|
class CSVExtender
|
|
10
10
|
# @param src_csv [String, CSV, #shift] path or CSV to read; paths are opened and closed by the extender
|
|
11
11
|
# @param dest_csv [String, CSV, #<<] path or CSV to write
|
|
12
|
-
# @param csv_options [Hash] options passed to CSV.open for paths
|
|
12
|
+
# @param csv_options [Hash] options passed to CSV.open for paths; the source is read with its :encoding,
|
|
13
|
+
# 'bom|utf-8' by default, and the output written in the encoding values were decoded to (see {EncodingOptions})
|
|
13
14
|
def initialize(src_csv, dest_csv, csv_options = {})
|
|
14
|
-
@src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
|
|
15
|
-
@dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
|
|
15
|
+
@src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', EncodingOptions.read(csv_options))
|
|
16
|
+
@dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', EncodingOptions.write(csv_options))
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
# Appends columns one row at a time, then closes the files it opened.
|
|
@@ -26,8 +26,15 @@ module CSVUtils
|
|
|
26
26
|
# @param lineno [Integer]
|
|
27
27
|
# @return [RowWrapper]
|
|
28
28
|
def self.create(headers, row, lineno)
|
|
29
|
-
row_wrapper =
|
|
29
|
+
row_wrapper = new
|
|
30
30
|
row_wrapper.lineno = lineno
|
|
31
|
+
# an index loop instead of headers.zip(row), which allocates an array per column for every row
|
|
32
|
+
idx = 0
|
|
33
|
+
size = headers.size
|
|
34
|
+
while idx < size
|
|
35
|
+
row_wrapper[headers[idx]] = row[idx]
|
|
36
|
+
idx += 1
|
|
37
|
+
end
|
|
31
38
|
row_wrapper
|
|
32
39
|
end
|
|
33
40
|
|
|
@@ -41,41 +48,40 @@ module CSVUtils
|
|
|
41
48
|
end
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
#
|
|
45
|
-
# @
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
# An iterator for a path, with the separators and encoding detected by {CSVOptions}.
|
|
52
|
+
# @example
|
|
53
|
+
# CSVUtils::CSVIterator.auto_detect('export.tsv').each { |row| puts row['name'] }
|
|
54
|
+
# @param path [String]
|
|
55
|
+
# @return [CSVIterator]
|
|
56
|
+
def self.auto_detect(path)
|
|
57
|
+
options = CSVOptions.new(path)
|
|
58
|
+
new(path, options.to_csv_options)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A path is opened by each method call and closed when the call returns,
|
|
62
|
+
# so an idle iterator holds no file handle.
|
|
63
|
+
# @param src_csv [String, CSV] path or CSV to read; a CSV must support rewind and is never closed
|
|
64
|
+
# @param csv_options [Hash] options passed to CSV.open for a path. The :encoding defaults to 'bom|utf-8'
|
|
65
|
+
# (see {EncodingOptions}); 'BINARY' reads bytes as is and 'Windows-1252:UTF-8' converts.
|
|
66
|
+
def initialize(src_csv, csv_options = {})
|
|
67
|
+
@src_csv = src_csv
|
|
68
|
+
@csv_options = EncodingOptions.read(csv_options)
|
|
49
69
|
end
|
|
50
70
|
|
|
51
71
|
# Yields each row from the start of the file.
|
|
52
72
|
# @param headers [Array<String>, nil] headers to use; by default the first row is read as the headers
|
|
53
73
|
# @yieldparam row [RowWrapper]
|
|
54
74
|
# @return [Enumerator, nil] an enumerator without a block
|
|
55
|
-
def each(headers = nil)
|
|
75
|
+
def each(headers = nil, &)
|
|
56
76
|
return enum_for(:each, headers) unless block_given?
|
|
57
77
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
lineno = 0
|
|
61
|
-
unless headers
|
|
62
|
-
headers = read_headers
|
|
63
|
-
lineno += 1
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
@prev_row = nil
|
|
67
|
-
while (row = @src_csv.shift)
|
|
68
|
-
lineno += 1
|
|
69
|
-
yield RowWrapper.create(headers, row, lineno)
|
|
70
|
-
@prev_row = row
|
|
71
|
-
end
|
|
78
|
+
open_csv { |csv| each_row(csv, headers, &) }
|
|
72
79
|
end
|
|
73
80
|
|
|
74
81
|
# The first row, without the byte order mark.
|
|
75
82
|
# @return [Array<String>] empty for an empty file
|
|
76
83
|
def headers
|
|
77
|
-
|
|
78
|
-
read_headers
|
|
84
|
+
open_csv { |csv| read_headers(csv) }
|
|
79
85
|
end
|
|
80
86
|
|
|
81
87
|
# Builds a lookup hash from every row.
|
|
@@ -85,8 +91,9 @@ module CSVUtils
|
|
|
85
91
|
# @return [Hash]
|
|
86
92
|
# @raise [RuntimeError] when key or value isn't a header
|
|
87
93
|
def to_hash(key, value = nil, &)
|
|
88
|
-
|
|
89
|
-
raise("
|
|
94
|
+
file_headers = headers
|
|
95
|
+
raise("header #{key} not found in #{file_headers}") unless file_headers.include?(key)
|
|
96
|
+
raise("headers #{value} not found in #{file_headers}") if value && !file_headers.include?(value)
|
|
90
97
|
|
|
91
98
|
value_proc =
|
|
92
99
|
if value
|
|
@@ -103,11 +110,12 @@ module CSVUtils
|
|
|
103
110
|
# Number of rows after the header row.
|
|
104
111
|
# @return [Integer]
|
|
105
112
|
def size
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
open_csv do |csv|
|
|
114
|
+
csv.shift
|
|
115
|
+
cnt = 0
|
|
116
|
+
cnt += 1 while csv.shift
|
|
117
|
+
cnt
|
|
118
|
+
end
|
|
111
119
|
end
|
|
112
120
|
|
|
113
121
|
# Yields the rows in batches.
|
|
@@ -134,11 +142,43 @@ module CSVUtils
|
|
|
134
142
|
|
|
135
143
|
private
|
|
136
144
|
|
|
145
|
+
# Opens a path for the duration of the block. Only a CSV that was passed in is rewound:
|
|
146
|
+
# rewinding goes back to byte 0, in front of a byte order mark the 'bom|' encoding skipped on open.
|
|
147
|
+
def open_csv
|
|
148
|
+
CSVWrapper.open(@src_csv, 'rb', @csv_options) do |csv|
|
|
149
|
+
csv.rewind unless @src_csv.is_a?(String)
|
|
150
|
+
yield csv
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def each_row(csv, headers)
|
|
155
|
+
@prev_row = nil
|
|
156
|
+
row = shift_first_row(csv)
|
|
157
|
+
lineno = 1
|
|
158
|
+
unless headers
|
|
159
|
+
headers = row || []
|
|
160
|
+
row = csv.shift
|
|
161
|
+
lineno += 1
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
while row
|
|
165
|
+
yield RowWrapper.create(headers, row, lineno)
|
|
166
|
+
@prev_row = row
|
|
167
|
+
row = csv.shift
|
|
168
|
+
lineno += 1
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
137
172
|
# an empty file has no headers, and an empty first header cell is nil
|
|
138
|
-
def read_headers
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
173
|
+
def read_headers(csv)
|
|
174
|
+
shift_first_row(csv) || []
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# the first row without the byte order mark, whether it's read as headers or as data
|
|
178
|
+
def shift_first_row(csv)
|
|
179
|
+
row = csv.shift
|
|
180
|
+
row[0] = ByteOrderMark.strip(row[0]) if row && row[0]
|
|
181
|
+
row
|
|
142
182
|
end
|
|
143
183
|
end
|
|
144
184
|
end
|
|
@@ -25,25 +25,40 @@ module CSVUtils
|
|
|
25
25
|
"\r"
|
|
26
26
|
].freeze
|
|
27
27
|
|
|
28
|
+
# @return [Hash{String => String}] UTF-16 and UTF-32 byte order marks, as binary strings, to the :encoding
|
|
29
|
+
# option that decodes them to UTF-8
|
|
30
|
+
WIDE_ENCODINGS = {
|
|
31
|
+
(+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-32BE:UTF-8',
|
|
32
|
+
(+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-32LE:UTF-8',
|
|
33
|
+
(+"\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-16BE:UTF-8',
|
|
34
|
+
(+"\xFF\xFE").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-16LE:UTF-8'
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# @return [Integer] bytes read to detect the encoding of a file without a byte order mark
|
|
38
|
+
SAMPLE_SIZE = 1024 * 1024
|
|
39
|
+
|
|
28
40
|
# @return [Integer, nil] number of headers, nil without a column separator
|
|
29
41
|
attr_reader :columns
|
|
30
42
|
# @return [String, nil] the byte order mark the file starts with, as a binary string
|
|
31
43
|
attr_reader :byte_order_mark
|
|
32
|
-
# @return [String] 'UTF-8', 'UTF-16' or 'UTF-32'
|
|
44
|
+
# @return [String] 'UTF-8', 'UTF-16' or 'UTF-32' from the byte order mark; without one, from the bytes of
|
|
45
|
+
# the first {SAMPLE_SIZE} bytes: 'UTF-8', 'Windows-1252' or 'ISO-8859-1' (see {CharacterEncoding.detect})
|
|
33
46
|
attr_reader :encoding
|
|
34
47
|
# @return [String, nil] one of {COL_SEPARATORS}
|
|
35
48
|
attr_reader :col_separator
|
|
36
49
|
# @return [String, nil] one of {ROW_SEPARATORS}
|
|
37
50
|
attr_reader :row_separator
|
|
38
51
|
|
|
39
|
-
# @param io [String, IO] path of the file, or an IO positioned at its first line
|
|
52
|
+
# @param io [String, IO] path of the file, or an IO positioned at its first line; up to {SAMPLE_SIZE}
|
|
53
|
+
# bytes are read from it
|
|
40
54
|
def initialize(io)
|
|
41
|
-
|
|
55
|
+
sample = read_sample(io)
|
|
56
|
+
line = first_line(sample)
|
|
42
57
|
|
|
43
58
|
@col_separator = auto_detect_col_sep(line)
|
|
44
59
|
@row_separator = auto_detect_row_sep(line)
|
|
45
60
|
@byte_order_mark = get_byte_order_mark(line)
|
|
46
|
-
@encoding = get_character_encoding(@byte_order_mark)
|
|
61
|
+
@encoding = get_character_encoding(@byte_order_mark, sample)
|
|
47
62
|
@columns = get_number_of_columns(line) if @col_separator
|
|
48
63
|
end
|
|
49
64
|
|
|
@@ -55,6 +70,21 @@ module CSVUtils
|
|
|
55
70
|
true
|
|
56
71
|
end
|
|
57
72
|
|
|
73
|
+
# Options for CSV.open, or any class in this library, with the detected separators and an :encoding that
|
|
74
|
+
# reads values as UTF-8 strings: 'bom|utf-8', 'Windows-1252:UTF-8', 'BOM|UTF-16LE:UTF-8' and so on.
|
|
75
|
+
# A separator that wasn't detected is left to CSV, and so is the row separator of UTF-16 and UTF-32 files,
|
|
76
|
+
# since it's detected from raw bytes.
|
|
77
|
+
# @example
|
|
78
|
+
# CSV.open(path, 'rb', **options.to_csv_options)
|
|
79
|
+
# CSVUtils::CSVSort.new(path, 'sorted.csv', true, options.to_csv_options).sort
|
|
80
|
+
# @return [Hash{Symbol => String}]
|
|
81
|
+
def to_csv_options
|
|
82
|
+
options = { encoding: encoding_option }
|
|
83
|
+
options[:col_sep] = col_separator if col_separator
|
|
84
|
+
options[:row_sep] = row_separator if row_separator && !wide_encoding?
|
|
85
|
+
options
|
|
86
|
+
end
|
|
87
|
+
|
|
58
88
|
# @api private
|
|
59
89
|
# @param line [String]
|
|
60
90
|
# @return [String, nil]
|
|
@@ -96,9 +126,10 @@ module CSVUtils
|
|
|
96
126
|
|
|
97
127
|
# @api private
|
|
98
128
|
# @param bom [String, nil]
|
|
129
|
+
# @param sample [String] bytes from the start of the file
|
|
99
130
|
# @return [String]
|
|
100
|
-
def get_character_encoding(bom)
|
|
101
|
-
BYTE_ORDER_MARKS[bom] ||
|
|
131
|
+
def get_character_encoding(bom, sample)
|
|
132
|
+
BYTE_ORDER_MARKS[bom] || CharacterEncoding.detect(sample)
|
|
102
133
|
end
|
|
103
134
|
|
|
104
135
|
# @api private
|
|
@@ -110,10 +141,28 @@ module CSVUtils
|
|
|
110
141
|
|
|
111
142
|
private
|
|
112
143
|
|
|
113
|
-
# an empty file reads as an empty
|
|
114
|
-
def
|
|
115
|
-
|
|
116
|
-
|
|
144
|
+
# an empty file reads as an empty sample, which makes the options invalid
|
|
145
|
+
def read_sample(io)
|
|
146
|
+
sample = (io.is_a?(String) ? File.open(io, 'rb') { |file| file.read(SAMPLE_SIZE) } : io.read(SAMPLE_SIZE)) || ''
|
|
147
|
+
sample.bytesize < SAMPLE_SIZE ? sample : drop_last_multibyte_char(sample)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# a full sample can end mid-character, so its last multibyte character, whole or not, is dropped
|
|
151
|
+
def drop_last_multibyte_char(sample)
|
|
152
|
+
sample.sub(/[\xC0-\xFF][\x80-\xBF]*\z/n, '')
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def first_line(sample)
|
|
156
|
+
line_end = sample.index("\n")
|
|
157
|
+
line_end ? sample.byteslice(0, line_end + 1) : sample
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def wide_encoding?
|
|
161
|
+
WIDE_ENCODINGS.key?(byte_order_mark)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def encoding_option
|
|
165
|
+
WIDE_ENCODINGS.fetch(byte_order_mark) { encoding == 'UTF-8' ? EncodingOptions::DEFAULT_ENCODING : "#{encoding}:UTF-8" }
|
|
117
166
|
end
|
|
118
167
|
|
|
119
168
|
def header_parse_options
|
data/lib/csv_utils/csv_report.rb
CHANGED
|
@@ -15,13 +15,14 @@ module CSVUtils
|
|
|
15
15
|
|
|
16
16
|
# @param csv [String, CSV, #<<] path to write, opened in 'wb' mode by default, or a CSV to append to
|
|
17
17
|
# @param headers [Array<String>, Class, nil] header row, or a {CSVRow} class to take the headers from
|
|
18
|
-
# @param csv_options [Hash] options passed to CSV.open for a path; :mode overrides the file mode
|
|
18
|
+
# @param csv_options [Hash] options passed to CSV.open for a path; :mode overrides the file mode and
|
|
19
|
+
# :encoding, UTF-8 by default, is the encoding of the file written
|
|
19
20
|
# @yieldparam report [CSVReport] when given, the block is passed to {#generate}
|
|
20
21
|
def initialize(csv, headers = nil, csv_options = {}, &block)
|
|
21
22
|
@csv =
|
|
22
23
|
if csv.is_a?(String)
|
|
23
24
|
@must_close = true
|
|
24
|
-
opts = csv_options
|
|
25
|
+
opts = EncodingOptions.write(csv_options)
|
|
25
26
|
mode = opts.delete(:mode) || 'wb'
|
|
26
27
|
CSV.open(csv, mode, **opts)
|
|
27
28
|
else
|
data/lib/csv_utils/csv_sort.rb
CHANGED
|
@@ -23,7 +23,8 @@ module CSVUtils
|
|
|
23
23
|
# @param csv_file [String] path of the file to sort
|
|
24
24
|
# @param new_csv_file [String] path of the sorted file to write
|
|
25
25
|
# @param has_headers [Boolean] whether the first row is a header row
|
|
26
|
-
# @param csv_options [Hash] options passed to CSV.open
|
|
26
|
+
# @param csv_options [Hash] options passed to CSV.open; the file is read with its :encoding, 'bom|utf-8' by
|
|
27
|
+
# default, and the sorted file written in the encoding values were decoded to (see {EncodingOptions})
|
|
27
28
|
def initialize(csv_file, new_csv_file, has_headers = true, csv_options = {})
|
|
28
29
|
@csv_file = csv_file
|
|
29
30
|
@new_csv_file = new_csv_file
|
|
@@ -51,11 +52,12 @@ module CSVUtils
|
|
|
51
52
|
|
|
52
53
|
# rubocop:disable-next Metrics/MethodLength
|
|
53
54
|
def merge_sort_csv_files(src_csv_file1, src_csv_file2, dest_csv_file)
|
|
54
|
-
|
|
55
|
+
# part files were written with the write options, so reading them back with those changes nothing
|
|
56
|
+
src1 = CSV.open(src_csv_file1, 'rb', **write_options)
|
|
55
57
|
begin
|
|
56
|
-
src2 = CSV.open(src_csv_file2, 'rb', **
|
|
58
|
+
src2 = CSV.open(src_csv_file2, 'rb', **write_options)
|
|
57
59
|
begin
|
|
58
|
-
dest = CSV.open(dest_csv_file, 'wb', **
|
|
60
|
+
dest = CSV.open(dest_csv_file, 'wb', **write_options)
|
|
59
61
|
begin
|
|
60
62
|
if @headers
|
|
61
63
|
dest << @headers
|
|
@@ -99,7 +101,7 @@ module CSVUtils
|
|
|
99
101
|
end
|
|
100
102
|
|
|
101
103
|
def create_sorted_csv_part_files(batch_size, &block)
|
|
102
|
-
src = CSV.open(csv_file, 'rb', **csv_options)
|
|
104
|
+
src = CSV.open(csv_file, 'rb', **EncodingOptions.read(csv_options))
|
|
103
105
|
begin
|
|
104
106
|
@headers = src.shift if has_headers
|
|
105
107
|
|
|
@@ -107,7 +109,7 @@ module CSVUtils
|
|
|
107
109
|
create_batch_part_proc = proc do
|
|
108
110
|
batch.sort!(&block)
|
|
109
111
|
@csv_part_files << "#{new_csv_file}.part.#{@csv_part_files.size}"
|
|
110
|
-
CSV.open(@csv_part_files.last, 'wb', **
|
|
112
|
+
CSV.open(@csv_part_files.last, 'wb', **write_options) do |csv|
|
|
111
113
|
csv << @headers if @headers
|
|
112
114
|
batch.each { |row| csv << row }
|
|
113
115
|
end
|
|
@@ -143,10 +145,21 @@ module CSVUtils
|
|
|
143
145
|
if @csv_part_files.size.positive?
|
|
144
146
|
FileUtils.mv(@csv_part_files.pop, new_csv_file)
|
|
145
147
|
else
|
|
146
|
-
|
|
148
|
+
write_headers_only
|
|
147
149
|
end
|
|
148
150
|
end
|
|
149
151
|
|
|
152
|
+
# a file without rows is written rather than copied, so it's in the same encoding as a sorted one
|
|
153
|
+
def write_headers_only
|
|
154
|
+
CSV.open(new_csv_file, 'wb', **write_options) do |csv|
|
|
155
|
+
csv << @headers if @headers
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def write_options
|
|
160
|
+
EncodingOptions.write(csv_options)
|
|
161
|
+
end
|
|
162
|
+
|
|
150
163
|
# removes the temporary files left behind when sorting fails part way through
|
|
151
164
|
def delete_csv_part_files
|
|
152
165
|
@csv_part_files.each { |file| FileUtils.rm_f(file) }
|
|
@@ -19,10 +19,11 @@ module CSVUtils
|
|
|
19
19
|
|
|
20
20
|
# @param src_csv [String, CSV, #shift] path or CSV to read; paths are opened and closed by the transformer
|
|
21
21
|
# @param dest_csv [String, CSV, #<<] path or CSV to write
|
|
22
|
-
# @param csv_options [Hash] options passed to CSV.open for paths
|
|
22
|
+
# @param csv_options [Hash] options passed to CSV.open for paths; the source is read with its :encoding,
|
|
23
|
+
# 'bom|utf-8' by default, and the output written in the encoding values were decoded to (see {EncodingOptions})
|
|
23
24
|
def initialize(src_csv, dest_csv, csv_options = {})
|
|
24
|
-
@src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
|
|
25
|
-
@dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
|
|
25
|
+
@src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', EncodingOptions.read(csv_options))
|
|
26
|
+
@dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', EncodingOptions.write(csv_options))
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# Reads the first row as the headers. Without it, every row is data and the output has no header row.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CSVUtils
|
|
4
|
+
# The one place the library decides how files are decoded and encoded. Every class reads with the
|
|
5
|
+
# :encoding CSV option, 'bom|utf-8' by default, so values are UTF-8 strings whatever the locale, and
|
|
6
|
+
# writes in the encoding the values were decoded to, so reading 'Windows-1252:UTF-8' writes UTF-8.
|
|
7
|
+
# File modes never carry an encoding: CSV raises when the mode and the :encoding option both have one.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# CSV.open(path, 'rb', **CSVUtils::EncodingOptions.read(col_sep: "\t"))
|
|
11
|
+
module EncodingOptions
|
|
12
|
+
# @return [String] the :encoding option used for reading when none is given
|
|
13
|
+
DEFAULT_ENCODING = 'bom|utf-8'
|
|
14
|
+
|
|
15
|
+
# Options for reading: the given options with the default :encoding when they have none.
|
|
16
|
+
# @param csv_options [Hash]
|
|
17
|
+
# @return [Hash]
|
|
18
|
+
def self.read(csv_options)
|
|
19
|
+
{ encoding: DEFAULT_ENCODING }.merge(csv_options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Options for writing values read with csv_options: the :encoding is the one values were decoded to.
|
|
23
|
+
# @param csv_options [Hash]
|
|
24
|
+
# @return [Hash]
|
|
25
|
+
def self.write(csv_options)
|
|
26
|
+
csv_options.merge(encoding: decoded_encoding(read(csv_options)[:encoding]))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The encoding of strings read with an :encoding option: the internal encoding of 'external:internal',
|
|
30
|
+
# otherwise the external one, without a 'bom|' prefix.
|
|
31
|
+
# @example
|
|
32
|
+
# CSVUtils::EncodingOptions.decoded_encoding('Windows-1252:UTF-8') # => 'UTF-8'
|
|
33
|
+
# CSVUtils::EncodingOptions.decoded_encoding('bom|utf-8') # => 'utf-8'
|
|
34
|
+
# @param encoding [String, Encoding]
|
|
35
|
+
# @return [String]
|
|
36
|
+
def self.decoded_encoding(encoding)
|
|
37
|
+
encoding.to_s.split(':').last.sub(/\Abom\|/i, '')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/csv_utils/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: csv-utils
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Doug Youch
|
|
@@ -76,6 +76,7 @@ files:
|
|
|
76
76
|
- bin/csv-validator
|
|
77
77
|
- lib/csv-utils.rb
|
|
78
78
|
- lib/csv_utils/byte_order_mark.rb
|
|
79
|
+
- lib/csv_utils/character_encoding.rb
|
|
79
80
|
- lib/csv_utils/csv_compare.rb
|
|
80
81
|
- lib/csv_utils/csv_extender.rb
|
|
81
82
|
- lib/csv_utils/csv_iterator.rb
|
|
@@ -86,6 +87,7 @@ files:
|
|
|
86
87
|
- lib/csv_utils/csv_sort.rb
|
|
87
88
|
- lib/csv_utils/csv_transformer.rb
|
|
88
89
|
- lib/csv_utils/csv_wrapper.rb
|
|
90
|
+
- lib/csv_utils/encoding_options.rb
|
|
89
91
|
- lib/csv_utils/version.rb
|
|
90
92
|
homepage: https://github.com/dougyouch/csv-utils
|
|
91
93
|
licenses:
|