carray 3.0.2 → 3.0.3

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.
Files changed (218) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +304 -0
  3. data/README.md +3 -4
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_dispatch.c +225 -0
  6. data/ext/ca_axis_group.c +177 -99
  7. data/ext/ca_binop_dispatch.c +54 -32
  8. data/ext/ca_binop_dispatch.h +1 -0
  9. data/ext/ca_categorical_iterator.c +66 -71
  10. data/ext/ca_compare.c +8 -2
  11. data/ext/ca_for_buffer.h +21 -68
  12. data/ext/ca_group_iter.c +27 -11
  13. data/ext/ca_kernel_iterator.c +663 -214
  14. data/ext/ca_kernel_iterator.h +231 -167
  15. data/ext/ca_monop_dispatch.c +4 -4
  16. data/ext/ca_monop_dispatch.h +4 -0
  17. data/ext/ca_obj_array.c +132 -22
  18. data/ext/ca_obj_bincmp.c +152 -121
  19. data/ext/ca_obj_binop.c +107 -210
  20. data/ext/ca_obj_bitarray.c +37 -24
  21. data/ext/ca_obj_bitfield.c +95 -62
  22. data/ext/ca_obj_block.c +1 -0
  23. data/ext/ca_obj_byte_swap.c +40 -40
  24. data/ext/ca_obj_const_string.c +96 -25
  25. data/ext/ca_obj_face.c +406 -64
  26. data/ext/ca_obj_face.h +92 -47
  27. data/ext/ca_obj_fake.c +21 -50
  28. data/ext/ca_obj_farray.c +1 -0
  29. data/ext/ca_obj_field.c +16 -3
  30. data/ext/ca_obj_fixlen_string.c +21 -21
  31. data/ext/ca_obj_grid.c +181 -124
  32. data/ext/ca_obj_meld.c +189 -107
  33. data/ext/ca_obj_moncmp.c +61 -61
  34. data/ext/ca_obj_monop.c +125 -107
  35. data/ext/ca_obj_object.c +543 -233
  36. data/ext/ca_obj_record.c +51 -16
  37. data/ext/ca_obj_reduce.c +5 -8
  38. data/ext/ca_obj_refer.c +117 -33
  39. data/ext/ca_obj_remap.c +57 -17
  40. data/ext/ca_obj_repeat.c +21 -5
  41. data/ext/ca_obj_roll.c +115 -11
  42. data/ext/ca_obj_select.c +47 -49
  43. data/ext/ca_obj_select_axis.c +58 -60
  44. data/ext/ca_obj_shift.c +22 -102
  45. data/ext/ca_obj_stack.c +223 -118
  46. data/ext/ca_obj_stride.c +195 -34
  47. data/ext/ca_obj_string.c +17 -18
  48. data/ext/ca_obj_tile.c +32 -16
  49. data/ext/ca_obj_time.c +23 -25
  50. data/ext/ca_obj_timedelta.c +21 -23
  51. data/ext/ca_obj_transpose.c +12 -3
  52. data/ext/ca_obj_triop.c +110 -117
  53. data/ext/ca_obj_window.c +149 -27
  54. data/ext/ca_op_ipower.c +24 -11
  55. data/ext/ca_op_powi.h +57 -30
  56. data/ext/ca_pow5_table.h +656 -0
  57. data/ext/ca_sort_kernels.h +70 -0
  58. data/ext/ca_sweep_engine.c +102 -79
  59. data/ext/ca_sweep_engine.h +7 -20
  60. data/ext/ca_triop_dispatch.c +4 -1
  61. data/ext/ca_triop_dispatch.h +5 -2
  62. data/ext/caframe_csv_reader.c +463 -0
  63. data/ext/carray.h +70 -27
  64. data/ext/carray_access.c +455 -211
  65. data/ext/carray_address_basis.c +177 -36
  66. data/ext/carray_attribute.c +45 -3
  67. data/ext/carray_bincount.c +32 -2
  68. data/ext/carray_broadcast.c +46 -14
  69. data/ext/carray_call_cfunc.c +0 -28
  70. data/ext/carray_cast.c +1444 -209
  71. data/ext/carray_cast_func.rb +34 -21
  72. data/ext/carray_conversion.c +197 -73
  73. data/ext/carray_copy.c +32 -11
  74. data/ext/carray_core.c +514 -122
  75. data/ext/carray_count.c +140 -87
  76. data/ext/carray_element.c +18 -27
  77. data/ext/carray_factorize.c +569 -277
  78. data/ext/carray_generate.c +76 -40
  79. data/ext/carray_histogram.c +152 -94
  80. data/ext/carray_hold.c +2 -1
  81. data/ext/carray_index_classifier.c +63 -96
  82. data/ext/carray_internal.h +385 -3
  83. data/ext/carray_lazy.c +834 -79
  84. data/ext/carray_loop.c +81 -65
  85. data/ext/carray_mask.c +682 -316
  86. data/ext/carray_math_kernel.h +1 -0
  87. data/ext/carray_median_percentile.c +221 -76
  88. data/ext/carray_memory_view.c +254 -248
  89. data/ext/carray_operator.c +1089 -359
  90. data/ext/carray_order.c +133 -69
  91. data/ext/carray_partition.c +102 -56
  92. data/ext/carray_random.c +273 -239
  93. data/ext/carray_scatter.c +308 -65
  94. data/ext/carray_segment.c +257 -0
  95. data/ext/carray_slab.c +170 -70
  96. data/ext/carray_sort.c +215 -218
  97. data/ext/carray_sort_kernel.c +8 -1
  98. data/ext/carray_struct.c +99 -21
  99. data/ext/carray_test.c +167 -64
  100. data/ext/carray_time_text.c +1182 -0
  101. data/ext/carray_utils.c +363 -98
  102. data/ext/extconf.rb +27 -2
  103. data/ext/mk_call_cfunc.rb +0 -2
  104. data/ext/mk_pow5_table.rb +35 -0
  105. data/ext/mkkernel.rb +1076 -846
  106. data/ext/ruby_carray.c +7 -1
  107. data/ext/version.h +4 -4
  108. data/ext/version.rb +4 -2
  109. data/lib/carray/attribute.rb +39 -0
  110. data/lib/carray/autoload_carray.rb +19 -0
  111. data/lib/carray/autoload_method_extension.rb +6 -0
  112. data/lib/carray/axis_group.rb +174 -26
  113. data/lib/carray/basics.rb +91 -25
  114. data/lib/carray/bincount_nd.rb +6 -0
  115. data/lib/carray/block_iterator.rb +118 -48
  116. data/lib/carray/boolean_reduce.rb +21 -29
  117. data/lib/carray/categorical.rb +94 -26
  118. data/lib/carray/categorical_iterator.rb +333 -826
  119. data/lib/carray/complex.rb +36 -29
  120. data/lib/carray/conditional.rb +94 -7
  121. data/lib/carray/const_string.rb +46 -0
  122. data/lib/carray/frame/concat.rb +157 -6
  123. data/lib/carray/frame/convert.rb +13 -7
  124. data/lib/carray/frame/csv_parser.rb +589 -82
  125. data/lib/carray/frame/describe.rb +79 -0
  126. data/lib/carray/frame/frame.rb +124 -25
  127. data/lib/carray/frame/group.rb +189 -63
  128. data/lib/carray/frame/io.rb +412 -72
  129. data/lib/carray/frame/join.rb +134 -46
  130. data/lib/carray/frame/records.rb +37 -16
  131. data/lib/carray/frame/reshape.rb +448 -0
  132. data/lib/carray/frame/sort.rb +26 -2
  133. data/lib/carray/frame/verbs.rb +715 -48
  134. data/lib/carray/frame.rb +2 -0
  135. data/lib/carray/fuse_source.rb +111 -2
  136. data/lib/carray/fusion.rb +229 -38
  137. data/lib/carray/inspect.rb +29 -11
  138. data/lib/carray/iterator.rb +14 -2
  139. data/lib/carray/lazy.rb +54 -30
  140. data/lib/carray/mask_gap_fill.rb +66 -52
  141. data/lib/carray/math.rb +32 -19
  142. data/lib/carray/meld_reduce.rb +40 -7
  143. data/lib/carray/methods/align_addr.rb +8 -4
  144. data/lib/carray/methods/bincount.rb +2 -2
  145. data/lib/carray/methods/bit_string.rb +14 -3
  146. data/lib/carray/methods/composition.rb +16 -10
  147. data/lib/carray/methods/factorize.rb +2 -2
  148. data/lib/carray/methods/gather_nd.rb +13 -2
  149. data/lib/carray/methods/insert_block.rb +9 -14
  150. data/lib/carray/methods/is_in.rb +117 -4
  151. data/lib/carray/methods/join.rb +1 -1
  152. data/lib/carray/methods/locate_addr.rb +177 -36
  153. data/lib/carray/methods/mode.rb +18 -1
  154. data/lib/carray/methods/repeat.rb +1 -20
  155. data/lib/carray/methods/resize.rb +21 -23
  156. data/lib/carray/methods/snap.rb +34 -12
  157. data/lib/carray/methods/unique.rb +11 -2
  158. data/lib/carray/record_order.rb +208 -0
  159. data/lib/carray/runtime.rb +14 -11
  160. data/lib/carray/segment_iterator.rb +990 -0
  161. data/lib/carray/serialize.rb +197 -27
  162. data/lib/carray/slab_iterator.rb +51 -27
  163. data/lib/carray/stack.rb +10 -4
  164. data/lib/carray/string_operation_extension.rb +4 -3
  165. data/lib/carray/struct.rb +142 -51
  166. data/lib/carray/struct_builder.rb +110 -14
  167. data/lib/carray/table.rb +2 -2
  168. data/lib/carray/time.rb +517 -103
  169. data/lib/carray/window_iterator.rb +127 -72
  170. data/lib/carray.rb +2 -2
  171. data/yard-stubs/ca_obj_array.rb +4 -4
  172. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  173. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  174. data/yard-stubs/ca_obj_fake.rb +1 -1
  175. data/yard-stubs/ca_obj_field.rb +1 -1
  176. data/yard-stubs/ca_obj_refer.rb +3 -3
  177. data/yard-stubs/ca_obj_roll.rb +0 -1
  178. data/yard-stubs/ca_obj_stride.rb +10 -7
  179. data/yard-stubs/ca_obj_transpose.rb +1 -1
  180. data/yard-stubs/ca_obj_window.rb +1 -2
  181. data/yard-stubs/carray_access.rb +5 -4
  182. data/yard-stubs/carray_broadcast.rb +1 -1
  183. data/yard-stubs/carray_cast.rb +78 -43
  184. data/yard-stubs/carray_conversion.rb +13 -1
  185. data/yard-stubs/carray_copy.rb +8 -3
  186. data/yard-stubs/carray_core.rb +3 -3
  187. data/yard-stubs/carray_count.rb +9 -0
  188. data/yard-stubs/carray_lazy.rb +5 -1
  189. data/yard-stubs/carray_loop.rb +4 -1
  190. data/yard-stubs/carray_mask.rb +34 -10
  191. data/yard-stubs/carray_math.rb +25 -13
  192. data/yard-stubs/carray_median_percentile.rb +10 -6
  193. data/yard-stubs/carray_order.rb +34 -13
  194. data/yard-stubs/carray_scatter.rb +10 -3
  195. data/yard-stubs/carray_segment.rb +72 -0
  196. data/yard-stubs/carray_slab.rb +2 -1
  197. data/yard-stubs/carray_sort.rb +43 -3
  198. data/yard-stubs/carray_test.rb +10 -0
  199. data/yard-stubs/carray_utils.rb +0 -26
  200. metadata +11 -19
  201. data/ext/ca_for_each_element.h +0 -239
  202. data/ext/carray_build_flags.h +0 -3
  203. data/ext/carray_config.h +0 -28
  204. data/ext/carray_kernels_bincmp.c +0 -4446
  205. data/ext/carray_kernels_binop.c +0 -11001
  206. data/ext/carray_kernels_init.c +0 -1131
  207. data/ext/carray_kernels_map.c +0 -3467
  208. data/ext/carray_kernels_moncmp.c +0 -2097
  209. data/ext/carray_kernels_monop.c +0 -18313
  210. data/ext/carray_kernels_reduce_aggregate.c +0 -26005
  211. data/ext/carray_kernels_reduce_boolean.c +0 -330
  212. data/ext/carray_kernels_reduce_cumulative.c +0 -14862
  213. data/ext/carray_kernels_reduce_extreme.c +0 -17494
  214. data/ext/carray_kernels_reduce_variance.c +0 -3910
  215. data/ext/carray_kernels_scan.c +0 -3693
  216. data/ext/carray_kernels_search.c +0 -32218
  217. data/ext/carray_kernels_sort.c +0 -10626
  218. data/ext/carray_kernels_triop.c +0 -1392
