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.
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 = csv_options.col_separator || ','
43
+ csv_options[:col_sep] ||= ','
43
44
 
44
- csv = CSV.open(
45
- file,
46
- 'rb',
47
- col_sep: col_sep,
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.rewind
57
- csv.shift if headers
50
+ CSV.open(file, 'rb', **CSVUtils::EncodingOptions.read(csv_options)) do |csv|
51
+ headers = options[:no_header] ? nil : csv.shift
58
52
 
59
- total_files = total_rows / options[:rows]
60
- total_files += 1 if (total_rows % options[:rows]) > 0
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
- num_rows = 0
63
- num_files = 0
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 << row
76
- num_rows += 1
65
+ ensure
66
+ out&.close
77
67
  end
78
68
 
79
- while (row = csv.shift)
80
- append_row_proc.call(row)
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
- $stderr.puts 'gem install rchardet'
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 str.valid_encoding?
20
+ converted = str.dup.force_encoding(current_encoding)
21
+ return nil unless converted.valid_encoding?
22
22
 
23
- str.encode('utf-8')
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".force_encoding('ASCII-8BIT'), '')
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
- $stderr.puts("header #{id_column_name} not found in current set of headers")
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
- $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: non UTF-8 characters found in #{col.inspect}"
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
- $stderr.puts "row(#{csv_lineno}),col(#{idx + 1}) #{headers[idx]}: unknown character encoding"
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 [Hash{String => String}] the primary record for :create and :update,
43
- # the secondary record for :delete
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
- def compare(secondary_data_file, &)
46
- open_csv(primary_data_file) do |src, src_headers|
47
- open_csv(secondary_data_file) do |dest, dest_headers|
48
- compare_records(src, src_headers, dest, dest_headers, &)
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, src_headers, dest, dest_headers)
57
- src_record = next_record(src_headers, src)
58
- dest_record = next_record(dest_headers, dest)
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(src_headers, src)
70
+ src_record = next_record(src, src_record)
65
71
  when :delete
66
72
  yield :delete, dest_record
67
- dest_record = next_record(dest_headers, dest)
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(src_headers, src)
71
- dest_record = next_record(dest_headers, dest)
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
- yield csv, read_headers(csv)
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 read_headers(csv)
98
- headers = csv.shift || []
99
- headers[0] = ByteOrderMark.strip(headers[0]) if headers[0]
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(headers, csv)
104
- return unless (row = csv.shift)
112
+ def next_record(source, previous_record = nil)
113
+ return unless (row = source[:reader].shift)
105
114
 
106
- headers.zip(row).to_h
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 number of the row in the file, counting the header row as line 1
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 = RowWrapper[headers.zip(row)]
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
- # @param src_csv [String, CSV] path or CSV to read; it must support rewind
45
- # @param csv_options [Hash] options passed to CSV.open for a path
46
- # @param mode [String] file mode for a path; 'rb:BINARY' reads bytes as is
47
- def initialize(src_csv, csv_options = {}, mode = 'rb')
48
- @src_csv = CSVUtils::CSVWrapper.new(src_csv, mode, csv_options)
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
- def each(headers = nil)
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
- @src_csv.rewind
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
- @src_csv.rewind
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 [RuntimeError] when key or value isn't a header
100
+ # @raise [HeaderNotFoundError] when key or value isn't a header
87
101
  def to_hash(key, value = nil, &)
88
- raise("header #{key} not found in #{headers}") unless headers.include?(key)
89
- raise("headers #{value} not found in #{headers}") if value && !headers.include?(value)
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
- @src_csv.rewind
107
- @src_csv.shift
108
- cnt = 0
109
- cnt += 1 while @src_csv.shift
110
- cnt
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
- headers = @src_csv.shift || []
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