csv-utils 0.3.25 → 0.6.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,102 +1,144 @@
1
- # Search a CSV given a series of steps
2
- class CSVUtils::CSVIterator
3
- include Enumerable
4
-
5
- attr_reader :prev_row
6
-
7
- class RowWrapper < Hash
8
- attr_accessor :lineno
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Enumerates the rows of a CSV file as hashes keyed by header, with the line number of each row.
5
+ #
6
+ # @example
7
+ # iterator = CSVUtils::CSVIterator.new('data.csv')
8
+ # iterator.each { |row| puts "#{row.lineno}: #{row['name']}" }
9
+ # iterator.select { |row| row['status'] == 'active' }
10
+ class CSVIterator
11
+ include Enumerable
12
+
13
+ # @return [Array<String>] the byte order marks stripped from the first header, as binary strings
14
+ BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS.keys.freeze
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
+ # A row as a hash of header to value that knows its line number in the file.
20
+ class RowWrapper < Hash
21
+ # @return [Integer] line number of the row in the file, counting the header row as line 1
22
+ attr_accessor :lineno
23
+
24
+ # @param headers [Array<String>]
25
+ # @param row [Array<String>]
26
+ # @param lineno [Integer]
27
+ # @return [RowWrapper]
28
+ def self.create(headers, row, lineno)
29
+ row_wrapper = RowWrapper[headers.zip(row)]
30
+ row_wrapper.lineno = lineno
31
+ row_wrapper
32
+ end
9
33
 
10
- def self.create(headers, row, lineno)
11
- row_wrapper = RowWrapper[headers.zip(row)]
12
- row_wrapper.lineno = lineno
13
- row_wrapper
34
+ # Numbered "header: value" lines for the non-blank values, as csv-grep prints them.
35
+ # @return [String]
36
+ def to_pretty_s
37
+ reject { |_, v| v.nil? || v.strip.empty? }
38
+ .each_with_index
39
+ .map { |(k, v), idx| format(' %-3d %s: %s', idx + 1, k, v) }
40
+ .join("\n") + "\n"
41
+ end
14
42
  end
15
43
 
16
- def to_pretty_s
17
- reject { |_, v| v.nil? || v.strip.empty? }
18
- .each_with_index
19
- .map { |(k, v), idx| sprintf(' %-3d %s: %s', idx+1, k, v) }
20
- .join("\n") + "\n"
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)
21
49
  end
22
- end
23
50
 
24
- def initialize(src_csv, csv_options = {}, mode = 'rb')
25
- @src_csv = CSVUtils::CSVWrapper.new(src_csv, mode, csv_options)
26
- end
51
+ # Yields each row from the start of the file.
52
+ # @param headers [Array<String>, nil] headers to use; by default the first row is read as the headers
53
+ # @yieldparam row [RowWrapper]
54
+ # @return [Enumerator, nil] an enumerator without a block
55
+ def each(headers = nil)
56
+ return enum_for(:each, headers) unless block_given?
27
57
 
28
- def each(headers = nil)
29
- @src_csv.rewind
58
+ @src_csv.rewind
30
59
 
31
- lineno = 0
32
- unless headers
33
- headers = @src_csv.shift
34
- strip_bom!(headers[0])
35
- lineno += 1
36
- end
60
+ lineno = 0
61
+ unless headers
62
+ headers = read_headers
63
+ lineno += 1
64
+ end
37
65
 
38
- @prev_row = nil
39
- while (row = @src_csv.shift)
40
- lineno += 1
41
- yield RowWrapper.create(headers, row, lineno)
42
- @prev_row = row
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
43
72
  end
44
- end
45
73
 
46
- def headers
47
- @src_csv.rewind
48
- headers = @src_csv.shift
49
- strip_bom!(headers[0])
50
- headers
51
- end
52
-
53
- def to_hash(key, value = nil)
54
- raise("header #{key} not found in #{headers}") unless headers.include?(key)
55
- raise("headers #{value} not found in #{headers}") if value && !headers.include?(value)
74
+ # The first row, without the byte order mark.
75
+ # @return [Array<String>] empty for an empty file
76
+ def headers
77
+ @src_csv.rewind
78
+ read_headers
79
+ end
56
80
 
