csv-utils 0.5.0 → 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,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,32 +2,54 @@
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
14
27
  def initialize(csv_file, new_csv_file, has_headers = true, csv_options = {})
15
28
  @csv_file = csv_file
16
29
  @new_csv_file = new_csv_file
17
30
  @has_headers = has_headers
18
31
  @csv_options = csv_options
19
32
  @csv_part_files = []
20
- @files_to_delete = []
21
33
  end
22
34
 
23
- def sort(batch_size = 100_000, &)
24
- create_sorted_csv_part_files(batch_size, &)
25
- merge_csv_part_files(&)
35
+ # Writes the sorted file. Temporary files are removed even when sorting fails.
36
+ # @param batch_size [Integer] rows held in memory and sorted at a time
37
+ # @yieldparam row1 [Array<String>]
38
+ # @yieldparam row2 [Array<String>]
39
+ # @yieldreturn [Integer] negative, 0 or positive like <=>; without a block rows are compared as arrays
40
+ # @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
26
48
  end
27
49
 
28
50
  private
29
51
 
30
- # rubocop:disable Metrics/MethodLength
52
+ # rubocop:disable-next Metrics/MethodLength
31
53
  def merge_sort_csv_files(src_csv_file1, src_csv_file2, dest_csv_file)
32
54
  src1 = CSV.open(src_csv_file1, 'rb', **csv_options)
33
55
  begin
@@ -75,7 +97,6 @@ module CSVUtils
75
97
  src1.close
76
98
  end
77
99
  end
78
- # rubocop:enable Metrics/MethodLength
79
100
 
80
101
  def create_sorted_csv_part_files(batch_size, &block)
81
102
  src = CSV.open(csv_file, 'rb', **csv_options)
@@ -110,21 +131,26 @@ module CSVUtils
110
131
  while @csv_part_files.size > 1
111
132
  file_merge_cnt += 1
112
133
 
113
- csv_part_file1 = @csv_part_files.shift
114
- csv_part_file2 = @csv_part_files.shift
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)
115
136
  @csv_part_files << "#{new_csv_file}.merge.#{file_merge_cnt}"
116
137
 
117
138
  merge_sort_csv_files(csv_part_file1, csv_part_file2, @csv_part_files.last, &)
118
139
 
119
- File.unlink(csv_part_file1)
120
- File.unlink(csv_part_file2)
140
+ @csv_part_files.shift(2).each { |file| File.unlink(file) }
121
141
  end
122
142
 
123
143
  if @csv_part_files.size.positive?
124
- FileUtils.mv(@csv_part_files.last, new_csv_file)
144
+ FileUtils.mv(@csv_part_files.pop, new_csv_file)
125
145
  else
126
146
  FileUtils.cp(@csv_file, new_csv_file)
127
147
  end
128
148
  end
149
+
150
+ # 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 = []
154
+ end
129
155
  end
130
156
  end
@@ -1,63 +1,106 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Transforms a CSV given a series of steps
4
3
  module CSVUtils
4
+ # Copies a CSV file through a pipeline of steps that filter, change and add to its rows in batches.
5
+ # Each step method returns the transformer so they can be chained, and {#process} runs them.
6
+ #
7
+ # Step blocks receive the row, the headers as they were when the step was added, and the value
8
+ # computed by the last {#additional_data} step for the current batch.
9
+ #
10
+ # @example
11
+ # CSVUtils::CSVTransformer.new('input.csv', 'output.csv')
12
+ # .read_headers
13
+ # .select { |row, _headers, _data| row[0].to_i > 100 }
14
+ # .append(['Email']) { |row, _headers, _data| ["#{row[1].downcase}@example.com"] }
15
+ # .process
5
16
  class CSVTransformer
17
+ # @return [Array<String>, nil] the headers the output will have after the steps added so far
6
18
  attr_reader :headers
7
19
 
20
+ # @param src_csv [String, CSV, #shift] path or CSV to read; paths are opened and closed by the transformer
21
+ # @param dest_csv [String, CSV, #<<] path or CSV to write
22
+ # @param csv_options [Hash] options passed to CSV.open for paths
8
23
  def initialize(src_csv, dest_csv, csv_options = {})
9
24
  @src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
10
25
  @dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
11
26
  end
12
27
 
28
+ # Reads the first row as the headers. Without it, every row is data and the output has no header row.
29
+ # @return [self]
13
30
  def read_headers
14
31
  @headers = @src_csv.shift
15
32
  self
16
33
  end
17
34
 
35
+ # Computes a value once per batch, passed to the steps that follow as their third argument.
36
+ # @yieldparam batch [Array<Array<String>>]
37
+ # @yieldparam headers [Array<String>, nil]
38
+ # @return [self]
18
39
  def additional_data(&block)
19
40
  steps << [:additional_data, @headers, block]
