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,63 +1,107 @@
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; 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})
8
24
  def initialize(src_csv, dest_csv, csv_options = {})
9
- @src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
10
- @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))
11
27
  end
12
28
 
29
+ # Reads the first row as the headers. Without it, every row is data and the output has no header row.
30
+ # @return [self]
13
31
  def read_headers
14
32
  @headers = @src_csv.shift
15
33
  self
16
34
  end
17
35
 
36
+ # Computes a value once per batch, passed to the steps that follow as their third argument.
37
+ # @yieldparam batch [Array<Array<String>>]
38
+ # @yieldparam headers [Array<String>, nil]
39
+ # @return [self]
18
40
  def additional_data(&block)
19
41
  steps << [:additional_data, @headers, block]
20
42
  self
21
43
  end
22
44
 
45
+ # Keeps the rows the block returns true for.
46
+ # @yieldparam row [Array<String>]
47
+ # @yieldparam headers [Array<String>, nil]
48
+ # @yieldparam additional_data [Object]
49
+ # @return [self]
23
50
  def select(&block)
24
51
  steps << [:select, @headers, block]
25
52
  self
26
53
  end
27
54
 
55
+ # Drops the rows the block returns true for.
56
+ # @yieldparam (see #select)
57
+ # @return [self]
28
58
  def reject(&block)
29
59
  steps << [:reject, @headers, block]
30
60
  self
31
61
  end
32
62
 
63
+ # Replaces each row with the block's result.
64
+ # @param new_headers [Array<String>, nil] the headers after this step
65
+ # @yieldparam (see #select)
66
+ # @yieldreturn [Array] the new row
67
+ # @return [self]
33
68
  def map(new_headers, &block)
34
69
  steps << [:map, @headers, block]
35
70
  @headers = new_headers
36
71
  self
37
72
  end
38
73
 
74
+ # Adds the block's values to the end of each row.
75
+ # @param additional_headers [Array<String>, nil] headers for the new values; nil drops the header row
76
+ # @yieldparam (see #select)
77
+ # @yieldreturn [Array] values to append
78
+ # @return [self]
39
79
  def append(additional_headers, &block)
40
80
  steps << [:append, @headers, block]
41
-
42
- if additional_headers
43
- @headers += additional_headers
44
- else
45
- @headers = nil
46
- end
47
-
81
+ # without headers (read_headers not called) the output has no header row to extend
82
+ @headers = (@headers + additional_headers if @headers && additional_headers)
48
83
  self
49
84
  end
50
85
 
86
+ # Calls the block for each row without changing it.
87
+ # @yieldparam (see #select)
88
+ # @return [self]
51
89
  def each(&block)
52
90
  steps << [:each, @headers, block]
53
91
  self
54
92
  end
55
93
 
94
+ # Replaces the headers written to the output.
95
+ # @param headers [Array<String>, nil]
96
+ # @return [self]
56
97
  def set_headers(headers)
57
98
  @headers = headers
58
99
  self
59
100
  end
60
101
 
102
+ # Runs the steps over every row, writes the output and closes the files it opened, even when a step raises.
103
+ # @param batch_size [Integer] rows read and run through the steps at a time
104
+ # @return [void]
61
105
  def process(batch_size = 10_000)
62
106
  batch = []
63
107
 
@@ -79,7 +123,7 @@ module CSVUtils
79
123
  end
80
124
 
81
125
  steps_proc.call if batch.size.positive?
82
-
126
+ ensure
83
127
  @src_csv.close
84
128
  @dest_csv.close
85
129
  end
@@ -90,33 +134,34 @@ module CSVUtils
90
134
  @steps ||= []
91
135
  end
92
136
 
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
-
137
+ # each step type has a process_<type>_step method that changes the batch in place
138
+ def process_step(step_type, current_headers, batch, &)
139
+ send(:"process_#{step_type}_step", current_headers, batch, &)
119
140
  batch
120
141
  end
142
+
143
+ def process_select_step(current_headers, batch)
144
+ batch.select! { |row| yield row, current_headers, @additional_data }
145
+ end
146
+
147
+ def process_reject_step(current_headers, batch)
148
+ batch.reject! { |row| yield row, current_headers, @additional_data }
149
+ end
150
+
151
+ def process_map_step(current_headers, batch)
152
+ batch.map! { |row| yield row, current_headers, @additional_data }
153
+ end
154
+
155
+ def process_append_step(current_headers, batch)
156
+ batch.map! { |row| row + yield(row, current_headers, @additional_data) }
157
+ end
158
+
159
+ def process_additional_data_step(current_headers, batch)
160
+ @additional_data = yield batch, current_headers
161
+ end
162
+
163
+ def process_each_step(current_headers, batch)
164
+ batch.each { |row| yield row, current_headers, @additional_data }
165
+ end
121
166
  end