57
- value_proc =
58
- if value
59
- proc { |row| row[value] }
60
- else
61
- proc { |row| yield(row) }
81
+ # Builds a lookup hash from every row.
82
+ # @param key [String] header whose value is the hash key
83
+ # @param value [String, nil] header whose value is the hash value
84
+ # @yieldparam row [RowWrapper] used instead of value to compute the hash value
85
+ # @return [Hash]
86
+ # @raise [RuntimeError] when key or value isn't a header
87
+ 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)
90
+
91
+ value_proc =
92
+ if value
93
+ proc { |row| row[value] }
94
+ else
95
+ proc(&)
96
+ end
97
+
98
+ to_h do |row|
99
+ [row[key], value_proc.call(row)]
62
100
  end
63
-
64
- each_with_object({}) do |row, hsh|
65
- hsh[row[key]] = value_proc.call(row)
66
101
  end
67
- end
68
102
 
69
- def size
70
- @src_csv.rewind
71
- @src_csv.shift
72
- cnt = 0
73
- while @src_csv.shift
74
- cnt +=1
103
+ # Number of rows after the header row.
104
+ # @return [Integer]
105
+ def size
106
+ @src_csv.rewind
107
+ @src_csv.shift
108
+ cnt = 0
109
+ cnt += 1 while @src_csv.shift
110
+ cnt
75
111
  end
76
- cnt
77
- end
78
112
 
79
- def each_batch(batch_size = 1_000)
80
- batch = []
81
-
82
- process_batch_proc = Proc.new do
83
- yield batch
113
+ # Yields the rows in batches.
114
+ # @param batch_size [Integer] rows per batch; the last batch may be smaller
115
+ # @yieldparam batch [Array<RowWrapper>]
116
+ # @return [nil]
117
+ def each_batch(batch_size = 1_000)
84
118
  batch = []
85
- end
86
119
 
87
- each do |row|
88
- batch << row
89
- process_batch_proc.call if batch.size >= batch_size
90
- end
120
+ process_batch_proc = proc do
121
+ yield batch
122
+ batch = []
123
+ end
91
124
 
92
- process_batch_proc.call if batch.size > 0
125
+ each do |row|
126
+ batch << row
127
+ process_batch_proc.call if batch.size >= batch_size
128
+ end
93
129
 
94
- nil
95
- end
130
+ process_batch_proc.call if batch.size.positive?
96
131
 
97
- private
132
+ nil
133
+ end
134
+
135
+ private
98
136
 
99
- def strip_bom!(col)
100
- col.sub!("\xEF\xBB\xBF".force_encoding('ASCII-8BIT'), '')
137
+ # 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
142
+ end
101
143
  end
102
144
  end
@@ -1,43 +1,44 @@
1
- # Auto detect a csv files options
1
+ # frozen_string_literal: true
2
+
2
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?
3
9
  class CSVOptions
10
+ # @return [Hash{String => String}] byte order marks, as binary strings, to the encoding they indicate
11
+ BYTE_ORDER_MARKS = ByteOrderMark::ENCODINGS
4
12
 
5
- # this list is from https://en.wikipedia.org/wiki/Byte_order_mark
6
- BYTE_ORDER_MARKS = {
7
- "\xEF\xBB\xBF".force_encoding('ASCII-8BIT') => 'UTF-8',
8
- "\xFE\xFF".force_encoding('ASCII-8BIT') => 'UTF-16',
9
- "\xFF\xFE".force_encoding('ASCII-8BIT') => 'UTF-16',
10
- "\x00\x00\xFE\xFF".force_encoding('ASCII-8BIT') => 'UTF-32',
11
- "\xFF\xFE\x00\x00".force_encoding('ASCII-8BIT') => 'UTF-32'
12
- }
13
-
13
+ # @return [Array<String>] column separators to look for, the first one found in the line wins
14
14
  COL_SEPARATORS = [
15
15
  "\x02",
16
16
  "\t",
17
17
  '|',
18
18
  ','
19
- ]
19
+ ].freeze
20
20
 
21
+ # @return [Array<String>] row separators to look for, the first one found in the line wins
21
22
  ROW_SEPARATORS = [
22
23
  "\r\n",
23
24
  "\n",
24
25
  "\r"
25
- ]
26
+ ].freeze
26
27
 
