csv-utils 0.7.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 +15 -0
- data/README.md +38 -6
- 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 +5 -0
- data/lib/csv_utils/character_encoding.rb +58 -8
- data/lib/csv_utils/csv_compare.rb +37 -21
- data/lib/csv_utils/csv_iterator.rb +22 -31
- data/lib/csv_utils/csv_options.rb +57 -28
- data/lib/csv_utils/csv_sort.rb +97 -97
- data/lib/csv_utils/csv_wrapper.rb +10 -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 +6 -1
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
|
|
@@ -30,127 +39,118 @@ module CSVUtils
|
|
|
30
39
|
@new_csv_file = new_csv_file
|
|
31
40
|
@has_headers = has_headers
|
|
32
41
|
@csv_options = csv_options
|
|
33
|
-
@
|
|
42
|
+
@temp_files = []
|
|
34
43
|
end
|
|
35
44
|
|
|
36
|
-
# Writes the sorted
|
|
45
|
+
# Writes the file sorted by comparing rows. Temporary files are removed even when sorting fails.
|
|
37
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
|
|
38
48
|
# @yieldparam row1 [Array<String>]
|
|
39
49
|
# @yieldparam row2 [Array<String>]
|
|
40
50
|
# @yieldreturn [Integer] negative, 0 or positive like <=>; without a block rows are compared as arrays
|
|
41
51
|
# @return [void]
|
|
42
|
-
def sort(batch_size = 100_000, &block)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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) }
|
|
49
66
|
end
|
|
50
67
|
|
|
51
68
|
private
|
|
52
69
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
begin
|
|
62
|
-
if @headers
|
|
63
|
-
dest << @headers
|
|
64
|
-
src1.shift
|
|
65
|
-
src2.shift
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
row1 = src1.shift
|
|
69
|
-
row2 = src2.shift
|
|
70
|
-
|
|
71
|
-
append_row1_proc = proc do
|
|
72
|
-
dest << row1
|
|
73
|
-
row1 = src1.shift
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
append_row2_proc = proc do
|
|
77
|
-
dest << row2
|
|
78
|
-
row2 = src2.shift
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
while row1 || row2
|
|
82
|
-
if row1.nil?
|
|
83
|
-
append_row2_proc.call
|
|
84
|
-
elsif row2.nil?
|
|
85
|
-
append_row1_proc.call
|
|
86
|
-
elsif yield(row1, row2) <= 0
|
|
87
|
-
append_row1_proc.call
|
|
88
|
-
else
|
|
89
|
-
append_row2_proc.call
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
ensure
|
|
93
|
-
dest.close
|
|
94
|
-
end
|
|
95
|
-
ensure
|
|
96
|
-
src2.close
|
|
97
|
-
end
|
|
98
|
-
ensure
|
|
99
|
-
src1.close
|
|
100
|
-
end
|
|
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
|
|
101
78
|
end
|
|
102
79
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
create_batch_part_proc = proc do
|
|
110
|
-
batch.sort!(&block)
|
|
111
|
-
@csv_part_files << "#{new_csv_file}.part.#{@csv_part_files.size}"
|
|
112
|
-
CSV.open(@csv_part_files.last, 'wb', **write_options) do |csv|
|
|
113
|
-
csv << @headers if @headers
|
|
114
|
-
batch.each { |row| csv << row }
|
|
115
|
-
end
|
|
116
|
-
batch = []
|
|
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 } }
|
|
117
86
|
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
118
89
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
123
94
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
95
|
+
merged = write_temp_file { |dest| merge_files(group, dest, key, compare) }
|
|
96
|
+
group.each { |file| File.unlink(file) }
|
|
97
|
+
merged
|
|
127
98
|
end
|
|
128
99
|
end
|
|
129
100
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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)
|
|
108
|
+
end
|
|
135
109
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
110
|
+
until queue.empty?
|
|
111
|
+
_, row, idx = queue.shift
|
|
112
|
+
dest << row
|
|
113
|
+
enqueue(queue, sources[idx].shift, idx, key, compare)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
139
117
|
|
|
140
|
-
|
|
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
|
|
141
121
|
|
|
142
|
-
|
|
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)
|
|
143
126
|
end
|
|
127
|
+
queue.insert(pos || queue.size, [value, row, idx])
|
|
128
|
+
end
|
|
129
|
+
|
|
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
|
|
144
139
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
149
146
|
end
|
|
147
|
+
@temp_files.last
|
|
150
148
|
end
|
|
151
149
|
|
|
152
150
|
# a file without rows is written rather than copied, so it's in the same encoding as a sorted one
|
|
153
|
-
def
|
|
151
|
+
def write_sorted_file(sorted_file)
|
|
152
|
+
return FileUtils.mv(sorted_file, new_csv_file) if sorted_file
|
|
153
|
+
|
|
154
154
|
CSV.open(new_csv_file, 'wb', **write_options) do |csv|
|
|
155
155
|
csv << @headers if @headers
|
|
156
156
|
end
|
|
@@ -161,9 +161,9 @@ module CSVUtils
|
|
|
161
161
|
end
|
|
162
162
|
|
|
163
163
|
# removes the temporary files left behind when sorting fails part way through
|
|
164
|
-
def
|
|
165
|
-
@
|
|
166
|
-
@
|
|
164
|
+
def delete_temp_files
|
|
165
|
+
@temp_files.each { |file| FileUtils.rm_f(file) }
|
|
166
|
+
@temp_files = []
|
|
167
167
|
end
|
|
168
168
|
end
|
|
169
169
|
end
|
|
@@ -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,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
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: csv-utils
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Doug Youch
|
|
@@ -88,6 +88,11 @@ files:
|
|
|
88
88
|
- lib/csv_utils/csv_transformer.rb
|
|
89
89
|
- lib/csv_utils/csv_wrapper.rb
|
|
90
90
|
- lib/csv_utils/encoding_options.rb
|
|
91
|
+
- lib/csv_utils/error.rb
|
|
92
|
+
- lib/csv_utils/header_not_found_error.rb
|
|
93
|
+
- lib/csv_utils/malformed_row_error.rb
|
|
94
|
+
- lib/csv_utils/row_reader.rb
|
|
95
|
+
- lib/csv_utils/unsorted_file_error.rb
|
|
91
96
|
- lib/csv_utils/version.rb
|
|
92
97
|
homepage: https://github.com/dougyouch/csv-utils
|
|
93
98
|
licenses:
|