dsv 0.12.7 → 0.12.9
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 +22 -0
- data/README.md +2 -0
- data/TODO +2 -1
- data/lib/DSV/UnterminatedQuote.rb +14 -0
- data/lib/DSV/VERSION.rb +1 -1
- data/lib/dsv.rb +28 -16
- data/test/dsv_test.rb +16 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7171a2d2de4dba26f3e304ca3e0c216e9bb093a36cef1420629e9f936e644889
|
|
4
|
+
data.tar.gz: 4ccf92f6000c7e5b9d82315babc17c816edd3df7587583eb79f2c16eb19125cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4727efe3e9571299914d4b6731169ac5a70eabd475176f06cb084dba35cfcc038420faf4740ad01bbedb9caea1b13ba21ed40a058f2699ca6f4fedf9ba47a0b1
|
|
7
|
+
data.tar.gz: 6b6143f85e9c6106344fed384228433057eff0641a38eff9e6f823a2d905cca416284b7068f80ff26529feb02045fe09f323e689614012bd267d0783aec469ba
|
data/CHANGELOG
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# dsv/CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 20261028
|
|
4
|
+
|
|
5
|
+
0.12.9: + DSV::UnterminatedQuote, raised where a quoted field does not close.
|
|
6
|
+
|
|
7
|
+
1. + lib/DSV/UnterminatedQuote.rb: DSV::UnterminatedQuote, with line, the line on which the field's row began.
|
|
8
|
+
2. ~ row_fields(), parse_row(), columns(): raise it where a quoted field is still open at the end of the source, or of the line or header given. Such a field had been dropped silently, with its row.
|
|
9
|
+
3. + line_of(): counts the lines before the row only when raising.
|
|
10
|
+
4. ~ test/dsv_test.rb: - the skip on a quoted field which does not close; + the line it names; the malformed examples now state the default rule.
|
|
11
|
+
5. ~ README.md: Quoting, the rule for input which isn't RFC 4180.
|
|
12
|
+
6. ~ TODO: 12, as UnterminatedQuote finds its line; + 15, the rule as an option.
|
|
13
|
+
7. ~ lib/DSV/VERSION.rb: /0.12.8/0.12.9/.
|
|
14
|
+
|
|
15
|
+
0.12.8: a stray quote no longer takes in the next line.
|
|
16
|
+
|
|
17
|
+
1. /complete_quoted_row()/row_fields()/: takes the next line into a row while a quoted field in it is open, rather than while its quotes are odd in number. A stray quote had made the count odd, and one beside an open field even.
|
|
18
|
+
2. ~ reassemble_quoted_fields(), split_row(): nil where a quoted field is still open at the end of the row.
|
|
19
|
+
3. ~ split_double_quoted(): takes a row as wholly quoted only where it ends in a closing quote.
|
|
20
|
+
4. + parse_fields(), from parse_row(), so that the read loop splits each row once.
|
|
21
|
+
5. ~ test/dsv_test.rb: - the skip on a stray quote; + a quoted field across lines after a stray quote.
|
|
22
|
+
6. ~ lib/DSV/VERSION.rb: /0.12.7/0.12.8/.
|
|
23
|
+
|
|
24
|
+
|
|
3
25
|
## 20261027
|
|
4
26
|
|
|
5
27
|
0.12.7: ~ reassemble_quoted_fields(): an escaped quote before a separator no longer closes a quoted field.
|
data/README.md
CHANGED
|
@@ -89,6 +89,8 @@ DSV.read('quoted.csv', headers: true, quote: :double) # every field is quoted,
|
|
|
89
89
|
|
|
90
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
91
|
|
|
92
|
+
Input which isn't RFC 4180 is read to one rule. A quote opens a field only at the start of one, so a quote anywhere else, as in `12" pizza`, is data. A quoted field closes only at a quote followed by the separator or the end of the row, so one which doesn't close by the end of the source, as `"abc` or `"ab"c` doesn't, raises `DSV::UnterminatedQuote`, whose `line` is the line its row began on.
|
|
93
|
+
|
|
92
94
|
### Writing
|
|
93
95
|
|
|
94
96
|
Set the columns, give the rows as Hashes, and write:
|
data/TODO
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
9. Writing options: minimal quoting, a field quoted only where it holds a separator, a quote or a row terminator; a row terminator inside a field kept, quoted, or folded to a space; the encoding declared and a transcode offered with any loss marked.
|
|
20
20
|
10. Symbols and Strings interchangeable for column names, in a selection, a columns: list and a row's keys; today a String is required.
|
|
21
21
|
11. A strict: option, off by default, that raises on a row with more or fewer fields than the header; today a short row gets only the columns it has and a long row's excess lands under nil.
|
|
22
|
-
12. A line number in each parse error,
|
|
22
|
+
12. A line number in each parse error, found only when the error is raised, from the source's position at that moment, as UnterminatedQuote finds its; never counted as it goes, since counting costs time on every row for the sake of the rare one.
|
|
23
23
|
13. Chunked processing of a large file: a bounded number of rows at a time handed to a block, so that nothing needs the whole file in memory and a partial result can be acted on before the rest is read.
|
|
24
24
|
14. Checks, all of them after the above: strict: (13), column types, and whatever else proves wanted; correct and published comes first.
|
|
25
|
+
15. The rule for malformed quoting as an option, its name to be settled, perhaps one with strict: (11): the default, as now; a stricter one, under which a quote that does not open a field raises too, and :double raises on a row which is not what it promises rather than reading it as unspecified quoting does; and a lenient one, never raising, text after a closing quote kept and a field which does not close running to the end of the source.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# DSV/UnterminatedQuote.rb
|
|
2
|
+
# DSV::UnterminatedQuote
|
|
3
|
+
|
|
4
|
+
class DSV
|
|
5
|
+
# A quoted field which does not close by the end of the source; line is the one on which its row began.
|
|
6
|
+
class UnterminatedQuote < ::StandardError
|
|
7
|
+
attr_reader :line
|
|
8
|
+
|
|
9
|
+
def initialize(line)
|
|
10
|
+
@line = line
|
|
11
|
+
super("A quoted field in the row beginning on line #{line} does not close.")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/DSV/VERSION.rb
CHANGED
data/lib/dsv.rb
CHANGED
|
@@ -8,6 +8,7 @@ require 'stringio'
|
|
|
8
8
|
require 'DSV/VERSION'
|
|
9
9
|
require 'DSV/File'
|
|
10
10
|
require 'DSV/String'
|
|
11
|
+
require 'DSV/UnterminatedQuote'
|
|
11
12
|
|
|
12
13
|
class DSV
|
|
13
14
|
|
|
@@ -180,7 +181,7 @@ class DSV
|
|
|
180
181
|
else
|
|
181
182
|
read_header
|
|
182
183
|
@rows = []
|
|
183
|
-
@source.each(@row_separator){|raw_row| @rows <<
|
|
184
|
+
@source.each(@row_separator){|raw_row| @rows << parse_fields(row_fields(raw_row), *selected_columns)}
|
|
184
185
|
@rows
|
|
185
186
|
end
|
|
186
187
|
end
|
|
@@ -211,7 +212,7 @@ class DSV
|
|
|
211
212
|
@columns = (
|
|
212
213
|
if header_row? && ['r', 'r+', 'a+'].include?(@mode) && (first_row = first_row?)
|
|
213
214
|
columns, i = {}, -1
|
|
214
|
-
split_row(first_row).each do |column_name|
|
|
215
|
+
(split_row(first_row) || raise(UnterminatedQuote.new(1))).each do |column_name|
|
|
215
216
|
columns[column_name] = columns.key?(column_name) ? [*columns[column_name], i += 1] : (i += 1)
|
|
216
217
|
end
|
|
217
218
|
columns
|
|
@@ -263,7 +264,7 @@ class DSV
|
|
|
263
264
|
value.nil? || value.empty?
|
|
264
265
|
end
|
|
265
266
|
|
|
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.
|
|
267
|
+
# 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. nil where a quoted field in the row is still open at its end. Direct string operations throughout; the scanner-based parser is on the branch scanner-parser.
|
|
267
268
|
def split_row(raw_row)
|
|
268
269
|
row = raw_row.chomp(@row_separator)
|
|
269
270
|
case @quote
|
|
@@ -302,10 +303,10 @@ class DSV
|
|
|
302
303
|
end
|
|
303
304
|
end
|
|
304
305
|
|
|
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
|
+
# The :double contract: a wholly quoted row split on quote-separator-quote, a wholly unquoted one plainly; nil for a row that is neither, or whose last field does not close.
|
|
306
307
|
def split_double_quoted(row)
|
|
307
308
|
if row.start_with?('"')
|
|
308
|
-
unescaped_fields(split_on(row.delete_prefix('"').delete_suffix('"'), quoted_separator))
|
|
309
|
+
unescaped_fields(split_on(row.delete_prefix('"').delete_suffix('"'), quoted_separator)) if trailing_quotes(row, 0).odd?
|
|
309
310
|
else
|
|
310
311
|
split_on(row, @column_separator) unless row.include?('"')
|
|
311
312
|
end
|
|
@@ -332,7 +333,7 @@ class DSV
|
|
|
332
333
|
)
|
|
333
334
|
end
|
|
334
335
|
|
|
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 closes a field where it ends in an odd run of quotes, a doubled quote being escaped, and a piece that is a lone quote opens or closes one.
|
|
336
|
+
# 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 closes a field where it ends in an odd run of quotes, a doubled quote being escaped, and a piece that is a lone quote opens or closes one; nil where a field is still open after the last piece.
|
|
336
337
|
def reassemble_quoted_fields(pieces, separators = nil)
|
|
337
338
|
fields = []
|
|
338
339
|
buffer = nil
|
|
@@ -350,7 +351,7 @@ class DSV
|
|
|
350
351
|
fields << piece
|
|
351
352
|
end
|
|
352
353
|
end
|
|
353
|
-
fields
|
|
354
|
+
fields unless buffer
|
|
354
355
|
end
|
|
355
356
|
|
|
356
357
|
# How many quotes a piece ends with, counting back to but not including the byte given, so that a field's opening quote is not taken for part of its closing. Read by byte, a quote being 34 in any ASCII-compatible encoding, so that nothing is allocated per character.
|
|
@@ -365,30 +366,41 @@ class DSV
|
|
|
365
366
|
separators ? separators[i - 1] : @column_separator
|
|
366
367
|
end
|
|
367
368
|
|
|
368
|
-
# A
|
|
369
|
-
def
|
|
370
|
-
raw_row << @source.gets(@row_separator)
|
|
371
|
-
raw_row
|
|
369
|
+
# A row's fields, the next line taken into the row while a quoted field in it is open, a quoted field being able to hold the row separator; a field still open at the end of the source raises UnterminatedQuote.
|
|
370
|
+
def row_fields(raw_row)
|
|
371
|
+
raw_row << @source.gets(@row_separator) until (fields = split_row(raw_row)) || @source.eof?
|
|
372
|
+
fields || raise(UnterminatedQuote.new(line_of(raw_row)))
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# The line on which a row read up to the source's position began, counted only when an error wants it, by reading the source again up to the row, so that no row which closes pays for it.
|
|
376
|
+
def line_of(raw_row)
|
|
377
|
+
start = @source.pos - raw_row.bytesize
|
|
378
|
+
@source.rewind
|
|
379
|
+
@source.read(start).to_s.scan(@row_separator).length + 1
|
|
372
380
|
end
|
|
373
381
|
|
|
374
382
|
def parse_row(raw_row, *selected_columns)
|
|
383
|
+
parse_fields(split_row(raw_row) || raise(UnterminatedQuote.new(1)), *selected_columns)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def parse_fields(fields, *selected_columns)
|
|
375
387
|
parsed_row = {}
|
|
376
388
|
i = -1
|
|
377
389
|
if selected_columns.empty?
|
|
378
390
|
if blank?(@columns)
|
|
379
|
-
|
|
391
|
+
fields.each{|column_value| parsed_row[i += 1] = column_value}
|
|
380
392
|
elsif repeated_names.empty?
|
|
381
|
-
|
|
393
|
+
fields.each{|column_value| parsed_row[attributes[i += 1]] = column_value}
|
|
382
394
|
else
|
|
383
|
-
|
|
395
|
+
fields.each{|column_value| store_field(parsed_row, attributes[i += 1], column_value)}
|
|
384
396
|
end
|
|
385
397
|
else
|
|
386
398
|
selected_columns.flatten!
|
|
387
399
|
case selected_columns[0]
|
|
388
400
|
when Integer
|
|
389
|
-
|
|
401
|
+
fields.each{|column_value| parsed_row[i] = column_value unless !selected_columns.include?(i += 1)}
|
|
390
402
|
else
|
|
391
|
-
|
|
403
|
+
fields.each{|column_value| store_field(parsed_row, attributes[i], column_value) unless !selected_columns.include?(attributes[i += 1])}
|
|
392
404
|
end
|
|
393
405
|
end
|
|
394
406
|
if @as_array
|
data/test/dsv_test.rb
CHANGED
|
@@ -554,7 +554,7 @@ describe DSV do
|
|
|
554
554
|
end
|
|
555
555
|
end
|
|
556
556
|
|
|
557
|
-
# Input which is not RFC 4180, read to
|
|
557
|
+
# Input which is not RFC 4180, read to the default rule: a quote is structure only where it opens a field, and a field it opens which does not close raises UnterminatedQuote.
|
|
558
558
|
describe "quoting on read, where the input is malformed" do
|
|
559
559
|
it "reads a quote which does not open a field as data" do
|
|
560
560
|
_(DSV.parse_line('a"b"c,d')).must_equal ['a"b"c', 'd']
|
|
@@ -562,15 +562,28 @@ describe DSV do
|
|
|
562
562
|
end
|
|
563
563
|
|
|
564
564
|
it "reads a stray quote in an unquoted field as data, without taking in the next line" do
|
|
565
|
-
skip "Observation: the quotes in a row are counted, so a stray quote leaves them unbalanced and the next line is taken into the row; unspecified since 0.11.4, :double since 0.12.5"
|
|
566
565
|
_(DSV.read("12\" pizza,9.99\nnext,row\n", as_array: true)).must_equal [['12" pizza', '9.99'], ['next', 'row']]
|
|
567
566
|
_(DSV.read("12\" pizza,9.99\nnext,row\n", as_array: true, quote: :double)).must_equal [['12" pizza', '9.99'], ['next', 'row']]
|
|
568
567
|
end
|
|
569
568
|
|
|
569
|
+
it "reads a quoted field across lines after a stray quote in the same row" do
|
|
570
|
+
_(DSV.read("a\"b,\"c\nd\"\n", as_array: true)).must_equal [['a"b', "c\nd"]]
|
|
571
|
+
_(DSV.read("a\"b,\"c\nd\"\n", as_array: true, quote: :double)).must_equal [['a"b', "c\nd"]]
|
|
572
|
+
end
|
|
573
|
+
|
|
570
574
|
it "raises UnterminatedQuote on a quoted field which does not close" do
|
|
571
|
-
skip "Observation: a quoted field which does not close is dropped silently, with the rest of its row"
|
|
572
575
|
_{DSV.read("\"abc,d\n")}.must_raise DSV::UnterminatedQuote
|
|
573
576
|
_{DSV.read("\"ab\"c,d\n")}.must_raise DSV::UnterminatedQuote
|
|
577
|
+
_{DSV.read("x,\"abc\n", quote: :double)}.must_raise DSV::UnterminatedQuote
|
|
578
|
+
_{DSV.parse_line('x,"abc')}.must_raise DSV::UnterminatedQuote
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
it "names the line on which the row holding the unterminated field began" do
|
|
582
|
+
error = _{DSV.read("a,b\n\"c\nd\",e\nf,\"g\nh\n")}.must_raise DSV::UnterminatedQuote
|
|
583
|
+
_(error.line).must_equal 4
|
|
584
|
+
_(error.message).must_include 'line 4'
|
|
585
|
+
error = _{DSV.read("name,note\nAda,\"unclosed\n", headers: true)}.must_raise DSV::UnterminatedQuote
|
|
586
|
+
_(error.line).must_equal 2
|
|
574
587
|
end
|
|
575
588
|
end
|
|
576
589
|
|
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
|
+
version: 0.12.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- thoran
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- dsv.gemspec
|
|
68
68
|
- lib/DSV/File.rb
|
|
69
69
|
- lib/DSV/String.rb
|
|
70
|
+
- lib/DSV/UnterminatedQuote.rb
|
|
70
71
|
- lib/DSV/VERSION.rb
|
|
71
72
|
- lib/dsv.rb
|
|
72
73
|
- test/VERSION_test.rb
|