dsv 0.12.4 → 0.12.5

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: 94796d930eb9448d20a7ac7c4a43598e2cdeaef2e4744f2309414f48a3b73dd9
4
- data.tar.gz: 6c9f3cf66144d9d9246080216e3200faf0eeaf169e3c6f023d07e3ae6b185a2b
3
+ metadata.gz: d6c8309ce7e75acd45ed55995d9c1ef8f04f1c6e945e56e80cb60d1fe4b7fa2a
4
+ data.tar.gz: 2f6a5693d250797bf9e9ce69f2d06c2fd144e5722c7f3e7996c0f4029b71fdea
5
5
  SHA512:
6
- metadata.gz: 357af1737793d37e8134c96912cfe4a666b6b41eccbba31a9bedbb67bc282c9f42abe0752df3775f803f13d71fe0f25639a64eb7748cad0eed34dd078dfcad8d
7
- data.tar.gz: 8c6abaa1867be5c7c9d649903f0d844be6a63971b4ac09551f3203a282be7bbb2739f669e9bbbaf33cc65ed3c99095b2c640a782c6291ce8e852524b93796075
6
+ metadata.gz: c91f3fd66f79475ec7c617f47545378a08af49e3a78e7b52b684496b3ab6cb22205e2c26a5c7cd025ca26956ac8730685e2a4344ce7505ea4e46ef737ef0d7e5
7
+ data.tar.gz: 3b2c669417438150a4fdfe421358e75362148ac7fa4fe8be1579c79b4c0f7f862283a2797dbef5bd02949cf9218daa61cdc194c0ca84844f443d39242e937245
data/CHANGELOG CHANGED
@@ -1,5 +1,19 @@
1
1
  # dsv/CHANGELOG
2
2
 
3
+ ## 20261016
4
+
5
+ 0.12.5: Regexp separators and quote: :double made to do what the README says.
6
+
7
+ 1. ~ split_row(): a Regexp separator reads a quoted field holding it, rejoined with the text matched; + pieces_and_separators(), + separator_before(), + split_on(). It had raised TypeError.
8
+ 2. ~ split_row() under :double: + split_double_quoted(), wholly quoted or wholly unquoted rows split fast, any other row read as with no mode named; + split_quoted(), + unescaped_fields(), + quoted_separator. "a",b,"c" had been read as one field.
9
+ 3. ~ complete_quoted_row(): carries a quoted field across lines under :double too.
10
+ 4. ~ writing with a Regexp separator raises ArgumentError; + writable_column_separator. The row renderer is chosen at the first write.
11
+ 5. ~ test/dsv_test.rb: - the skip on a Regexp separator with a quoted field; + examples of the above.
12
+ 6. ~ README.md: writing with a Regexp raises; how :double reads a row it does not promise.
13
+ 7. ~ TODO: 5, - the Regexp separator as a reason for the scanner parser.
14
+ 8. ~ lib/DSV/VERSION.rb: /0.12.4/0.12.5/.
15
+
16
+
3
17
  ## 20260913, 1005
4
18
 
5
19
  0.12.4: + dsv(.rb).gemspec, Gemfile, Rakefile, .gitignore and LICENSE.
@@ -41,7 +55,7 @@
41
55
 
42
56
  0.11.18: + TODO, and the header's Todo, Ideas and Bugs sections retired into it.
43
57
 
44
- 1. + TODO: what survives of the header's three sections and of _libraries/SimpleCSV/notes.txt, triaged: what is done or fixed is dropped with its version named in the reconciliation, and the rest is grouped as 0.12.0, decided; 0.13.0, speed; and features after, the guessing mode (separator, header, and a header made from the data where there is none), templated ingestion as a whole-row Regexp, the duplicate-header modes, the writing options, symbols and strings interchangeably, a strict: option, and a line number on parse errors.
58
+ 1. + TODO: what survives of the header's three sections and of SimpleCSV's notes, triaged: what is done or fixed is dropped with its version named in the reconciliation, and the rest is grouped as 0.12.0, decided; 0.13.0, speed; and features after, the guessing mode (separator, header, and a header made from the data where there is none), templated ingestion as a whole-row Regexp, the duplicate-header modes, the writing options, symbols and strings interchangeably, a strict: option, and a line number on parse errors.
45
59
  2. ~ lib/SimpleCSV.rb: - the Todo, Ideas and Bugs sections; the header is the name, the date, the version and the description. /0.11.17/0.11.18/.
