csv-utils 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 423eacb18512c595abe83765ccfd4d945f1e2e0d18a6370b6841997eb428ddb7
4
- data.tar.gz: 3d2df7f1ee62eaebc25d00281401bf49f0fccdb809ec03cbac90e6bf5ba2fd62
3
+ metadata.gz: c07e7e0ae21409f1fcac6d5b150e1577146d79a47ba9e13b8fb28d90d50fb9c6
4
+ data.tar.gz: cb27bd0e1edbfc74747c5107f6d740b86e6cedcd547857d0eff2f836cddd4b9d
5
5
  SHA512:
6
- metadata.gz: 2f9856120036ae6a87e2e758186221b7c0b48aa8d94365f7bdf4bffdf8f292868e43cf3c378eff7460902d710de2859d5406ac9ef5384c07f9728f2f1bad4ef4
7
- data.tar.gz: 9e96e672cb1f4da44ae67de48d3e783cf1d2b180c0d8f4a9706ce1ace07b9ac456a689653dfe84537c0cab71e6c58cdb24e5caeec0bcc86bc73a374615ac4e0a
6
+ metadata.gz: b7402f247e5cead6fba5259df4465836778026e2abdfd2a40281c22ed21679f60e15b23d913d7bb1bb559345557b31d3e024ab181a11c131d9159a7d64794e10
7
+ data.tar.gz: 9fd6953261a7e29e2e7676ce770311921b447c899624f105e4d3496ed89018cc515a1b21ecf2f94b36def0a492808105da0953df48680d6beed03776b3d0e6a7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/dougyouch/csv-utils/compare/v0.6.0...v0.7.0) (2026-10-05)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **encoding:** CSVIterator no longer takes a file mode, files are decoded as UTF-8 whatever the locale, and output is written in the decoded encoding. See UPGRADING.md.
9
+
10
+ ### Features
11
+
12
+ * **encoding:** read every file with the encoding option and write the decoded encoding ([1a9ec4f](https://github.com/dougyouch/csv-utils/commit/1a9ec4f98cbaadfff7ef89fec0d586cea828cf91))
13
+ * **iterator:** open files per call, build rows without zip, add auto_detect ([34ab89a](https://github.com/dougyouch/csv-utils/commit/34ab89ae49dad76507371d2bdf9a7cd310c2cf67))
14
+ * **options:** detect windows-1252 and read utf-8 regardless of locale ([10eeca6](https://github.com/dougyouch/csv-utils/commit/10eeca69bf094fb8c2cb5b005bc74687bde61169))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **iterator:** strip the byte order mark after rewind and from explicit-header rows ([bf8804b](https://github.com/dougyouch/csv-utils/commit/bf8804b1c2ce16738779f3b9ecc7631c3b29a115))
20
+
3
21
  ## [0.6.0](https://github.com/dougyouch/csv-utils/compare/v0.5.0...v0.6.0) (2026-10-05)
4
22
 
5
23
 
data/README.md CHANGED
@@ -6,7 +6,7 @@ Streaming tools for CSV files that are too big or too broken to load into memory
6
6
  [![Coverage](https://raw.githubusercontent.com/dougyouch/csv-utils/badges/coverage.svg)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
7
7
  [![Branch Coverage](https://raw.githubusercontent.com/dougyouch/csv-utils/badges/branches.svg)](https://github.com/dougyouch/csv-utils/actions/workflows/ci.yml)
8
8
 
9
- [API reference](https://rubydoc.info/gems/csv-utils) · [Changelog](CHANGELOG.md) · [Architecture](ARCHITECTURE.md)
9
+ [API reference](https://rubydoc.info/gems/csv-utils) · [Changelog](CHANGELOG.md) · [Upgrading](UPGRADING.md) · [Architecture](ARCHITECTURE.md)
10
10
 
11
11
  ## Features
12
12
 
@@ -19,6 +19,7 @@ Streaming tools for CSV files that are too big or too broken to load into memory
19
19
  - **CSV Iteration**: Efficient iteration over CSV files with batch support
20
20
  - **CSV Extension**: Extend CSV files with additional columns
21
21
  - **CSV Options**: Auto-detect CSV file properties (separators, encoding, BOM)
22
+ - **Encodings**: Files are read as UTF-8 whatever the locale, or in the encoding you pass or `CSVOptions` detects
22
23
  - **Byte Order Marks**: UTF-8, UTF-16 and UTF-32 byte order marks are stripped from the first header
23
24
  - **CLI Tools**: Command-line utilities for CSV debugging and manipulation
24
25
 
@@ -232,6 +233,14 @@ iterator.each.with_index { |row, idx| puts "#{idx}: #{row['name']}" }
232
233
 
233
234
  Line numbers count the header row as line 1, so they match what an editor shows. `headers` returns `[]` for an empty file.
234
235
 
236
+ Given a path, the iterator opens the file for each call (`each`, `headers`, `size`, ...) and closes it when the call returns, even when the block raises or stops early, so an idle iterator holds no file handle. A CSV object passed in is rewound, never closed.
237
+
238
+ Let `CSVOptions` work out the separators and encoding:
239
+
240
+ ```ruby
241
+ iterator = CSVUtils::CSVIterator.auto_detect('export.tsv')
242
+ ```
243
+
235
244
  ### Matching CSV Rows
236
245
 
237
246
  Filter CSV rows using regex patterns:
@@ -295,11 +304,19 @@ options = CSVUtils::CSVOptions.new('data.csv')
295
304
  options.valid? # true if separators detected
296
305
  options.col_separator # detected column separator
297
306
  options.row_separator # detected row separator
298
- options.encoding # detected encoding (UTF-8, UTF-16, UTF-32)
307
+ options.encoding # detected encoding (UTF-8, UTF-16, UTF-32, Windows-1252, ISO-8859-1)
299
308
  options.columns # number of columns
300
309
  options.byte_order_mark # BOM if present
310
+
311
+ # Open the file with what was detected, with CSV or any class in this gem
312
+ CSV.open('data.csv', 'rb', **options.to_csv_options)
313
+ CSVUtils::CSVSort.new('data.csv', 'sorted.csv', true, options.to_csv_options).sort
301
314
  ```
302
315
 
316
+ `to_csv_options` returns the detected `col_sep` and `row_sep`, leaving out any that weren't found, and an `encoding` that reads values as UTF-8 strings: `'bom|utf-8'` for UTF-8 files, `'BOM|UTF-16LE:UTF-8'` and the like for files with a UTF-16 or UTF-32 byte order mark (CSV detects the row separator for those), and `'Windows-1252:UTF-8'` for files that aren't valid UTF-8.
317
+
318
+ Without a byte order mark, the encoding comes from the first megabyte (`CSVOptions::SAMPLE_SIZE`): valid UTF-8 is `UTF-8`, anything else is `Windows-1252`, the usual encoding of Excel exports, or `ISO-8859-1` when the sample has one of the five bytes Windows-1252 leaves undefined. A file whose first non-UTF-8 byte comes after the sample is still read as UTF-8.
319
+
303
320
  Supported column separators: `\x02`, `\t`, `|`, `,` (the first one found in the header line wins)
304
321
  Supported row separators: `\r\n`, `\n`, `\r`
305
322
 
@@ -307,15 +324,20 @@ Headers are parsed as a CSV row, so a quoted header like `"Last, First"` counts
307
324
 
308
325
  ### Encodings and Byte Order Marks
309
326
 
310
- Files are opened with mode `'rb'`. With csv 3.3 and later, when Ruby's default external encoding is UTF-8 (the usual case), CSV reads `'rb'` files as UTF-8 and raises `CSV::InvalidEncodingError` on bytes that aren't valid UTF-8. To read a file's bytes as is, pass an explicit encoding:
327
+ Every class handles encodings the same way:
328
+
329
+ - **Reading** uses the `encoding:` CSV option, `'bom|utf-8'` by default, so values are UTF-8 strings whatever the locale (including `LANG=C` in containers and cron jobs). Bytes that aren't valid UTF-8 raise `CSV::InvalidEncodingError`.
330
+ - **Writing** (`CSVExtender`, `CSVTransformer`, `CSVSort`) uses the encoding values were decoded to, so a file read with `'Windows-1252:UTF-8'` is written as UTF-8. `CSVReport` writes UTF-8 unless you pass `encoding:`.
331
+ - **File modes never carry an encoding.** CSV raises `ArgumentError: encoding specified twice` when the mode and the `encoding:` option both have one.
311
332
 
312
333
  ```ruby
313
- iterator = CSVUtils::CSVIterator.new('latin1.csv', {}, 'rb:BINARY')
334
+ iterator = CSVUtils::CSVIterator.new('export.csv', encoding: 'Windows-1252:UTF-8')
335
+ iterator = CSVUtils::CSVIterator.new('latin1.csv', encoding: 'BINARY') # raw bytes
314
336
  comparator = CSVUtils::CSVCompare.new('primary.csv', ['updated_at'], encoding: 'BINARY') { |src, dest| src['id'] <=> dest['id'] }
315
- sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true, encoding: 'BINARY')
337
+ sorter = CSVUtils::CSVSort.new('input.csv', 'output.csv', true, CSVUtils::CSVOptions.new('input.csv').to_csv_options)
316
338
  ```
317
339
 
318
- `CSVSort`, `CSVExtender` and `CSVTransformer` take CSV options as their last argument and `CSVIterator` takes them second; they're passed to `CSV.open` for file paths.
340
+ `CSVSort`, `CSVExtender`, `CSVTransformer`, `CSVCompare` and `CSVReport` take CSV options as their last argument and `CSVIterator` takes them second; they're passed to `CSV.open` for file paths. `CSVUtils::EncodingOptions` holds the rules above. Upgrading from 0.6? See [UPGRADING.md](UPGRADING.md).
319
341
 
320
342
  `CSVUtils::ByteOrderMark` detects and strips UTF-8, UTF-16 and UTF-32 byte order marks; `CSVIterator`, `CSVCompare` and `CSVOptions` use it to clean the first header.
321
343
 
@@ -24,7 +24,7 @@ require 'csv-utils'
24
24
  file = ARGV[0] || raise('no CSV file specified')
25
25
 
26
26
  # read bytes as is, so values that aren't valid UTF-8 can still be compared
27
- csv = CSVUtils::CSVIterator.new(file, {}, 'rb:BINARY')
27
+ csv = CSVUtils::CSVIterator.new(file, encoding: 'BINARY')
28
28
 
29
29
  missing_headers = options[:ignore_columns] - csv.headers
30
30
  unless missing_headers.empty?
data/lib/csv-utils.rb CHANGED
@@ -6,6 +6,8 @@ require 'csv'
6
6
  # into memory. Each class is autoloaded on first use.
7
7
  module CSVUtils
8
8
  autoload :ByteOrderMark, 'csv_utils/byte_order_mark'
9
+ autoload :CharacterEncoding, 'csv_utils/character_encoding'
10
+ autoload :EncodingOptions, 'csv_utils/encoding_options'
9
11
  autoload :CSVCompare, 'csv_utils/csv_compare'
10
12
  autoload :CSVExtender, 'csv_utils/csv_extender'
11
13
  autoload :CSVIterator, 'csv_utils/csv_iterator'
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVUtils
4
+ # Guesses the character encoding of a file without a byte order mark from a sample of its bytes.
5
+ module CharacterEncoding
6
+ # Bytes Windows-1252 leaves undefined; converting them to UTF-8 raises.
7
+ # @return [Regexp]
8
+ WINDOWS_1252_UNDEFINED = /[\x81\x8D\x8F\x90\x9D]/n
9
+
10
+ # 'UTF-8' when the sample is valid UTF-8. Otherwise 'Windows-1252', the usual encoding of Excel exports,
11
+ # or 'ISO-8859-1' when the sample has a byte Windows-1252 leaves undefined, since every ISO-8859-1 byte
12
+ # converts to UTF-8.
13
+ # @param sample [String] bytes from the start of the file, ending on a whole character
14
+ # @return [String]
15
+ def self.detect(sample)
16
+ bytes = sample.b
17
+ return 'UTF-8' if bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding?
18
+ return 'ISO-8859-1' if bytes.match?(WINDOWS_1252_UNDEFINED)
19
+
20
+ 'Windows-1252'
21
+ end
22
+ end
23
+ end
@@ -24,7 +24,7 @@ module CSVUtils
24
24
  # @param update_comparison_columns [Array<String>, nil] columns compared on matching records;
25
25
  # without them no updates are yielded
26
26
  # @param csv_options [Hash] options passed to CSV.open for both files, ex: col_sep: "\t",
27
- # or encoding: 'BINARY' to read bytes that aren't valid UTF-8
27
+ # or encoding: 'BINARY' to read bytes that aren't valid UTF-8; the :encoding defaults to 'bom|utf-8'
28
28
  # @yieldparam src [Hash{String => String}] record from the primary file
29
29
  # @yieldparam dest [Hash{String => String}] record from the secondary file
30
30
  # @yieldreturn [Integer] negative, 0 or positive like <=>, using the same order both files are sorted by
@@ -88,7 +88,7 @@ module CSVUtils
88
88
  end
89
89
 
90
90
  def open_csv(file)
91
- csv = CSV.open(file, 'rb', **csv_options)
91
+ csv = CSV.open(file, 'rb', **EncodingOptions.read(csv_options))
92
92
  yield csv, read_headers(csv)
93
93
  ensure
94
94
  csv&.close
@@ -9,10 +9,11 @@ module CSVUtils
9
9
  class CSVExtender
10
10
  # @param src_csv [String, CSV, #shift] path or CSV to read; paths are opened and closed by the extender
11
11
  # @param dest_csv [String, CSV, #<<] path or CSV to write
12
- # @param csv_options [Hash] options passed to CSV.open for paths
12
+ # @param csv_options [Hash] options passed to CSV.open for paths; the source is read with its :encoding,
13
+ # 'bom|utf-8' by default, and the output written in the encoding values were decoded to (see {EncodingOptions})
13
14
  def initialize(src_csv, dest_csv, csv_options = {})
14
- @src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', csv_options)
15
- @dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', csv_options)
15
+ @src_csv = CSVUtils::CSVWrapper.new(src_csv, 'rb', EncodingOptions.read(csv_options))
16
+ @dest_csv = CSVUtils::CSVWrapper.new(dest_csv, 'wb', EncodingOptions.write(csv_options))
16
17
  end
17
18
 
18
19
  # Appends columns one row at a time, then closes the files it opened.
@@ -26,8 +26,15 @@ module CSVUtils
26
26
  # @param lineno [Integer]
27
27
  # @return [RowWrapper]
28
28
  def self.create(headers, row, lineno)
29
- row_wrapper = RowWrapper[headers.zip(row)]
29
+ row_wrapper = new
30
30
  row_wrapper.lineno = lineno
31
+ # an index loop instead of headers.zip(row), which allocates an array per column for every row
32
+ idx = 0
33
+ size = headers.size
34
+ while idx < size
35
+ row_wrapper[headers[idx]] = row[idx]
36
+ idx += 1
37
+ end
31
38
  row_wrapper
32
39
  end
33
40
 
@@ -41,41 +48,40 @@ module CSVUtils
41
48
  end
42
49
  end
43
50
 
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)
51
+ # An iterator for a path, with the separators and encoding detected by {CSVOptions}.
52
+ # @example
53
+ # CSVUtils::CSVIterator.auto_detect('export.tsv').each { |row| puts row['name'] }
54
+ # @param path [String]
55
+ # @return [CSVIterator]
56
+ def self.auto_detect(path)
57
+ options = CSVOptions.new(path)
58
+ new(path, options.to_csv_options)
59
+ end
60
+
61
+ # A path is opened by each method call and closed when the call returns,
62
+ # so an idle iterator holds no file handle.
63
+ # @param src_csv [String, CSV] path or CSV to read; a CSV must support rewind and is never closed
64
+ # @param csv_options [Hash] options passed to CSV.open for a path. The :encoding defaults to 'bom|utf-8'
65
+ # (see {EncodingOptions}); 'BINARY' reads bytes as is and 'Windows-1252:UTF-8' converts.
66
+ def initialize(src_csv, csv_options = {})
67
+ @src_csv = src_csv
68
+ @csv_options = EncodingOptions.read(csv_options)
49
69
  end
50
70
 
51
71
  # Yields each row from the start of the file.
52
72
  # @param headers [Array<String>, nil] headers to use; by default the first row is read as the headers
53
73
  # @yieldparam row [RowWrapper]
54
74
  # @return [Enumerator, nil] an enumerator without a block
55
- def each(headers = nil)
75
+ def each(headers = nil, &)
56
76
  return enum_for(:each, headers) unless block_given?
57
77
 
58
- @src_csv.rewind
59
-
60
- lineno = 0
61
- unless headers
62
- headers = read_headers
63
- lineno += 1
64
- end
65
-
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
78
+ open_csv { |csv| each_row(csv, headers, &) }
72
79
  end
73
80
 
74
81
  # The first row, without the byte order mark.
75
82
  # @return [Array<String>] empty for an empty file
76
83
  def headers
77
- @src_csv.rewind
78
- read_headers
84
+ open_csv { |csv| read_headers(csv) }
79
85
  end
80
86
 
81
87
  # Builds a lookup hash from every row.
@@ -85,8 +91,9 @@ module CSVUtils
85
91
  # @return [Hash]
86
92
  # @raise [RuntimeError] when key or value isn't a header
87
93
  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)
94
+ file_headers = headers
95
+ raise("header #{key} not found in #{file_headers}") unless file_headers.include?(key)
96
+ raise("headers #{value} not found in #{file_headers}") if value && !file_headers.include?(value)
90
97
 
91
98
  value_proc =
92
99
  if value
@@ -103,11 +110,12 @@ module CSVUtils
103
110
  # Number of rows after the header row.
104
111
  # @return [Integer]
105
112
  def size
106
- @src_csv.rewind
107
- @src_csv.shift
108
- cnt = 0
109
- cnt += 1 while @src_csv.shift
110
- cnt
113
+ open_csv do |csv|
114
+ csv.shift
115
+ cnt = 0
116
+ cnt += 1 while csv.shift
117
+ cnt
118
+ end
111
119
  end
112
120
 
113
121
  # Yields the rows in batches.
@@ -134,11 +142,43 @@ module CSVUtils
134
142
 
135
143
  private
136
144
 
145
+ # Opens a path for the duration of the block. Only a CSV that was passed in is rewound:
146
+ # rewinding goes back to byte 0, in front of a byte order mark the 'bom|' encoding skipped on open.
147
+ def open_csv
148
+ CSVWrapper.open(@src_csv, 'rb', @csv_options) do |csv|
149
+ csv.rewind unless @src_csv.is_a?(String)
150
+ yield csv
151
+ end
152
+ end
153
+
154
+ def each_row(csv, headers)
155
+ @prev_row = nil
156
+ row = shift_first_row(csv)
157
+ lineno = 1
158
+ unless headers
159
+ headers = row || []
160
+ row = csv.shift
161
+ lineno += 1
162
+ end
163
+
164
+ while row
165
+ yield RowWrapper.create(headers, row, lineno)
166
+ @prev_row = row
167
+ row = csv.shift
168
+ lineno += 1
169
+ end
170
+ end
171
+
137
172
  # 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
173
+ def read_headers(csv)
174
+ shift_first_row(csv) || []
175
+ end
176
+
177
+ # the first row without the byte order mark, whether it's read as headers or as data
178
+ def shift_first_row(csv)
179
+ row = csv.shift
180
+ row[0] = ByteOrderMark.strip(row[0]) if row && row[0]
181
+ row
142
182
  end
143
183
  end
144
184
  end
@@ -25,25 +25,40 @@ module CSVUtils
25
25
  "\r"
26
26
  ].freeze
27
27
 
28
+ # @return [Hash{String => String}] UTF-16 and UTF-32 byte order marks, as binary strings, to the :encoding
29
+ # option that decodes them to UTF-8
30
+ WIDE_ENCODINGS = {
31
+ (+"\x00\x00\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-32BE:UTF-8',
32
+ (+"\xFF\xFE\x00\x00").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-32LE:UTF-8',
33
+ (+"\xFE\xFF").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-16BE:UTF-8',
34
+ (+"\xFF\xFE").force_encoding('ASCII-8BIT').freeze => 'BOM|UTF-16LE:UTF-8'
35
+ }.freeze
36
+
37
+ # @return [Integer] bytes read to detect the encoding of a file without a byte order mark
38
+ SAMPLE_SIZE = 1024 * 1024
39
+
28
40
  # @return [Integer, nil] number of headers, nil without a column separator
29
41
  attr_reader :columns
30
42
  # @return [String, nil] the byte order mark the file starts with, as a binary string
31
43
  attr_reader :byte_order_mark
32
- # @return [String] 'UTF-8', 'UTF-16' or 'UTF-32', from the byte order mark; 'UTF-8' without one
44
+ # @return [String] 'UTF-8', 'UTF-16' or 'UTF-32' from the byte order mark; without one, from the bytes of
45
+ # the first {SAMPLE_SIZE} bytes: 'UTF-8', 'Windows-1252' or 'ISO-8859-1' (see {CharacterEncoding.detect})
33
46
  attr_reader :encoding
34
47
  # @return [String, nil] one of {COL_SEPARATORS}
35
48
  attr_reader :col_separator
36
49
  # @return [String, nil] one of {ROW_SEPARATORS}
37
50
  attr_reader :row_separator
38
51
 
39
- # @param io [String, IO] path of the file, or an IO positioned at its first line
52
+ # @param io [String, IO] path of the file, or an IO positioned at its first line; up to {SAMPLE_SIZE}
53
+ # bytes are read from it
40
54
  def initialize(io)
41
- line = read_first_line(io)
55
+ sample = read_sample(io)
56
+ line = first_line(sample)
42
57
 
43
58
  @col_separator = auto_detect_col_sep(line)
44
59
  @row_separator = auto_detect_row_sep(line)
45
60
  @byte_order_mark = get_byte_order_mark(line)
46
- @encoding = get_character_encoding(@byte_order_mark)
61
+ @encoding = get_character_encoding(@byte_order_mark, sample)
47
62
  @columns = get_number_of_columns(line) if @col_separator
48
63
  end
49
64
 
@@ -55,6 +70,21 @@ module CSVUtils
55
70
  true
56
71
  end
57
72
 
73
+ # Options for CSV.open, or any class in this library, with the detected separators and an :encoding that
74
+ # reads values as UTF-8 strings: 'bom|utf-8', 'Windows-1252:UTF-8', 'BOM|UTF-16LE:UTF-8' and so on.
75
+ # A separator that wasn't detected is left to CSV, and so is the row separator of UTF-16 and UTF-32 files,
76
+ # since it's detected from raw bytes.
77
+ # @example
78
+ # CSV.open(path, 'rb', **options.to_csv_options)
79
+ # CSVUtils::CSVSort.new(path, 'sorted.csv', true, options.to_csv_options).sort
80
+ # @return [Hash{Symbol => String}]
81
+ def to_csv_options
82
+ options = { encoding: encoding_option }
83
+ options[:col_sep] = col_separator if col_separator
84
+ options[:row_sep] = row_separator if row_separator && !wide_encoding?
85
+ options
86
+ end
87
+
58
88
  # @api private
59
89
  # @param line [String]
60
90
  # @return [String, nil]
@@ -96,9 +126,10 @@ module CSVUtils
96
126
 
97
127
  # @api private
98
128
  # @param bom [String, nil]
129
+ # @param sample [String] bytes from the start of the file
99
130
  # @return [String]
100
- def get_character_encoding(bom)
101
- BYTE_ORDER_MARKS[bom] || 'UTF-8'
131
+ def get_character_encoding(bom, sample)
132
+ BYTE_ORDER_MARKS[bom] || CharacterEncoding.detect(sample)
102
133
  end
103
134
 
104
135
  # @api private
@@ -110,10 +141,28 @@ module CSVUtils
110
141
 
111
142
  private
112
143
 
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 || ''
144
+ # an empty file reads as an empty sample, which makes the options invalid
145
+ def read_sample(io)
146
+ sample = (io.is_a?(String) ? File.open(io, 'rb') { |file| file.read(SAMPLE_SIZE) } : io.read(SAMPLE_SIZE)) || ''
147
+ sample.bytesize < SAMPLE_SIZE ? sample : drop_last_multibyte_char(sample)
148
+ end
149
+
150
+ # a full sample can end mid-character, so its last multibyte character, whole or not, is dropped
151
+ def drop_last_multibyte_char(sample)
152
+ sample.sub(/[\xC0-\xFF][\x80-\xBF]*\z/n, '')
153
+ end
154
+
155
+ def first_line(sample)
156
+ line_end = sample.index("\n")
157
+ line_end ? sample.byteslice(0, line_end + 1) : sample
158
+ end
159
+
160
+ def wide_encoding?
161
+ WIDE_ENCODINGS.key?(byte_order_mark)
162
+ end
163
+
164
+ def encoding_option
165
+ WIDE_ENCODINGS.fetch(byte_order_mark) { encoding == 'UTF-8' ? EncodingOptions::DEFAULT_ENCODING : "#{encoding}:UTF-8" }
117
166
  end
118
167
 
119
168
  def header_parse_options
@@ -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.dup
25
+ opts = EncodingOptions.write(csv_options)
25
26
  mode = opts.delete(:mode) || 'wb'
26
27
  CSV.open(csv, mode, **opts)
27
28
  else
@@ -23,7 +23,8 @@ module CSVUtils
23
23
  # @param csv_file [String] path of the file to sort
24
24
  # @param new_csv_file [String] path of the sorted file to write
25
25
  # @param has_headers [Boolean] whether the first row is a header row
26
- # @param csv_options [Hash] options passed to CSV.open
26
+ # @param csv_options [Hash] options passed to CSV.open; the file is read with its :encoding, 'bom|utf-8' by
27
+ # default, and the sorted file written in the encoding values were decoded to (see {EncodingOptions})
27
28
  def initialize(csv_file, new_csv_file, has_headers = true, csv_options = {})
28
29
  @csv_file = csv_file
29
30
  @new_csv_file = new_csv_file
@@ -51,11 +52,12 @@ module CSVUtils
51
52
 
52
53
  # rubocop:disable-next Metrics/MethodLength
53
54
  def merge_sort_csv_files(src_csv_file1, src_csv_file2, dest_csv_file)
54
- src1 = CSV.open(src_csv_file1, 'rb', **csv_options)
55
+ # part files were written with the write options, so reading them back with those changes nothing
56
+ src1 = CSV.open(src_csv_file1, 'rb', **write_options)
55
57
  begin
56
- src2 = CSV.open(src_csv_file2, 'rb', **csv_options)
58
+ src2 = CSV.open(src_csv_file2, 'rb', **write_options)
57
59
  begin
58
- dest = CSV.open(dest_csv_file, 'wb', **csv_options)
60
+ dest = CSV.open(dest_csv_file, 'wb', **write_options)
59
61
  begin
60
62
  if @headers
61
63
  dest << @headers
@@ -99,7 +101,7 @@ module CSVUtils
99
101
  end
100
102
 
101
103
  def create_sorted_csv_part_files(batch_size, &block)
102
- src = CSV.open(csv_file, 'rb', **csv_options)
104
+ src = CSV.open(csv_file, 'rb', **EncodingOptions.read(csv_options))
103
105
  begin
104
106
  @headers = src.shift if has_headers
105
107
 
@@ -107,7 +109,7 @@ module CSVUtils
107
109
  create_batch_part_proc = proc do
108
110
  batch.sort!(&block)
109
111
  @csv_part_files << "#{new_csv_file}.part.#{@csv_part_files.size}"
110
- CSV.open(@csv_part_files.last, 'wb', **csv_options) do |csv|
112
+ CSV.open(@csv_part_files.last, 'wb', **write_options) do |csv|
111
113
  csv << @headers if @headers
112
114
  batch.each { |row| csv << row }
113
115
  end
@@ -143,10 +145,21 @@ module CSVUtils
143
145
  if @csv_part_files.size.positive?
144
146
  FileUtils.mv(@csv_part_files.pop, new_csv_file)
145
147
  else
146
- FileUtils.cp(@csv_file, new_csv_file)
148
+ write_headers_only
147
149
  end
148
150
  end
149
151
 
152
+ # a file without rows is written rather than copied, so it's in the same encoding as a sorted one
153
+ def write_headers_only
154
+ CSV.open(new_csv_file, 'wb', **write_options) do |csv|
155
+ csv << @headers if @headers
156
+ end
157
+ end
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
164
  def delete_csv_part_files
152
165
  @csv_part_files.each { |file| FileUtils.rm_f(file) }
@@ -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.
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CSVUtils
4
4
  # Gem version, bumped by release-please
5
- VERSION = '0.6.0'
5
+ VERSION = '0.7.0'
6
6
  end
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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Doug Youch
@@ -76,6 +76,7 @@ files:
76
76
  - bin/csv-validator
77
77
  - lib/csv-utils.rb
78
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,6 +87,7 @@ 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
90
+ - lib/csv_utils/encoding_options.rb
89
91
  - lib/csv_utils/version.rb
90
92
  homepage: https://github.com/dougyouch/csv-utils
91
93
  licenses: