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.
@@ -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 first line.
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, the first one found in the line wins
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
@@ -25,26 +28,52 @@ module CSVUtils
25
28
  "\r"
26
29
  ].freeze
27
30
 
31
+ # @return [Hash{String => String}] UTF-16 and UTF-32 byte order marks, as binary strings, to the :encoding
32
+ # option that decodes them to UTF-8
33
+ WIDE_ENCODINGS = {
34
+ (+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-32BE:UTF-8',
35
+ (+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-32LE:UTF-8',
36
+ (+"\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-16BE:UTF-8',
37
+ (+"\xFF\xFE").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-16LE:UTF-8'
38
+ }.freeze
39
+
40
+ # @return [Integer] bytes read to detect the encoding of a file without a byte order mark
41
+ SAMPLE_SIZE = 1024 * 1024
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
+
28
49
  # @return [Integer, nil] number of headers, nil without a column separator
29
50
  attr_reader :columns
30
51
  # @return [String, nil] the byte order mark the file starts with, as a binary string
31
52
  attr_reader :byte_order_mark
32
- # @return [String] 'UTF-8', 'UTF-16' or 'UTF-32', from the byte order mark; 'UTF-8' without one
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})
33
56
  attr_reader :encoding
34
57
  # @return [String, nil] one of {COL_SEPARATORS}
35
58
  attr_reader :col_separator
36
59
  # @return [String, nil] one of {ROW_SEPARATORS}
37
60
  attr_reader :row_separator
38
61
 
39
- # @param io [String, IO] path of the file, or an IO positioned at its first line
40
- def initialize(io)
41
- line = read_first_line(io)
42
-
43
- @col_separator = auto_detect_col_sep(line)
44
- @row_separator = auto_detect_row_sep(line)
45
- @byte_order_mark = get_byte_order_mark(line)
46
- @encoding = get_character_encoding(@byte_order_mark)
47
- @columns = get_number_of_columns(line) if @col_separator
62
+ # @param io [String, IO] path of the file, or an IO positioned at its first line; up to {SAMPLE_SIZE}
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
48
77
  end
49
78
 
50
79
  # Whether both separators were found. An empty file, or a single line without a newline, isn't valid.
@@ -55,11 +84,30 @@ module CSVUtils
55
84
  true
56
85
  end
57
86
 
87
+ # Options for CSV.open, or any class in this library, with the detected separators and an :encoding that
88
+ # reads values as UTF-8 strings: 'bom|utf-8', 'Windows-1252:UTF-8', 'BOM|UTF-16LE:UTF-8' and so on.
89
+ # A separator that wasn't detected is left to CSV, and so is the row separator of UTF-16 and UTF-32 files,
90
+ # since it's detected from raw bytes.
91
+ # @example
92
+ # CSV.open(path, 'rb', **options.to_csv_options)
93
+ # CSVUtils::CSVSort.new(path, 'sorted.csv', true, options.to_csv_options).sort
94
+ # @return [Hash{Symbol => String}]
95
+ def to_csv_options
96
+ options = { encoding: encoding_option }
97
+ options[:col_sep] = col_separator if col_separator
98
+ options[:row_sep] = row_separator if row_separator && !wide_encoding?
99
+ options
100
+ end
101
+
58
102
  # @api private
59
- # @param line [String]
60
- # @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
61
105
  def auto_detect_col_sep(line)
62
- COL_SEPARATORS.detect { |sep| line.include?(sep) }
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)]
63
111
  end
64
112
 
65
113
  # @api private
@@ -96,9 +144,14 @@ module CSVUtils
96
144
 
97
145
  # @api private
98
146
  # @param bom [String, nil]
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
99
149
  # @return [String]
100
- def get_character_encoding(bom)
101
- BYTE_ORDER_MARKS[bom] || 'UTF-8'
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)
102
155
  end
103
156
 
104
157
  # @api private
@@ -110,10 +163,35 @@ module CSVUtils
110
163
 
111
164
  private
112
165
 