20
41
  self
21
42
  end
22
43
 
44
+ # Keeps the rows the block returns true for.
45
+ # @yieldparam row [Array<String>]
46
+ # @yieldparam headers [Array<String>, nil]
47
+ # @yieldparam additional_data [Object]
48
+ # @return [self]
23
49
  def select(&block)
24
50
  steps << [:select, @headers, block]
25
51
  self
26
52
  end
27
53
 
54
+ # Drops the rows the block returns true for.
55
+ # @yieldparam (see #select)
56
+ # @return [self]
28
57
  def reject(&block)
29
58
  steps << [:reject, @headers, block]
30
59
  self
31
60
  end
32
61
 
62
+ # Replaces each row with the block's result.
63
+ # @param new_headers [Array<String>, nil] the headers after this step
64
+ # @yieldparam (see #select)
65
+ # @yieldreturn [Array] the new row
66
+ # @return [self]
33
67
  def map(new_headers, &block)
34
68
  steps << [:map, @headers, block]
35
69
  @headers = new_headers
36
70
  self
37
71
  end
38
72
 
73
+ # Adds the block's values to the end of each row.
74
+ # @param additional_headers [Array<String>, nil] headers for the new values; nil drops the header row
75
+ # @yieldparam (see #select)
76
+ # @yieldreturn [Array] values to append
77
+ # @return [self]
39
78
  def append(additional_headers, &block)
40
79
  steps << [:append, @headers, block]
41
-
42
- if additional_headers
43
- @headers += additional_headers
44
- else
45
- @headers = nil
46
- end
47
-
80
+ # without headers (read_headers not called) the output has no header row to extend
81
+ @headers = (@headers + additional_headers if @headers && additional_headers)
48
82
  self
49
83
  end
50
84
 
85
+ # Calls the block for each row without changing it.
86
+ # @yieldparam (see #select)
87
+ # @return [self]
51
88
  def each(&block)
52
89
  steps << [:each, @headers, block]
53
90
  self
54
91
  end
55
92
 
93
+ # Replaces the headers written to the output.
94
+ # @param headers [Array<String>, nil]
95
+ # @return [self]
56
96
  def set_headers(headers)
57
97
  @headers = headers
58
98
  self
59
99
  end
60
100
 
101
+ # Runs the steps over every row, writes the output and closes the files it opened, even when a step raises.
102
+ # @param batch_size [Integer] rows read and run through the steps at a time
103
+ # @return [void]
61
104
  def process(batch_size = 10_000)
62
105
  batch = []
63
106
 
@@ -79,7 +122,7 @@ module CSVUtils
79
122
  end
80
123
 
81
124
  steps_proc.call if batch.size.positive?
82
-
125
+ ensure
83
126
  @src_csv.close
84
127
  @dest_csv.close
85
128
  end
@@ -90,33 +133,34 @@ module CSVUtils
90
133
  @steps ||= []
91
134
  end
92
135
 
93
- def process_step(step_type, current_headers, batch, &block)
94
- case step_type
95
- when :select
96
- batch.select! do |row|
97
- block.call row, current_headers, @additional_data
98
- end
99
- when :reject
100
- batch.reject! do |row|
101
- block.call row, current_headers, @additional_data
102
- end
103
- when :map
104
- batch.map! do |row|
105
- block.call row, current_headers, @additional_data
106
- end
107
- when :append
108
- batch.map! do |row|
109
- row + block.call(row, current_headers, @additional_data)
110
- end
111
- when :additional_data
112
- @additional_data = block.call(batch, current_headers)
113
- when :each
114
- batch.each do |row|
115
- block.call(row, current_headers, @additional_data)
116
- end
117
- end
118
-
136
+ # each step type has a process_<type>_step method that changes the batch in place
137
+ def process_step(step_type, current_headers, batch, &)
138
+ send(:"process_#{step_type}_step", current_headers, batch, &)
119
139
  batch
120
140
  end
141
+
142
+ def process_select_step(current_headers, batch)
143
+ batch.select! { |row| yield row, current_headers, @additional_data }
144
+ end
145
+
146
+ def process_reject_step(current_headers, batch)
147
+ batch.reject! { |row| yield row, current_headers, @additional_data }
148
+ end
149
+
150
+ def process_map_step(current_headers, batch)
151
+ batch.map! { |row| yield row, current_headers, @additional_data }
152
+ end
153
+
154
+ def process_append_step(current_headers, batch)
155
+ batch.map! { |row| row + yield(row, current_headers, @additional_data) }
156
+ end
157
+
158
+ def process_additional_data_step(current_headers, batch)
159
+ @additional_data = yield batch, current_headers
160
+ end
161
+
162
+ def process_each_step(current_headers, batch)
163
+ batch.each { |row| yield row, current_headers, @additional_data }
164
+ end
121
165
  end
