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
|
@@ -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
|
|
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
|
|
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'
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
114
|
-
def
|
|
115
|
-
|
|
116
|
-
|
|
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
|
data/lib/csv_utils/csv_report.rb
CHANGED
|
@@ -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
|
|
25
|
+
opts = EncodingOptions.write(csv_options)
|
|
25
26
|
mode = opts.delete(:mode) || 'wb'
|
|
26
27
|
CSV.open(csv, mode, **opts)
|
|
27
28
|
else
|
data/lib/csv_utils/csv_sort.rb
CHANGED
|
@@ -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
|
|
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
|
-
@
|
|
42
|
+
@temp_files = []
|
|
33
43
|
end
|
|
34
44
|
|
|
35
|
-
# Writes the sorted
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
152
|
-
@
|
|
153
|
-
@
|
|
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
|
data/lib/csv_utils/version.rb
CHANGED