113
- # an empty file reads as an empty line, which makes the options invalid
114
- def read_first_line(io)
115
- line = io.is_a?(String) ? File.open(io, 'rb', &:gets) : io.gets
116
- line || ''
166
+ # an empty file reads as an empty sample, which makes the options invalid
167
+ def open_io(io, &)
168
+ io.is_a?(String) ? File.open(io, 'rb', &) : yield(io)
169
+ end
170
+
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
178
+ end
179
+
180
+ # The header row, which a quoted line break doesn't end. A stray quote falls back to the first line.
181
+ def first_line(sample)
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
187
+ end
188
+
189
+ def wide_encoding?
190
+ WIDE_ENCODINGS.key?(byte_order_mark)
191
+ end
192
+
193
+ def encoding_option
194
+ WIDE_ENCODINGS.fetch(byte_order_mark) { encoding == 'UTF-8' ? EncodingOptions::DEFAULT_ENCODING : "#{encoding}:UTF-8" }
117
195
  end
118
196
 
119
197
  def header_parse_options
@@ -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.dup
25
+ opts = EncodingOptions.write(csv_options)
25
26
  mode = opts.delete(:mode) || 'wb'
26
27
  CSV.open(csv, mode, **opts)
27
28
  else
@@ -4,11 +4,20 @@ require 'fileutils'
4
4
 
5
5
  module CSVUtils
6
6
  # Sorts a CSV file too large to load into memory with an external merge sort: batches of rows are
7
- # sorted into temporary part files next to the new file, which are then merged in pairs.
7
+ # sorted into temporary files, which are then merged up to {MERGE_WIDTH} at a time, so most files
8
+ # are sorted with a single merge pass.
8
9
  #
9
10
  # @example
10
11
  # CSVUtils::CSVSort.new('input.csv', 'sorted.csv').sort { |a, b| a[0].to_i <=> b[0].to_i }
12
+ # CSVUtils::CSVSort.new('input.csv', 'sorted.csv').sort_by { |row| row[0].to_i }
11
13
  class CSVSort
14
+ # @return [Integer] temporary files merged at once, which bounds the files open at the same time
15
+ MERGE_WIDTH = 64
16
+
17
+ ROW_KEY = proc { |row| row }
18
+ KEY_COMPARE = proc { |key1, key2| key1 <=> key2 }
19
+ private_constant :ROW_KEY, :KEY_COMPARE
20
+
12
21
  # @return [String] path of the file to sort
13
22
  attr_reader :csv_file
14
23
  # @return [String] path of the sorted file to write
@@ -23,134 +32,138 @@ module CSVUtils
23
32
  # @param csv_file [String] path of the file to sort
24
33
  # @param new_csv_file [String] path of the sorted file to write
25
34
  # @param has_headers [Boolean] whether the first row is a header row
26
- # @param csv_options [Hash] options passed to CSV.open
35
+ # @param csv_options [Hash] options passed to CSV.open; the file is read with its :encoding, 'bom|utf-8' by
36
+ # default, and the sorted file written in the encoding values were decoded to (see {EncodingOptions})
27
37
  def initialize(csv_file, new_csv_file, has_headers = true, csv_options = {})
28
38
  @csv_file = csv_file
29
39
  @new_csv_file = new_csv_file
30
40
  @has_headers = has_headers
31
41
  @csv_options = csv_options
32
- @csv_part_files = []
42
+ @temp_files = []
33
43
  end
34
44
 
35
- # Writes the sorted file. Temporary files are removed even when sorting fails.
45
+ # Writes the file sorted by comparing rows. Temporary files are removed even when sorting fails.
36
46
  # @param batch_size [Integer] rows held in memory and sorted at a time
47
+ # @param tmp_dir [String, nil] directory for the temporary files; the sorted file's directory by default
37
48
  # @yieldparam row1 [Array<String>]
38
49
  # @yieldparam row2 [Array<String>]
39
50
  # @yieldreturn [Integer] negative, 0 or positive like <=>; without a block rows are compared as arrays
40
51
  # @return [void]