27
- attr_reader :columns,
28
- :byte_order_mark,
29
- :encoding,
30
- :col_separator,
31
- :row_separator
32
-
28
+ # @return [Integer, nil] number of headers, nil without a column separator
29
+ attr_reader :columns
30
+ # @return [String, nil] the byte order mark the file starts with, as a binary string
31
+ attr_reader :byte_order_mark
32
+ # @return [String] 'UTF-8', 'UTF-16' or 'UTF-32', from the byte order mark; 'UTF-8' without one
33
+ attr_reader :encoding
34
+ # @return [String, nil] one of {COL_SEPARATORS}
35
+ attr_reader :col_separator
36
+ # @return [String, nil] one of {ROW_SEPARATORS}
37
+ attr_reader :row_separator
33
38
 
39
+ # @param io [String, IO] path of the file, or an IO positioned at its first line
34
40
  def initialize(io)
35
- line =
36
- if io.is_a?(String)
37
- File.open(io, 'rb', &:readline)
38
- else
39
- io.readline
40
- end
41
+ line = read_first_line(io)
41
42
 
42
43
  @col_separator = auto_detect_col_sep(line)
43
44
  @row_separator = auto_detect_row_sep(line)
@@ -46,42 +47,79 @@ module CSVUtils
46
47
  @columns = get_number_of_columns(line) if @col_separator
47
48
  end
48
49
 
50
+ # Whether both separators were found. An empty file, or a single line without a newline, isn't valid.
51
+ # @return [Boolean]
49
52
  def valid?
50
53
  return false if @col_separator.nil? || @row_separator.nil?
51
54
 
52
55
  true
53
56
  end
54
57
 
58
+ # @api private
59
+ # @param line [String]
60
+ # @return [String, nil]
55
61
  def auto_detect_col_sep(line)
56
62
  COL_SEPARATORS.detect { |sep| line.include?(sep) }
57
63
  end
58
64
 
65
+ # @api private
66
+ # @param line [String]
67
+ # @return [String, nil]
59
68
  def auto_detect_row_sep(line)
60
69
  ROW_SEPARATORS.detect { |sep| line.include?(sep) }
61
70
  end
62
71
 
72
+ # Parses the header line like a CSV row, so quoted headers can contain the separator,
73
+ # and falls back to a plain split when the line is malformed.
74
+ # @param line [String] the first line of the file
75
+ # @return [Array<String, nil>] headers without the byte order mark or row separator
63
76
  def get_headers(line)
64
- headers = line.split(col_separator)
65
- headers[0] = strip_byte_order_marks(headers[0])
66
- headers
77
+ line = strip_byte_order_marks(line)
78
+ CSV.parse_line(line, **header_parse_options) || []
79
+ rescue CSV::MalformedCSVError
80
+ line.chomp(row_separator.to_s).split(col_separator, -1)
67
81
  end
68
82
 
83
+ # @api private
84
+ # @param line [String]
85
+ # @return [Integer]
69
86
  def get_number_of_columns(line)
70
87
  get_headers(line).size
71
88
  end
72
89
 
90
+ # @api private
91
+ # @param line [String]
92
+ # @return [String, nil]
73
93
  def get_byte_order_mark(line)
74
- BYTE_ORDER_MARKS.keys.detect do |bom|
75
- line =~ /\A#{bom}/
76
- end
94
+ ByteOrderMark.detect(line)
77
95
  end
78
96
 
97
+ # @api private
98
+ # @param bom [String, nil]
99
+ # @return [String]
79
100
  def get_character_encoding(bom)
80
101
  BYTE_ORDER_MARKS[bom] || 'UTF-8'
81
102
  end
82
103
 
104
+ # @api private
105
+ # @param header [String]
106
+ # @return [String]
83
107
  def strip_byte_order_marks(header)
84
- @byte_order_marks ? header.sub(@byte_order_marks, '') : header
108
+ ByteOrderMark.strip(header)
109
+ end
110
+
111
+ private
112
+
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 || ''
117
+ end
118
+
119
+ def header_parse_options
120
+ options = { col_sep: col_separator }
121
+ options[:row_sep] = row_separator if row_separator
122
+ options
85
123
  end
86
124
  end
87
125
  end
@@ -1,15 +1,29 @@
1
- # Builds a csv file from csv rows
1
+ # frozen_string_literal: true
2
+
2
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
3
10
  class CSVReport
4
- attr_reader :csv,
5
- :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
6
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
19
+ # @yieldparam report [CSVReport] when given, the block is passed to {#generate}
7
20
  def initialize(csv, headers = nil, csv_options = {}, &block)
8
21
  @csv =
9
22
  if csv.is_a?(String)
10
23
  @must_close = true
