csv-utils 0.7.0 → 0.8.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 +15 -0
- data/README.md +38 -6
- data/bin/csv-change-eol +11 -10
- data/bin/csv-diff +33 -21
- data/bin/csv-duplicate-finder +7 -8
- data/bin/csv-explorer +2 -1
- data/bin/csv-find-error +12 -11
- data/bin/csv-grep +2 -1
- data/bin/csv-readline +64 -58
- data/bin/csv-splitter +23 -35
- data/bin/csv-validator +12 -14
- data/lib/csv-utils.rb +5 -0
- data/lib/csv_utils/character_encoding.rb +58 -8
- data/lib/csv_utils/csv_compare.rb +37 -21
- data/lib/csv_utils/csv_iterator.rb +22 -31
- data/lib/csv_utils/csv_options.rb +57 -28
- data/lib/csv_utils/csv_sort.rb +97 -97
- data/lib/csv_utils/csv_wrapper.rb +10 -0
- data/lib/csv_utils/error.rb +8 -0
- data/lib/csv_utils/header_not_found_error.rb +21 -0
- data/lib/csv_utils/malformed_row_error.rb +20 -0
- data/lib/csv_utils/row_reader.rb +51 -0
- data/lib/csv_utils/unsorted_file_error.rb +21 -0
- data/lib/csv_utils/version.rb +1 -1
- metadata +6 -1
data/bin/csv-validator
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'csv'
|
|
4
5
|
begin
|
|
5
6
|
require 'rchardet'
|
|
6
7
|
rescue LoadError
|
|
7
|
-
|
|
8
|
+
warn 'gem install rchardet'
|
|
8
9
|
exit 1
|
|
9
10
|
end
|
|
10
11
|
|
|
12
|
+
# checks copies, so the row's values keep their bytes for the messages and the correction file
|
|
11
13
|
def utf8?(str)
|
|
12
|
-
str
|
|
13
|
-
.force_encoding('utf-8')
|
|
14
|
-
.valid_encoding?
|
|
14
|
+
str.dup.force_encoding('utf-8').valid_encoding?
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def convert_to_utf8(str, current_encoding)
|
|
18
18
|
return nil unless current_encoding
|
|
19
19
|
|
|
20
|
-
str.force_encoding(current_encoding)
|
|
21
|
-
return nil unless
|
|
20
|
+
converted = str.dup.force_encoding(current_encoding)
|
|
21
|
+
return nil unless converted.valid_encoding?
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
converted.encode('utf-8')
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def detect_encoding(col)
|
|
@@ -28,7 +28,7 @@ def detect_encoding(col)
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def strip_bom!(col)
|
|
31
|
-
col.sub!("\xEF\xBB\xBF".
|
|
31
|
+
col.sub!("\xEF\xBB\xBF".b, '')
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
id_column_name = ARGV[1]
|
|
@@ -42,7 +42,7 @@ begin
|
|
|
42
42
|
|
|
43
43
|
id_column_name ||= headers[0]
|
|
44
44
|
unless headers.include?(id_column_name)
|
|
45
|
-
|
|
45
|
+
warn("header #{id_column_name} not found in current set of headers")
|
|
46
46
|
exit 1
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -64,20 +64,18 @@ begin
|
|
|
64
64
|
while (row = csv.shift)
|
|
65
65
|
csv_lineno += 1
|
|
66
66
|
|
|
67
|
-
unless row.size == headers.size
|
|
68
|
-
$stderr.puts "row(#{csv_lineno}): invalid number of columns, expected #{headers.size} got #{row.size}"
|
|
69
|
-
end
|
|
67
|
+
warn "row(#{csv_lineno}): invalid number of columns, expected #{headers.size} got #{row.size}" unless row.size == headers.size
|
|
70
68
|
|
|
71
69
|
row.each_with_index do |col, idx|
|
|
72
70
|
next if col.nil? || utf8?(col)
|
|
73
71
|
|
|
74
|
-
|
|
72
|
+
warn "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: non UTF-8 characters found in #{col.inspect}"
|
|
75
73
|
encoding = detect_encoding(col)
|
|
76
74
|
if (col_utf8_encoded = convert_to_utf8(col, encoding))
|
|
77
75
|
puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: converted to UTF-8 from #{encoding} \"#{col_utf8_encoded}\""
|
|
78
76
|
out_proc.call [row[id_column_num], csv_lineno, (idx + 1), headers[idx], col_utf8_encoded]
|
|
79
77
|
else
|
|
80
|
-
|
|
78
|
+
warn "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: unknown character encoding"
|
|
81
79
|
end
|
|
82
80
|
end
|
|
83
81
|
end
|
data/lib/csv-utils.rb
CHANGED
|
@@ -8,6 +8,11 @@ module CSVUtils
|
|
|
8
8
|
autoload :ByteOrderMark, 'csv_utils/byte_order_mark'
|
|
9
9
|
autoload :CharacterEncoding, 'csv_utils/character_encoding'
|
|
10
10
|
autoload :EncodingOptions, 'csv_utils/encoding_options'
|
|
11
|
+
autoload :Error, 'csv_utils/error'
|
|
12
|
+
autoload :HeaderNotFoundError, 'csv_utils/header_not_found_error'
|
|
13
|
+
autoload :MalformedRowError, 'csv_utils/malformed_row_error'
|
|
14
|
+
autoload :RowReader, 'csv_utils/row_reader'
|
|
15
|
+
autoload :UnsortedFileError, 'csv_utils/unsorted_file_error'
|
|
11
16
|
autoload :CSVCompare, 'csv_utils/csv_compare'
|
|
12
17
|
autoload :CSVExtender, 'csv_utils/csv_extender'
|
|
13
18
|
autoload :CSVIterator, 'csv_utils/csv_iterator'
|
|
@@ -1,21 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CSVUtils
|
|
4
|
-
# Guesses the character encoding of a file without a byte order mark from
|
|
4
|
+
# Guesses the character encoding of a file without a byte order mark from its bytes.
|
|
5
5
|
module CharacterEncoding
|
|
6
6
|
# Bytes Windows-1252 leaves undefined; converting them to UTF-8 raises.
|
|
7
7
|
# @return [Regexp]
|
|
8
8
|
WINDOWS_1252_UNDEFINED = /[\x81\x8D\x8F\x90\x9D]/n
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
|
|
10
|
+
# The last character of a chunk when it's multibyte, whole or cut off: a lead byte and continuation bytes.
|
|
11
|
+
TRAILING_MULTIBYTE_CHAR = /[\xC0-\xFF][\x80-\xBF]*\z/n
|
|
12
|
+
private_constant :TRAILING_MULTIBYTE_CHAR
|
|
13
|
+
|
|
14
|
+
# 'UTF-8' when the bytes are valid UTF-8. Otherwise 'Windows-1252', the usual encoding of Excel exports,
|
|
15
|
+
# or 'ISO-8859-1' when they have a byte Windows-1252 leaves undefined, since every ISO-8859-1 byte
|
|
12
16
|
# converts to UTF-8.
|
|
13
|
-
# @param sample [String] bytes from the start of the file
|
|
17
|
+
# @param sample [String] bytes from the start of the file
|
|
18
|
+
# @param truncated [Boolean] whether the sample was cut off, so its last character may be incomplete
|
|
19
|
+
# @return [String]
|
|
20
|
+
def self.detect(sample, truncated: false)
|
|
21
|
+
scan([sample], truncated)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Like {.detect}, for a file read in chunks, so it can check a whole file without holding it in memory.
|
|
25
|
+
# A character split between chunks is checked whole.
|
|
26
|
+
# @param chunks [Enumerable<String>] the file's bytes in order
|
|
27
|
+
# @return [String]
|
|
28
|
+
def self.detect_stream(chunks)
|
|
29
|
+
scan(chunks, false)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @api private
|
|
33
|
+
# @param chunks [Enumerable<String>]
|
|
34
|
+
# @param truncated [Boolean] whether to ignore an incomplete character at the end
|
|
35
|
+
# @return [String]
|
|
36
|
+
def self.scan(chunks, truncated)
|
|
37
|
+
utf8 = true
|
|
38
|
+
undefined = false
|
|
39
|
+
carry = ''.b
|
|
40
|
+
chunks.each do |chunk|
|
|
41
|
+
bytes = carry + chunk.b
|
|
42
|
+
whole, carry = split_trailing_char(bytes)
|
|
43
|
+
utf8 &&= whole.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
44
|
+
undefined ||= bytes.match?(WINDOWS_1252_UNDEFINED)
|
|
45
|
+
break unless utf8 || !undefined
|
|
46
|
+
end
|
|
47
|
+
utf8 &&= truncated || carry.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
48
|
+
encoding_name(utf8, undefined)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @api private
|
|
52
|
+
# @param bytes [String] binary
|
|
53
|
+
# @return [Array(String, String)] the bytes before their last multibyte character, and that character
|
|
54
|
+
def self.split_trailing_char(bytes)
|
|
55
|
+
tail_start = [bytes.bytesize - 4, 0].max
|
|
56
|
+
idx = bytes.byteslice(tail_start..) =~ TRAILING_MULTIBYTE_CHAR
|
|
57
|
+
return [bytes.dup, ''.b] unless idx
|
|
58
|
+
|
|
59
|
+
[bytes.byteslice(0, tail_start + idx), bytes.byteslice((tail_start + idx)..)]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @api private
|
|
63
|
+
# @param utf8 [Boolean]
|
|
64
|
+
# @param undefined [Boolean]
|
|
14
65
|
# @return [String]
|
|
15
|
-
def self.
|
|
16
|
-
|
|
17
|
-
return '
|
|
18
|
-
return 'ISO-8859-1' if bytes.match?(WINDOWS_1252_UNDEFINED)
|
|
66
|
+
def self.encoding_name(utf8, undefined)
|
|
67
|
+
return 'UTF-8' if utf8
|
|
68
|
+
return 'ISO-8859-1' if undefined
|
|
19
69
|
|
|
20
70
|
'Windows-1252'
|
|
21
71
|
end
|
|
@@ -37,15 +37,21 @@ module CSVUtils
|
|
|
37
37
|
|
|
38
38
|
# Walks both files and yields each difference.
|
|
39
39
|
# @param secondary_data_file [String] path of the file to bring in line with the primary file
|
|
40
|
+
# @param check_order [Boolean] raise when a file isn't sorted the way the compare block expects, by comparing
|
|
41
|
+
# each record with the one before it in the same file. A file is only checked when the block compares its
|
|
42
|
+
# first record equal to itself, so a block written for different headers in each file skips the check.
|
|
40
43
|
# @yieldparam action [Symbol] :create (only in the primary file), :update (in both, with changed
|
|
41
44
|
# update_comparison_columns) or :delete (only in the secondary file)
|
|
42
|
-
# @yieldparam record [
|
|
43
|
-
#
|
|
45
|
+
# @yieldparam record [CSVIterator::RowWrapper] the primary record for :create and :update, the secondary
|
|
46
|
+
# record for :delete; a Hash of header to value that knows the line it starts on
|
|
44
47
|
# @return [void]
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
# @raise [HeaderNotFoundError] when an update_comparison_column isn't in one of the files
|
|
49
|
+
# @raise [UnsortedFileError] when check_order finds a record out of order
|
|
50
|
+
def compare(secondary_data_file, check_order: true, &)
|
|
51
|
+
@check_order = check_order
|
|
52
|
+
open_csv(primary_data_file) do |src|
|
|
53
|
+
open_csv(secondary_data_file) do |dest|
|
|
54
|
+
compare_records(src, dest, &)
|
|
49
55
|
end
|
|
50
56
|
end
|
|
51
57
|
end
|
|
@@ -53,22 +59,22 @@ module CSVUtils
|
|
|
53
59
|
private
|
|
54
60
|
|
|
55
61
|
# walks both sorted files at once, advancing whichever side is behind
|
|
56
|
-
def compare_records(src,
|
|
57
|
-
src_record = next_record(
|
|
58
|
-
dest_record = next_record(
|
|
62
|
+
def compare_records(src, dest)
|
|
63
|
+
src_record = next_record(src)
|
|
64
|
+
dest_record = next_record(dest)
|
|
59
65
|
|
|
60
66
|
while src_record || dest_record
|
|
61
67
|
case record_action(src_record, dest_record)
|
|
62
68
|
when :create
|
|
63
69
|
yield :create, src_record
|
|
64
|
-
src_record = next_record(
|
|
70
|
+
src_record = next_record(src, src_record)
|
|
65
71
|
when :delete
|
|
66
72
|
yield :delete, dest_record
|
|
67
|
-
dest_record = next_record(
|
|
73
|
+
dest_record = next_record(dest, dest_record)
|
|
68
74
|
else
|
|
69
75
|
yield :update, src_record if update_row?(src_record, dest_record)
|
|
70
|
-
src_record = next_record(
|
|
71
|
-
dest_record = next_record(
|
|
76
|
+
src_record = next_record(src, src_record)
|
|
77
|
+
dest_record = next_record(dest, dest_record)
|
|
72
78
|
end
|
|
73
79
|
end
|
|
74
80
|
end
|
|
@@ -87,23 +93,33 @@ module CSVUtils
|
|
|
87
93
|
end
|
|
88
94
|
end
|
|
89
95
|
|
|
96
|
+
# yields the file's path, reader, headers and whether its order is checked, decided at its first record
|
|
90
97
|
def open_csv(file)
|
|
91
98
|
csv = CSV.open(file, 'rb', **EncodingOptions.read(csv_options))
|
|
92
|
-
|
|
99
|
+
reader = RowReader.new(csv)
|
|
100
|
+
headers = reader.shift || []
|
|
101
|
+
check_update_comparison_columns(headers)
|
|
102
|
+
yield({ file: file, reader: reader, headers: headers, check_order: nil })
|
|
93
103
|
ensure
|
|
94
104
|
csv&.close
|
|
95
105
|
end
|
|
96
106
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
headers
|
|
107
|
+
def check_update_comparison_columns(headers)
|
|
108
|
+
missing = (update_comparison_columns || []) - headers
|
|
109
|
+
raise HeaderNotFoundError.new(missing.first, headers) unless missing.empty?
|
|
101
110
|
end
|
|
102
111
|
|
|
103
|
-
def next_record(
|
|
104
|
-
return unless (row =
|
|
112
|
+
def next_record(source, previous_record = nil)
|
|
113
|
+
return unless (row = source[:reader].shift)
|
|
105
114
|
|
|
106
|
-
|
|
115
|
+
record = CSVIterator::RowWrapper.create(source[:headers], row, source[:reader].lineno)
|
|
116
|
+
source[:check_order] = @check_order && compare_proc.call(record, record).zero? if source[:check_order].nil?
|
|
117
|
+
check_order(source[:file], previous_record, record) if previous_record && source[:check_order]
|
|
118
|
+
record
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def check_order(file, previous_record, record)
|
|
122
|
+
raise UnsortedFileError.new(file, record.lineno) if compare_proc.call(previous_record, record).positive?
|
|
107
123
|
end
|
|
108
124
|
|
|
109
125
|
def update_row?(src_record, dest_record)
|
|
@@ -13,12 +13,10 @@ module CSVUtils
|
|
|
13
13
|
# @return [Array<String>] the byte order marks stripped from the first header, as binary strings
|
|
14
14
|
BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS.keys.freeze
|
|
15
15
|
|
|
16
|
-
# @return [Array<String>, nil] the raw row read before the current one, for debugging malformed rows
|
|
17
|
-
attr_reader :prev_row
|
|
18
|
-
|
|
19
16
|
# A row as a hash of header to value that knows its line number in the file.
|
|
20
17
|
class RowWrapper < Hash
|
|
21
|
-
# @return [Integer] line
|
|
18
|
+
# @return [Integer] line the row starts on in the file, counting the header row as line 1. A quoted value
|
|
19
|
+
# with a line break spans several lines, so this can differ from the row's position in the file.
|
|
22
20
|
attr_accessor :lineno
|
|
23
21
|
|
|
24
22
|
# @param headers [Array<String>]
|
|
@@ -52,9 +50,10 @@ module CSVUtils
|
|
|
52
50
|
# @example
|
|
53
51
|
# CSVUtils::CSVIterator.auto_detect('export.tsv').each { |row| puts row['name'] }
|
|
54
52
|
# @param path [String]
|
|
53
|
+
# @param full_scan [Boolean] check the encoding of the whole file, not only its first megabyte
|
|
55
54
|
# @return [CSVIterator]
|
|
56
|
-
def self.auto_detect(path)
|
|
57
|
-
options = CSVOptions.new(path)
|
|
55
|
+
def self.auto_detect(path, full_scan: false)
|
|
56
|
+
options = CSVOptions.new(path, full_scan: full_scan)
|
|
58
57
|
new(path, options.to_csv_options)
|
|
59
58
|
end
|
|
60
59
|
|
|
@@ -68,10 +67,19 @@ module CSVUtils
|
|
|
68
67
|
@csv_options = EncodingOptions.read(csv_options)
|
|
69
68
|
end
|
|
70
69
|
|
|
70
|
+
# The raw row read before the current one (the header row for the first row), for debugging;
|
|
71
|
+
# after a full pass it's the last row.
|
|
72
|
+
# @return [Array<String>, nil]
|
|
73
|
+
def prev_row
|
|
74
|
+
@reader&.prev_row
|
|
75
|
+
end
|
|
76
|
+
|
|
71
77
|
# Yields each row from the start of the file.
|
|
72
78
|
# @param headers [Array<String>, nil] headers to use; by default the first row is read as the headers
|
|
73
79
|
# @yieldparam row [RowWrapper]
|
|
74
80
|
# @return [Enumerator, nil] an enumerator without a block
|
|
81
|
+
# @raise [MalformedRowError] for a row CSV can't parse, with the line it starts on and {#prev_row}
|
|
82
|
+
# @raise [CSV::InvalidEncodingError] for bytes that don't match the encoding, with their line
|
|
75
83
|
def each(headers = nil, &)
|
|
76
84
|
return enum_for(:each, headers) unless block_given?
|
|
77
85
|
|
|
@@ -89,11 +97,11 @@ module CSVUtils
|
|
|
89
97
|
# @param value [String, nil] header whose value is the hash value
|
|
90
98
|
# @yieldparam row [RowWrapper] used instead of value to compute the hash value
|
|
91
99
|
# @return [Hash]
|
|
92
|
-
# @raise [
|
|
100
|
+
# @raise [HeaderNotFoundError] when key or value isn't a header
|
|
93
101
|
def to_hash(key, value = nil, &)
|
|
94
102
|
file_headers = headers
|
|
95
|
-
raise(
|
|
96
|
-
raise(
|
|
103
|
+
raise HeaderNotFoundError.new(key, file_headers) unless file_headers.include?(key)
|
|
104
|
+
raise HeaderNotFoundError.new(value, file_headers) if value && !file_headers.include?(value)
|
|
97
105
|
|
|
98
106
|
value_proc =
|
|
99
107
|
if value
|
|
@@ -152,33 +160,16 @@ module CSVUtils
|
|
|
152
160
|
end
|
|
153
161
|
|
|
154
162
|
def each_row(csv, headers)
|
|
155
|
-
@
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
163
|
+
@reader = RowReader.new(csv)
|
|
164
|
+
headers ||= @reader.shift || []
|
|
165
|
+
while (row = @reader.shift)
|
|
166
|
+
yield RowWrapper.create(headers, row, @reader.lineno)
|
|
169
167
|
end
|
|
170
168
|
end
|
|
171
169
|
|
|
172
170
|
# an empty file has no headers, and an empty first header cell is nil
|
|
173
171
|
def read_headers(csv)
|
|
174
|
-
|
|
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
|
|
172
|
+
RowReader.new(csv).shift || []
|
|
182
173
|
end
|
|
183
174
|
end
|
|
184
175
|
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module CSVUtils
|
|
4
|
-
# Detects the separators, byte order mark, encoding and column count of a CSV file from its
|
|
4
|
+
# Detects the separators, byte order mark, encoding and column count of a CSV file from its header row
|
|
5
|
+
# and, for the encoding, its first {SAMPLE_SIZE} bytes or, with full_scan, all of them.
|
|
5
6
|
#
|
|
6
7
|
# @example
|
|
7
8
|
# options = CSVUtils::CSVOptions.new('data.csv')
|
|
@@ -10,12 +11,14 @@ module CSVUtils
|
|
|
10
11
|
# @return [Hash{String => String}] byte order marks, as binary strings, to the encoding they indicate
|
|
11
12
|
BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS
|
|
12
13
|
|
|
13
|
-
# @return [Array<String>] column separators to look for
|
|
14
|
+
# @return [Array<String>] column separators to look for; the one found most often outside quotes in the
|
|
15
|
+
# header row wins, and on a tie the one listed first
|
|
14
16
|
COL_SEPARATORS = [
|
|
15
17
|
"\x02",
|
|
16
18
|
"\t",
|
|
17
19
|
'|',
|
|
18
|
-
','
|
|
20
|
+
',',
|
|
21
|
+
';'
|
|
19
22
|
].freeze
|
|
20
23
|
|
|
21
24
|
# @return [Array<String>] row separators to look for, the first one found in the line wins
|
|
@@ -37,12 +40,19 @@ module CSVUtils
|
|
|
37
40
|
# @return [Integer] bytes read to detect the encoding of a file without a byte order mark
|
|
38
41
|
SAMPLE_SIZE = 1024 * 1024
|
|
39
42
|
|
|
43
|
+
QUOTED = /"[^"]*"/n
|
|
44
|
+
# the header row: up to the first line break outside quotes
|
|
45
|
+
HEADER_ROW = /\A(?:[^"\r\n]|"[^"]*")*(?:\r\n|\n|\r)?/n
|
|
46
|
+
LINE_BREAK = /\r\n|\n|\r/n
|
|
47
|
+
private_constant :QUOTED, :HEADER_ROW, :LINE_BREAK
|
|
48
|
+
|
|
40
49
|
# @return [Integer, nil] number of headers, nil without a column separator
|
|
41
50
|
attr_reader :columns
|
|
42
51
|
# @return [String, nil] the byte order mark the file starts with, as a binary string
|
|
43
52
|
attr_reader :byte_order_mark
|
|
44
|
-
# @return [String] 'UTF-8', 'UTF-16' or 'UTF-32' from the byte order mark; without one, from the
|
|
45
|
-
#
|
|
53
|
+
# @return [String] 'UTF-8', 'UTF-16' or 'UTF-32' from the byte order mark; without one, from the first
|
|
54
|
+
# {SAMPLE_SIZE} bytes, or all of them with full_scan: 'UTF-8', 'Windows-1252' or 'ISO-8859-1'
|
|
55
|
+
# (see {CharacterEncoding.detect})
|
|
46
56
|
attr_reader :encoding
|
|
47
57
|
# @return [String, nil] one of {COL_SEPARATORS}
|
|
48
58
|
attr_reader :col_separator
|
|
@@ -50,16 +60,20 @@ module CSVUtils
|
|
|
50
60
|
attr_reader :row_separator
|
|
51
61
|
|
|
52
62
|
# @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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
# bytes are read from it, or all of it with full_scan
|
|
64
|
+
# @param full_scan [Boolean] check the encoding of the whole file, in {SAMPLE_SIZE} chunks, for files whose
|
|
65
|
+
# first bytes that aren't UTF-8 may come after the sample
|
|
66
|
+
def initialize(io, full_scan: false)
|
|
67
|
+
open_io(io) do |input|
|
|
68
|
+
sample = input.read(SAMPLE_SIZE) || ''
|
|
69
|
+
line = first_line(sample)
|
|
70
|
+
|
|
71
|
+
@col_separator = auto_detect_col_sep(line)
|
|
72
|
+
@row_separator = auto_detect_row_sep(line)
|
|
73
|
+
@byte_order_mark = get_byte_order_mark(line)
|
|
74
|
+
@encoding = get_character_encoding(@byte_order_mark, sample, full_scan ? input : nil)
|
|
75
|
+
@columns = get_number_of_columns(line) if @col_separator
|
|
76
|
+
end
|
|
63
77
|
end
|
|
64
78
|
|
|
65
79
|
# Whether both separators were found. An empty file, or a single line without a newline, isn't valid.
|
|
@@ -86,10 +100,14 @@ module CSVUtils
|
|
|
86
100
|
end
|
|
87
101
|
|
|
88
102
|
# @api private
|
|
89
|
-
# @param line [String]
|
|
90
|
-
# @return [String, nil]
|
|
103
|
+
# @param line [String] the header row
|
|
104
|
+
# @return [String, nil] the separator found most often outside quotes, nil when there's none
|
|
91
105
|
def auto_detect_col_sep(line)
|
|
92
|
-
|
|
106
|
+
unquoted = line.b.gsub(QUOTED, '')
|
|
107
|
+
counts = COL_SEPARATORS.map { |sep| unquoted.count(sep) }
|
|
108
|
+
return if counts.max.zero?
|
|
109
|
+
|
|
110
|
+
COL_SEPARATORS[counts.index(counts.max)]
|
|
93
111
|
end
|
|
94
112
|
|
|
95
113
|
# @api private
|
|
@@ -127,9 +145,13 @@ module CSVUtils
|
|
|
127
145
|
# @api private
|
|
128
146
|
# @param bom [String, nil]
|
|
129
147
|
# @param sample [String] bytes from the start of the file
|
|
148
|
+
# @param rest [IO, nil] the rest of the file, to scan it all
|
|
130
149
|
# @return [String]
|
|
131
|
-
def get_character_encoding(bom, sample)
|
|
132
|
-
BYTE_ORDER_MARKS[bom]
|
|
150
|
+
def get_character_encoding(bom, sample, rest = nil)
|
|
151
|
+
return BYTE_ORDER_MARKS[bom] if bom
|
|
152
|
+
return CharacterEncoding.detect_stream(chunks(sample, rest)) if rest
|
|
153
|
+
|
|
154
|
+
CharacterEncoding.detect(sample, truncated: sample.bytesize == SAMPLE_SIZE)
|
|
133
155
|
end
|
|
134
156
|
|
|
135
157
|
# @api private
|
|
@@ -142,19 +164,26 @@ module CSVUtils
|
|
|
142
164
|
private
|
|
143
165
|
|
|
144
166
|
# an empty file reads as an empty sample, which makes the options invalid
|
|
145
|
-
def
|
|
146
|
-
|
|
147
|
-
sample.bytesize < SAMPLE_SIZE ? sample : drop_last_multibyte_char(sample)
|
|
167
|
+
def open_io(io, &)
|
|
168
|
+
io.is_a?(String) ? File.open(io, 'rb', &) : yield(io)
|
|
148
169
|
end
|
|
149
170
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
171
|
+
def chunks(sample, rest)
|
|
172
|
+
Enumerator.new do |chunks|
|
|
173
|
+
chunks << sample
|
|
174
|
+
while (chunk = rest.read(SAMPLE_SIZE))
|
|
175
|
+
chunks << chunk
|
|
176
|
+
end
|
|
177
|
+
end
|
|
153
178
|
end
|
|
154
179
|
|
|
180
|
+
# The header row, which a quoted line break doesn't end. A stray quote falls back to the first line.
|
|
155
181
|
def first_line(sample)
|
|
156
|
-
|
|
157
|
-
|
|
182
|
+
line = sample.b[HEADER_ROW]
|
|
183
|
+
return line if line.bytesize == sample.bytesize || line.end_with?("\n", "\r")
|
|
184
|
+
|
|
185
|
+
line_break = sample.b.match(LINE_BREAK)
|
|
186
|
+
line_break ? sample.b.byteslice(0, line_break.end(0)) : sample.b
|
|
158
187
|
end
|
|
159
188
|
|
|
160
189
|
def wide_encoding?
|