csv-utils 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Auto detect a csv files options
4
3
  module CSVUtils
4
+ # Detects the separators, byte order mark, encoding and column count of a CSV file from its first line.
5
+ #
6
+ # @example
7
+ # options = CSVUtils::CSVOptions.new('data.csv')
8
+ # CSV.open('data.csv', 'rb', col_sep: options.col_separator) if options.valid?
5
9
  class CSVOptions
6
- # this list is from https://en.wikipedia.org/wiki/Byte_order_mark
7
- BYTE_ORDER_MARKS = {
8
- (+"\xEF\xBB\xBF").force_encoding('ASCII-8BIT') => 'UTF-8',
9
- (+"\xFE\xFF").force_encoding('ASCII-8BIT') => 'UTF-16',
10
- (+"\xFF\xFE").force_encoding('ASCII-8BIT') => 'UTF-16',
11
- (+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT') => 'UTF-32',
12
- (+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT') => 'UTF-32'
13
- }.freeze
10
+ # @return [Hash{String => String}] byte order marks, as binary strings, to the encoding they indicate
11
+ BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS
14
12
 
13
+ # @return [Array<String>] column separators to look for, the first one found in the line wins
15
14
  COL_SEPARATORS = [
16
15
  "\x02",
17
16
  "\t",
@@ -19,69 +18,157 @@ module CSVUtils
19
18
  ','
20
19
  ].freeze
21
20
 
21
+ # @return [Array<String>] row separators to look for, the first one found in the line wins
22
22
  ROW_SEPARATORS = [
23
23
  "\r\n",
24
24
  "\n",
25
25
  "\r"
26
26
  ].freeze
27
27
 
28
- attr_reader :columns,
29
- :byte_order_mark,
30
- :encoding,
31
- :col_separator,
32
- :row_separator
28
+ # @return [Hash{String => String}] UTF-16 and UTF-32 byte order marks, as binary strings, to the :encoding
29
+ # option that decodes them to UTF-8
30
+ WIDE_ENCODINGS = {
31
+ (+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-32BE:UTF-8',
32
+ (+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-32LE:UTF-8',
33
+ (+"\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-16BE:UTF-8',
34
+ (+"\xFF\xFE").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-16LE:UTF-8'
35
+ }.freeze
36
+
37
+ # @return [Integer] bytes read to detect the encoding of a file without a byte order mark
38
+ SAMPLE_SIZE = 1024 * 1024
33
39
 
40
+ # @return [Integer, nil] number of headers, nil without a column separator
41
+ attr_reader :columns
42
+ # @return [String, nil] the byte order mark the file starts with, as a binary string
43
+ attr_reader :byte_order_mark
44
+ # @return [String] 'UTF-8', 'UTF-16' or 'UTF-32' from the byte order mark; without one, from the bytes of
45
+ # the first {SAMPLE_SIZE} bytes: 'UTF-8', 'Windows-1252' or 'ISO-8859-1' (see {CharacterEncoding.detect})
46
+ attr_reader :encoding
47
+ # @return [String, nil] one of {COL_SEPARATORS}
48
+ attr_reader :col_separator
49
+ # @return [String, nil] one of {ROW_SEPARATORS}
50
+ attr_reader :row_separator
51
+
52
+ # @param io [String, IO] path of the file, or an IO positioned at its first line; up to {SAMPLE_SIZE}
53
+ # bytes are read from it
34
54
  def initialize(io)
35
- line =
36
- if io.is_a?(String)
37
- File.open(io, 'rb', &:readline)
38
- else
39
- io.readline
40
- end
55
+ sample = read_sample(io)
56
+ line = first_line(sample)
41
57
 
42
58
  @col_separator = auto_detect_col_sep(line)
43
59
  @row_separator = auto_detect_row_sep(line)
44
60
  @byte_order_mark = get_byte_order_mark(line)
45
- @encoding = get_character_encoding(@byte_order_mark)
61
+ @encoding = get_character_encoding(@byte_order_mark, sample)
46
62
  @columns = get_number_of_columns(line) if @col_separator
47
63
  end
48
64
 
65
+ # Whether both separators were found. An empty file, or a single line without a newline, isn't valid.
66
+ # @return [Boolean]
49
67
  def valid?
50
68
  return false if @col_separator.nil? || @row_separator.nil?
51
69
 
52
70
  true
53
71
  end
54
72
 
73
+ # Options for CSV.open, or any class in this library, with the detected separators and an :encoding that
74
+ # reads values as UTF-8 strings: 'bom|utf-8', 'Windows-1252:UTF-8', 'BOM|UTF-16LE:UTF-8' and so on.
75
+ # A separator that wasn't detected is left to CSV, and so is the row separator of UTF-16 and UTF-32 files,
76
+ # since it's detected from raw bytes.
77
+ # @example
78
+ # CSV.open(path, 'rb', **options.to_csv_options)
79
+ # CSVUtils::CSVSort.new(path, 'sorted.csv', true, options.to_csv_options).sort
80
+ # @return [Hash{Symbol => String}]
81
+ def to_csv_options
82
+ options = { encoding: encoding_option }
83
+ options[:col_sep] = col_separator if col_separator
84
+ options[:row_sep] = row_separator if row_separator && !wide_encoding?
85
+ options
86
+ end
87
+
88
+ # @api private
89
+ # @param line [String]
90
+ # @return [String, nil]
55
91
  def auto_detect_col_sep(line)
56
92
  COL_SEPARATORS.detect { |sep| line.include?(sep) }
57
93
  end
58
94
 
95
+ # @api private
96
+ # @param line [String]
97
+ # @return [String, nil]
59
98
  def auto_detect_row_sep(line)
60
99
  ROW_SEPARATORS.detect { |sep| line.include?(sep) }
61
100
  end
62
101
 
102
+ # Parses the header line like a CSV row, so quoted headers can contain the separator,
103
+ # and falls back to a plain split when the line is malformed.
104
+ # @param line [String] the first line of the file
105
+ # @return [Array<String, nil>] headers without the byte order mark or row separator
63
106
  def get_headers(line)
64
- headers = line.split(col_separator)
65
- headers[0] = strip_byte_order_marks(headers[0])
66
- headers
107
+ line = strip_byte_order_marks(line)
108
+ CSV.parse_line(line, **header_parse_options) || []
109
+ rescue CSV::MalformedCSVError
110
+ line.chomp(row_separator.to_s).split(col_separator, -1)
67
111
  end
68
112
 
113
+ # @api private
114
+ # @param line [String]
115
+ # @return [Integer]
69
116
  def get_number_of_columns(line)
70
117
  get_headers(line).size
71
118
  end
72
119
 
120
+ # @api private
121
+ # @param line [String]
122
+ # @return [String, nil]
73
123
  def get_byte_order_mark(line)
74
- BYTE_ORDER_MARKS.keys.detect do |bom|
75
- line =~ /\A#{bom}/
76
- end
124
+ ByteOrderMark.detect(line)
77
125
  end
78
126
 
79
- def get_character_encoding(bom)
80
- BYTE_ORDER_MARKS[bom] || 'UTF-8'
127
+ # @api private
128
+ # @param bom [String, nil]
129
+ # @param sample [String] bytes from the start of the file
130
+ # @return [String]
131
+ def get_character_encoding(bom, sample)
132
+ BYTE_ORDER_MARKS[bom] || CharacterEncoding.detect(sample)
81
133
  end
82
134
 
135
+ # @api private
136
+ # @param header [String]
137
+ # @return [String]
83
138
  def strip_byte_order_marks(header)
84
- @byte_order_mark ? header.sub(@byte_order_mark, '') : header
139
+ ByteOrderMark.strip(header)
140
+ end
141
+
142
+ private
143
+
144
+ # an empty file reads as an empty sample, which makes the options invalid
145
+ def read_sample(io)
146
+ sample = (io.is_a?(String) ? File.open(io, 'rb') { |file| file.read(SAMPLE_SIZE) } : io.read(SAMPLE_SIZE)) || ''
147
+ sample.bytesize < SAMPLE_SIZE ? sample : drop_last_multibyte_char(sample)
148
+ end
149
+
150
+ # a full sample can end mid-character, so its last multibyte character, whole or not, is dropped
151
+ def drop_last_multibyte_char(sample)
152
+ sample.sub(/[\xC0-\xFF][\x80-\xBF]*\z/n, '')
153
+ end
154
+
155
+ def first_line(sample)
156
+ line_end = sample.index("\n")
157
+ line_end ? sample.byteslice(0, line_end + 1) : sample
158
+ end
159
+
160
+ def wide_encoding?
161
+ WIDE_ENCODINGS.key?(byte_order_mark)
162
+ end
163
+
164
+ def encoding_option
165
+ WIDE_ENCODINGS.fetch(byte_order_mark) { encoding == 'UTF-8' ? EncodingOptions::DEFAULT_ENCODING : "#{encoding}:UTF-8" }
166
+ end
167
+
168
+ def header_parse_options
169
+ options = { col_sep: col_separator }
170
+ options[:row_sep] = row_separator if row_separator
171
+ options
85
172
  end
86
173
  end
87
174
  end
@@ -1,16 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Builds a csv file from csv rows
4
3
  module CSVUtils
4
+ # Writes a CSV file from arrays or {CSVRow} objects.
5
+ #
6
+ # @example
7
+ # CSVUtils::CSVReport.new('users.csv', UserCSVRow) do |report|
8
+ # users.each { |user| report << user }
9
+ # end
5
10
  class CSVReport
6
- attr_reader :csv,
7
- :must_close
11
+ # @return [CSV, #<<] where rows are written
12
+ attr_reader :csv
13
+ # @return [Boolean] whether the report opened the file and closes it after {#generate}
14
+ attr_reader :must_close
8
15
 
16
+ # @param csv [String, CSV, #<<] path to write, opened in 'wb' mode by default, or a CSV to append to
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 and
19
+ # :encoding, UTF-8 by default, is the encoding of the file written
20
+ # @yieldparam report [CSVReport] when given, the block is passed to {#generate}
9
21
  def initialize(csv, headers = nil, csv_options = {}, &block)
10
22
  @csv =
11
23
  if csv.is_a?(String)
12
24
  @must_close = true
13
- opts = csv_options.dup
25
+ opts = EncodingOptions.write(csv_options)
14
26
  mode = opts.delete(:mode) || 'wb'
15
27
  CSV.open(csv, mode, **opts)
16
28
  else
@@ -23,11 +35,18 @@ module CSVUtils
23
35
  generate(&block) if block
24
36
  end
25
37
 
38
+ # Yields the report, then closes the file if the report opened it, even when the block raises.
39
+ # @yieldparam report [CSVReport]
40
+ # @return [void]
26
41
  def generate
27
42
  yield self
43
+ ensure
28
44
  close if @must_close
29
45
  end
30
46
 
47
+ # Writes a row.
48
+ # @param csv_row [Array, #to_a] an array of values or a {CSVRow} object
49
+ # @return [CSV, #<<]
31
50
  def append(csv_row)
32
51
  @csv <<
33
52
  if csv_row.is_a?(Array)
@@ -38,10 +57,15 @@ module CSVUtils
38
57
  end
39
58
  alias << append
40
59
 
60
+ # Writes the header row.
61
+ # @param csv_row [Array<String>, #csv_headers] headers, or a {CSVRow} class or object to take them from
62
+ # @return [CSV, #<<]
41
63
  def add_headers(csv_row)
42
64
  append(csv_row.is_a?(Array) ? csv_row : csv_row.csv_headers)
43
65
  end
44
66
 
67
+ # Closes the underlying CSV.
68
+ # @return [void]
45
69
  def close
46
70
  @csv.close
47
71
  end
@@ -3,18 +3,44 @@
3
3
  require 'inheritance-helper'
4
4
 
5
5
  module CSVUtils
6
+ # Mixin that declares how an object turns into a CSV row, for use with {CSVReport}.
7
+ # Columns are inherited by subclasses.
8
+ #
9
+ # @example
10
+ # class UserCSVRow < User
11
+ # include CSVUtils::CSVRow
12
+ #
13
+ # csv_column :id, header: 'ID'
14
+ # csv_column :name
15
+ # csv_column(:email) { email.downcase }
16
+ # end
6
17
  module CSVRow
18
+ # @api private
19
+ # @param base [Class]
20
+ # @return [void]
7
21
  def self.included(base)
8
22
  base.extend InheritanceHelper::Methods
9
23
  base.extend ClassMethods
10
24
  end
11
25
 
26
+ # Class level DSL for declaring columns.
12
27
  module ClassMethods
28
+ # Declared columns, keyed by name, in declaration order.
29
+ # @return [Hash{Symbol => Hash}]
13
30
  def csv_columns
14
31
  {}
15
32
  end
16
33
 
34
+ # Declares a column.
35
+ # @param header [Symbol, String] column name; also the default header and the method that provides the value
36
+ # @param options [Hash]
37
+ # @option options [String] :header header text
38
+ # @option options [Symbol] :method method that provides the value
39
+ # @option options [Proc] :proc evaluated on the object to provide the value
40
+ # @yield evaluated on the object to provide the value, like :proc
41
+ # @return [void]
17
42
  def csv_column(header, options = {}, &block)
43
+ options = options.dup
18
44
  options[:header] ||= header.to_s
19
45
 
20
46
  if block
@@ -26,6 +52,7 @@ module CSVUtils
26
52
  add_value_to_class_method(:csv_columns, header => options)
27
53
  end
28
54
 
55
+ # @return [Array<String>] the header of each column
29
56
  def csv_headers
30
57
  csv_columns.values.map { |column_options| csv_column_header(column_options) }
31
58
  end
@@ -37,11 +64,14 @@ module CSVUtils
37
64
  end
38
65
  end
39
66
 
67
+ # The value of each column.
68
+ # @return [Array]
40
69
  def csv_row
41
70
  self.class.csv_columns.values.map { |column_options| csv_column_value(column_options) }
42
71
  end
43
72
  alias to_a csv_row
44
73
 
74
+ # @return [Array<String>] the header of each column
45
75
  def csv_headers
46
76
  self.class.csv_headers
47
77
  end
@@ -1,15 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CSVUtils
4
+ # Matches rows (hashes of header to value) whose values match a regular expression.
5
+ #
6
+ # @example
7
+ # matcher = CSVUtils::CSVRowMatcher.new(/error/i, ['status', 'message'])
8
+ # CSVUtils::CSVIterator.new('logs.csv').select(&matcher)
4
9
  class CSVRowMatcher
5
- attr_accessor :regex,
6
- :columns
10
+ # @return [Regexp]
11
+ attr_accessor :regex
12
+ # @return [Array<String>, :all] headers to search, or :all for every value
13
+ attr_accessor :columns
7
14
 
15
+ # @param regex [Regexp]
16
+ # @param columns [Array<String>, :all] headers to search
8
17
  def initialize(regex, columns = :all)
9
18
  self.regex = regex
10
19
  self.columns = columns
11
20
  end
12
21
 
22
+ # Whether any searched value matches. Missing and nil values never match.
23
+ # @param row [Hash{String => String}]
24
+ # @return [Boolean]
13
25
  def match?(row)
14
26
  if columns == :all
15
27
  row.each_value do |value|
@@ -25,6 +37,8 @@ module CSVUtils
25
37
  false
26
38
  end
27
39
 
40
+ # Lets the matcher be passed to Enumerable methods as a block, as in rows.select(&matcher).
41
+ # @return [Proc]
28
42
  def to_proc
29
43
  proc do |row|
30
44
  match?(row)
@@ -2,38 +2,62 @@
2
2
 
3
3
  require 'fileutils'
4
4
 
5
- # Utility class for sorting the rows for a csv file
6
5
  module CSVUtils
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.
8
+ #
9
+ # @example
10
+ # CSVUtils::CSVSort.new('input.csv', 'sorted.csv').sort { |a, b| a[0].to_i <=> b[0].to_i }
7
11
  class CSVSort
8
- attr_reader :csv_file,
9
- :new_csv_file,
10
- :has_headers,
11
- :csv_options,
12
- :headers
13
-
12
+ # @return [String] path of the file to sort
13
+ attr_reader :csv_file
14
+ # @return [String] path of the sorted file to write
15
+ attr_reader :new_csv_file
16
+ # @return [Boolean] whether the first row is a header row, kept at the top
17
+ attr_reader :has_headers
18
+ # @return [Hash] options passed to CSV.open
19
+ attr_reader :csv_options
20
+ # @return [Array<String>, nil] the header row, once {#sort} has read it
21
+ attr_reader :headers
22
+
23
+ # @param csv_file [String] path of the file to sort
24
+ # @param new_csv_file [String] path of the sorted file to write
25
+ # @param has_headers [Boolean] whether the first row is a header row
26
+ # @param csv_options [Hash] options passed to CSV.open; the file is read with its :encoding, 'bom|utf-8' by
27
+ # default, and the sorted file written in the encoding values were decoded to (see {EncodingOptions})
14
28
  def initialize(csv_file, new_csv_file, has_headers = true, csv_options = {})
15
29
  @csv_file = csv_file
16
30
  @new_csv_file = new_csv_file
17
31
  @has_headers = has_headers
18
32
  @csv_options = csv_options
19
33
  @csv_part_files = []
20
- @files_to_delete = []
21
34
  end
22
35
 
23
- def sort(batch_size = 100_000, &)
24
- create_sorted_csv_part_files(batch_size, &)
25
- merge_csv_part_files(&)
36
+ # Writes the sorted file. Temporary files are removed even when sorting fails.
37
+ # @param batch_size [Integer] rows held in memory and sorted at a time
38
+ # @yieldparam row1 [Array<String>]
39
+ # @yieldparam row2 [Array<String>]
40
+ # @yieldreturn [Integer] negative, 0 or positive like <=>; without a block rows are compared as arrays
41
+ # @return [void]
42
+ def sort(batch_size = 100_000, &block)
43
+ block ||= proc { |row1, row2| row1 <=> row2 }
44
+ @csv_part_files = []
45
+ create_sorted_csv_part_files(batch_size, &block)
46
+ merge_csv_part_files(&block)
47
+ ensure
48
+ delete_csv_part_files
26
49
  end
27
50
 
28
51
  private
29
52
 
30
- # rubocop:disable Metrics/MethodLength
53
+ # rubocop:disable-next Metrics/MethodLength
31
54
  def merge_sort_csv_files(src_csv_file1, src_csv_file2, dest_csv_file)
32
- src1 = CSV.open(src_csv_file1, 'rb', **csv_options)
55
+ # part files were written with the write options, so reading them back with those changes nothing
56
+ src1 = CSV.open(src_csv_file1, 'rb', **write_options)
33
57
  begin
34
- src2 = CSV.open(src_csv_file2, 'rb', **csv_options)
58
+ src2 = CSV.open(src_csv_file2, 'rb', **write_options)
35
59
  begin
36
- dest = CSV.open(dest_csv_file, 'wb', **csv_options)
60
+ dest = CSV.open(dest_csv_file, 'wb', **write_options)
37
61
  begin
38
62
  if @headers
39
63
  dest << @headers
@@ -75,10 +99,9 @@ module CSVUtils
75
99
  src1.close
76
100
  end
77
101
  end
78
- # rubocop:enable Metrics/MethodLength
79
102
 
80
103
  def create_sorted_csv_part_files(batch_size, &block)
81
- src = CSV.open(csv_file, 'rb', **csv_options)
104
+ src = CSV.open(csv_file, 'rb', **EncodingOptions.read(csv_options))
82
105
  begin
83
106
  @headers = src.shift if has_headers
84
107
 
@@ -86,7 +109,7 @@ module CSVUtils
86
109
  create_batch_part_proc = proc do
87
110
  batch.sort!(&block)
88
111
  @csv_part_files << "#{new_csv_file}.part.#{@csv_part_files.size}"
89
- CSV.open(@csv_part_files.last, 'wb', **csv_options) do |csv|
112
+ CSV.open(@csv_part_files.last, 'wb', **write_options) do |csv|
90
113
  csv << @headers if @headers
91
114
  batch.each { |row| csv << row }
92
115
  end
@@ -110,21 +133,37 @@ module CSVUtils
110
133
  while @csv_part_files.size > 1
111
134
  file_merge_cnt += 1
112
135
 
113
- csv_part_file1 = @csv_part_files.shift
114
- csv_part_file2 = @csv_part_files.shift
136
+ # inputs stay in the list until merged, so a failed merge still cleans them up
137
+ csv_part_file1, csv_part_file2 = @csv_part_files.first(2)
115
138
  @csv_part_files << "#{new_csv_file}.merge.#{file_merge_cnt}"
116
139
 
117
140
  merge_sort_csv_files(csv_part_file1, csv_part_file2, @csv_part_files.last, &)
118
141
 
119
- File.unlink(csv_part_file1)
120
- File.unlink(csv_part_file2)
142
+ @csv_part_files.shift(2).each { |file| File.unlink(file) }
121
143
  end
122
144
 
123
145
  if @csv_part_files.size.positive?
124
- FileUtils.mv(@csv_part_files.last, new_csv_file)
146
+ FileUtils.mv(@csv_part_files.pop, new_csv_file)
125
147
  else
126
- FileUtils.cp(@csv_file, new_csv_file)
148
+ write_headers_only
149
+ end
150
+ end
151
+
152
+ # a file without rows is written rather than copied, so it's in the same encoding as a sorted one
153
+ def write_headers_only
154
+ CSV.open(new_csv_file, 'wb', **write_options) do |csv|
155
+ csv << @headers if @headers
127
156
  end
128
157
  end
158
+
159
+ def write_options
160
+ EncodingOptions.write(csv_options)
161
+ end
162
+
163
+ # removes the temporary files left behind when sorting fails part way through
164
+ def delete_csv_part_files
165
+ @csv_part_files.each { |file| FileUtils.rm_f(file) }
166
+ @csv_part_files = []
167
+ end
129
168
  end
130
169
  end