46
60
 
47
61
  0.11.17: ~ SimpleCSV.header_row, the header row as names.
@@ -168,7 +182,7 @@
168
182
 
169
183
  1. + test/SimpleCSV_test.rb: MiniTest spec style, 102 examples. 76 pass and state the interface as it stands; 26 are skipped and state what the library should do and does not yet, each skip message naming its finding in the Handoff 0 closing note, so that the skip count is the fault list and each fix from here removes one skip. The RFC 4180 cases are rewritten from stdlib CSV's parsing tests. Run as `ruby test/SimpleCSV_test.rb`.
170
184
  2. ~ lib/SimpleCSV.rb: - the Changes section of the header, its entries being here from 0.10.0 on; the date and version lines stay. From here each entry is written by hand, one per commit.
171
- 3. 0.10.4 was the last revision imported from the staging tree; from here every change is a commit.
185
+ 3. 0.10.4 was the last imported version; from here every change is a commit.
172
186
 
173
187
  ### Note that prior to 0.10.4 predates this being in git.
174
188
 
data/README.md CHANGED
@@ -76,7 +76,7 @@ DSV.read('people.txt', headers: true, column_separator: /,\s*/)
76
76
  DSV.read('people.csv', headers: true, row_separator: "\r\n")
77
77
  ```
78
78
 
79
- `col_sep:` and `row_sep:` are accepted as spellings of those. Writing takes the same two options, so a file round-trips through whatever it was read with.
79
+ `col_sep:` and `row_sep:` are accepted as spellings of those. Writing takes the same two options, so a file round-trips through whatever it was read with. A Regexp says what may be read as a separator, not what to write, so writing with one raises `ArgumentError`; give a String to write.
80
80
 
81
81
  ### Quoting
82
82
 
@@ -87,6 +87,8 @@ DSV.read('plain.csv', headers: true, quote: :none) # no field is quoted; quot
87
87
  DSV.read('quoted.csv', headers: true, quote: :double) # every field is quoted, as DSV writes them
88
88
  ```
89
89
 
90
+ Under `:double` a row that is wholly unquoted, as a header row often is, is split plainly, and a row that is neither wholly quoted nor wholly unquoted is read as it would be with no mode named, so naming the mode never costs a correct read.
91
+
90
92
  ### Writing
91
93
 
92
94
  Set the columns, give the rows as Hashes, and write:
data/TODO CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  ## 0.13.0, speed
11
11
 
12
- 5. The saved-up speed change, with the evidence in the reconciliation: two string predicates in place of four regex matches per piece in split_csv; rows built by zip.to_h; a benchmark script in the repository against stdlib CSV, and the README's speed sentence written from its output. The scanner-based parser on the branch scanner-parser is judged here, for the Regexp separator with a quoted field if for nothing else.
12
+ 5. The saved-up speed change, with the evidence in the reconciliation: two string predicates in place of four regex matches per piece in split_csv; rows built by zip.to_h; a benchmark script in the repository against stdlib CSV, and the README's speed sentence written from its output. The scanner-based parser on the branch scanner-parser is judged here.
13
13
 
14
14
  ## Features, after
15
15
 
data/lib/DSV/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # DSV::VERSION
3
3
 
4
4
  class DSV
5
- VERSION = '0.12.4'
5
+ VERSION = '0.12.5'
6
6
  end
data/lib/dsv.rb CHANGED
@@ -156,7 +156,6 @@ class DSV
156
156
  @column_separator = options[:column_separator] || options[:col_sep] || ','
157
157
  @selected_columns = options[:selected_columns]
158
158
  @as_array = options[:as_array] || false
159
- @render_row = row_renderer
160
159
  if options[:columns]
161
160
  self.columns = options[:columns]
162
161
  else
@@ -264,32 +263,84 @@ class DSV
264
263
  value.nil? || value.empty?
265
264
  end
266
265
 
267
- # One row into its fields, by the quote mode: :none splits; :double splits a wholly quoted row on quote-separator-quote and a wholly unquoted one plainly; unspecified splits and reassembles a quoted field that held the separator, a doubled quote inside it reading as one. Direct string operations throughout; the scanner-based parser is on the branch scanner-parser.
266
+ # One row into its fields, by the quote mode: :none splits; :double splits a row as it promises, wholly quoted or wholly unquoted, and one that is neither as unspecified would; unspecified splits and reassembles a quoted field that held the separator, a doubled quote inside it reading as one. Direct string operations throughout; the scanner-based parser is on the branch scanner-parser.
268
267
  def split_row(raw_row)
269
268
  row = raw_row.chomp(@row_separator)
270
269
  case @quote
271
270
  when :none, :unquoted
272
- row.split(@column_separator, -1)
271
+ split_on(row, @column_separator)
273
272
  when :double, :double_quoted, :double_quotes
274
- if row.start_with?('"')
275
- row.delete_prefix('"').delete_suffix('"').split('"' + @column_separator + '"', -1).collect{|field| field.gsub('""', '"')}
276
- else
277
- row.split(@column_separator, -1)
278
- end
273
+ split_double_quoted(row) || split_quoted(row)
274
+ else
275
+ split_quoted(row)
276
+ end
277
+ end
278
+
279
+ # A row split on a separator, String or Regexp; a Regexp's matches divide it, since String#split would also return any groups it captures.
280
+ def split_on(row, separator)
281
+ separator.is_a?(::Regexp) ? pieces_and_separators(row, separator).first : row.split(separator, -1)
282
+ end
283
+
284
+ # A row's pieces between a Regexp's matches, with the text of each match, so that a quoted field may be joined back with the separator it held.
285
+ def pieces_and_separators(row, separator)
286
+ pieces, separators, position = [], [], 0
287
+ row.scan(separator) do
288
+ match = ::Regexp.last_match
289
+ pieces << row[position...match.begin(0)]
290
+ separators << match[0]
291
+ position = match.end(0)
292
+ end
293
+ [pieces << row[position..], separators]
294
+ end
295
+
296
+ # Unspecified quoting: the row split on the separator, a quoted field that held it joined back with the separator's own text.
297
+ def split_quoted(row)
298
+ if @column_separator.is_a?(::Regexp)
299
+ reassemble_quoted_fields(*pieces_and_separators(row, @column_separator))
279
300
  else
280
301
  reassemble_quoted_fields(row.split(@column_separator, -1))
281
302
  end
282
303
  end
283
304
 
284
- # Pieces of a row split on the separator, the pieces of a quoted field that held the separator joined back with it; a piece that is a lone quote opens or closes a field.
285
- def reassemble_quoted_fields(pieces)
305
+ # The :double contract: a wholly quoted row split on quote-separator-quote, a wholly unquoted one plainly; nil for a row that is neither.
306
+ def split_double_quoted(row)
307
+ if row.start_with?('"')
308
+ unescaped_fields(split_on(row.delete_prefix('"').delete_suffix('"'), quoted_separator))
309
+ else
310
+ split_on(row, @column_separator) unless row.include?('"')
311
+ end
312
+ end
313
+
314
+ # The fields of a row split as wholly quoted, a doubled quote read as one; nil where a field holds a lone quote, the mark of a row that was not. A field has twice the quotes it has unescaped exactly when every quote in it is one of a doubled pair.
315
+ def unescaped_fields(fields)
316
+ fields.collect do |field|
317
+ next field unless field.include?('"')
318
+ unescaped = field.gsub('""', '"')
319
+ return nil unless field.count('"') == 2 * unescaped.count('"')
320
+ unescaped
321
+ end
322
+ end
323
+
324
+ # Quote, separator, quote: what divides the fields of a wholly quoted row.
325
+ def quoted_separator
326
+ @quoted_separator ||= (
327
+ if @column_separator.is_a?(::Regexp)
328
+ ::Regexp.new('"(?:' + @column_separator.source + ')"', @column_separator.options)
329
+ else
330
+ '"' + @column_separator + '"'
331
+ end
332
+ )
333
+ end
334
+
335
+ # Pieces of a row split on the separator, the pieces of a quoted field that held the separator joined back with it, or with the text it matched where the separator is a Regexp; a piece that is a lone quote opens or closes a field.
336
+ def reassemble_quoted_fields(pieces, separators = nil)
286
337
  fields = []
287
338
  buffer = nil
288
- pieces.each do |piece|
339
+ pieces.each_with_index do |piece, i|
289
340
  if buffer && !piece.end_with?('"')
290
- buffer << @column_separator << piece
341
+ buffer << separator_before(i, separators) << piece
291
342
  elsif buffer
292
- fields << (buffer << @column_separator << piece.delete_suffix('"')).gsub('""', '"')
343
+ fields << (buffer << separator_before(i, separators) << piece.delete_suffix('"')).gsub('""', '"')
293
344
  buffer = nil
294
345
  elsif piece.start_with?('"') && (piece.length == 1 || !piece.end_with?('"'))
295
346
  buffer = +piece.delete_prefix('"')
@@ -302,9 +353,14 @@ class DSV
302
353
  fields
303
354
  end
304
355
 
305
- # A quoted field may hold the row separator: while the quotes in a row are unbalanced the next line belongs to it.
356
+ # What stood between a piece and the one before it: the separator, or the text a Regexp separator matched there.
357
+ def separator_before(i, separators)
358
+ separators ? separators[i - 1] : @column_separator
359
+ end
360
+
361
+ # A quoted field may hold the row separator: while the quotes in a row are unbalanced the next line belongs to it, in every quote mode which reads quotes as structure, :none and :unquoted reading them as data.
306
362
  def complete_quoted_row(raw_row)
307
- raw_row << @source.gets(@row_separator).to_s while @quote.nil? && raw_row.count('"').odd? && !@source.eof?
363
+ raw_row << @source.gets(@row_separator).to_s while @quote != :none && @quote != :unquoted && raw_row.count('"').odd? && !@source.eof?
308
364
  raw_row
309
365
  end
310
366
 
@@ -377,10 +433,17 @@ class DSV
377
433
  alias_method :write_csv_row, :write_row
378
434
 
379
435
  def write_values(values)
436
+ @render_row ||= row_renderer
380
437
  @source.write(@render_row.call(values))
381
438
  end
382
439
 
383
- # Chosen once from the quote mode: how a row of values is rendered, with the instance's separators, a quote inside a quoted value doubled as RFC 4180 has it. The spacey modes put a space after each separator, as Array#to_csv_row did.
440
+ # The column separator a row is written with: a Regexp says what may be read as one, not what to write.
441
+ def writable_column_separator
442
+ raise ::ArgumentError, "a Regexp column separator can be read with, not written with; give a String to write" if @column_separator.is_a?(::Regexp)
443
+ @column_separator
444
+ end
445
+
446
+ # Chosen once, at the first write, from the quote mode: how a row of values is rendered, with the instance's separators, a quote inside a quoted value doubled as RFC 4180 has it. The spacey modes put a space after each separator, as Array#to_csv_row did.
384
447
  def row_renderer
385
448
  quote, spacey = case @quote&.to_sym
386
449
  when :none, :unquoted then [nil, false]
@@ -390,7 +453,7 @@ class DSV
390
453
  when :spacey_double then ['"', true]
391
454
  else ['"', false]
392
455
  end
393
- joiner = spacey ? @column_separator + ' ' : @column_separator
456
+ joiner = spacey ? writable_column_separator + ' ' : writable_column_separator
394
457
  if quote
395
458
  doubled = quote * 2
396
459
  ->(values){values.collect{|value| quote + value.to_s.gsub(quote, doubled) + quote}.join(joiner) + @row_separator}
data/test/dsv_test.rb CHANGED
@@ -442,8 +442,21 @@ describe DSV do
442
442
  end
443
443
 
444
444
  it "reads a regular expression separator with a quoted field holding the separator" do
445
- skip "Observation: reassembling a quoted field concatenates the separator back in, which a Regexp cannot be; the parked scanner parser handles it"
446
445
  _(DSV.read("a,b\n\"x, y\",2\n", headers: true, column_separator: /,\s*/)).must_equal [{'a' => 'x, y', 'b' => '2'}]