122
166
  end
@@ -1,25 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Wraps a CSV object, if wrapper opens the csv file it will close it
4
3
  module CSVUtils
4
+ # Wraps a path or an open CSV so callers can read, write and close it the same way.
5
+ # It closes only the files it opened itself.
5
6
  class CSVWrapper
7
+ # @return [CSV, #shift, #<<] the CSV being wrapped
6
8
  attr_reader :csv
7
9
 
10
+ # @param csv [String, CSV, #shift, #<<] path to open, or a CSV (or array of rows) to use as is
11
+ # @param mode [String] file mode for a path
12
+ # @param csv_options [Hash] options passed to CSV.open for a path
8
13
  def initialize(csv, mode, csv_options)
9
14
  open(csv, mode, csv_options)
10
15
  end
11
16
 
17
+ # Wraps a path or CSV; with a block, yields the wrapper and closes it afterwards, even when the block raises.
18
+ # @param file [String, CSV] path or CSV
19
+ # @param mode [String] file mode for a path
20
+ # @param csv_options [Hash] options passed to CSV.open for a path
21
+ # @yieldparam csv [CSVWrapper]
22
+ # @return [CSVWrapper, Object] the wrapper, or the block's value
12
23
  def self.open(file, mode, csv_options = {})
13
24
  csv = new(file, mode, csv_options)
25
+ return csv unless block_given?
14
26
 
15
- if block_given?
27
+ begin
16
28
  yield csv
29
+ ensure
17
30
  csv.close
18
- else
19
- csv
20
31
  end
21
32
  end
22
33
 
34
+ # @api private
35
+ # @return [CSV, #shift, #<<]
23
36
  def open(csv, mode, csv_options)
24
37
  if csv.is_a?(String)
25
38
  @close_when_done = true
@@ -30,18 +43,25 @@ module CSVUtils
30
43
  end
31
44
  end
32
45
 
46
+ # @param row [Array]
47
+ # @return [CSV, #<<]
33
48
  def <<(row)
34
49
  csv << row
35
50
  end
36
51
 
52
+ # @return [Array<String>, nil] the next row, nil at the end
37
53
  def shift
38
54
  csv.shift
39
55
  end
40
56
 
57
+ # Moves back to the first row.
58
+ # @return [void]
41
59
  def rewind
42
60
  csv.rewind
43
61
  end
44
62
 
63
+ # Closes the CSV if the wrapper opened it.
64
+ # @return [void]
45
65
  def close
46
66
  csv.close if close_when_done?
47
67
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Gem version, bumped by release-please
5
+ VERSION = '0.6.0'
6
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: csv-utils
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-01-31 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: csv
@@ -15,31 +15,39 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '0'
18
+ version: '3.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '0'
25
+ version: '3.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: inheritance-helper
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: '0.2'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '2'
33
36
  type: :runtime
34
37
  prerelease: false
35
38
  version_requirements: !ruby/object:Gem::Requirement
36
39
  requirements:
37
40
  - - ">="
38
41
  - !ruby/object:Gem::Version
39
- version: '0'
40
- description: A Ruby library for CSV file processing featuring comparison, transformation,
41
- sorting, and validation. Includes CLI tools for debugging malformed CSVs, auto-detection
42
- of encodings and separators, and efficient handling of large files.
42
+ version: '0.2'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '2'
46
+ description: Streaming tools for CSV files too big or too broken to load into memory.
47
+ Compare two sorted files into create, update and delete actions, sort with an external
48
+ merge sort, transform and extend rows in batches, and build reports from Ruby objects.
49
+ Detects separators, byte order marks and encodings, and ships command line tools
50
+ that pinpoint malformed rows, diff, grep, split and validate CSV files.
43
51
  email: dougyouch@gmail.com
44
52
  executables:
45
53
  - csv-change-eol
@@ -54,15 +62,7 @@ executables:
54
62
  extensions: []
55
63
  extra_rdoc_files: []
56
64
  files:
57
- - ".github/workflows/ci.yml"
58
- - ".gitignore"
59
- - ".rubocop.yml"
60
- - ".ruby-gemset"
61
- - ".ruby-version"
62
- - ARCHITECTURE.md
63
- - CLAUDE.md
64
- - Gemfile
65
- - Gemfile.lock
65
+ - CHANGELOG.md
66
66
  - LICENSE
67
67
  - README.md
68
68
  - bin/csv-change-eol
@@ -74,8 +74,8 @@ files:
74
74
  - bin/csv-readline
75
75
  - bin/csv-splitter
76
76
  - bin/csv-validator
77
- - csv-utils.gemspec
78
77
  - lib/csv-utils.rb
78
+ - lib/csv_utils/byte_order_mark.rb
79
79
  - lib/csv_utils/csv_compare.rb
