csv-utils 0.6.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 +33 -0
- data/README.md +65 -11
- 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 +7 -0
- data/lib/csv_utils/character_encoding.rb +73 -0
- data/lib/csv_utils/csv_compare.rb +39 -23
- data/lib/csv_utils/csv_extender.rb +4 -3
- data/lib/csv_utils/csv_iterator.rb +70 -39
- data/lib/csv_utils/csv_options.rb +100 -22
- data/lib/csv_utils/csv_report.rb +3 -2
- data/lib/csv_utils/csv_sort.rb +107 -94
- data/lib/csv_utils/csv_transformer.rb +4 -3
- data/lib/csv_utils/csv_wrapper.rb +10 -0
- data/lib/csv_utils/encoding_options.rb +40 -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 +8 -1
data/bin/csv-splitter
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require 'optparse'
|
|
4
5
|
|
|
@@ -37,47 +38,34 @@ def get_split_file_name(file, num_files, total_files)
|
|
|
37
38
|
end
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
csv_options = CSVUtils::CSVOptions.new(file)
|
|
41
|
+
csv_options = CSVUtils::CSVOptions.new(file).to_csv_options.merge(liberal_parsing: true)
|
|
41
42
|
# a single column file has no separator to detect
|
|
42
|
-
col_sep
|
|
43
|
+
csv_options[:col_sep] ||= ','
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
liberal_parsing: true
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
headers = options[:no_header] ? nil : csv.shift
|
|
52
|
-
|
|
53
|
-
total_rows = 0
|
|
54
|
-
total_rows += 1 while csv.shift
|
|
45
|
+
# one pass: parts are written under temporary names, then renamed once the number of parts is known
|
|
46
|
+
part_files = []
|
|
47
|
+
out = nil
|
|
48
|
+
num_rows = 0
|
|
55
49
|
|
|
56
|
-
csv
|
|
57
|
-
csv.shift
|
|
50
|
+
CSV.open(file, 'rb', **CSVUtils::EncodingOptions.read(csv_options)) do |csv|
|
|
51
|
+
headers = options[:no_header] ? nil : csv.shift
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
while (row = csv.shift)
|
|
54
|
+
if out.nil? || num_rows >= options[:rows]
|
|
55
|
+
out&.close
|
|
56
|
+
num_rows = 0
|
|
57
|
+
part_files << "#{file}.part-#{part_files.size + 1}.tmp"
|
|
58
|
+
out = CSV.open(part_files.last, 'wb', **CSVUtils::EncodingOptions.write(csv_options))
|
|
59
|
+
out << headers if headers
|
|
60
|
+
end
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
out = nil
|
|
65
|
-
|
|
66
|
-
append_row_proc = proc do |row|
|
|
67
|
-
if out.nil? || num_rows >= options[:rows]
|
|
68
|
-
out.close if out
|
|
69
|
-
num_rows = 0
|
|
70
|
-
num_files += 1
|
|
71
|
-
out = CSV.open(get_split_file_name(file, num_files, total_files), 'wb', col_sep: col_sep)
|
|
72
|
-
out << headers if headers
|
|
62
|
+
out << row
|
|
63
|
+
num_rows += 1
|
|
73
64
|
end
|
|
74
|
-
|
|
75
|
-
out
|
|
76
|
-
num_rows += 1
|
|
65
|
+
ensure
|
|
66
|
+
out&.close
|
|
77
67
|
end
|
|
78
68
|
|
|
79
|
-
|
|
80
|
-
|
|
69
|
+
part_files.each_with_index do |part_file, idx|
|
|
70
|
+
File.rename(part_file, get_split_file_name(file, idx + 1, part_files.size))
|
|
81
71
|
end
|
|
82
|
-
|
|
83
|
-
out&.close
|
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
|
@@ -6,6 +6,13 @@ 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'
|
|
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'
|
|
9
16
|
autoload :CSVCompare, 'csv_utils/csv_compare'
|
|
10
17
|
autoload :CSVExtender, 'csv_utils/csv_extender'
|
|
11
18
|
autoload :CSVIterator, 'csv_utils/csv_iterator'
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CSVUtils
|
|
4
|
+
# Guesses the character encoding of a file without a byte order mark from 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
|
+
# 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
|
|
16
|
+
# converts to UTF-8.
|
|
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]
|
|
65
|
+
# @return [String]
|
|
66
|
+
def self.encoding_name(utf8, undefined)
|
|
67
|
+
return 'UTF-8' if utf8
|
|
68
|
+
return 'ISO-8859-1' if undefined
|
|
69
|
+
|
|
70
|
+
'Windows-1252'
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
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
|
|
@@ -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
|
-
csv = CSV.open(file, 'rb', **csv_options)
|
|
92
|
-
|
|
98
|
+
csv = CSV.open(file, 'rb', **EncodingOptions.read(csv_options))
|
|
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)
|
|
@@ -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.
|
|
@@ -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>]
|
|
@@ -26,8 +24,15 @@ module CSVUtils
|
|
|
26
24
|
# @param lineno [Integer]
|
|
27
25
|
# @return [RowWrapper]
|
|
28
26
|
def self.create(headers, row, lineno)
|
|
29
|
-
row_wrapper =
|
|
27
|
+
row_wrapper = new
|
|
30
28
|
row_wrapper.lineno = lineno
|
|
29
|
+
# an index loop instead of headers.zip(row), which allocates an array per column for every row
|
|
30
|
+
idx = 0
|
|
31
|
+
size = headers.size
|
|
32
|
+
while idx < size
|
|
33
|
+
row_wrapper[headers[idx]] = row[idx]
|
|
34
|
+
idx += 1
|
|
35
|
+
end
|
|
31
36
|
row_wrapper
|
|
32
37
|
end
|
|
33
38
|
|
|
@@ -41,41 +46,50 @@ module CSVUtils
|
|
|
41
46
|
end
|
|
42
47
|
end
|
|
43
48
|
|
|
44
|
-
#
|
|
45
|
-
# @
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
# An iterator for a path, with the separators and encoding detected by {CSVOptions}.
|
|
50
|
+
# @example
|
|
51
|
+
# CSVUtils::CSVIterator.auto_detect('export.tsv').each { |row| puts row['name'] }
|
|
52
|
+
# @param path [String]
|
|
53
|
+
# @param full_scan [Boolean] check the encoding of the whole file, not only its first megabyte
|
|
54
|
+
# @return [CSVIterator]
|
|
55
|
+
def self.auto_detect(path, full_scan: false)
|
|
56
|
+
options = CSVOptions.new(path, full_scan: full_scan)
|
|
57
|
+
new(path, options.to_csv_options)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A path is opened by each method call and closed when the call returns,
|
|
61
|
+
# so an idle iterator holds no file handle.
|
|
62
|
+
# @param src_csv [String, CSV] path or CSV to read; a CSV must support rewind and is never closed
|
|
63
|
+
# @param csv_options [Hash] options passed to CSV.open for a path. The :encoding defaults to 'bom|utf-8'
|
|
64
|
+
# (see {EncodingOptions}); 'BINARY' reads bytes as is and 'Windows-1252:UTF-8' converts.
|
|
65
|
+
def initialize(src_csv, csv_options = {})
|
|
66
|
+
@src_csv = src_csv
|
|
67
|
+
@csv_options = EncodingOptions.read(csv_options)
|
|
68
|
+
end
|
|
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
|
|
49
75
|
end
|
|
50
76
|
|
|
51
77
|
# Yields each row from the start of the file.
|
|
52
78
|
# @param headers [Array<String>, nil] headers to use; by default the first row is read as the headers
|
|
53
79
|
# @yieldparam row [RowWrapper]
|
|
54
80
|
# @return [Enumerator, nil] an enumerator without a block
|
|
55
|
-
|
|
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
|
|
83
|
+
def each(headers = nil, &)
|
|
56
84
|
return enum_for(:each, headers) unless block_given?
|
|
57
85
|
|
|
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
|
|
86
|
+
open_csv { |csv| each_row(csv, headers, &) }
|
|
72
87
|
end
|
|
73
88
|
|
|
74
89
|
# The first row, without the byte order mark.
|
|
75
90
|
# @return [Array<String>] empty for an empty file
|
|
76
91
|
def headers
|
|
77
|
-
|
|
78
|
-
read_headers
|
|
92
|
+
open_csv { |csv| read_headers(csv) }
|
|
79
93
|
end
|
|
80
94
|
|
|
81
95
|
# Builds a lookup hash from every row.
|
|
@@ -83,10 +97,11 @@ module CSVUtils
|
|
|
83
97
|
# @param value [String, nil] header whose value is the hash value
|
|
84
98
|
# @yieldparam row [RowWrapper] used instead of value to compute the hash value
|
|
85
99
|
# @return [Hash]
|
|
86
|
-
# @raise [
|
|
100
|
+
# @raise [HeaderNotFoundError] when key or value isn't a header
|
|
87
101
|
def to_hash(key, value = nil, &)
|
|
88
|
-
|
|
89
|
-
raise(
|
|
102
|
+
file_headers = headers
|
|
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)
|
|
90
105
|
|
|
91
106
|
value_proc =
|
|
92
107
|
if value
|
|
@@ -103,11 +118,12 @@ module CSVUtils
|
|
|
103
118
|
# Number of rows after the header row.
|
|
104
119
|
# @return [Integer]
|
|
105
120
|
def size
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
121
|
+
open_csv do |csv|
|
|
122
|
+
csv.shift
|
|
123
|
+
cnt = 0
|
|
124
|
+
cnt += 1 while csv.shift
|
|
125
|
+
cnt
|
|
126
|
+
end
|
|
111
127
|
end
|
|
112
128
|
|
|
113
129
|
# Yields the rows in batches.
|
|
@@ -134,11 +150,26 @@ module CSVUtils
|
|
|
134
150
|
|
|
135
151
|
private
|
|
136
152
|
|
|
153
|
+
# Opens a path for the duration of the block. Only a CSV that was passed in is rewound:
|
|
154
|
+
# rewinding goes back to byte 0, in front of a byte order mark the 'bom|' encoding skipped on open.
|
|
155
|
+
def open_csv
|
|
156
|
+
CSVWrapper.open(@src_csv, 'rb', @csv_options) do |csv|
|
|
157
|
+
csv.rewind unless @src_csv.is_a?(String)
|
|
158
|
+
yield csv
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def each_row(csv, headers)
|
|
163
|
+
@reader = RowReader.new(csv)
|
|
164
|
+
headers ||= @reader.shift || []
|
|
165
|
+
while (row = @reader.shift)
|
|
166
|
+
yield RowWrapper.create(headers, row, @reader.lineno)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
137
170
|
# an empty file has no headers, and an empty first header cell is nil
|
|
138
|
-
def read_headers
|
|
139
|
-
|
|
140
|
-
headers[0] = ByteOrderMark.strip(headers[0]) if headers[0]
|
|
141
|
-
headers
|
|
171
|
+
def read_headers(csv)
|
|
172
|
+
RowReader.new(csv).shift || []
|
|
142
173
|
end
|
|
143
174
|
end
|
|
144
175
|
end
|