@@ -4,12 +4,16 @@ require "carray/frame/csv_parser"
4
4
 
5
5
  class CAFrame
6
6
  # Read a CSV into a frame. The header row supplies column names (Strings,
7
- # §3.7); every column is built raw as an object CArray of the cell strings
8
- # (§4.2 -- read and arrange only, no type-inference engine). Casting is a
9
- # separate step: pass +types:+ ({ "temp" => :float64 }, or the array-key /
10
- # reverse forms of +cast+) to cast named columns on load, or call +cast+
11
- # later. Broken cells fail to_type and become UNDEF automatically
12
- # (parse-mask, §6-2).
7
+ # §3.7); every column is built raw as a CAString of the cell strings, all
8
+ # over one object array (§4.2). Casting is a separate step: +types:+ takes
9
+ # anything +cast+ takes as a map -- { "temp" => :float64 }, :infer, or a
10
+ # map with +default:+ -- and casts the frame with it on load, which is the
11
+ # same as calling +cast+ afterwards. Broken cells become UNDEF
12
+ # automatically (parse-mask, §6-2).
13
+ #
14
+ # CAFrame.from_csv("obs.csv", types: :infer)
15
+ # CAFrame.from_csv("obs.csv", types: { default: :infer, "code" => :int32, "id" => nil })
16
+ # CAFrame.from_csv("obs.csv", types: { default: :float64, "station" => nil })
13
17
  #