446
+ _(DSV.read("\"x, y\",2\n", column_separator: /,\s*/)).must_equal [{0 => 'x, y', 1 => '2'}]
447
+ end
448
+
449
+ it "reads a regular expression separator under quote: :double" do
450
+ _(DSV.read("\"a\"; \"b\"\n\"x; y\";\"2\"\n", headers: true, quote: :double, column_separator: /;\s*/)).must_equal [{'a' => 'x; y', 'b' => '2'}]
451
+ end
452
+
453
+ it "reads a regular expression separator that captures, without the captures becoming fields" do
454
+ _(DSV.read("1;2,3\n", quote: :none, column_separator: /(;|,)/)).must_equal [{0 => '1', 1 => '2', 2 => '3'}]
455
+ _(DSV.read("\"x;y\",2\n", column_separator: /(;|,)/)).must_equal [{0 => 'x;y', 1 => '2'}]
456
+ end
457
+
458
+ it "reads with a regular expression separator under the spacey quote modes" do
459
+ _(DSV.read("1, 2\n", quote: :spacey_none, column_separator: /,\s*/)).must_equal [{0 => '1', 1 => '2'}]
447
460
  end
448
461
  end
449
462
 
@@ -519,6 +532,20 @@ describe DSV do
519
532
  it "keeps a separator inside a quoted field under quote: :double" do
520
533
  _(DSV.read("a,b\n\"x, y\",\"2\"\n", headers: true, quote: :double)).must_equal [{'a' => 'x, y', 'b' => '2'}]
521
534
  end
535
+
536
+ it "reads a row that is neither wholly quoted nor wholly unquoted under quote: :double as it would unspecified" do
537
+ _(DSV.read("\"a\",b,\"c\"\n", quote: :double)).must_equal [{0 => 'a', 1 => 'b', 2 => 'c'}]
538
+ _(DSV.read("1,\"x\"\n", quote: :double)).must_equal [{0 => '1', 1 => 'x'}]
539
+ _(DSV.read("\"a\",\"b\",c\n", quote: :double)).must_equal [{0 => 'a', 1 => 'b', 2 => 'c'}]
540
+ _(DSV.read("a,b\n1,\"x, y\"\n", headers: true, quote: :double)).must_equal [{'a' => '1', 'b' => 'x, y'}]
541
+ end
542
+
543
+ it "reads a quoted field holding the row separator as one field across lines under quote: :double, as DSV writes it" do
544
+ _(DSV.read("\"x\ny\",\"2\"\n\"z\",\"3\"\n", quote: :double)).must_equal [{0 => "x\ny", 1 => '2'}, {0 => 'z', 1 => '3'}]
545
+ _(DSV.read("1,\"x\ny\"\n", quote: :double)).must_equal [{0 => '1', 1 => "x\ny"}]
546
+ output = written(headers: true, columns: ['a', 'b']){|csv| csv.rows = [{'a' => "x\ny", 'b' => '2'}]; csv.write}
547
+ _(DSV.read(output, headers: true, quote: :double)).must_equal [{'a' => "x\ny", 'b' => '2'}]
548
+ end
522
549
  end
523
550
 
524
551
  describe "#write" do
@@ -597,6 +624,11 @@ describe DSV do
597
624
  _(written(columns: [:a, :b], quote: :spacey_none){|csv| csv.write_row('a' => 1, 'b' => 2)}).must_equal "1, 2\n"
598
625
  _(written(columns: [:a, :b], quote: :spacey_double){|csv| csv.write_row('a' => 1, 'b' => 2)}).must_equal "\"1\", \"2\"\n"
599
626
  end
627
+
628
+ it "refuses to write with a regular expression separator, there being no one string to write between fields" do
629
+ error = _{written(columns: [:a, :b], column_separator: /,\s*/){|csv| csv.write_row('a' => 1, 'b' => 2)}}.must_raise ArgumentError
630
+ _(error.message).must_match(/Regexp/)
631
+ end
600
632
  end
601
633
 
602
634
  describe "DSV::String" do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dsv
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.4
4
+ version: 0.12.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran
@@ -91,7 +91,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
91
  - !ruby/object:Gem::Version
92
92
  version: '0'
93
93
  requirements: []
94
- rubygems_version: 4.0.21
94
+ rubygems_version: 4.0.23
95
95
  specification_version: 4
96
96
  summary: Read and write delimiter-separated values.
97
97
  test_files: []