dsv 0.12.4

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.
data/lib/dsv.rb ADDED
@@ -0,0 +1,472 @@
1
+ # dsv.rb
2
+ # DSV
3
+
4
+ # Description: Delimiter-separated values: reading and writing CSV and its relatives, tabulated data to and from files and strings, with any delimiter on either side.
5
+
6
+ require 'stringio'
7
+
8
+ require 'DSV/VERSION'
9
+ require 'DSV/File'
10
+ require 'DSV/String'
11
+
12
+ class DSV
13
+
14
+ class << self
15
+
16
+ # The trailing Hash of an argument list, removed from it; or none.
17
+ def extract_options(args)
18
+ args.last.is_a?(::Hash) ? args.pop : {}
19
+ end
20
+
21
+ # A mode as Ruby's File spells it, from any of the spellings the library accepts, a Symbol included; unspecified is r.
22
+ def normalised_mode(mode)
23
+ case mode.to_s
24
+ when 'r', 'r+', 'w', 'w+', 'a', 'a+' then mode.to_s
25
+ when 'read_only', 'read-only', 'readonly' then 'r'
26
+ when 'rw', 'read_write', 'read-write', 'readwrite' then 'r+'
27
+ when 'write_only', 'write-only', 'writeonly' then 'w'
28
+ when 'append' then 'a'
29
+ else 'r'
30
+ end
31
+ end
32
+
33
+ def source_type(source)
34
+ if ::File.exist?(source)
35
+ DSV::File
36
+ else
37
+ DSV::String
38
+ end
39
+ end
40
+
41
+ def open(source, *args, &block)
42
+ csv_file = new(source, *args)
43
+ if block
44
+ begin
45
+ yield csv_file
46
+ csv_file
47
+ ensure
48
+ csv_file.close
49
+ end
50
+ else
51
+ csv_file
52
+ end
53
+ end
54
+
55
+ # The class-level read, parse and each take a column selection before the options, as the instance-level ones take it as arguments.
56
+ def each(source, *args, &block)
57
+ options = DSV.extract_options(args)
58
+ new(source, options).each(*args, &block)
59
+ end
60
+ alias_method :foreach, :each
61
+
62
+ def collect(source, *args, &block)
63
+ new_collection = []
64
+ each(source, *args){|row| new_collection << block.call(row)}
65
+ new_collection
66
+ end
67
+ alias_method :map, :collect
68
+
69
+ def select(source, *args, &block)
70
+ new_collection = []
71
+ each(source, *args){|row| new_collection << row if block.call(row)}
72
+ new_collection
73
+ end
74
+ alias_method :find_all, :select
75
+
76
+ def reject(source, *args, &block)
77
+ new_collection = []
78
+ each(source, *args){|row| new_collection << row unless block.call(row)}
79
+ new_collection
80
+ end
81
+
82
+ def detect(source, *args, &block)
83
+ each(source, *args){|row| return row if block.call(row)}
84
+ nil
85
+ end
86
+ alias_method :find, :detect
87
+
88
+ def read(source, *args, &block)
89
+ if block
90
+ parse(source, *args, &block)
91
+ else
92
+ options = DSV.extract_options(args)
93
+ new(source, options).read_csv(*args)
94
+ end
95
+ end
96
+ alias_method :read_csv, :read
97
+
98
+ def parse(source, *args, &block)
99
+ if block
100
+ each(source, *args, &block)
101
+ else
102
+ read(source, *args)
103
+ end
104
+ end
105
+ alias_method :parse_csv, :parse
106
+
107
+ def write(source, *args)
108
+ new(source, *args).write_csv
109
+ end
110
+ alias_method :write_csv, :write
111
+
112
+ # The header row as names, or nil where there is none; first_row is the raw line.
113
+ def header_row(source, *args)
114
+ new(source, *args).attributes
115
+ end
116
+
117
+ def first_row(source, *args)
118
+ new(source, *args).first_row
119
+ end
120
+
121
+ def attributes(source, *args)
122
+ new(source, *args).attributes
123
+ end
124
+
125
+ def columns(source, *args)
126
+ new(source, *args).columns
127
+ end
128
+
129
+ def parse_line(raw_row, *args) # For FasterCSV compatibility.
130
+ options = DSV.extract_options(args)
131
+ row_separator = options[:row_separator] || options[:row_sep] || "\n"
132
+ column_separator = options[:column_separator] || options[:col_sep] || ','
133
+ sc = DSV.new(raw_row, :quote => nil, :as_array => true, :row_separator => row_separator, :column_separator => column_separator)
134
+ sc.parse_row(raw_row)
135
+ end
136
+
137
+ end # class << self
138
+
139
+ include Enumerable
140
+
141
+ attr_accessor :header_row, :mode, :quote, :row_separator, :selected_columns, :as_array, :rows
142
+
143
+ def initialize(source, *args)
144
+ @source = (
145
+ if source.is_a?(::String)
146
+ DSV.source_type(source).new(source, *args).source
147
+ else
148
+ source
149
+ end
150
+ )
151
+ options = DSV.extract_options(args)
152
+ @header_row = options[:header_row] || options[:headers] || options[:header] || false
153
+ @mode = DSV.normalised_mode(options[:mode])
154
+ @quote = options[:quote] || nil
155
+ @row_separator = options[:row_separator] || options[:row_sep] || "\n"
156
+ @column_separator = options[:column_separator] || options[:col_sep] || ','
157
+ @selected_columns = options[:selected_columns]
158
+ @as_array = options[:as_array] || false
159
+ @render_row = row_renderer
160
+ if options[:columns]
161
+ self.columns = options[:columns]
162
+ else
163
+ self.columns
164
+ end
165
+ @rows = []
166
+ end
167
+
168
+ def close
169
+ @source.close
170
+ end
171
+
172
+ # A call's own selection, or the instance's selected_columns: where the call gives none.
173
+ def selection(selected_columns)
174
+ selected_columns.empty? ? Array(@selected_columns) : selected_columns
175
+ end
176
+
177
+ def read(*selected_columns, &block)
178
+ selected_columns = selection(selected_columns)
179
+ if block
180
+ parse(*selected_columns, &block)
181
+ else
182
+ read_header
183
+ @rows = []
184
+ @source.each(@row_separator){|raw_row| @rows << parse_row(complete_quoted_row(raw_row), *selected_columns)}
185
+ @rows
186
+ end
187
+ end
188
+ alias_method :read_csv, :read
189
+
190
+ def read_header
191
+ columns
192
+ if header_row?
193
+ (@source.rewind; @source.gets(@row_separator))
194
+ else
195
+ @source.rewind
196
+ end
197
+ end
198
+ alias_method :read_csv_header, :read_header
199
+
200
+ def parse(*selected_columns, &block)
201
+ if block
202
+ each(*selected_columns, &block)
203
+ else
204
+ read(*selected_columns)
205
+ end
206
+ end
207
+ alias_method :parse_csv, :parse
208
+
209
+ # Memoised upon whether the header has been looked for rather than upon what it gave, since a source with no header row yields nil legitimately and ||= would look again, reading and rewinding the source, on every call.
210
+ def columns
211
+ return @columns if defined?(@columns)
212
+ @columns = (
213
+ if header_row? && ['r', 'r+', 'a+'].include?(@mode) && (first_row = first_row?)
214
+ columns, i = {}, -1
215
+ split_row(first_row).each do |column_name|
216
+ columns[column_name] = columns.key?(column_name) ? [*columns[column_name], i += 1] : (i += 1)
217
+ end
218
+ columns
219
+ else
220
+ nil
221
+ end
222
+ )
223
+ end
224
+
225
+ def columns=(*column_order)
226
+ @columns = {}
227
+ @attributes = @repeated_names = nil
228
+ column_order.flatten!
229
+ if column_order[0].is_a?(Hash)
230
+ column_order[0].each{|column_name, column_position| @columns[column_name.to_s] = column_position}
231
+ else
232
+ i = -1
233
+ column_order.each{|column| @columns[column.to_s] = @columns.key?(column.to_s) ? [*@columns[column.to_s], i += 1] : (i += 1)}
234
+ end
235
+ end
236
+
237
+ # The names that occur at more than one position; under such a name a row holds an Array of values, one per position, in order.
238
+ def repeated_names
239
+ @repeated_names ||= (columns || {}).select{|name, position| position.is_a?(::Array)}.keys
240
+ end
241
+
242
+ # A row's value for a name, keyed for the row: assigned where the name occurs once, gathered where it repeats.
243
+ def store_field(parsed_row, name, value)
244
+ if repeated_names.include?(name)
245
+ (parsed_row[name] ||= []) << value
246
+ else
247
+ parsed_row[name] = value
248
+ end
249
+ end
250
+
251
+ # A row's values in position order: an Array under a repeated name is spread back over its positions, and a single value under one is written at each.
252
+ def values_in_order(row)
253
+ occurrence = ::Hash.new(0)
254
+ attributes.collect do |name|
255
+ value = row[name]
256
+ value = value[occurrence[name]] if value.is_a?(::Array) && repeated_names.include?(name)
257
+ occurrence[name] += 1
258
+ value
259
+ end
260
+ end
261
+
262
+ # nil or empty, which a Hash of columns, an Array of names and an unset selection all may be.
263
+ def blank?(value)
264
+ value.nil? || value.empty?
265
+ end
266
+
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.
268
+ def split_row(raw_row)
269
+ row = raw_row.chomp(@row_separator)
270
+ case @quote
271
+ when :none, :unquoted
272
+ row.split(@column_separator, -1)
273
+ 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
279
+ else
280
+ reassemble_quoted_fields(row.split(@column_separator, -1))
281
+ end
282
+ end
283
+
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)
286
+ fields = []
287
+ buffer = nil
288
+ pieces.each do |piece|
289
+ if buffer && !piece.end_with?('"')
290
+ buffer << @column_separator << piece
291
+ elsif buffer
292
+ fields << (buffer << @column_separator << piece.delete_suffix('"')).gsub('""', '"')
293
+ buffer = nil
294
+ elsif piece.start_with?('"') && (piece.length == 1 || !piece.end_with?('"'))
295
+ buffer = +piece.delete_prefix('"')
296
+ elsif piece.start_with?('"') && piece.end_with?('"')
297
+ fields << piece.delete_prefix('"').delete_suffix('"').gsub('""', '"')
298
+ else
299
+ fields << piece
300
+ end
301
+ end
302
+ fields
303
+ end
304
+
305
+ # A quoted field may hold the row separator: while the quotes in a row are unbalanced the next line belongs to it.
306
+ def complete_quoted_row(raw_row)
307
+ raw_row << @source.gets(@row_separator).to_s while @quote.nil? && raw_row.count('"').odd? && !@source.eof?
308
+ raw_row
309
+ end
310
+
311
+ def parse_row(raw_row, *selected_columns)
312
+ parsed_row = {}
313
+ i = -1
314
+ if selected_columns.empty?
315
+ if blank?(@columns)
316
+ split_row(raw_row).each{|column_value| parsed_row[i += 1] = column_value}
317
+ elsif repeated_names.empty?
318
+ split_row(raw_row).each{|column_value| parsed_row[attributes[i += 1]] = column_value}
319
+ else
320
+ split_row(raw_row).each{|column_value| store_field(parsed_row, attributes[i += 1], column_value)}
321
+ end
322
+ else
323
+ selected_columns.flatten!
324
+ case selected_columns[0]
325
+ when Integer
326
+ split_row(raw_row).each{|column_value| parsed_row[i] = column_value unless !selected_columns.include?(i += 1)}
327
+ else
328
+ split_row(raw_row).each{|column_value| store_field(parsed_row, attributes[i], column_value) unless !selected_columns.include?(attributes[i += 1])}
329
+ end
330
+ end
331
+ if @as_array
332
+ if blank?(@columns)
333
+ (0..(parsed_row.size - 1)).inject([]){|a,i| a << parsed_row[i]}
334
+ else
335
+ values_in_order(parsed_row)
336
+ end
337
+ else
338
+ parsed_row
339
+ end
340
+ end
341
+
342
+ # With no columns defined, rows keyed by name supply them, and the header, from the first row's keys; rows keyed by position write positionally and have no header to write.
343
+ def write(*selected_columns)
344
+ prepare_to_rewrite if @mode == 'r+'
345
+ self.columns = @rows[0].keys if blank?(columns) && @rows[0].is_a?(::Hash) && @rows[0].keys[0].is_a?(::String)
346
+ write_header(*selected_columns) if header_row? && attributes
347
+ each{|row| write_row(row, *selected_columns)}
348
+ end
349
+ alias_method :write_csv, :write
350
+
351
+ # Under r+ a write replaces the file: the rows are read first if they have not been, then the file is emptied, once, here, rather than at the end of every read.
352
+ def prepare_to_rewrite
353
+ read_csv unless @rows[0]
354
+ @source.rewind
355
+ @source.truncate(0)
356
+ end
357
+
358
+ def write_header(*selected_columns)
359
+ selected_columns.flatten!
360
+ names = selected_columns.empty? ? attributes : selected_columns
361
+ write_values(names)
362
+ end
363
+ alias_method :write_csv_header, :write_header
364
+
365
+ # A nil value is an empty field, so the columns after it keep their places; with no columns defined a row's values are written in the row's own order.
366
+ def write_row(row, *selected_columns)
367
+ selected_columns.flatten!
368
+ values = if !selected_columns.empty?
369
+ selected_columns.collect{|column| row[column]}
370
+ elsif attributes
371
+ values_in_order(row)
372
+ else
373
+ row.values
374
+ end
375
+ write_values(values)
376
+ end
377
+ alias_method :write_csv_row, :write_row
378
+
379
+ def write_values(values)
380
+ @source.write(@render_row.call(values))
381
+ end
382
+
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.
384
+ def row_renderer
385
+ quote, spacey = case @quote&.to_sym
386
+ when :none, :unquoted then [nil, false]
387
+ when :spacey_none, :spacey_unquoted then [nil, true]
388
+ when :single then ["'", false]
389
+ when :spacey_single then ["'", true]
390
+ when :spacey_double then ['"', true]
391
+ else ['"', false]
392
+ end
393
+ joiner = spacey ? @column_separator + ' ' : @column_separator
394
+ if quote
395
+ doubled = quote * 2
396
+ ->(values){values.collect{|value| quote + value.to_s.gsub(quote, doubled) + quote}.join(joiner) + @row_separator}
397
+ else
398
+ ->(values){values.join(joiner) + @row_separator}
399
+ end
400
+ end
401
+
402
+ def each(*selected_columns)
403
+ return to_enum(:each, *selected_columns) unless block_given?
404
+ selected_columns = selection(selected_columns)
405
+ selected_columns.flatten!
406
+ if @rows[0]
407
+ if selected_columns.empty?
408
+ @rows.each{|row| yield row}
409
+ else
410
+ @rows.each do |row|
411
+ yield selected_columns.inject({}){|hash, column_name| hash[column_name] = row[column_name]; hash}
412
+ end
413
+ end
414
+ else
415
+ if selected_columns.empty?
416
+ read_csv.each{|row| yield row}
417
+ else
418
+ read_csv(selected_columns).each do |row|
419
+ yield selected_columns.inject({}){|hash, column_name| hash[column_name] = row[column_name]; hash}
420
+ end
421
+ end
422
+ end
423
+ end
424
+ alias_method :each_row, :each
425
+
426
+ def attributes
427
+ @attributes ||= (
428
+ if blank?(columns)
429
+ nil
430
+ else
431
+ a = []
432
+ columns.each do |k,v|
433
+ case v
434
+ when Array
435
+ v.each{|e| a << [k, e]}
436
+ else
437
+ a << [k, v]
438
+ end
439
+ end
440
+ a.sort{|a,b| a[1] <=> b[1]}.collect{|a| a[0]}
441
+ end
442
+ )
443
+ end
444
+
445
+ def attributes=(attributes)
446
+ @attributes = attributes
447
+ end
448
+
449
+ def header_row?
450
+ @header_row
451
+ end
452
+
453
+ def first_row
454
+ @source.rewind
455
+ return_value = @source.gets(@row_separator)
456
+ @source.rewind
457
+ return_value
458
+ end
459
+ alias_method :first_row?, :first_row
460
+
461
+ def to_a
462
+ read_csv unless @rows[0]
463
+ if @as_array
464
+ @rows
465
+ elsif blank?(@columns)
466
+ @rows.collect(&:values)
467
+ else
468
+ @rows.collect{|row| values_in_order(row)}
469
+ end
470
+ end
471
+
472
+ end # class DSV
@@ -0,0 +1,20 @@
1
+ # test/VERSION_test.rb
2
+
3
+ require_relative './helper'
4
+
5
+ describe DSV do
6
+ describe "VERSION" do
7
+ it "is a string" do
8
+ _(DSV::VERSION).must_be_instance_of String
9
+ end
10
+
11
+ it "is three numbers separated by dots" do
12
+ _(DSV::VERSION).must_match(/\A\d+\.\d+\.\d+\z/)
13
+ end
14
+
15
+ it "matches the newest entry in the CHANGELOG" do
16
+ changelog = File.read(File.expand_path('../CHANGELOG', __dir__))
17
+ _(changelog[/^(\d+\.\d+\.\d+):/, 1]).must_equal(DSV::VERSION)
18
+ end
19
+ end
20
+ end