14
18
  # +source+ is a path, or an open IO -- anything answering +gets+, which a
15
19
  # StringIO is. So CSV that is already in memory does not have to go to a
@@ -30,67 +34,214 @@ class CAFrame
30
34
  # strip: trim spaces from unquoted fields (default false, RFC spacing)
31
35
  # +encoding+: open-mode encoding for a path (default "bom|utf-8", strips a
32
36
  # BOM). It has nothing to open when +source+ is an IO, so there
33
- # the IO's own encoding governs and a BOM is the caller's.
37
+ # the IO's own encoding governs and a BOM is the caller's. To
38
+ # read a file in another encoding, name it and the one to
39
+ # transcode to: "CP932:UTF-8" for a CSV written by Excel in
40
+ # Japanese.
34
41
  # parser: a callable source -> [headers, rows] to inject another parser
35
42
  # (e.g. the stdlib +csv+, or a typed-table source); when given,
36
43
  # sep/quote/strip/encoding and any block are that parser's concern.
44
+ # on_error: what a +types:+ cast does with a cell that does not read
45
+ # (:mask default, :warn, :raise); see +cast+.
46
+ #
47
+ # Where the header and the data are is given by the index of the line,
48
+ # from 0, as in File.readlines (an error names the line from 1, as an
49
+ # editor numbers it):
50
+ # header: the line of the column names (default 0), or false for none
51
+ # data: the first line of the data (default the line after the
52
+ # header), or a Range of lines; a record that starts in it is
53
+ # read whole
54
+ # column_names: the names, for a file with none or to replace them; the
55
+ # header then defaults to none
56
+ # columns: the columns to read, by name or by index from 0 (Integers
57
+ # and Ranges), in the order the frame has them; the others are
58
+ # read past without a String made for their fields
37
59
  #
38
- # A block gives reading control for files with preamble lines, a units row,
39
- # or no header (memo §11.2), using +skip+ / +header+ / +column_names+ /
40
- # +body+ (see CSVReader). Without a block the default is +header+ then +body+.
60
+ # CAFrame.from_csv("obs.csv", header: 2) # a title above
61
+ # CAFrame.from_csv("obs.csv", header: 0, data: 3) # units on lines 1-2
62
+ # CAFrame.from_csv("big.csv", data: 1..100) # the first 100 lines
63
+ # CAFrame.from_csv("raw.csv", column_names: %w[date temp rh])
64
+ # CAFrame.from_csv("wide.csv", columns: %w[date temp])
41
65
  #
42
- # CAFrame.from_csv("obs.csv") do
43
- # skip 2; header; skip 1; body
44
- # end
66
+ # A block, given the reader, reads in any other order with +skip+ /
67
+ # +header+ / +header(name)+ / +column_names+ / +columns+ / +data+ (see
68
+ # CSVReader):
69
+ #
70
+ # CAFrame.from_csv("obs.csv") { |r| r.skip 2; r.header; r.skip 1; r.data }
45
71
  #
46
72
  # A missing field -- an unquoted empty one, or a cell a short row never
47
73
  # reached -- is UNDEF in the frame, whether or not the column is cast by
48
74
  # +types:+. A quoted empty field ("") is the empty string, which is a value.