41
- def sort(batch_size = 100_000, &block)
42
- block ||= proc { |row1, row2| row1 <=> row2 }
43
- @csv_part_files = []
44
- create_sorted_csv_part_files(batch_size, &block)
45
- merge_csv_part_files(&block)
46
- ensure
47
- delete_csv_part_files
52
+ def sort(batch_size = 100_000, tmp_dir: nil, &block)
53
+ compare = block || KEY_COMPARE
54
+ run_sort(batch_size, tmp_dir, ROW_KEY, compare) { |batch| batch.sort!(&compare) }
55
+ end
56
+
57
+ # Writes the file sorted by a key computed once per row, which is faster than {#sort} when
58
+ # the comparison would otherwise convert values (ex: to_i) on every comparison.
59
+ # @param batch_size [Integer] rows held in memory and sorted at a time
60
+ # @param tmp_dir [String, nil] directory for the temporary files; the sorted file's directory by default
61
+ # @yieldparam row [Array<String>]
62
+ # @yieldreturn [Comparable] the sort key; keys are compared with <=>, so use arrays for several columns
63
+ # @return [void]
64
+ def sort_by(batch_size = 100_000, tmp_dir: nil, &key)
65
+ run_sort(batch_size, tmp_dir, key, KEY_COMPARE) { |batch| batch.sort_by!(&key) }
48
66
  end
49
67
 
50
68
  private
51
69
 
52
- # rubocop:disable-next Metrics/MethodLength
53
- def merge_sort_csv_files(src_csv_file1, src_csv_file2, dest_csv_file)
54
- src1 = CSV.open(src_csv_file1, 'rb', **csv_options)
55
- begin
56
- src2 = CSV.open(src_csv_file2, 'rb', **csv_options)
57
- begin
58
- dest = CSV.open(dest_csv_file, 'wb', **csv_options)
59
- begin
60
- if @headers
61
- dest << @headers
62
- src1.shift
63
- src2.shift
64
- end
65
-
66
- row1 = src1.shift
67
- row2 = src2.shift
68
-
69
- append_row1_proc = proc do
70
- dest << row1
71
- row1 = src1.shift
72
- end
73
-
74
- append_row2_proc = proc do
75
- dest << row2
76
- row2 = src2.shift
77
- end
78
-
79
- while row1 || row2
80
- if row1.nil?
81
- append_row2_proc.call
82
- elsif row2.nil?
83
- append_row1_proc.call
84
- elsif yield(row1, row2) <= 0
85
- append_row1_proc.call
86
- else
87
- append_row2_proc.call
88
- end
89
- end
90
- ensure
91
- dest.close
92
- end
93
- ensure
94
- src2.close
70
+ def run_sort(batch_size, tmp_dir, key, compare, &)
71
+ @tmp_dir = tmp_dir || File.dirname(new_csv_file)
72
+ @temp_files = []
73
+ part_files = create_sorted_part_files(batch_size, &)
74
+ part_files = merge_pass(part_files, key, compare) while part_files.size > 1
75
+ write_sorted_file(part_files.first)
76
+ ensure
77
+ delete_temp_files
78
+ end
79
+
80
+ def create_sorted_part_files(batch_size, &sort_batch)
81
+ CSV.open(csv_file, 'rb', **EncodingOptions.read(csv_options)) do |src|
82
+ @headers = (src.shift if has_headers)
83
+ src.each_slice(batch_size).map do |batch|
84
+ sorted = sort_batch.call(batch)
85
+ write_temp_file { |csv| sorted.each { |row| csv << row } }
95
86
  end
96
- ensure
97
- src1.close
98
87
  end
99
88
  end
100
89
 
101
- def create_sorted_csv_part_files(batch_size, &block)
102
- src = CSV.open(csv_file, 'rb', **csv_options)
103
- begin
104
- @headers = src.shift if has_headers
105
-
106
- batch = []
107
- create_batch_part_proc = proc do
108
- batch.sort!(&block)
109
- @csv_part_files << "#{new_csv_file}.part.#{@csv_part_files.size}"
110
- CSV.open(@csv_part_files.last, 'wb', **csv_options) do |csv|
111
- csv << @headers if @headers
112
- batch.each { |row| csv << row }
113
- end
114
- batch = []
115
- end
90
+ # merges consecutive groups of files, so each pass divides the number of files by MERGE_WIDTH
91
+ def merge_pass(files, key, compare)
92
+ files.each_slice(MERGE_WIDTH).map do |group|
93
+ next group.first if group.size == 1
94
+
95
+ merged = write_temp_file { |dest| merge_files(group, dest, key, compare) }
96
+ group.each { |file| File.unlink(file) }
97
+ merged
98
+ end
99
+ end
116
100
 