80
80
  - lib/csv_utils/csv_extender.rb
81
81
  - lib/csv_utils/csv_iterator.rb
@@ -86,12 +86,16 @@ files:
86
86
  - lib/csv_utils/csv_sort.rb
87
87
  - lib/csv_utils/csv_transformer.rb
88
88
  - lib/csv_utils/csv_wrapper.rb
89
- - script/console
89
+ - lib/csv_utils/version.rb
90
90
  homepage: https://github.com/dougyouch/csv-utils
91
91
  licenses:
92
92
  - MIT
93
93
  metadata:
94
94
  rubygems_mfa_required: 'true'
95
+ source_code_uri: https://github.com/dougyouch/csv-utils
96
+ changelog_uri: https://github.com/dougyouch/csv-utils/blob/master/CHANGELOG.md
97
+ bug_tracker_uri: https://github.com/dougyouch/csv-utils/issues
98
+ documentation_uri: https://rubydoc.info/gems/csv-utils
95
99
  rdoc_options: []
96
100
  require_paths:
97
101
  - lib
@@ -99,14 +103,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
99
103
  requirements:
100
104
  - - ">="
101
105
  - !ruby/object:Gem::Version
102
- version: '0'
106
+ version: '3.3'
103
107
  required_rubygems_version: !ruby/object:Gem::Requirement
104
108
  requirements:
105
109
  - - ">="
106
110
  - !ruby/object:Gem::Version
107
111
  version: '0'
108
112
  requirements: []
109
- rubygems_version: 3.6.2
113
+ rubygems_version: 4.0.20
110
114
  specification_version: 4
111
- summary: Comprehensive CSV manipulation and debugging utilities for Ruby
115
+ summary: Compare, sort, transform and debug large or malformed CSV files
112
116
  test_files: []
@@ -1,53 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [master]
6
- pull_request:
7
- branches: [master]
8
-
9
- jobs:
10
- lint:
11
- name: RuboCop
12
- runs-on: ubuntu-latest
13
- steps:
14
- - uses: actions/checkout@v4
15
-
16
- - name: Set up Ruby
17
- uses: ruby/setup-ruby@v1
18
- with:
19
- ruby-version: '3.4'
20
- bundler-cache: true
21
-
22
- - name: Run RuboCop
23
- run: bundle exec rubocop --format github
24
-
25
- test:
26
- name: Tests (Ruby ${{ matrix.ruby }})
27
- runs-on: ubuntu-latest
28
- strategy:
29
- fail-fast: false
30
- matrix:
31
- ruby: ['3.2', '3.3', '3.4']
32
-
33
- steps:
34
- - uses: actions/checkout@v4
35
-
36
- - name: Set up Ruby ${{ matrix.ruby }}
37
- uses: ruby/setup-ruby@v1
38
- with:
39
- ruby-version: ${{ matrix.ruby }}
40
- bundler-cache: true
41
-
42
- - name: Run tests
43
- run: bundle exec rspec
44
-
45
- - name: Upload coverage to Codecov
46
- if: matrix.ruby == '3.4'
47
- uses: codecov/codecov-action@v4
48
- with:
49
- files: coverage/coverage.xml
50
- fail_ci_if_error: false
51
- verbose: true
52
- env:
53
- CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
data/.gitignore DELETED
@@ -1,52 +0,0 @@
1
- *.gem
2
- *.rbc
3
- /.config
4
- /coverage/
5
- /InstalledFiles
6
- /pkg/
7
- /spec/reports/
8
- /spec/examples.txt
9
- /test/tmp/
10
- /test/version_tmp/
11
- /tmp/
12
-
13
- # Used by dotenv library to load environment variables.
14
- # .env
15
-
16
- ## Specific to RubyMotion:
17
- .dat*
18
- .repl_history
19
- build/
20
- *.bridgesupport
21
- build-iPhoneOS/
22
- build-iPhoneSimulator/
23
-
24
- ## Specific to RubyMotion (use of CocoaPods):
25
- #
26
- # We recommend against adding the Pods directory to your .gitignore. However
27
- # you should judge for yourself, the pros and cons are mentioned at:
28
- # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
29
- #
30
- # vendor/Pods/
31
-
32
- ## Documentation cache and generated files:
33
- /.yardoc/
34
- /_yardoc/
35
- /doc/
36
- /rdoc/
37
-
38
- ## Environment normalization:
39
- /.bundle/
40
- /vendor/bundle
41
- /lib/bundler/man/
42
-
43
- # for a library or gem, you might want to ignore these files since the code is
44
- # intended to run in multiple environments; otherwise, check them in:
45
- # Gemfile.lock
46
- # .ruby-version
47
- # .ruby-gemset
48
-
49
- # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
50
- .rvmrc
51
-
52
- *~