carray 3.0.2 → 3.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +568 -0
- data/README.md +6 -6
- data/carray.gemspec +1 -1
- data/ext/ca_axis_descriptor.h +27 -24
- data/ext/ca_axis_dispatch.c +220 -0
- data/ext/ca_axis_group.c +201 -123
- data/ext/ca_binop_dispatch.c +46 -52
- data/ext/ca_binop_dispatch.h +1 -1
- data/ext/ca_categorical_iterator.c +249 -183
- data/ext/ca_compare.c +7 -12
- data/ext/ca_for_buffer.h +21 -68
- data/ext/ca_group_iter.c +38 -23
- data/ext/ca_kernel_iterator.c +1765 -2076
- data/ext/ca_kernel_iterator.h +584 -566
- data/ext/ca_monop_dispatch.c +86 -63
- data/ext/ca_monop_dispatch.h +5 -0
- data/ext/ca_obj_array.c +223 -56
- data/ext/ca_obj_bincmp.c +172 -138
- data/ext/ca_obj_binop.c +116 -230
- data/ext/ca_obj_bitarray.c +40 -26
- data/ext/ca_obj_bitfield.c +100 -68
- data/ext/ca_obj_block.c +19 -9
- data/ext/ca_obj_byte_swap.c +45 -46
- data/ext/ca_obj_const_string.c +269 -44
- data/ext/ca_obj_face.c +612 -89
- data/ext/ca_obj_face.h +111 -47
- data/ext/ca_obj_fake.c +36 -79
- data/ext/ca_obj_farray.c +7 -3
- data/ext/ca_obj_field.c +33 -8
- data/ext/ca_obj_fixlen_string.c +23 -24
- data/ext/ca_obj_grid.c +541 -210
- data/ext/ca_obj_meld.c +331 -111
- data/ext/ca_obj_moncmp.c +80 -65
- data/ext/ca_obj_monop.c +177 -149
- data/ext/ca_obj_object.c +664 -248
- data/ext/ca_obj_record.c +63 -23
- data/ext/ca_obj_reduce.c +18 -16
- data/ext/ca_obj_refer.c +140 -58
- data/ext/ca_obj_remap.c +85 -24
- data/ext/ca_obj_repeat.c +24 -9
- data/ext/ca_obj_roll.c +121 -16
- data/ext/ca_obj_select.c +61 -58
- data/ext/ca_obj_select_axis.c +73 -107
- data/ext/ca_obj_shift.c +32 -102
- data/ext/ca_obj_stack.c +372 -122
- data/ext/ca_obj_stride.c +348 -140
- data/ext/ca_obj_string.c +88 -22
- data/ext/ca_obj_tile.c +38 -21
- data/ext/ca_obj_time.c +45 -31
- data/ext/ca_obj_timedelta.c +43 -29
- data/ext/ca_obj_transpose.c +17 -6
- data/ext/ca_obj_triop.c +125 -141
- data/ext/ca_obj_window.c +330 -44
- data/ext/ca_op_ipower.c +24 -11
- data/ext/ca_op_powi.h +57 -30
- data/ext/ca_pow5_table.h +656 -0
- data/ext/ca_sort_kernels.h +70 -0
- data/ext/ca_sweep_engine.c +104 -81
- data/ext/ca_sweep_engine.h +7 -20
- data/ext/ca_triop_dispatch.c +4 -1
- data/ext/ca_triop_dispatch.h +5 -2
- data/ext/caframe_csv_reader.c +542 -0
- data/ext/carray.h +241 -92
- data/ext/carray_access.c +608 -276
- data/ext/carray_address_basis.c +494 -97
- data/ext/carray_attribute.c +129 -4
- data/ext/carray_bincount.c +39 -7
- data/ext/carray_broadcast.c +56 -19
- data/ext/carray_build_flags.h +1 -1
- data/ext/carray_call_cfunc.c +108 -28
- data/ext/carray_cast.c +1576 -223
- data/ext/carray_cast_func.rb +77 -24
- data/ext/carray_class.c +4 -3
- data/ext/carray_conversion.c +208 -75
- data/ext/carray_copy.c +82 -24
- data/ext/carray_core.c +646 -162
- data/ext/carray_count.c +171 -88
- data/ext/carray_element.c +94 -60
- data/ext/carray_factorize.c +670 -324
- data/ext/carray_generate.c +260 -83
- data/ext/carray_histogram.c +209 -106
- data/ext/carray_hold.c +14 -16
- data/ext/carray_index_classifier.c +422 -243
- data/ext/carray_internal.h +692 -3
- data/ext/carray_kernels_bincmp.c +7 -0
- data/ext/carray_kernels_binop.c +487 -120
- data/ext/carray_kernels_init.c +653 -50
- data/ext/carray_kernels_moncmp.c +15 -8
- data/ext/carray_kernels_monop.c +99 -43
- data/ext/carray_kernels_reduce_aggregate.c +3925 -6112
- data/ext/carray_kernels_reduce_boolean.c +112 -36
- data/ext/carray_kernels_reduce_cumulative.c +2127 -6903
- data/ext/carray_kernels_reduce_extreme.c +3032 -2892
- data/ext/carray_kernels_reduce_variance.c +439 -500
- data/ext/carray_kernels_scan.c +1097 -488
- data/ext/carray_kernels_search.c +3787 -2176
- data/ext/carray_kernels_sort.c +1200 -920
- data/ext/carray_kernels_triop.c +479 -7
- data/ext/carray_key_windows.c +216 -0
- data/ext/carray_lazy.c +967 -153
- data/ext/carray_loop.c +81 -65
- data/ext/carray_mask.c +910 -324
- data/ext/carray_math_kernel.h +1 -0
- data/ext/carray_median_percentile.c +299 -100
- data/ext/carray_memory_view.c +872 -758
- data/ext/carray_operator.c +1171 -364
- data/ext/carray_order.c +505 -133
- data/ext/carray_partition.c +112 -65
- data/ext/carray_random.c +327 -239
- data/ext/carray_round.c +273 -0
- data/ext/carray_scatter.c +346 -78
- data/ext/carray_segment.c +257 -0
- data/ext/carray_slab.c +249 -86
- data/ext/carray_sort.c +224 -227
- data/ext/carray_sort_kernel.c +8 -1
- data/ext/carray_struct.c +118 -39
- data/ext/carray_test.c +193 -69
- data/ext/carray_time_text.c +1182 -0
- data/ext/carray_undef.c +4 -0
- data/ext/carray_utils.c +579 -113
- data/ext/extconf.rb +27 -2
- data/ext/mk_call_cfunc.rb +19 -4
- data/ext/mk_pow5_table.rb +35 -0
- data/ext/mkkernel.rb +1202 -1014
- data/ext/ruby_carray.c +17 -5
- data/ext/version.h +4 -4
- data/ext/version.rb +4 -2
- data/lib/carray/attribute.rb +160 -17
- data/lib/carray/autoload_carray.rb +23 -27
- data/lib/carray/autoload_method_extension.rb +20 -5
- data/lib/carray/axis_group.rb +360 -63
- data/lib/carray/basics.rb +162 -28
- data/lib/carray/bincount_nd.rb +18 -2
- data/lib/carray/block_iterator.rb +231 -58
- data/lib/carray/boolean_reduce.rb +34 -42
- data/lib/carray/categorical.rb +242 -52
- data/lib/carray/categorical_iterator.rb +437 -827
- data/lib/carray/complex.rb +41 -34
- data/lib/carray/conditional.rb +150 -14
- data/lib/carray/const_string.rb +59 -4
- data/lib/carray/construct.rb +15 -11
- data/lib/carray/data_type_extension.rb +1 -0
- data/lib/carray/distance_limit.rb +77 -0
- data/lib/carray/fixlen_string.rb +7 -1
- data/lib/carray/frame/column_verbs.rb +70 -0
- data/lib/carray/frame/concat.rb +157 -6
- data/lib/carray/frame/convert.rb +82 -7
- data/lib/carray/frame/csv_parser.rb +688 -87
- data/lib/carray/frame/describe.rb +80 -0
- data/lib/carray/frame/frame.rb +369 -45
- data/lib/carray/frame/group.rb +631 -90
- data/lib/carray/frame/io.rb +506 -79
- data/lib/carray/frame/join.rb +411 -62
- data/lib/carray/frame/records.rb +116 -19
- data/lib/carray/frame/reshape.rb +455 -0
- data/lib/carray/frame/sort.rb +30 -18
- data/lib/carray/frame/verbs.rb +868 -61
- data/lib/carray/frame/windows.rb +117 -0
- data/lib/carray/frame.rb +4 -0
- data/lib/carray/fuse_source.rb +118 -2
- data/lib/carray/fusion.rb +257 -42
- data/lib/carray/histogram.rb +6 -1
- data/lib/carray/inspect.rb +54 -13
- data/lib/carray/iterator.rb +99 -9
- data/lib/carray/key_windows.rb +620 -0
- data/lib/carray/lazy.rb +216 -193
- data/lib/carray/mask_gap_fill.rb +112 -92
- data/lib/carray/math.rb +32 -19
- data/lib/carray/meld_reduce.rb +52 -21
- data/lib/carray/methods/align_addr.rb +8 -4
- data/lib/carray/methods/bin.rb +26 -18
- data/lib/carray/methods/bincount.rb +18 -2
- data/lib/carray/methods/bit_string.rb +15 -4
- data/lib/carray/methods/choose.rb +16 -3
- data/lib/carray/methods/composition.rb +18 -10
- data/lib/carray/methods/factorize.rb +2 -2
- data/lib/carray/methods/gather_nd.rb +25 -5
- data/lib/carray/methods/insert_block.rb +9 -14
- data/lib/carray/methods/is_between.rb +59 -0
- data/lib/carray/methods/is_in.rb +140 -10
- data/lib/carray/methods/join.rb +2 -7
- data/lib/carray/methods/lag.rb +49 -0
- data/lib/carray/methods/locate_addr.rb +182 -45
- data/lib/carray/methods/mode.rb +18 -1
- data/lib/carray/methods/nunique.rb +11 -6
- data/lib/carray/methods/repeat.rb +1 -20
- data/lib/carray/methods/resize.rb +21 -23
- data/lib/carray/methods/snap.rb +34 -12
- data/lib/carray/methods/unique.rb +11 -2
- data/lib/carray/methods/value_counts.rb +4 -11
- data/lib/carray/record_order.rb +208 -0
- data/lib/carray/rng.rb +3 -0
- data/lib/carray/runtime.rb +65 -17
- data/lib/carray/segment_iterator.rb +1221 -0
- data/lib/carray/serialize.rb +214 -30
- data/lib/carray/slab_iterator.rb +101 -27
- data/lib/carray/stack.rb +10 -4
- data/lib/carray/string.rb +105 -5
- data/lib/carray/string_operation_extension.rb +12 -4
- data/lib/carray/struct.rb +137 -58
- data/lib/carray/struct_builder.rb +124 -17
- data/lib/carray/time.rb +1222 -186
- data/lib/carray/window_iterator.rb +242 -81
- data/lib/carray.rb +9 -2
- data/yard-stubs/ca_obj_array.rb +4 -4
- data/yard-stubs/ca_obj_bitarray.rb +7 -0
- data/yard-stubs/ca_obj_bitfield.rb +23 -12
- data/yard-stubs/ca_obj_fake.rb +1 -1
- data/yard-stubs/ca_obj_field.rb +5 -1
- data/yard-stubs/ca_obj_grid.rb +21 -10
- data/yard-stubs/ca_obj_refer.rb +5 -4
- data/yard-stubs/ca_obj_roll.rb +2 -3
- data/yard-stubs/ca_obj_shift.rb +5 -1
- data/yard-stubs/ca_obj_stride.rb +10 -7
- data/yard-stubs/ca_obj_tile.rb +1 -1
- data/yard-stubs/ca_obj_transpose.rb +1 -1
- data/yard-stubs/ca_obj_window.rb +9 -5
- data/yard-stubs/carray_access.rb +11 -5
- data/yard-stubs/carray_attribute.rb +0 -5
- data/yard-stubs/carray_broadcast.rb +1 -1
- data/yard-stubs/carray_cast.rb +88 -43
- data/yard-stubs/carray_conversion.rb +15 -2
- data/yard-stubs/carray_copy.rb +12 -5
- data/yard-stubs/carray_core.rb +3 -3
- data/yard-stubs/carray_count.rb +9 -0
- data/yard-stubs/carray_element.rb +10 -4
- data/yard-stubs/carray_generate.rb +21 -1
- data/yard-stubs/carray_lazy.rb +5 -1
- data/yard-stubs/carray_loop.rb +4 -1
- data/yard-stubs/carray_mask.rb +60 -12
- data/yard-stubs/carray_math.rb +52 -14
- data/yard-stubs/carray_median_percentile.rb +15 -8
- data/yard-stubs/carray_memory_view.rb +40 -23
- data/yard-stubs/carray_order.rb +61 -29
- data/yard-stubs/carray_random.rb +13 -4
- data/yard-stubs/carray_scatter.rb +21 -9
- data/yard-stubs/carray_segment.rb +72 -0
- data/yard-stubs/carray_slab.rb +10 -2
- data/yard-stubs/carray_sort.rb +49 -8
- data/yard-stubs/carray_test.rb +15 -3
- data/yard-stubs/carray_utils.rb +0 -26
- metadata +19 -5
- data/ext/ca_for_each_element.h +0 -239
- data/ext/carray_kernels_map.c +0 -3467
- data/lib/carray/arrow_tensor.rb +0 -401
- data/lib/carray/table.rb +0 -108
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
# * An empty unquoted field is nil (missing); an empty quoted field ("") is
|
|
14
14
|
# the empty String. Both cast to UNDEF for numeric columns (parse-mask).
|
|
15
15
|
# * A UTF-8 BOM is stripped via the "bom|utf-8" read mode by default.
|
|
16
|
-
# *
|
|
16
|
+
# * Malformed input raises MalformedCSV naming the file and the line: a
|
|
17
|
+
# quote inside an unquoted field, text after a closing quote, a quoted
|
|
18
|
+
# field still open at the end of input, or a record with more fields
|
|
19
|
+
# than there are columns. A record continues onto the next line only
|
|
20
|
+
# inside a quoted field.
|
|
17
21
|
#
|
|
18
22
|
# Two entry points share the Tokenizer:
|
|
19
23
|
# * CSVParser.parse / parse_file -- whole-file parse into [headers, rows].
|
|
@@ -22,6 +26,7 @@
|
|
|
22
26
|
# column_names).
|
|
23
27
|
|
|
24
28
|
require "strscan"
|
|
29
|
+
require "stringio"
|
|
25
30
|
|
|
26
31
|
class CAFrame
|
|
27
32
|
# CSV tokenizer behind `CAFrame.from_csv`. It produces raw String cells
|
|
@@ -36,9 +41,35 @@ class CAFrame
|
|
|
36
41
|
module CSVParser
|
|
37
42
|
module_function
|
|
38
43
|
|
|
39
|
-
# Raised when the input cannot be
|
|
40
|
-
#
|
|
41
|
-
|
|
44
|
+
# Raised when the input cannot be read as written. The message starts
|
|
45
|
+
# with "path:line:" (or "line N:" for an IO with no path), the form an
|
|
46
|
+
# editor or terminal jumps to; the record number follows when it is not
|
|
47
|
+
# the line's, as after a quoted field of several lines or a +skip+.
|
|
48
|
+
# +path+, +lineno+ and +record+ give the same for a program.
|
|
49
|
+
class MalformedCSV < StandardError
|
|
50
|
+
attr_accessor :path
|
|
51
|
+
attr_reader :lineno, :record, :detail
|
|
52
|
+
|
|
53
|
+
def initialize(detail = nil, lineno: nil, record: nil, path: nil)
|
|
54
|
+
@detail = detail
|
|
55
|
+
@lineno = lineno
|
|
56
|
+
@record = record
|
|
57
|
+
@path = path
|
|
58
|
+
super(detail)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def message
|
|
62
|
+
return super if @detail.nil?
|
|
63
|
+
where =
|
|
64
|
+
if @path && @lineno then "#{@path}:#{@lineno}: "
|
|
65
|
+
elsif @path then "#{@path}: "
|
|
66
|
+
elsif @lineno then "line #{@lineno}: "
|
|
67
|
+
else ""
|
|
68
|
+
end
|
|
69
|
+
tail = @record && @record != @lineno ? " (record #{@record})" : ""
|
|
70
|
+
"#{where}#{@detail}#{tail}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
42
73
|
|
|
43
74
|
# Parse a file into [headers, rows]. +encoding+ is an IO open-mode encoding
|
|
44
75
|
# string; the default strips a leading BOM and reads UTF-8.
|
|
@@ -47,7 +78,8 @@ class CAFrame
|
|
|
47
78
|
end
|
|
48
79
|
|
|
49
80
|
# Parse an IO (or anything answering +gets+). The first record supplies the
|
|
50
|
-
# headers; the rest are data rows.
|
|
81
|
+
# headers; the rest are data rows. Blank lines are skipped, including in a
|
|
82
|
+
# one-column file (from_csv reads them there as missing cells).
|
|
51
83
|
def parse(io, sep: ",", quote: '"', strip: false)
|
|
52
84
|
tok = Tokenizer.new(sep, quote, strip)
|
|
53
85
|
headers = nil
|
|
@@ -62,111 +94,250 @@ class CAFrame
|
|
|
62
94
|
[headers, rows]
|
|
63
95
|
end
|
|
64
96
|
|
|
65
|
-
# Read one logical record, joining continuation lines while a quoted field
|
|
66
|
-
# is still open (an odd number of quote characters means unbalanced).
|
|
67
|
-
def read_record(io, quote)
|
|
68
|
-
line = io.gets
|
|
69
|
-
return nil if line.nil?
|
|
70
|
-
rec = line.dup
|
|
71
|
-
while rec.count(quote).odd?
|
|
72
|
-
more = io.gets
|
|
73
|
-
raise MalformedCSV, "unterminated quoted field at end of input" if more.nil?
|
|
74
|
-
rec << more
|
|
75
|
-
end
|
|
76
|
-
rec.chomp!
|
|
77
|
-
rec
|
|
78
|
-
end
|
|
79
|
-
|
|
80
97
|
# Splits records into fields. Regexps are compiled once and reused across
|
|
81
98
|
# every record, so per-row cost stays low.
|
|
82
99
|
class Tokenizer
|
|
100
|
+
attr_reader :sep, :quote, :strip
|
|
101
|
+
|
|
83
102
|
def initialize(sep, quote, strip)
|
|
103
|
+
unless sep.is_a?(String) && !sep.empty?
|
|
104
|
+
raise ArgumentError, "sep: must be a non-empty String (got #{sep.inspect})"
|
|
105
|
+
end
|
|
106
|
+
unless quote.is_a?(String) && quote.size == 1
|
|
107
|
+
raise ArgumentError, "quote: must be one character (got #{quote.inspect})"
|
|
108
|
+
end
|
|
109
|
+
if sep.include?(quote)
|
|
110
|
+
raise ArgumentError, "sep: #{sep.inspect} cannot contain the quote character"
|
|
111
|
+
end
|
|
84
112
|
@sep = sep
|
|
85
113
|
@quote = quote
|
|
86
114
|
@strip = strip
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
@
|
|
90
|
-
|
|
115
|
+
s = Regexp.escape(sep)
|
|
116
|
+
q = Regexp.escape(quote)
|
|
117
|
+
@sep_re = /#{s}/
|
|
118
|
+
# String#split(" ") splits on runs of whitespace (awk style); a
|
|
119
|
+
# Regexp splits at each separator as written, as the C reader does.
|
|
120
|
+
@split_sep = sep == " " ? @sep_re : sep
|
|
121
|
+
# strip: skips the spaces and tabs around a field, but not one that is
|
|
122
|
+
# the separator: with sep: "\t" a tab ends the field.
|
|
123
|
+
pad = [" ", "\t"].reject { |c| sep.include?(c) }
|
|
124
|
+
@pad_re = pad.empty? ? nil : /[#{Regexp.escape(pad.join)}]+/
|
|
125
|
+
@quote_re = /#{q}/
|
|
126
|
+
# An unquoted field runs up to the separator, a quote or the end of
|
|
127
|
+
# the record; a CR that does not end the record is part of the field.
|
|
128
|
+
# A character class says "up to the separator" for a one-character
|
|
129
|
+
# one; a longer one has to be matched as a whole, or "::" would end
|
|
130
|
+
# the field at a single ":".
|
|
131
|
+
@unquoted =
|
|
132
|
+
if sep.size == 1
|
|
133
|
+
/(?:[^#{s}#{q}\r\n]|\r(?!\n|\z))*/
|
|
134
|
+
else
|
|
135
|
+
/(?:(?!#{s})(?:[^#{q}\r\n]|\r(?!\n|\z)))*/
|
|
136
|
+
end
|
|
137
|
+
@inner = /[^#{q}]*/
|
|
138
|
+
@eol = /\r?\n|\r\z/
|
|
139
|
+
@blank_re = /\A[ \t]*\z/
|
|
140
|
+
@sep2 = sep * 2
|
|
141
|
+
@qq = quote * 2
|
|
142
|
+
# A whole quoted field: its quotes closed, the ones inside doubled.
|
|
143
|
+
@quoted_re = /\A#{q}(?:[^#{q}]|#{q}#{q})*#{q}\z/m
|
|
91
144
|
@recno = 0
|
|
145
|
+
@lineno = 0
|
|
146
|
+
@record_line = 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The lines read so far, and the line the last record started on.
|
|
150
|
+
attr_reader :lineno, :record_line
|
|
151
|
+
|
|
152
|
+
# The next line of +io+, counted.
|
|
153
|
+
def gets(io)
|
|
154
|
+
line = io.gets
|
|
155
|
+
@lineno += 1 if line
|
|
156
|
+
line
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A MalformedCSV at +line+ of the current record.
|
|
160
|
+
def malformed(detail, line = @record_line)
|
|
161
|
+
MalformedCSV.new(detail, lineno: line, record: @recno)
|
|
92
162
|
end
|
|
93
163
|
|
|
94
|
-
# Fields of the next record, or nil at EOF. A blank line
|
|
95
|
-
# separator
|
|
96
|
-
# and is skipped as noise between records.
|
|
97
|
-
#
|
|
98
|
-
#
|
|
164
|
+
# Fields of the next record, or nil at EOF. A blank line -- empty, or
|
|
165
|
+
# only spaces and tabs with no separator -- cannot be a row of a file
|
|
166
|
+
# with more than one column and is skipped as noise between records.
|
|
167
|
+
# In a single-column file it is a row: an empty line is the only
|
|
168
|
+
# spelling a missing single field has, which is what to_csv writes for
|
|
169
|
+
# a masked cell, and spaces are a value. So the caller passes
|
|
99
170
|
# +blank_is_row: true+ once the column count is known to be one, and
|
|
100
|
-
# the
|
|
101
|
-
|
|
171
|
+
# the line becomes a row for build_frame to pad. +blank?+ says whether
|
|
172
|
+
# the record just returned was such a line, for a caller that learns
|
|
173
|
+
# the column count only after the body.
|
|
174
|
+
#
|
|
175
|
+
# A line with no quote character is a whole record. One with a quote
|
|
176
|
+
# goes to the scanner, which reads further lines only while a field
|
|
177
|
+
# that opened with a quote is still open -- so a quote inside an
|
|
178
|
+
# unquoted field is malformed rather than a reason to swallow the lines
|
|
179
|
+
# after it, and a field of many lines is read once, not re-counted.
|
|
180
|
+
def read(io, blank_is_row: false, last_line: nil)
|
|
102
181
|
loop do
|
|
103
|
-
|
|
104
|
-
|
|
182
|
+
return nil if last_line && @lineno >= last_line
|
|
183
|
+
line = gets(io)
|
|
184
|
+
return nil if line.nil?
|
|
105
185
|
@recno += 1
|
|
106
|
-
|
|
107
|
-
|
|
186
|
+
@record_line = @lineno
|
|
187
|
+
@blank = false
|
|
188
|
+
if line.include?(@quote)
|
|
189
|
+
return (!@strip && quoted_line(line)) || scan(line, io)
|
|
190
|
+
end
|
|
191
|
+
rec = line.chomp
|
|
192
|
+
if rec.match?(@blank_re) && !rec.include?(@sep)
|
|
193
|
+
next unless blank_is_row
|
|
194
|
+
@blank = true
|
|
195
|
+
end
|
|
196
|
+
return simple(rec)
|
|
108
197
|
end
|
|
109
198
|
end
|
|
110
199
|
|
|
200
|
+
def blank?
|
|
201
|
+
@blank
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Count records and lines read elsewhere (the C reader), so the
|
|
205
|
+
# numbers in an error stay those of the file.
|
|
206
|
+
def advance(records, lines)
|
|
207
|
+
@recno += records
|
|
208
|
+
@lineno += lines
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# An empty field lies at the start or the end of the record or between
|
|
212
|
+
# two separators, so a record without one is the split as it stands.
|
|
111
213
|
private def simple(rec)
|
|
112
|
-
rec.split(@
|
|
113
|
-
|
|
114
|
-
cell.empty? ? nil : cell
|
|
214
|
+
fields = rec.split(@split_sep, -1)
|
|
215
|
+
if @strip
|
|
216
|
+
fields.map! { |cell| cell = cell.strip; cell.empty? ? nil : cell }
|
|
217
|
+
elsif rec.empty? || rec.start_with?(@sep) || rec.end_with?(@sep) || rec.include?(@sep2)
|
|
218
|
+
fields.map! { |cell| cell.empty? ? nil : cell }
|
|
115
219
|
end
|
|
220
|
+
fields
|
|
116
221
|
end
|
|
117
222
|
|
|
118
|
-
|
|
119
|
-
|
|
223
|
+
# The fields of a one-line record holding quotes, read by splitting at
|
|
224
|
+
# the separator: a quoted field is the pieces from one that opens with
|
|
225
|
+
# a quote to the one that closes it (it may hold the separator). nil
|
|
226
|
+
# when the record is not that simple -- a quoted field open at the end
|
|
227
|
+
# of the line, a quote inside an unquoted field, text after a closing
|
|
228
|
+
# quote -- for the scanner to read or to report.
|
|
229
|
+
private def quoted_line(line)
|
|
230
|
+
parts = line.chomp.split(@split_sep, -1)
|
|
231
|
+
fields = []
|
|
232
|
+
i = 0
|
|
233
|
+
while i < parts.size
|
|
234
|
+
part = parts[i]
|
|
235
|
+
if part.start_with?(@quote)
|
|
236
|
+
until part.match?(@quoted_re)
|
|
237
|
+
i += 1
|
|
238
|
+
return nil if i >= parts.size
|
|
239
|
+
part = part + @sep + parts[i]
|
|
240
|
+
end
|
|
241
|
+
inner = part[1...-1]
|
|
242
|
+
fields << (inner.include?(@quote) ? inner.gsub(@qq, @quote) : inner)
|
|
243
|
+
elsif part.include?(@quote)
|
|
244
|
+
return nil
|
|
245
|
+
else
|
|
246
|
+
fields << (part.empty? ? nil : part)
|
|
247
|
+
end
|
|
248
|
+
i += 1
|
|
249
|
+
end
|
|
250
|
+
fields
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Fields of the record that starts on +line+, reading more lines from
|
|
254
|
+
# +io+ while a quoted field is open. strip: skips spaces before a field,
|
|
255
|
+
# so a quote after them still opens one.
|
|
256
|
+
private def scan(line, io)
|
|
257
|
+
sc = StringScanner.new(line)
|
|
120
258
|
fields = []
|
|
121
259
|
loop do
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
end
|
|
260
|
+
sc.skip(@pad_re) if @strip && @pad_re
|
|
261
|
+
if sc.skip(@quote_re)
|
|
262
|
+
fields << quoted_field(sc, io, fields.size + 1, line_at(sc))
|
|
263
|
+
# A closed quoted field ends at the separator or the end of the
|
|
264
|
+
# record. Anything else there is malformed, and read on it would
|
|
265
|
+
# drop the rest of the record without a word. strip: lets spaces
|
|
266
|
+
# through, as it does around an unquoted field.
|
|
267
|
+
sc.skip(@pad_re) if @strip && @pad_re
|
|
268
|
+
unless sc.eos? || sc.check(@sep_re) || sc.check(@eol)
|
|
269
|
+
raise malformed("text #{sc.rest.chomp[0, 20].inspect} after the closing quote " \
|
|
270
|
+
"of field #{fields.size}", line_at(sc))
|
|
134
271
|
end
|
|
135
|
-
fields << buf
|
|
136
272
|
else
|
|
137
|
-
cell = sc.scan(@unquoted)
|
|
273
|
+
cell = sc.scan(@unquoted)
|
|
274
|
+
if sc.check(@quote_re)
|
|
275
|
+
raise malformed("a quote inside unquoted field #{fields.size + 1} " \
|
|
276
|
+
"(#{(cell + sc.rest.chomp)[0, 20].inspect}); a field holding " \
|
|
277
|
+
"a quote is written quoted, with the quote doubled", line_at(sc))
|
|
278
|
+
end
|
|
138
279
|
cell = cell.strip if @strip
|
|
139
280
|
fields << (cell.empty? ? nil : cell)
|
|
140
281
|
end
|
|
141
|
-
break unless sc.
|
|
282
|
+
break unless sc.skip(@sep_re)
|
|
142
283
|
end
|
|
284
|
+
sc.skip(@eol)
|
|
143
285
|
fields
|
|
144
286
|
end
|
|
287
|
+
|
|
288
|
+
# The line of the file the scanner is at: the record's first line and
|
|
289
|
+
# the line breaks it has passed in quoted fields.
|
|
290
|
+
private def line_at(sc)
|
|
291
|
+
@record_line + sc.string[0, sc.pos].count("\n")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The rest of a field whose opening quote has been read, through its
|
|
295
|
+
# closing quote. A doubled quote is a literal one; the end of the line
|
|
296
|
+
# inside the field brings the next line in.
|
|
297
|
+
private def quoted_field(sc, io, field_no, open_line)
|
|
298
|
+
buf = +""
|
|
299
|
+
loop do
|
|
300
|
+
buf << sc.scan(@inner)
|
|
301
|
+
if sc.eos?
|
|
302
|
+
more = gets(io)
|
|
303
|
+
if more.nil?
|
|
304
|
+
raise malformed("quoted field #{field_no} opened here is never closed", open_line)
|
|
305
|
+
end
|
|
306
|
+
sc << more
|
|
307
|
+
next
|
|
308
|
+
end
|
|
309
|
+
sc.skip(@quote_re)
|
|
310
|
+
break unless sc.skip(@quote_re)
|
|
311
|
+
buf << @quote
|
|
312
|
+
end
|
|
313
|
+
buf
|
|
314
|
+
end
|
|
145
315
|
end
|
|
146
316
|
end
|
|
147
317
|
|
|
148
|
-
# The
|
|
149
|
-
#
|
|
150
|
-
# in order, how to consume the stream:
|
|
318
|
+
# The reading-control block for CAFrame.from_csv. A file often has
|
|
319
|
+
# preamble lines, a units row, or no header at all; the block is given the
|
|
320
|
+
# reader and says, in order, how to consume the stream:
|
|
151
321
|
#
|
|
152
|
-
# CAFrame.from_csv(path) do
|
|
153
|
-
# skip 3 # drop 3 preamble lines
|
|
154
|
-
# header # next record supplies the column names
|
|
155
|
-
# skip 1 # drop a units row
|
|
156
|
-
#
|
|
322
|
+
# CAFrame.from_csv(path) do |r|
|
|
323
|
+
# r.skip 3 # drop 3 preamble lines
|
|
324
|
+
# r.header # next record supplies the column names
|
|
325
|
+
# r.skip 1 # drop a units row
|
|
326
|
+
# r.data # the rest are data rows
|
|
157
327
|
# end
|
|
158
328
|
#
|
|
159
|
-
# CAFrame.from_csv(path)
|
|
160
|
-
# column_names "date", "temp", "rh"
|
|
161
|
-
# body
|
|
162
|
-
# end
|
|
329
|
+
# CAFrame.from_csv(path) { it.column_names "date", "temp", "rh"; it.data }
|
|
163
330
|
#
|
|
164
|
-
# The verbs are +skip+ / +header+ / +column_names+ / +
|
|
165
|
-
# value useful inline (header returns its fields) and the ordering is the
|
|
166
|
-
# script.
|
|
331
|
+
# The verbs are +skip+ / +header+ / +column_names+ / +columns+ / +data+;
|
|
332
|
+
# each returns a value useful inline (header returns its fields) and the ordering is the
|
|
333
|
+
# script. The reader is a parameter, not self, so a local variable named
|
|
334
|
+
# +data+ or +header+ cannot stand in for the verb. Without a block,
|
|
335
|
+
# from_csv reads by its header: / data: / column_names: (see +layout+).
|
|
167
336
|
class CSVReader
|
|
168
|
-
def initialize(io, sep: ",", quote: '"', strip: false)
|
|
337
|
+
def initialize(io, sep: ",", quote: '"', strip: false, read_text_as_const_string: false)
|
|
338
|
+
@read_text_as_const_string = read_text_as_const_string
|
|
169
339
|
@io = io
|
|
340
|
+
@path = io.path if io.respond_to?(:path)
|
|
170
341
|
@tok = CSVParser::Tokenizer.new(sep, quote, strip)
|
|
171
342
|
@names = nil
|
|
172
343
|
@rows = nil
|
|
@@ -174,45 +345,475 @@ class CAFrame
|
|
|
174
345
|
|
|
175
346
|
# Drop +n+ raw lines (preamble, units, notes).
|
|
176
347
|
def skip(n = 1)
|
|
177
|
-
n.
|
|
348
|
+
unless n.is_a?(Integer) && n >= 0
|
|
349
|
+
raise ArgumentError, "skip takes a number of lines (got #{n.inspect})"
|
|
350
|
+
end
|
|
351
|
+
n.times { @tok.gets(@io) }
|
|
352
|
+
@skipped = true if n > 0
|
|
178
353
|
self
|
|
179
354
|
end
|
|
180
355
|
|
|
181
356
|
# Read one record. With no argument it becomes the column names; with a
|
|
182
|
-
# name it is a secondary header (e.g. units)
|
|
183
|
-
#
|
|
357
|
+
# name it is a secondary header (e.g. units): its field j becomes the
|
|
358
|
+
# attribute +name+ of column j (an empty field gives none). Returns the
|
|
359
|
+
# record's fields either way.
|
|
184
360
|
def header(name = nil)
|
|
185
|
-
|
|
186
|
-
|
|
361
|
+
# A named header of a file with at most one column may be an empty
|
|
362
|
+
# line: that is how to_csv writes a column without the attribute, so
|
|
363
|
+
# the line is the header's, as an empty line is a row in the data.
|
|
364
|
+
blank_is_row = !name.nil? && !@names.nil? && @names.size <= 1
|
|
365
|
+
fields = @tok.read(@io, blank_is_row: blank_is_row)
|
|
366
|
+
if fields.nil?
|
|
367
|
+
# A file with no columns has nothing to give a named header.
|
|
368
|
+
return [] if !name.nil? && @names&.empty?
|
|
369
|
+
# An input with no record at all is a table with no columns: that is
|
|
370
|
+
# what to_csv writes for a frame without columns or index. Once lines
|
|
371
|
+
# have been skipped to reach the header, a missing header is an error.
|
|
372
|
+
if name.nil? && !@skipped
|
|
373
|
+
@names = []
|
|
374
|
+
@header_line = @tok.lineno
|
|
375
|
+
@names_to_check = nil
|
|
376
|
+
return []
|
|
377
|
+
end
|
|
378
|
+
raise CSVParser::MalformedCSV.new("header expected but input ended",
|
|
379
|
+
lineno: @tok.lineno + 1)
|
|
380
|
+
end
|
|
187
381
|
if name.nil?
|
|
188
|
-
|
|
382
|
+
# An empty field names no column: the column takes the name a file
|
|
383
|
+
# without a header gives it, from its place in the line.
|
|
384
|
+
@names = fields.each_with_index.map { |f, j| f.to_s.empty? ? "c#{j}" : f.to_s }
|
|
385
|
+
@header_line = @tok.record_line
|
|
386
|
+
@names_to_check = nil
|
|
189
387
|
else
|
|
190
|
-
|
|
388
|
+
key = name.to_s
|
|
389
|
+
@named_headers ||= {}
|
|
390
|
+
if @named_headers.key?(key)
|
|
391
|
+
raise ArgumentError, "from_csv: header(#{name.inspect}) is read twice"
|
|
392
|
+
end
|
|
393
|
+
@named_headers[key] = [fields, @tok.record_line, name]
|
|
191
394
|
end
|
|
192
395
|
fields
|
|
193
396
|
end
|
|
194
397
|
|
|
195
|
-
# Set the column names explicitly
|
|
398
|
+
# Set the column names explicitly: for a file with no header, or in
|
|
399
|
+
# place of the names a header gave. Their number has to be the file's
|
|
400
|
+
# number of columns -- the header's, or else that of the first record
|
|
401
|
+
# of the data -- or a name would go to no column, or a column without
|
|
402
|
+
# one.
|
|
196
403
|
def column_names(*names)
|
|
197
|
-
|
|
404
|
+
names = names.flatten.map(&:to_s)
|
|
405
|
+
if names.include?("")
|
|
406
|
+
raise ArgumentError, "from_csv: column_names: a column name can not be empty"
|
|
407
|
+
end
|
|
408
|
+
if @header_line
|
|
409
|
+
check_column_names(names, @names.size, @header_line)
|
|
410
|
+
else
|
|
411
|
+
@names_to_check = names
|
|
412
|
+
end
|
|
413
|
+
@names = names
|
|
414
|
+
self
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Read only these columns: names of the header or of column_names, or
|
|
418
|
+
# indexes of the file's columns from 0 (Integers or Ranges), in the order
|
|
419
|
+
# the frame is to have them. The fields of the others are read past, so
|
|
420
|
+
# a few columns of a wide file cost little more than a narrow file.
|
|
421
|
+
#
|
|
422
|
+
# A Hash selects by its keys, as above (a name or an index, not a Range),
|
|
423
|
+
# and names the columns read with its values:
|
|
424
|
+
#
|
|
425
|
+
# r.columns(2 => "code", "c7" => "area1")
|
|
426
|
+
def columns(*selection)
|
|
427
|
+
@renames = nil
|
|
428
|
+
if selection.size == 1 && selection[0].is_a?(Hash)
|
|
429
|
+
@renames = column_renames(selection[0])
|
|
430
|
+
selection = selection[0].keys
|
|
431
|
+
end
|
|
432
|
+
selection = selection.flatten
|
|
433
|
+
if selection.empty?
|
|
434
|
+
raise ArgumentError, "from_csv: columns: names no column"
|
|
435
|
+
end
|
|
436
|
+
selection.each do |c|
|
|
437
|
+
next if c.is_a?(String)
|
|
438
|
+
CAFrame.__send__(:column_key, c, "from_csv", "columns: a column name") if c.is_a?(Symbol)
|
|
439
|
+
next if c.is_a?(Integer) && c >= 0
|
|
440
|
+
next if c.is_a?(Range) && (c.begin.nil? || (c.begin.is_a?(Integer) && c.begin >= 0)) &&
|
|
441
|
+
(c.end.nil? || c.end.is_a?(Integer))
|
|
442
|
+
raise ArgumentError, "from_csv: columns: takes column names, or indexes from 0 " \
|
|
443
|
+
"and Ranges of them (got #{c.inspect})"
|
|
444
|
+
end
|
|
445
|
+
@selection = selection
|
|
198
446
|
self
|
|
199
447
|
end
|
|
200
448
|
|
|
201
|
-
#
|
|
202
|
-
def
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
449
|
+
# The new names a Hash given to +columns+ holds, checked.
|
|
450
|
+
private def column_renames(map)
|
|
451
|
+
map.each do |key, name|
|
|
452
|
+
if key.is_a?(Range)
|
|
453
|
+
raise ArgumentError, "from_csv: columns: a Hash selects one column per key, " \
|
|
454
|
+
"not a Range (got #{key.inspect})"
|
|
455
|
+
end
|
|
456
|
+
CAFrame.__send__(:column_key, name, "from_csv", "columns: a column name",
|
|
457
|
+
hint: "given for #{key.inspect}")
|
|
458
|
+
if name.empty?
|
|
459
|
+
raise ArgumentError, "from_csv: columns: a column name can not be empty " \
|
|
460
|
+
"(given for #{key.inspect})"
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
CAFrame.__send__(:refuse_repeated_names, map.values, "from_csv", "columns: names")
|
|
464
|
+
map.values
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# The indexes of the selected columns, in the order asked for, among
|
|
468
|
+
# ncol columns named +names+ (nil: by index only). nil when ncol is not
|
|
469
|
+
# known yet and the selection needs it.
|
|
470
|
+
private def selected_indexes(names, ncol)
|
|
471
|
+
indexes = @selection.flat_map do |c|
|
|
472
|
+
case c
|
|
473
|
+
when String
|
|
474
|
+
# Without a header the columns are named c0, c1, ... by position.
|
|
475
|
+
j = names ? names.index(c) : c[/\Ac(0|[1-9]\d*)\z/, 1]&.to_i
|
|
476
|
+
if j.nil?
|
|
477
|
+
raise KeyError, "from_csv: columns: names no column #{c.inspect}" +
|
|
478
|
+
(names ? "" : " (with no header the columns are c0, c1, ...)")
|
|
479
|
+
end
|
|
480
|
+
j
|
|
481
|
+
when Integer then c
|
|
482
|
+
else
|
|
483
|
+
first = c.begin || 0
|
|
484
|
+
last = c.end
|
|
485
|
+
return nil if last.nil? && ncol.nil?
|
|
486
|
+
last = ncol - 1 if last.nil?
|
|
487
|
+
last -= 1 if c.end && c.exclude_end?
|
|
488
|
+
(first..last).to_a
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
CAFrame.__send__(:refuse_repeated_names, indexes, "from_csv", "columns: selects column")
|
|
492
|
+
if ncol && (bad = indexes.find { |j| j >= ncol })
|
|
493
|
+
raise ArgumentError, "from_csv: columns: #{bad} is past the #{ncol} columns of the file"
|
|
494
|
+
end
|
|
495
|
+
indexes
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# The indexes C is to read, ascending, when it can tell which they are
|
|
499
|
+
# before it reads; nil for every column.
|
|
500
|
+
private def columns_to_read
|
|
501
|
+
@selection && selected_indexes(@names, @names&.size)&.sort
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
private def check_column_names(names, ncol, line)
|
|
505
|
+
return if names.size == ncol
|
|
506
|
+
raise ArgumentError, "from_csv: column_names: gives #{names.size} " \
|
|
507
|
+
"name#{names.size == 1 ? '' : 's'} for the #{ncol} columns of line #{line}"
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Consume the remaining records as data rows. A second data adds what is
|
|
511
|
+
# left, which is nothing; it does not discard the rows already read.
|
|
512
|
+
#
|
|
513
|
+
# Without names the column count is known only once every row is read,
|
|
514
|
+
# so blank lines are kept and marked, and +result+ drops them unless the
|
|
515
|
+
# file turns out to have one column -- the same rows a header would have
|
|
516
|
+
# given.
|
|
517
|
+
#
|
|
518
|
+
# Without strip:, the rest of the input is read in C (CArray.__csv_read_body_as_string_cells__)
|
|
519
|
+
# straight into the table, with the column count of the names, or of the
|
|
520
|
+
# first record when there are none. When C declines -- text not in
|
|
521
|
+
# UTF-8, a record it does not take, or one longer than the first in a
|
|
522
|
+
# file without names -- the text from there on is read here, which pads
|
|
523
|
+
# the earlier rows or reports what is wrong.
|
|
524
|
+
def data
|
|
525
|
+
read_data(nil)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# The header: / data: / column_names: of from_csv, as the verbs above.
|
|
529
|
+
# They index the lines of the file from 0, as File.readlines does (an
|
|
530
|
+
# error names a line from 1, as an editor does). header: defaults to 0,
|
|
531
|
+
# or to none (false) when column_names: is given; data: is the first line
|
|
532
|
+
# of the data or a Range of lines, by default from the line after the
|
|
533
|
+
# header. A record that starts within data: is read whole.
|
|
534
|
+
def layout(header: nil, data: nil, column_names: nil, columns: nil, attrs: nil)
|
|
535
|
+
header = column_names ? false : 0 if header.nil?
|
|
536
|
+
unless header == false || (header.is_a?(Integer) && header >= 0)
|
|
537
|
+
raise ArgumentError, "from_csv: header: takes the index of a line, from 0, " \
|
|
538
|
+
"or false (got #{header.inspect})"
|
|
539
|
+
end
|
|
540
|
+
attr_keys = attrs.nil? ? [] : CAFrame.send(:csv_attr_keys, attrs, "from_csv")
|
|
541
|
+
if !header && !attr_keys.empty?
|
|
542
|
+
raise ArgumentError, "from_csv: attrs: reads the lines below the header, " \
|
|
543
|
+
"so it needs a header line"
|
|
544
|
+
end
|
|
545
|
+
first, last = CSVReader.data_lines(data)
|
|
546
|
+
if header
|
|
547
|
+
skip(header - @tok.lineno) if header > @tok.lineno
|
|
548
|
+
self.header
|
|
549
|
+
attr_keys.each { |key| self.header(key.to_sym) }
|
|
550
|
+
end
|
|
551
|
+
column_names(*column_names) if column_names
|
|
552
|
+
self.columns(*(columns.is_a?(Array) ? columns : [columns])) if columns
|
|
553
|
+
first ||= @tok.lineno
|
|
554
|
+
if first < @tok.lineno
|
|
555
|
+
raise ArgumentError, "from_csv: data: #{first} does not come after header: #{header}" +
|
|
556
|
+
(attr_keys.empty? ? "" : " and the #{attr_keys.size} attrs: lines")
|
|
557
|
+
end
|
|
558
|
+
skip(first - @tok.lineno)
|
|
559
|
+
read_data(last && last + 1)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# [first, last] line indexes of a data: setting, nil for an end left open.
|
|
563
|
+
def self.data_lines(spec)
|
|
564
|
+
first, last =
|
|
565
|
+
case spec
|
|
566
|
+
when nil then [nil, nil]
|
|
567
|
+
when Integer then [spec, nil]
|
|
568
|
+
when Range
|
|
569
|
+
e = spec.end
|
|
570
|
+
e -= 1 if e.is_a?(Integer) && spec.exclude_end?
|
|
571
|
+
[spec.begin, e]
|
|
572
|
+
else
|
|
573
|
+
raise ArgumentError, "from_csv: data: takes the index of a line or a Range of " \
|
|
574
|
+
"them, from 0 (got #{spec.inspect})"
|
|
575
|
+
end
|
|
576
|
+
[first, last].each do |n|
|
|
577
|
+
next if n.nil? || (n.is_a?(Integer) && n >= 0)
|
|
578
|
+
raise ArgumentError, "from_csv: data: lines are indexed from 0 (got #{spec.inspect})"
|
|
579
|
+
end
|
|
580
|
+
if first && last && last < first
|
|
581
|
+
raise ArgumentError, "from_csv: data: #{spec.inspect} ends before it starts"
|
|
582
|
+
end
|
|
583
|
+
[first, last]
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# The records from here through those starting on +last+ (nil: the end).
|
|
587
|
+
private def read_data(last)
|
|
588
|
+
body_in_c(last) if @rows.nil? && !@tok.strip && @io.respond_to?(:read)
|
|
589
|
+
return self if @table
|
|
590
|
+
rows = (@rows ||= [])
|
|
591
|
+
@blank_rows ||= []
|
|
592
|
+
blank_is_row = @names.nil? || @names.size == 1
|
|
593
|
+
ncol = @names&.size
|
|
594
|
+
while (fields = @tok.read(@io, blank_is_row: blank_is_row, last_line: last))
|
|
595
|
+
if @names_to_check && !@tok.blank?
|
|
596
|
+
check_column_names(@names_to_check, fields.size, @tok.record_line)
|
|
597
|
+
@names_to_check = nil
|
|
598
|
+
end
|
|
599
|
+
if ncol && fields.size > ncol
|
|
600
|
+
raise @tok.malformed("#{fields.size} fields, but there are #{ncol} columns")
|
|
601
|
+
end
|
|
602
|
+
@blank_rows << rows.size if @names.nil? && @tok.blank?
|
|
206
603
|
rows << fields
|
|
207
604
|
end
|
|
208
|
-
@rows = rows
|
|
209
605
|
self
|
|
210
606
|
end
|
|
211
607
|
|
|
608
|
+
# Run the block, naming the file in a MalformedCSV it raises.
|
|
609
|
+
def reporting
|
|
610
|
+
yield
|
|
611
|
+
rescue CSVParser::MalformedCSV => e
|
|
612
|
+
e.path ||= @path
|
|
613
|
+
raise
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# The text C reads at a time when the column count is known; a chunk
|
|
617
|
+
# ends at a line end, and not inside a quoted field.
|
|
618
|
+
CHUNK_BYTES = 1 << 22
|
|
619
|
+
|
|
620
|
+
# With names, the input goes to C a chunk at a time, so the text is not
|
|
621
|
+
# held whole beside its cells. Without them, the column count and which
|
|
622
|
+
# blank lines are rows are known only at the end, so it goes in whole;
|
|
623
|
+
# so it does when the IO transcodes, since read(length) does not.
|
|
624
|
+
#
|
|
625
|
+
# With read_text_as_const_string:, the body goes to C whole, into one
|
|
626
|
+
# CAConstString per column over the text itself: the text is the buffer
|
|
627
|
+
# the columns read, so it is held whole either way. When C declines, the
|
|
628
|
+
# text is read here from the start of the body.
|
|
629
|
+
private def read_body_into_const_string_columns
|
|
630
|
+
ncol = @names && !@names_to_check ? @names.size : 0
|
|
631
|
+
keep = columns_to_read
|
|
632
|
+
text = read_rest
|
|
633
|
+
n, buffer, pairs, records = CArray.__csv_read_body_as_const_string_columns__(text, @tok.sep, @tok.quote, ncol, keep)
|
|
634
|
+
unless buffer
|
|
635
|
+
@rows = []
|
|
636
|
+
@io = StringIO.new(text)
|
|
637
|
+
return
|
|
638
|
+
end
|
|
639
|
+
if @names_to_check
|
|
640
|
+
check_column_names(@names_to_check, n, @tok.lineno + first_record_line(text))
|
|
641
|
+
@names_to_check = nil
|
|
642
|
+
end
|
|
643
|
+
@tok.advance(records, text.count("\n") + (text.empty? || text.end_with?("\n") ? 0 : 1))
|
|
644
|
+
@table = ConstStringColumns.new(pairs.map { |pr| CAConstString.wrap(pr, buffer: buffer) })
|
|
645
|
+
@table_ncol = n
|
|
646
|
+
@table_keep = keep
|
|
647
|
+
@rows = []
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# The columns read_body_into_const_string_columns made, as +result+
|
|
651
|
+
# hands them to CAFrame.from_csv.
|
|
652
|
+
ConstStringColumns = Struct.new(:columns)
|
|
653
|
+
|
|
654
|
+
private def body_in_c(last = nil, chunk_bytes = CHUNK_BYTES)
|
|
655
|
+
return read_body_into_const_string_columns if last.nil? && @read_text_as_const_string
|
|
656
|
+
chunked = last.nil? && @names && @io.respond_to?(:external_encoding) &&
|
|
657
|
+
@io.internal_encoding.nil?
|
|
658
|
+
# Names that have to agree with the first record leave its column
|
|
659
|
+
# count to C, to be checked against them.
|
|
660
|
+
ncol = @names && !@names_to_check ? @names.size : 0
|
|
661
|
+
keep = columns_to_read
|
|
662
|
+
cells = []
|
|
663
|
+
loop do
|
|
664
|
+
text = if chunked then next_chunk(chunk_bytes)
|
|
665
|
+
elsif last then lines_through(last)
|
|
666
|
+
else read_rest
|
|
667
|
+
end
|
|
668
|
+
break if text.nil?
|
|
669
|
+
n, flat, records = CArray.__csv_read_body_as_string_cells__(text, @tok.sep, @tok.quote, ncol, keep)
|
|
670
|
+
unless flat
|
|
671
|
+
# This chunk and the rest are read by the Ruby tokenizer, after the
|
|
672
|
+
# rows read so far.
|
|
673
|
+
if keep && ncol > 0 && !cells.empty?
|
|
674
|
+
# The rows read so far hold the selected fields only; spread
|
|
675
|
+
# them back to their columns for the Ruby reader.
|
|
676
|
+
@rows = cells.each_slice(keep.size).map do |row|
|
|
677
|
+
full = Array.new(ncol)
|
|
678
|
+
keep.each_with_index { |j, k| full[j] = UNDEF.equal?(row[k]) ? nil : row[k] }
|
|
679
|
+
full
|
|
680
|
+
end
|
|
681
|
+
else
|
|
682
|
+
@rows = ncol > 0 ? cells.each_slice(ncol).to_a : []
|
|
683
|
+
end
|
|
684
|
+
@io = StringIO.new(chunked ? text + (@io.read || "") : text)
|
|
685
|
+
return
|
|
686
|
+
end
|
|
687
|
+
if @names_to_check
|
|
688
|
+
check_column_names(@names_to_check, n, @tok.lineno + first_record_line(text))
|
|
689
|
+
@names_to_check = nil
|
|
690
|
+
end
|
|
691
|
+
ncol = n
|
|
692
|
+
cells.concat(flat)
|
|
693
|
+
@tok.advance(records, text.count("\n") + (text.empty? || text.end_with?("\n") ? 0 : 1))
|
|
694
|
+
break unless chunked
|
|
695
|
+
end
|
|
696
|
+
if ncol == 0 # no record, for the names to be checked against
|
|
697
|
+
@rows = []
|
|
698
|
+
return
|
|
699
|
+
end
|
|
700
|
+
width = keep ? keep.size : ncol
|
|
701
|
+
@table = CArray.object(cells.size / width, width) { cells }
|
|
702
|
+
@table_ncol = ncol
|
|
703
|
+
@table_keep = keep
|
|
704
|
+
@rows = []
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# The line, from 1 within +text+, of its first record: past the blank
|
|
708
|
+
# lines before it, as C reads them.
|
|
709
|
+
private def first_record_line(text)
|
|
710
|
+
line = 1
|
|
711
|
+
text.each_line do |l|
|
|
712
|
+
rec = l.chomp
|
|
713
|
+
break unless rec.match?(/\A[ \t]*\z/) && !rec.include?(@tok.sep)
|
|
714
|
+
line += 1
|
|
715
|
+
end
|
|
716
|
+
line
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# The lines through +last+, read on past any quoted field still open.
|
|
720
|
+
private def lines_through(last)
|
|
721
|
+
buf = +""
|
|
722
|
+
(last - @tok.lineno).times do
|
|
723
|
+
line = @io.gets or break
|
|
724
|
+
buf << line
|
|
725
|
+
end
|
|
726
|
+
while buf.count(@tok.quote).odd? && (more = @io.gets)
|
|
727
|
+
buf << more
|
|
728
|
+
end
|
|
729
|
+
buf
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
# The rest of the input, in the IO's encoding. Read in pieces when the IO
|
|
733
|
+
# does not transcode: IO#read without a length checks the text's encoding
|
|
734
|
+
# as it reads, and the C reader checks it again faster.
|
|
735
|
+
private def read_rest
|
|
736
|
+
unless @io.respond_to?(:internal_encoding) && @io.internal_encoding.nil?
|
|
737
|
+
return @io.read || ""
|
|
738
|
+
end
|
|
739
|
+
buf = +""
|
|
740
|
+
while (piece = @io.read(CHUNK_BYTES))
|
|
741
|
+
buf << piece
|
|
742
|
+
end
|
|
743
|
+
buf.force_encoding(@io.external_encoding || Encoding.default_external)
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
# The next chunk_bytes of the input, read on to the end of its line and
|
|
747
|
+
# past any quoted field still open, in the IO's encoding; nil at EOF.
|
|
748
|
+
private def next_chunk(chunk_bytes)
|
|
749
|
+
buf = @io.read(chunk_bytes)
|
|
750
|
+
return nil if buf.nil?
|
|
751
|
+
buf.force_encoding(@io.external_encoding || Encoding.default_external)
|
|
752
|
+
if !buf.end_with?("\n") && (rest = @io.gets)
|
|
753
|
+
buf << rest
|
|
754
|
+
end
|
|
755
|
+
while buf.count(@tok.quote).odd? && (more = @io.gets)
|
|
756
|
+
buf << more
|
|
757
|
+
end
|
|
758
|
+
buf
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# The attributes the named headers give the columns +result+ returns, as
|
|
762
|
+
# [{key => value}, ...] in the same order. A column of the file the
|
|
763
|
+
# selection left out takes no part.
|
|
764
|
+
def column_attrs (ncol)
|
|
765
|
+
return nil unless @named_headers
|
|
766
|
+
indexes = @selection ? selected_indexes(@names, ncol) : (0...ncol).to_a
|
|
767
|
+
attrs = indexes.map { {} }
|
|
768
|
+
@named_headers.each do |key, (fields, line, name)|
|
|
769
|
+
if fields.size > ncol
|
|
770
|
+
raise ArgumentError, "from_csv: header(#{name.inspect}) on line #{line} " \
|
|
771
|
+
"has #{fields.size} fields for the #{ncol} columns"
|
|
772
|
+
end
|
|
773
|
+
indexes.each_with_index do |j, k|
|
|
774
|
+
value = fields[j]
|
|
775
|
+
attrs[k][key] = value unless value.nil?
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
attrs
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# The number of columns of the file, once the data is read.
|
|
782
|
+
def file_ncol
|
|
783
|
+
return @table_ncol if @table
|
|
784
|
+
@names ? @names.size : ((@rows || []).map(&:size).max || 0)
|
|
785
|
+
end
|
|
786
|
+
|
|
212
787
|
# [names_or_nil, rows] for CAFrame.from_csv to build from. names is nil when
|
|
213
788
|
# neither header nor column_names ran (positional names are generated).
|
|
214
789
|
def result
|
|
215
|
-
|
|
790
|
+
return table_result if @table
|
|
791
|
+
rows = @rows || []
|
|
792
|
+
if @names.nil? && @blank_rows && !@blank_rows.empty? &&
|
|
793
|
+
rows.map(&:size).max != 1
|
|
794
|
+
drop = @blank_rows.to_h { |i| [i, true] }
|
|
795
|
+
rows = rows.reject.with_index { |_, i| drop[i] }
|
|
796
|
+
end
|
|
797
|
+
return [@names, rows] unless @selection
|
|
798
|
+
ncol = @names ? @names.size : (rows.map(&:size).max || 0)
|
|
799
|
+
indexes = selected_indexes(@names, ncol)
|
|
800
|
+
names = @renames || indexes.map { |j| @names ? @names[j] : "c#{j}" }
|
|
801
|
+
[names, rows.map { |r| r.values_at(*indexes) }]
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# The table C read, its columns those selected, in the order asked for.
|
|
805
|
+
private def table_result
|
|
806
|
+
return [@names, @table] unless @selection
|
|
807
|
+
indexes = selected_indexes(@names, @table_ncol)
|
|
808
|
+
names = @renames || indexes.map { |j| @names ? @names[j] : "c#{j}" }
|
|
809
|
+
kept = @table_keep || (0...@table_ncol).to_a
|
|
810
|
+
order = indexes.map { |j| kept.index(j) }
|
|
811
|
+
table =
|
|
812
|
+
if order == (0...kept.size).to_a then @table
|
|
813
|
+
elsif @table.is_a?(ConstStringColumns) then ConstStringColumns.new(@table.columns.values_at(*order))
|
|
814
|
+
else @table[nil, CA_INT64(order)]
|
|
815
|
+
end
|
|
816
|
+
[names, table]
|
|
216
817
|
end
|
|
217
818
|
end
|
|
218
819
|
end
|