117
- while (row = src.shift)
118
- batch << row
119
- create_batch_part_proc.call if batch.size >= batch_size
101
+ # keeps the next row of every file in a queue sorted by key, so each row written is the smallest left
102
+ def merge_files(files, dest, key, compare)
103
+ open_temp_files(files) do |sources|
104
+ queue = []
105
+ sources.each_with_index do |src, idx|
106
+ src.shift if @headers
107
+ enqueue(queue, src.shift, idx, key, compare)
120
108
  end
121
109
 
122
- create_batch_part_proc.call if batch.size.positive?
123
- ensure
124
- src.close
110
+ until queue.empty?
111
+ _, row, idx = queue.shift
112
+ dest << row
113
+ enqueue(queue, sources[idx].shift, idx, key, compare)
114
+ end
125
115
  end
126
116
  end
127
117
 
128
- def merge_csv_part_files(&)
129
- file_merge_cnt = 0
118
+ # rows with equal keys keep the order of the files they came from
119
+ def enqueue(queue, row, idx, key, compare)
120
+ return unless row
130
121
 
131
- while @csv_part_files.size > 1
132
- file_merge_cnt += 1
133
-
134
- # inputs stay in the list until merged, so a failed merge still cleans them up
135
- csv_part_file1, csv_part_file2 = @csv_part_files.first(2)
136
- @csv_part_files << "#{new_csv_file}.merge.#{file_merge_cnt}"
122
+ value = key.call(row)
123
+ pos = queue.bsearch_index do |other_value, _, other_idx|
124
+ result = compare.call(value, other_value)
125
+ result.negative? || (result.zero? && idx < other_idx)
126
+ end
127
+ queue.insert(pos || queue.size, [value, row, idx])
128
+ end
137
129
 
138
- merge_sort_csv_files(csv_part_file1, csv_part_file2, @csv_part_files.last, &)
130
+ def open_temp_files(files)
131
+ sources = []
132
+ # not map: sources has to hold the files opened before one fails to open, so ensure closes them
133
+ # rubocop:disable-next Style/MapIntoArray
134
+ files.each { |file| sources << CSV.open(file, 'rb', **write_options) }
135
+ yield sources
136
+ ensure
137
+ sources.each(&:close)
138
+ end
139
139
 
140
- @csv_part_files.shift(2).each { |file| File.unlink(file) }
140
+ # temporary files are written with the write options, so reading them back with those changes nothing
141
+ def write_temp_file
142
+ @temp_files << File.join(@tmp_dir, "#{File.basename(new_csv_file)}.#{@temp_files.size}.tmp")
143
+ CSV.open(@temp_files.last, 'wb', **write_options) do |csv|
144
+ csv << @headers if @headers
145
+ yield csv
141
146
  end
147
+ @temp_files.last
148
+ end
142
149
 
143
- if @csv_part_files.size.positive?
144
- FileUtils.mv(@csv_part_files.pop, new_csv_file)
145
- else
146
- FileUtils.cp(@csv_file, new_csv_file)
150
+ # a file without rows is written rather than copied, so it's in the same encoding as a sorted one
151
+ def write_sorted_file(sorted_file)
152
+ return FileUtils.mv(sorted_file, new_csv_file) if sorted_file
153
+
154
+ CSV.open(new_csv_file, 'wb', **write_options) do |csv|
155
+ csv << @headers if @headers
147
156
  end
148
157
  end
149
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
- def delete_csv_part_files
152
- @csv_part_files.each { |file| FileUtils.rm_f(file) }
153
- @csv_part_files = []
164
+ def delete_temp_files
165
+ @temp_files.each { |file| FileUtils.rm_f(file) }
166
+ @temp_files = []
154
167
  end
155
168
  end
156
169
  end
@@ -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.
@@ -54,6 +54,16 @@ module CSVUtils
54
54
  csv.shift
55
55
  end
56
56
 
57
+ # @return [String, nil] the raw text of the last row read, with its row separator
58
+ def line
59
+ csv.line
60
+ end
61
+
62
+ # @return [String] the row separator, detected from the file when it was :auto
63
+ def row_sep
64
+ csv.row_sep
65
+ end
66
+
57
67
  # Moves back to the first row.
58
68
  # @return [void]
59
69
  def rewind