49
75
  # So the mask +to_csv+ writes comes back as a mask.
50
76
  #
51
- # Columns are handed to the frame as CABlock views over one backing object
52
- # array (§3.6 view-by-default); casting a column materializes it, and +copy+
53
- # gives an independent frame.
54
- def self.from_csv(source, types: nil,
77
+ # Files that spell "missing" some other way say so with +missing:+. A field
78
+ # whose text is one of the given tokens is UNDEF too, before +types:+
79
+ # casts, so a -999 sentinel never reaches the column as a number:
80
+ #
81
+ # CAFrame.from_csv("obs.csv", missing: ["-999", "///"]) # every column
82
+ # CAFrame.from_csv("obs.csv", missing: { "temp" => "-999" }) # one column
83
+ # CAFrame.from_csv("obs.csv", missing: { default: "-999", "id" => [] })
84
+ #
85
+ # Tokens are Strings and are compared with the field's text as read (after
86
+ # +strip:+, quoted or not), so "-999" does not match "-999.0". In a Hash,
87
+ # +:default+ gives the tokens for every column and a column name gives that
88
+ # column's own, which replace the default ([] or "" for none); without
89
+ # +:default+ the columns it does not name keep only the empty field. As in
90
+ # +to_csv+, "" means the empty field, so a quoted "" is still a value.
91
+ #
92
+ # Without +types:+, the columns are handed to the frame as CABlock views
93
+ # over one backing object array (§3.6 view-by-default). With +types:+, the
94
+ # body is read without making a String per cell: a column cast to a number
95
+ # is read from the text directly, and a column left as text becomes its own
96
+ # CAString. Casting a column materializes it, and +copy+ gives an
97
+ # independent frame.
98
+ def self.from_csv(source, types: nil, on_error: :mask, missing: nil,
99
+ header: nil, data: nil, column_names: nil, columns: nil,
55
100
  sep: ",", quote: '"', strip: false,
56
101
  encoding: "bom|utf-8", parser: nil, &block)
102
+ layout = { header: header, data: data, column_names: column_names,
103
+ columns: columns }.compact
104
+ if block && !layout.empty?
105
+ raise ArgumentError, "from_csv: give #{layout.keys.map { |k| "#{k}:" }.join(', ')} " \
106
+ "or a reading block, not both"
107
+ end
108
+ if block && block.arity != 1
109
+ raise ArgumentError, "from_csv: the reading block takes the reader as its " \
110
+ "parameter, as in from_csv(path) { |r| r.skip 2; r.header; r.data }"
111
+ end
112
+ missing = missing_tokens(missing) if missing
113
+ # With types:, the body is read into CAConstString columns, so a column
114
+ # cast to a number never becomes Strings; the text columns left over
115
+ # become CAString below. Without types: every column stays text, so the
116
+ # body goes straight to Strings. missing: masks text in place, which
117
+ # wants the Strings first, so it takes that path too.
118
+ read_text_as_const_string = !types.nil? && missing.nil?
57
119
  names, rows =
58
120
  if parser
59
121
  parser.call(source)
60
122
  elsif source.respond_to?(:gets)
61
- read_csv(source, sep: sep, quote: quote, strip: strip, &block)
123
+ with_encoding_hint("open the IO in the file's encoding, as in " \
124
+ "File.open(path, \"r:<file encoding>:UTF-8\")") do
125
+ read_csv(source, sep: sep, quote: quote, strip: strip, layout: layout,
126
+ read_text_as_const_string: read_text_as_const_string, &block)
127
+ end
62
128
  else
63
- File.open(source, "r:#{encoding}") do |io|
64
- read_csv(io, sep: sep, quote: quote, strip: strip, &block)
129
+ with_encoding_hint("it was opened as #{encoding.inspect}; pass the " \
130
+ "file's encoding, as in " \
131
+ "encoding: \"<file encoding>:UTF-8\"") do
132
+ File.open(source, "r:#{encoding}") do |io|
133
+ read_csv(io, sep: sep, quote: quote, strip: strip, layout: layout,
134
+ read_text_as_const_string: read_text_as_const_string, &block)
135
+ end
65
136
  end
66
137
  end
67
138
 
68
139
  frame = build_frame(names, rows)
69
- frame.cast(types) if types
140
+ mask_missing_tokens(frame, missing) if missing
141
+ frame.cast(types, on_error: on_error, option_name: :types) unless types.nil?
142
+ convert_const_string_columns_to_castring(frame)
70
143
  frame
71
144
  end
72
145
 
146
+ # A text column from_csv reads is a CAString, whichever way the body was
147
+ # read: the string operations (strip!, gsub!, ...) write through to it.
148
+ def self.convert_const_string_columns_to_castring(frame)
149
+ frame.column_names.each do |name|
150
+ col = frame[name]
151
+ next unless col.is_a?(CAConstString)
152
+ frame[name] = col.to_string
153
+ end
154
+ end
155
+ private_class_method :convert_const_string_columns_to_castring
156
+
157
+ # Split a +missing:+ argument, as from_csv and to_csv both take it, into the
158
+ # setting for every column and a Hash of per-column settings that replace
159
+ # it. A Hash says :default for every column and a column name for one;
160
+ # column names are Strings, so the Symbol cannot collide with one. A
161
+ # setting that is not a Hash is the default.
162
+ def self.split_missing(missing, verb)
163
+ return [missing, {}] unless missing.is_a?(Hash)
164
+ default = nil
165
+ per_column = {}
166
+ missing.each do |key, value|
167
+ case key
168
+ when :default then default = value
169
+ when String then per_column[key] = value
170
+ else
171
+ raise ArgumentError,
172
+ "#{verb}: missing: takes column names (Strings) and :default " \
173
+ "as keys (got #{key.inspect})"
174
+ end
175
+ end
176
+ [default, per_column]
177
+ end
178
+
179
+ # The read side of missing:, checked before the file is read: each setting
180
+ # becomes an Array of token Strings.
181
+ def self.missing_tokens(missing)
182
+ default, per_column = split_missing(missing, "from_csv")
183
+ tokens = lambda do |value|
184
+ list = Array(value)
185
+ bad = list.reject { |t| t.is_a?(String) }
186
+ unless bad.empty?
187
+ raise ArgumentError,
188
+ "missing: tokens are matched against a field's text, so give them " \
189
+ "as Strings (#{bad.map { |t| t.to_s.inspect }.join(', ')}, not " \
190
+ "#{bad.map(&:inspect).join(', ')})"
191
+ end
192
+ # "" is the empty field, which is missing already; as on the write side
193
+ # it adds nothing, so a quoted "" stays the empty string.
194
+ list.reject(&:empty?)
195
+ end
196
+ [tokens.(default), per_column.transform_values(&tokens)]
197
+ end
198
+
199
+ def self.mask_missing_tokens(frame, missing)
200
+ default, per_column = missing
201
+ per_column.each_key { |name| frame[name] } # KeyError for a name the file lacks
202
+ frame.column_names.each do |name|
203
+ col = frame[name]
204
+ per_column.fetch(name, default).each { |t| col[:eq, t] = UNDEF }
205
+ end
206
+ end
207
+
208
+ private_class_method :split_missing, :missing_tokens, :mask_missing_tokens
209
+
73
210
  # Drive the reading-control DSL over one open IO and hand back
74
211
  # [names, rows]. Shared by the path and the IO source, so the two cannot
75
212
  # come to read a file differently.
76
- def self.read_csv (io, sep:, quote:, strip:, &block)
77
- reader = CSVReader.new(io, sep: sep, quote: quote, strip: strip)
78
- if block
79
- block.arity == 1 ? block.call(reader) : reader.instance_exec(&block)
80
- else
81
- reader.header
82
- reader.body
213
+ def self.read_csv (io, sep:, quote:, strip:, layout:, read_text_as_const_string: false, &block)
214
+ reader = CSVReader.new(io, sep: sep, quote: quote, strip: strip,
215
+ read_text_as_const_string: read_text_as_const_string)
216
+ reader.reporting do
217
+ block ? block.call(reader) : reader.layout(**layout)
83
218
  end
84
219
  reader.result
85
220
  end
86
221
 
87
222
  private_class_method :read_csv
88
223
 
89
- # Build a frame from parsed [names, rows]. When names is nil (headerless and
90
- # no column_names) positional names "c0".."cN" are generated from the widest
91
- # row. Rows are squared off to the column count (short rows padded with nil,
92
- # over-long rows raise), one 2-D object array is bulk-filled, and each column
93
- # is a view into it (§3.6).
224
+ # Re-raise a failure to decode the input with a line saying how to name the
225
+ # file's encoding. The error and its class are kept; only the message grows.
226
+ # Which encoding the file is in cannot be told from its bytes, so the hint
227
+ # names the option, not a value.
228
+ def self.with_encoding_hint(hint)
229
+ yield
230
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError,
231
+ Encoding::CompatibilityError, ArgumentError => e
232
+ raise if e.is_a?(ArgumentError) && !e.message.include?("invalid byte sequence")
233
+ if e.is_a?(Encoding::CompatibilityError)
234
+ # The text decoded; the separator or quote is in another encoding.
235
+ raise e.class, "#{e.message} -- sep: and quote: are matched against the " \
236
+ "text as read, so read the file transcoded to UTF-8, as in " \
237
+ "encoding: \"<file encoding>:UTF-8\"", e.backtrace
238
+ end
239
+ raise e.class, "#{e.message} -- the CSV is not in the encoding it was " \
240
+ "read as: #{hint}", e.backtrace
241
+ end
242
+
243
+ private_class_method :with_encoding_hint
244
+
94
245
  # Write the frame as CSV. CSV is a flat table of scalar cells, so this is the
95
246
  # text form of the same all-scalar subset +to_ca+ requires (§11.9): every
96
247
  # column must be 1-D. Unlike +to_ca+ it does not promote to a common data type --
@@ -110,14 +261,50 @@ class CAFrame
110
261
  # csv = df.to_csv # get a String
111
262
  #
112
263
  # Options: +sep+ / +quote+ mirror +from_csv+; +header+ writes the name row
113
- # (default true); +index+ writes the index column (default true).
114
- def to_csv(path = nil, sep: ",", quote: '"', header: true, index: true)
264
+ # (default true); +index+ writes the index column (default true);
265
+ # +encoding+ transcodes the text before it is written or returned (default
266
+ # nil leaves it as built, UTF-8). A character the encoding cannot hold
267
+ # raises Encoding::UndefinedConversionError rather than being dropped. A
268
+ # String cell in another encoding is transcoded to UTF-8; one with bytes
269
+ # that are not valid in it, or with no encoding (ASCII-8BIT), raises. Each
270
+ # names the row and the column of the cell.
271
+ #
272
+ # df.to_csv("out.csv", encoding: "CP932") # for Excel in Japanese
273
+ #
274
+ # +missing:+ writes a masked cell as that String instead of an empty field,
275
+ # for a reader that expects a sentinel; +from_csv(missing:)+ with the same
276
+ # String reads it back as UNDEF. A Hash sets it per column the way
277
+ # +from_csv+ does: +:default+ for every column, and a column name (or the
278
+ # index's axis name) for one, replacing the default; "" is the empty field.
279
+ # A cell whose value would be written as the same text raises, since the
280
+ # file could not tell the two apart.
281
+ #
282
+ # df.to_csv("out.csv", missing: "-999")
283
+ # df.to_csv("out.csv", missing: { default: "-999", "comment" => "" })
284
+ def to_csv(path = nil, sep: ",", quote: '"', header: true, index: true,
285
+ encoding: nil, missing: nil)
286
+ default, per_column = CAFrame.send(:split_missing, missing, "to_csv")
287
+ [default, *per_column.values].each do |text|
288
+ next if text.nil? || text.is_a?(String)
289
+ raise ArgumentError, "to_csv: missing: is the String written for a masked " \
290
+ "cell (got #{text.inspect})"
291
+ end
115
292
  nd = @columns.find { |_, c| c.ndim != 1 }
116
293
  if nd
117
294
  raise ArgumentError,
118
295
  "to_csv needs all-scalar (1-D) columns; #{nd.first.inspect} is " \
119
296
  "#{nd.last.ndim}-D — export it per column or via to_records + JSON"
120
297
  end
298
+ # A record cell has no text form that reads back; its members do.
299
+ rec = @columns.find { |_, c| c.is_a?(CARecord) }
300
+ if rec
301
+ name, col = rec
302
+ member = col.members.first
303
+ raise ArgumentError,
304
+ "to_csv cannot write the record column #{name.inspect}; write its " \
305
+ "members as columns, e.g. df[#{"#{name}_#{member}".inspect}] = " \
306
+ "df[#{name.inspect}][#{member.inspect}]"
307
+ end
121
308
 
122
309
  names = []
123
310
  formatted = []
@@ -129,17 +316,46 @@ class CAFrame
129
316
  names << name
130
317
  formatted << format_csv_column(col)
131
318
  end
319
+ unknown = per_column.keys - names
320
+ raise KeyError, "to_csv: missing: names no column #{unknown.first.inspect}" unless unknown.empty?
321
+ formatted.each_with_index do |fcol, j|
322
+ token = per_column.fetch(names[j], default)
323
+ next if token.nil? || token.empty? # the empty field, as without missing:
324
+ i = fcol.index(token)
325
+ if i
326
+ raise ArgumentError,
327
+ "to_csv: row #{i} of #{names[j].inspect} is written as " \
328
+ "#{token.inspect}, the text given for a masked cell"
329
+ end
330
+ fcol.map! { |text| text.nil? ? token : text }
331
+ end
132
332
 
133
- out = +""
134
- if header
135
- out << names.map { |t| quote_csv_field(t, sep, quote) }.join(sep) << "\n"
333
+ out = begin
334
+ csv_text(names, formatted, header, sep, quote)
335
+ rescue Encoding::CompatibilityError
336
+ nil
136
337
  end
137
- @nrow.times do |i|
138
- out << formatted.map { |fcol| quote_csv_field(fcol[i], sep, quote) }.join(sep) << "\n"
338
+ # A cell in another encoding, or with bytes that are not text, is found
339
+ # and named here, after the text was built in one pass, so a frame of
340
+ # UTF-8 text pays nothing for the check.
341
+ unless out && out.encoding == Encoding::UTF_8 && out.valid_encoding?
342
+ names = names.each_with_index.map { |t, j| csv_text_in_utf8(t, "the name of column #{j}") }
343
+ formatted = formatted.each_with_index.map do |fcol, j|
344
+ fcol.each_with_index.map { |t, i| csv_text_in_utf8(t, "row #{i} of #{names[j].inspect}") }
345
+ end
346
+ out = csv_text(names, formatted, header, sep, quote)
347
+ end
348
+
349
+ if encoding
350
+ out = begin
351
+ out.encode(encoding)
352
+ rescue Encoding::UndefinedConversionError => e
353
+ raise csv_unwritable_cell(names, formatted, encoding) || e
354
+ end
139
355
  end
140
356
 
141
357
  if path
142
- File.write(path, out)
358
+ File.binwrite(path, out)
143
359
  self
144
360
  else
145
361
  out
@@ -177,19 +393,19 @@ class CAFrame
177
393
 
178
394
  # +to_s+ is the whole frame, +inspect+ the middle-elided one -- so +puts df+
179
395
  # dumps everything and +p df+ stays a screenful. +inspect+ leads with the
180
- # same summary line it always had (nrow, variable data types, index), so the
396
+ # same summary line it always had (nrow, column data types, index), so the
181
397
  # table under it needs no row-count footer.
182
398
  def to_s
183
399
  to_table(rows: nil)
184
400
  end
185
401
 
186
- # The summary line (nrow, variable data types, index) followed by the
402
+ # The summary line (nrow, column data types, index) followed by the
187
403
  # middle-elided table.
188
404
  # @return [String]
189
405
  def inspect
190
406
  parts = @columns.map { |k, v| "#{k}:#{v.data_type}#{v.ndim > 1 ? v.shape[1..].inspect : ''}" }
191
407
  idx = @index ? " index=#{@axis_name.inspect}" : ""
192
- head = "#<CAFrame nrow=#{@nrow} vars=[#{parts.join(', ')}]#{idx}>"
408
+ head = "#<CAFrame nrow=#{@nrow} columns=[#{parts.join(', ')}]#{idx}>"
193
409
  # The table counts the index as a column, so a frame whose only data is its
194
410
  # index has one to show. Gate on the same thing render_table does.
195
411
  return head if @columns.empty? && @index.nil?
@@ -232,7 +448,7 @@ class CAFrame
232
448
  out << table_row(widths.map { |w| "-" * w }, widths, aligns) << "\n"
233
449
  body.each { |cells| out << table_row(cells, widths, aligns) << "\n" }
234
450
  if footer && positions.size - positions.count(nil) < @nrow
235
- out << "(#{plural(@nrow, 'row')}, #{plural(@columns.size, 'variable')})\n"
451
+ out << "(#{plural(@nrow, 'row')}, #{plural(@columns.size, 'column')})\n"
236
452
  end
237
453
  out
238
454
  end
@@ -285,17 +501,31 @@ class CAFrame
285
501
  "#{n} #{noun}#{n == 1 ? '' : 's'}"
286
502
  end
287
503
 
288
- # Render one N-D cell the way Array#inspect would, except that a masked
289
- # element prints as the table's missing marker rather than as UNDEF.
290
- private def format_nested_cell(v)
504
+ # Render one N-D cell as a bracketed list. Each element is shown as a
505
+ # scalar cell of its kind would be -- floats rounded to +precision+, a
506
+ # masked element as the table's missing marker -- except that a String
507
+ # keeps its quotes, so the list stays readable when one holds a comma.
508
+ private def format_nested_cell(v, precision)
291
509
  case v
292
- when Array then "[" + v.map { |x| format_nested_cell(x) }.join(", ") + "]"
293
- else UNDEF.equal?(v) || v.nil? ? "_" : v.inspect
510
+ when Array then "[" + v.map { |x| format_nested_cell(x, precision) }.join(", ") + "]"
511
+ when String then v.inspect
512
+ else format_scalar_cell(v, precision)
294
513
  end
295
514
  end
296
515
 
297
516
  private def format_table_cell(col, i, precision)
298
517
  e = elem_at(col, i)
518
+ if e.is_a?(CArray)
519
+ # An N-D cell renders its elements, and a masked element among them
520
+ # takes the same marker a masked scalar does -- UNDEF's own inspect
521
+ # would put a second spelling of "missing" in the same table.
522
+ format_nested_cell(e.to_a, precision)
523
+ else
524
+ format_scalar_cell(e, precision)
525
+ end
526
+ end
527
+
528
+ private def format_scalar_cell(e, precision)
299
529
  if UNDEF.equal?(e) || e.nil?
300
530
  "_"
301
531
  elsif e.is_a?(Float) && precision
@@ -303,11 +533,6 @@ class CAFrame
303
533
  # sets the column width for every other row and makes the table hard
304
534
  # to read. precision: nil prints the value as Ruby renders it.
305
535
  e.round(precision).to_s
306
- elsif e.is_a?(CArray)
307
- # An N-D cell renders its elements, and a masked element among them
308
- # takes the same marker a masked scalar does -- UNDEF's own inspect
309
- # would put a second spelling of "missing" in the same table.
310
- format_nested_cell(e.to_a)
311
536
  elsif e.is_a?(String)
312
537
  e
313
538
  elsif e.respond_to?(:iso8601)
@@ -318,6 +543,15 @@ class CAFrame
318
543
  end
319
544
 
320
545
  private def format_csv_column(col)
546
+ # A float32 value goes out as its own shortest decimal ("0.1"), which
547
+ # from_csv reads back as the same value, not as the double it widens
548
+ # to ("0.10000000149011612"); cmplx64 has that in each part.
549
+ if !col.face? && (col.data_type == CA_FLOAT32 || col.data_type == CA_CMPLX64)
550
+ col = col.__shortest_float64__
551
+ end
552
+ # A boolean goes out as 0 / 1, as it does in every other serialization;
553
+ # from_csv reads it back with types: { name => :boolean }.
554
+ col = col.to_type(:uint8) if !col.face? && col.data_type == CA_BOOLEAN
321
555
  col.to_a.map do |e|
322
556
  if UNDEF.equal?(e) || e.nil?
323
557
  nil
@@ -331,43 +565,149 @@ class CAFrame
331
565
  end
332
566
  end
333
567
 
568
+ private def csv_text(names, formatted, header, sep, quote)
569
+ out = +""
570
+ if header
571
+ out << names.map { |t| quote_csv_field(t, sep, quote) }.join(sep) << "\n"
572
+ end
573
+ @nrow.times do |i|
574
+ out << formatted.map { |fcol| quote_csv_field(fcol[i], sep, quote) }.join(sep) << "\n"
575
+ end
576
+ out
577
+ end
578
+
579
+ # The text of a cell as UTF-8, the encoding the CSV is built in: text in
580
+ # another encoding is transcoded; bytes that are not valid in their
581
+ # encoding, or have none (ASCII-8BIT), raise naming the cell.
582
+ private def csv_text_in_utf8(text, where)
583
+ return text if text.nil?
584
+ enc = text.encoding
585
+ unless text.valid_encoding?
586
+ raise Encoding::InvalidByteSequenceError,
587
+ "to_csv: #{where} (#{csv_excerpt(text)}) is not valid #{enc}"
588
+ end
589
+ return text if enc == Encoding::UTF_8 || (enc.ascii_compatible? && text.ascii_only?)
590
+ if enc == Encoding::BINARY
591
+ raise Encoding::CompatibilityError,
592
+ "to_csv: #{where} (#{csv_excerpt(text)}) is bytes with no encoding " \
593
+ "(ASCII-8BIT); give it one with force_encoding"
594
+ end
595
+ text.encode(Encoding::UTF_8)
596
+ rescue Encoding::UndefinedConversionError
597
+ raise Encoding::UndefinedConversionError,
598
+ "to_csv: #{where} (#{csv_excerpt(text)}) has a character with no UTF-8 form in #{enc}"
599
+ end
600
+
601
+ # The start of a cell's text, quoted, for an error. Text is shown as it
602
+ # is, whatever the locale (inspect would escape it where the locale is not
603
+ # UTF-8); control characters and bytes that are not text are escaped.
604
+ private def csv_excerpt(text)
605
+ head = text[0, 20]
606
+ return head.dump unless head.valid_encoding? && head.encoding != Encoding::BINARY
607
+ head = head.encode(Encoding::UTF_8)
608
+ '"' + head.gsub(/[\x00-\x1f\x7f"\\]/) { |c| c.dump[1..-2] } + '"'
609
+ rescue EncodingError
610
+ text[0, 20].dump
611
+ end
612
+
613
+ # The error for the first cell (or name) +encoding+ cannot hold, or nil.
614
+ private def csv_unwritable_cell(names, formatted, encoding)
615
+ cells = names.each_with_index.map { |t, j| [t, "the name of column #{j}"] }
616
+ formatted.each_with_index do |fcol, j|
617
+ fcol.each_with_index { |t, i| cells << [t, "row #{i} of #{names[j].inspect}"] if t }
618
+ end
619
+ cells.each do |text, where|
620
+ text.encode(encoding)
621
+ rescue Encoding::UndefinedConversionError => e
622
+ return Encoding::UndefinedConversionError.new(
623
+ "to_csv: #{where} (#{csv_excerpt(text)}): #{csv_excerpt(e.error_char)} (U+#{format("%04X", e.error_char.ord)}) cannot be written in #{encoding}")
624
+ end
625
+ nil
626
+ end
627
+
334
628
  private def quote_csv_field(text, sep, quote)
335
629
  return "" if text.nil?
630
+ # With a separator longer than one character, a value can end in part of
631
+ # it ("a:" before "::"), and the reader would find the separator inside
632
+ # the value; such a value is quoted too. A value of spaces and tabs is
633
+ # quoted so that it is neither a blank line the reader skips (a header of
634
+ # one column) nor a field strip: empties.
336
635
  if text.empty? || text.include?(sep) || text.include?(quote) ||
337
- text.include?("\n") || text.include?("\r")
636
+ text.include?("\n") || text.include?("\r") || text.each_byte.all? { |b| b == 0x20 || b == 0x09 } ||
637
+ (sep.size > 1 && (text + sep).index(sep) != text.size)
338
638
  quote + text.gsub(quote, quote * 2) + quote
339
639
  else
340
640
  text
341
641
  end
342
642
  end
343
643
 
644
+ # Build a frame from parsed [names, rows]. When names is nil (headerless and
645
+ # no column_names) positional names "c0".."cN" are generated from the widest
646
+ # row. Rows are squared off to the column count (short rows padded with nil,
647
+ # over-long rows raise), one 2-D object array is bulk-filled, and each column
648
+ # is a view into it (§3.6).
344
649
  def self.build_frame(names, rows)
345
- ncol = names ? names.size : (rows.map(&:size).max || 0)
650
+ ncol = if names then names.size
651
+ elsif rows.is_a?(CArray) then rows.shape[1]
652
+ else rows.map(&:size).max || 0
653
+ end
346
654
  names ||= Array.new(ncol) { |j| "c#{j}" }
655
+ # A frame holds one column per name, so a repeated one would keep only
656
+ # the last of its columns.
657
+ dup = names.tally.select { |_, count| count > 1 }.keys
658
+ unless dup.empty?
659
+ raise ArgumentError,
660
+ "the header names #{dup.map(&:inspect).join(', ')} more than once; " \
661
+ "name the columns yourself: from_csv(path, header: 0, " \
662
+ "column_names: [...])"
663
+ end
347
664
 
348
665
  cols = {}
349
- if rows.empty?
350
- names.each { |name| cols[name] = CArray.object(0) }
666
+ # Columns the C reader built as CAConstStrings over the text, for
667
+ # cast to read without a String per cell.
668
+ if rows.is_a?(CSVReader::ConstStringColumns)
669
+ names.zip(rows.columns) { |name, col| cols[name] = col }
351
670
  return new(cols)
352
671
  end
353
- rows.each_with_index do |r, i|
354
- if r.size < ncol
355
- r.concat(Array.new(ncol - r.size))
356
- elsif r.size > ncol
357
- raise ArgumentError,
358
- "row #{i + 1} has #{r.size} fields, expected #{ncol}"
359
- end
672
+ # A table the C reader built: one row per record, missing cells masked.
673
+ table = rows if rows.is_a?(CArray) && rows.elements > 0
674
+ if table.nil? && (rows.is_a?(CArray) || rows.empty?)
675
+ names.each { |name| cols[name] = CArray.string(CArray.object(0)) }
676
+ return new(cols)
360
677
  end
361
- table = CArray.object(rows.size, ncol) { rows }
678
+ table ||= table_of_rows(rows, ncol)
679
+ # Each column is a CAString over its view of the table: the text, with
680
+ # the string operations (strip!, gsub, extract, ...) at hand, writing
681
+ # through to the same cells.
682
+ names.each_with_index { |name, j| cols[name] = CArray.string(table[nil, j]) }
683
+ new(cols)
684
+ end
685
+ private_class_method :build_frame
686
+
687
+ def self.table_of_rows(rows, ncol)
362
688
  # A missing field is UNDEF, not a Ruby nil sitting in a cell. The
363
689
  # tokenizer says missing with nil (an unquoted empty field; a quoted
364
690
  # one is the empty string and stays a value), and an object array will
365
691
  # hold that nil quite happily -- so a column read without `types:` used
366
692
  # to carry nil where the same column read with one carried UNDEF, and
367
- # the mask a to_csv had written did not survive the trip back.
368
- table[:eq, nil] = UNDEF
369
- names.each_with_index { |name, j| cols[name] = table[nil, j] }
370
- new(cols)
693
+ # the mask a to_csv had written did not survive the trip back. A row
694
+ # holding a nil gets UNDEF in its place before the table is built (an
695
+ # UNDEF in the rows becomes a masked cell), so a row with nothing
696
+ # missing costs no pass over its cells. Short rows are padded, and a
697
+ # row with a nil is changed, on a copy: the rows may be a parser:
698
+ # callable's own arrays.
699
+ rows = rows.each_with_index.map do |r, i|
700
+ if r.size > ncol
701
+ raise ArgumentError,
702
+ "row #{i + 1} has #{r.size} fields, expected #{ncol}"
703
+ end
704
+ r = r + Array.new(ncol - r.size) if r.size < ncol
705
+ # compact tests for nil without calling == on every cell, as
706
+ # include?(nil) would.
707
+ r = r.map { |cell| cell.nil? ? UNDEF : cell } if r.compact.size < ncol
708
+ r
709
+ end
710
+ CArray.object(rows.size, ncol) { rows }
371
711
  end
372
- private_class_method :build_frame
712
+ private_class_method :table_of_rows
373
713
  end