11
- mode = csv_options.delete(:mode) || 'wb'
12
- CSV.open(csv, mode, **csv_options)
24
+ opts = csv_options.dup
25
+ mode = opts.delete(:mode) || 'wb'
26
+ CSV.open(csv, mode, **opts)
13
27
  else
14
28
  @must_close = false
15
29
  csv
@@ -20,11 +34,18 @@ module CSVUtils
20
34
  generate(&block) if block
21
35
  end
22
36
 
37
+ # Yields the report, then closes the file if the report opened it, even when the block raises.
38
+ # @yieldparam report [CSVReport]
39
+ # @return [void]
23
40
  def generate
24
41
  yield self
42
+ ensure
25
43
  close if @must_close
26
44
  end
27
45
 
46
+ # Writes a row.
47
+ # @param csv_row [Array, #to_a] an array of values or a {CSVRow} object
48
+ # @return [CSV, #<<]
28
49
  def append(csv_row)
29
50
  @csv <<
30
51
  if csv_row.is_a?(Array)
@@ -35,10 +56,15 @@ module CSVUtils
35
56
  end
36
57
  alias << append
37
58
 
59
+ # Writes the header row.
60
+ # @param csv_row [Array<String>, #csv_headers] headers, or a {CSVRow} class or object to take them from
61
+ # @return [CSV, #<<]
38
62
  def add_headers(csv_row)
39
63
  append(csv_row.is_a?(Array) ? csv_row : csv_row.csv_headers)
40
64
  end
41
65
 
66
+ # Closes the underlying CSV.
67
+ # @return [void]
42
68
  def close
43
69
  @csv.close
44
70
  end
@@ -1,18 +1,46 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'inheritance-helper'
2
4
 
3
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
4
17
  module CSVRow
18
+ # @api private
19
+ # @param base [Class]
20
+ # @return [void]
5
21
  def self.included(base)
6
22
  base.extend InheritanceHelper::Methods
7
23
  base.extend ClassMethods
8
24
  end
9
25
 
26
+ # Class level DSL for declaring columns.
10
27
  module ClassMethods
28
+ # Declared columns, keyed by name, in declaration order.
29
+ # @return [Hash{Symbol => Hash}]
11
30
  def csv_columns
12
31
  {}
13
32
  end
14
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]
15
42
  def csv_column(header, options = {}, &block)
43
+ options = options.dup
16
44
  options[:header] ||= header.to_s
17
45
 
18
46
  if block
@@ -24,6 +52,7 @@ module CSVUtils
24
52
  add_value_to_class_method(:csv_columns, header => options)
25
53
  end
26
54
 
55
+ # @return [Array<String>] the header of each column
27
56
  def csv_headers
28
57
  csv_columns.values.map { |column_options| csv_column_header(column_options) }
29
58
  end
@@ -35,11 +64,14 @@ module CSVUtils
35
64
  end
36
65
  end
37
66
 
67
+ # The value of each column.
68
+ # @return [Array]
38
69
  def csv_row
39
70
  self.class.csv_columns.values.map { |column_options| csv_column_value(column_options) }
40
71
  end
41
- alias_method :to_a, :csv_row
72
+ alias to_a csv_row
42
73
 
74
+ # @return [Array<String>] the header of each column
43
75
  def csv_headers
44
76
  self.class.csv_headers
45
77
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
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)
9
+ class CSVRowMatcher
10
+ # @return [Regexp]
11
+ attr_accessor :regex
12
+ # @return [Array<String>, :all] headers to search, or :all for every value
13
+ attr_accessor :columns
14
+
15
+ # @param regex [Regexp]
16
+ # @param columns [Array<String>, :all] headers to search
17
+ def initialize(regex, columns = :all)
18
+ self.regex = regex
19
+ self.columns = columns
20
+ end
21
+
22
+ # Whether any searched value matches. Missing and nil values never match.
23
+ # @param row [Hash{String => String}]
24
+ # @return [Boolean]
25
+ def match?(row)
26
+ if columns == :all
27
+ row.each_value do |value|
28
+ return true if value&.match?(regex)
29
+ end
30
+ else
31
+ columns.each do |column_name|
32
+ value = row[column_name]
33
+ return true if value&.match?(regex)
34
+ end
35
+ end
36
+
37
+ false
38
+ end
39
+
40
+ # Lets the matcher be passed to Enumerable methods as a block, as in rows.select(&matcher).
41
+ # @return [Proc]
42
+ def to_proc
43
+ proc do |row|
44
+ match?(row)
45
+ end
46
+ end
47
+ end
48
+ end