@@ -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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Included in every exception this library raises, so `rescue CSVUtils::Error` catches all of them.
5
+ # Each exception also subclasses the error Ruby or CSV would raise, so existing rescues keep working.
6
+ module Error
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Raised when a header the caller named isn't in the file. A RuntimeError, as before this class existed.
5
+ class HeaderNotFoundError < RuntimeError
6
+ include Error
7
+
8
+ # @return [String] the header that wasn't found
9
+ attr_reader :header
10
+ # @return [Array<String>] the headers of the file
11
+ attr_reader :headers
12
+
13
+ # @param header [String]
14
+ # @param headers [Array<String>]
15
+ def initialize(header, headers)
16
+ @header = header
17
+ @headers = headers
18
+ super("header #{header} not found in #{headers}")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # A CSV::MalformedCSVError with the physical line the bad row starts on, rather than CSV's row count,
5
+ # and the row read before it.
6
+ class MalformedRowError < CSV::MalformedCSVError
7
+ include Error
8
+
9
+ # @return [Array<String>, nil] the last row read successfully
10
+ attr_reader :prev_row
11
+
12
+ # @param message [String] CSV's message without its "in line N." suffix
13
+ # @param line_number [Integer] physical line the bad row starts on
14
+ # @param prev_row [Array<String>, nil]
15
+ def initialize(message, line_number, prev_row)
16
+ @prev_row = prev_row
17
+ super(message, line_number)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Reads rows from a CSV positioned at its first line, keeping the physical line each row starts on.
5
+ # CSV counts rows, so a quoted value with a line break would make its line numbers fall behind.
6
+ # The byte order mark is stripped from the first row, and rows CSV can't parse raise {MalformedRowError}.
7
+ #
8
+ # @example
9
+ # reader = CSVUtils::RowReader.new(csv)
10
+ # headers = reader.shift
11
+ # while (row = reader.shift)
12
+ # puts "#{reader.lineno}: #{row.inspect}"
13
+ # end
14
+ class RowReader
15
+ # @return [Integer, nil] line the last row read starts on, counting the first line as 1
16
+ attr_reader :lineno
17
+ # @return [Array<String>, nil] the row read before the last one
18
+ attr_reader :prev_row
19
+
20
+ # @param csv [CSV, CSVWrapper] positioned at the first line
21
+ def initialize(csv)
22
+ @csv = csv
23
+ @last_line = 0
24
+ @row = nil
25
+ @prev_row = nil
26
+ end
27
+
28
+ # @return [Array<String>, nil] the next row, nil at the end
29
+ # @raise [MalformedRowError] for a row CSV can't parse, with the line it starts on
30
+ # @raise [CSV::InvalidEncodingError] for bytes that don't match the encoding; CSV raises it when it buffers
31
+ # them, ahead of the rows before them, with the right line already
32
+ def shift
33
+ row = @csv.shift
34
+ @lineno = @last_line + 1
35
+ count_lines(row) if row
36
+ @prev_row = @row
37
+ @row = row
38
+ rescue CSV::InvalidEncodingError
39
+ raise
40
+ rescue CSV::MalformedCSVError => e
41
+ raise MalformedRowError.new(e.message.delete_suffix(" in line #{e.line_number}."), @last_line + 1, @row)
42
+ end
43
+
44
+ private
45
+
46
+ def count_lines(row)
47
+ row[0] = ByteOrderMark.strip(row[0]) if @last_line.zero? && row[0]
48
+ @last_line += @csv.line.count(@csv.row_sep[-1])
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Raised by {CSVCompare} when a file isn't sorted the way its compare block expects.
5
+ class UnsortedFileError < RuntimeError
6
+ include Error
7
+
8
+ # @return [String] path of the file
9
+ attr_reader :file
10
+ # @return [Integer] physical line of the first record out of order
11
+ attr_reader :lineno
12
+
13
+ # @param file [String]
14
+ # @param lineno [Integer]
15
+ def initialize(file, lineno)
16
+ @file = file
17
+ @lineno = lineno
18
+ super("#{file} isn't sorted: the record on line #{lineno} sorts before the one above it")
19
+ end
20
+ end
21
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CSVUtils
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.6.0'
5
+ VERSION = '0.8.0'
6
6
  end