122
167
  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,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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Gem version, bumped by release-please
5
+ VERSION = '0.7.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.7.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,9 @@ 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
+ - lib/csv_utils/character_encoding.rb
79
80
  - lib/csv_utils/csv_compare.rb
80
81
  - lib/csv_utils/csv_extender.rb
81
82
  - lib/csv_utils/csv_iterator.rb
@@ -86,12 +87,17 @@ files:
86
87
  - lib/csv_utils/csv_sort.rb
87
88
  - lib/csv_utils/csv_transformer.rb
88
89
  - lib/csv_utils/csv_wrapper.rb
89
- - script/console
90
+ - lib/csv_utils/encoding_options.rb
91
+ - lib/csv_utils/version.rb
90
92
  homepage: https://github.com/dougyouch/csv-utils
91
93
  licenses:
92
94
  - MIT
93
95
  metadata:
94
96
  rubygems_mfa_required: 'true'
97
+ source_code_uri: https://github.com/dougyouch/csv-utils
98
+ changelog_uri: https://github.com/dougyouch/csv-utils/blob/master/CHANGELOG.md
99
+ bug_tracker_uri: https://github.com/dougyouch/csv-utils/issues
100
+ documentation_uri: https://rubydoc.info/gems/csv-utils
95
101
  rdoc_options: []
96
102
  require_paths:
97
103
  - lib
@@ -99,14 +105,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
99
105
  requirements:
100
106
  - - ">="
101
107
  - !ruby/object:Gem::Version
102
- version: '0'
108
+ version: '3.3'
103
109
  required_rubygems_version: !ruby/object:Gem::Requirement
104
110
  requirements:
105
111
  - - ">="
106
112
  - !ruby/object:Gem::Version
107
113
  version: '0'
108
114
  requirements: []
109
- rubygems_version: 3.6.2
115
+ rubygems_version: 4.0.20
110
116
  specification_version: 4
111
- summary: Comprehensive CSV manipulation and debugging utilities for Ruby
117
+ summary: Compare, sort, transform and debug large or malformed CSV files
112
118
  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
- *~
data/.rubocop.yml DELETED
@@ -1,81 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 3.2
3
- NewCops: enable
4
- SuggestExtensions: false
5
- Exclude:
6
- - 'bin/**/*'
7
- - 'vendor/**/*'
8
- - 'coverage/**/*'
9
-
10
- # Relaxed metrics for existing codebase
11
- Metrics/MethodLength:
12
- Max: 35
13
-
14
- Metrics/AbcSize:
15
- Max: 40
16
-
17
- Metrics/ClassLength:
18
- Max: 200
19
-
20
- Metrics/CyclomaticComplexity:
21
- Max: 15
22
-
23
- Metrics/PerceivedComplexity:
24
- Max: 15
25
-
26
- Metrics/BlockLength:
27
- Exclude:
28
- - 'spec/**/*'
29
- - '*.gemspec'
30
-
31
- # Style preferences
32
- Style/Documentation:
33
- Enabled: false
34
-
35
- Style/FrozenStringLiteralComment:
36
- EnforcedStyle: always
37
-
38
- Layout/LineLength:
39
- Max: 130
40
- Exclude:
41
- - 'spec/**/*'
42
-
43
- # File naming - allow hyphenated gem name
44
- Naming/FileName:
45
- Exclude:
46
- - 'lib/csv-utils.rb'
47
-
48
- # Allow set_ prefix for transformer DSL methods
49
- Naming/AccessorMethodName:
50
- Exclude:
51
- - 'lib/csv_utils/csv_transformer.rb'
52
-
53
- # Allow Kernel#open for pipe support (intentional design)
54
- Security/Open:
55
- Exclude:
56
- - 'lib/csv_utils/csv_wrapper.rb'
57
-
58
- # Duplicate branches are intentional in comparison logic
59
- Lint/DuplicateBranch:
60
- Exclude:
61
- - 'lib/csv_utils/csv_compare.rb'
62
- - 'lib/csv_utils/csv_sort.rb'
63
-
64
- # Allow empty blocks in specs (used for testing iteration behavior)
65
- Lint/EmptyBlock:
66
- Exclude:
67
- - 'spec/**/*'
68
-
69
- # Style relaxations for existing code patterns
70
- Style/OptionalBooleanParameter:
71
- Enabled: false
72
-
73
- Style/StringConcatenation:
74
- Enabled: false
75
-
76
- Style/FormatStringToken:
77
- Enabled: false
78
-
79
- # Gemspec settings
80
- Gemspec/RequiredRubyVersion:
81
- Enabled: false
data/.ruby-gemset DELETED
@@ -1 +0,0 @@
1
- csv-utils
data/.ruby-version DELETED
@@ -1 +0,0 @@
1
- 3.4.2