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.
Files changed (247) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +568 -0
  3. data/README.md +6 -6
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_descriptor.h +27 -24
  6. data/ext/ca_axis_dispatch.c +220 -0
  7. data/ext/ca_axis_group.c +201 -123
  8. data/ext/ca_binop_dispatch.c +46 -52
  9. data/ext/ca_binop_dispatch.h +1 -1
  10. data/ext/ca_categorical_iterator.c +249 -183
  11. data/ext/ca_compare.c +7 -12
  12. data/ext/ca_for_buffer.h +21 -68
  13. data/ext/ca_group_iter.c +38 -23
  14. data/ext/ca_kernel_iterator.c +1765 -2076
  15. data/ext/ca_kernel_iterator.h +584 -566
  16. data/ext/ca_monop_dispatch.c +86 -63
  17. data/ext/ca_monop_dispatch.h +5 -0
  18. data/ext/ca_obj_array.c +223 -56
  19. data/ext/ca_obj_bincmp.c +172 -138
  20. data/ext/ca_obj_binop.c +116 -230
  21. data/ext/ca_obj_bitarray.c +40 -26
  22. data/ext/ca_obj_bitfield.c +100 -68
  23. data/ext/ca_obj_block.c +19 -9
  24. data/ext/ca_obj_byte_swap.c +45 -46
  25. data/ext/ca_obj_const_string.c +269 -44
  26. data/ext/ca_obj_face.c +612 -89
  27. data/ext/ca_obj_face.h +111 -47
  28. data/ext/ca_obj_fake.c +36 -79
  29. data/ext/ca_obj_farray.c +7 -3
  30. data/ext/ca_obj_field.c +33 -8
  31. data/ext/ca_obj_fixlen_string.c +23 -24
  32. data/ext/ca_obj_grid.c +541 -210
  33. data/ext/ca_obj_meld.c +331 -111
  34. data/ext/ca_obj_moncmp.c +80 -65
  35. data/ext/ca_obj_monop.c +177 -149
  36. data/ext/ca_obj_object.c +664 -248
  37. data/ext/ca_obj_record.c +63 -23
  38. data/ext/ca_obj_reduce.c +18 -16
  39. data/ext/ca_obj_refer.c +140 -58
  40. data/ext/ca_obj_remap.c +85 -24
  41. data/ext/ca_obj_repeat.c +24 -9
  42. data/ext/ca_obj_roll.c +121 -16
  43. data/ext/ca_obj_select.c +61 -58
  44. data/ext/ca_obj_select_axis.c +73 -107
  45. data/ext/ca_obj_shift.c +32 -102
  46. data/ext/ca_obj_stack.c +372 -122
  47. data/ext/ca_obj_stride.c +348 -140
  48. data/ext/ca_obj_string.c +88 -22
  49. data/ext/ca_obj_tile.c +38 -21
  50. data/ext/ca_obj_time.c +45 -31
  51. data/ext/ca_obj_timedelta.c +43 -29
  52. data/ext/ca_obj_transpose.c +17 -6
  53. data/ext/ca_obj_triop.c +125 -141
  54. data/ext/ca_obj_window.c +330 -44
  55. data/ext/ca_op_ipower.c +24 -11
  56. data/ext/ca_op_powi.h +57 -30
  57. data/ext/ca_pow5_table.h +656 -0
  58. data/ext/ca_sort_kernels.h +70 -0
  59. data/ext/ca_sweep_engine.c +104 -81
  60. data/ext/ca_sweep_engine.h +7 -20
  61. data/ext/ca_triop_dispatch.c +4 -1
  62. data/ext/ca_triop_dispatch.h +5 -2
  63. data/ext/caframe_csv_reader.c +542 -0
  64. data/ext/carray.h +241 -92
  65. data/ext/carray_access.c +608 -276
  66. data/ext/carray_address_basis.c +494 -97
  67. data/ext/carray_attribute.c +129 -4
  68. data/ext/carray_bincount.c +39 -7
  69. data/ext/carray_broadcast.c +56 -19
  70. data/ext/carray_build_flags.h +1 -1
  71. data/ext/carray_call_cfunc.c +108 -28
  72. data/ext/carray_cast.c +1576 -223
  73. data/ext/carray_cast_func.rb +77 -24
  74. data/ext/carray_class.c +4 -3
  75. data/ext/carray_conversion.c +208 -75
  76. data/ext/carray_copy.c +82 -24
  77. data/ext/carray_core.c +646 -162
  78. data/ext/carray_count.c +171 -88
  79. data/ext/carray_element.c +94 -60
  80. data/ext/carray_factorize.c +670 -324
  81. data/ext/carray_generate.c +260 -83
  82. data/ext/carray_histogram.c +209 -106
  83. data/ext/carray_hold.c +14 -16
  84. data/ext/carray_index_classifier.c +422 -243
  85. data/ext/carray_internal.h +692 -3
  86. data/ext/carray_kernels_bincmp.c +7 -0
  87. data/ext/carray_kernels_binop.c +487 -120
  88. data/ext/carray_kernels_init.c +653 -50
  89. data/ext/carray_kernels_moncmp.c +15 -8
  90. data/ext/carray_kernels_monop.c +99 -43
  91. data/ext/carray_kernels_reduce_aggregate.c +3925 -6112
  92. data/ext/carray_kernels_reduce_boolean.c +112 -36
  93. data/ext/carray_kernels_reduce_cumulative.c +2127 -6903
  94. data/ext/carray_kernels_reduce_extreme.c +3032 -2892
  95. data/ext/carray_kernels_reduce_variance.c +439 -500
  96. data/ext/carray_kernels_scan.c +1097 -488
  97. data/ext/carray_kernels_search.c +3787 -2176
  98. data/ext/carray_kernels_sort.c +1200 -920
  99. data/ext/carray_kernels_triop.c +479 -7
  100. data/ext/carray_key_windows.c +216 -0
  101. data/ext/carray_lazy.c +967 -153
  102. data/ext/carray_loop.c +81 -65
  103. data/ext/carray_mask.c +910 -324
  104. data/ext/carray_math_kernel.h +1 -0
  105. data/ext/carray_median_percentile.c +299 -100
  106. data/ext/carray_memory_view.c +872 -758
  107. data/ext/carray_operator.c +1171 -364
  108. data/ext/carray_order.c +505 -133
  109. data/ext/carray_partition.c +112 -65
  110. data/ext/carray_random.c +327 -239
  111. data/ext/carray_round.c +273 -0
  112. data/ext/carray_scatter.c +346 -78
  113. data/ext/carray_segment.c +257 -0
  114. data/ext/carray_slab.c +249 -86
  115. data/ext/carray_sort.c +224 -227
  116. data/ext/carray_sort_kernel.c +8 -1
  117. data/ext/carray_struct.c +118 -39
  118. data/ext/carray_test.c +193 -69
  119. data/ext/carray_time_text.c +1182 -0
  120. data/ext/carray_undef.c +4 -0
  121. data/ext/carray_utils.c +579 -113
  122. data/ext/extconf.rb +27 -2
  123. data/ext/mk_call_cfunc.rb +19 -4
  124. data/ext/mk_pow5_table.rb +35 -0
  125. data/ext/mkkernel.rb +1202 -1014
  126. data/ext/ruby_carray.c +17 -5
  127. data/ext/version.h +4 -4
  128. data/ext/version.rb +4 -2
  129. data/lib/carray/attribute.rb +160 -17
  130. data/lib/carray/autoload_carray.rb +23 -27
  131. data/lib/carray/autoload_method_extension.rb +20 -5
  132. data/lib/carray/axis_group.rb +360 -63
  133. data/lib/carray/basics.rb +162 -28
  134. data/lib/carray/bincount_nd.rb +18 -2
  135. data/lib/carray/block_iterator.rb +231 -58
  136. data/lib/carray/boolean_reduce.rb +34 -42
  137. data/lib/carray/categorical.rb +242 -52
  138. data/lib/carray/categorical_iterator.rb +437 -827
  139. data/lib/carray/complex.rb +41 -34
  140. data/lib/carray/conditional.rb +150 -14
  141. data/lib/carray/const_string.rb +59 -4
  142. data/lib/carray/construct.rb +15 -11
  143. data/lib/carray/data_type_extension.rb +1 -0
  144. data/lib/carray/distance_limit.rb +77 -0
  145. data/lib/carray/fixlen_string.rb +7 -1
  146. data/lib/carray/frame/column_verbs.rb +70 -0
  147. data/lib/carray/frame/concat.rb +157 -6
  148. data/lib/carray/frame/convert.rb +82 -7
  149. data/lib/carray/frame/csv_parser.rb +688 -87
  150. data/lib/carray/frame/describe.rb +80 -0
  151. data/lib/carray/frame/frame.rb +369 -45
  152. data/lib/carray/frame/group.rb +631 -90
  153. data/lib/carray/frame/io.rb +506 -79
  154. data/lib/carray/frame/join.rb +411 -62
  155. data/lib/carray/frame/records.rb +116 -19
  156. data/lib/carray/frame/reshape.rb +455 -0
  157. data/lib/carray/frame/sort.rb +30 -18
  158. data/lib/carray/frame/verbs.rb +868 -61
  159. data/lib/carray/frame/windows.rb +117 -0
  160. data/lib/carray/frame.rb +4 -0
  161. data/lib/carray/fuse_source.rb +118 -2
  162. data/lib/carray/fusion.rb +257 -42
  163. data/lib/carray/histogram.rb +6 -1
  164. data/lib/carray/inspect.rb +54 -13
  165. data/lib/carray/iterator.rb +99 -9
  166. data/lib/carray/key_windows.rb +620 -0
  167. data/lib/carray/lazy.rb +216 -193
  168. data/lib/carray/mask_gap_fill.rb +112 -92
  169. data/lib/carray/math.rb +32 -19
  170. data/lib/carray/meld_reduce.rb +52 -21
  171. data/lib/carray/methods/align_addr.rb +8 -4
  172. data/lib/carray/methods/bin.rb +26 -18
  173. data/lib/carray/methods/bincount.rb +18 -2
  174. data/lib/carray/methods/bit_string.rb +15 -4
  175. data/lib/carray/methods/choose.rb +16 -3
  176. data/lib/carray/methods/composition.rb +18 -10
  177. data/lib/carray/methods/factorize.rb +2 -2
  178. data/lib/carray/methods/gather_nd.rb +25 -5
  179. data/lib/carray/methods/insert_block.rb +9 -14
  180. data/lib/carray/methods/is_between.rb +59 -0
  181. data/lib/carray/methods/is_in.rb +140 -10
  182. data/lib/carray/methods/join.rb +2 -7
  183. data/lib/carray/methods/lag.rb +49 -0
  184. data/lib/carray/methods/locate_addr.rb +182 -45
  185. data/lib/carray/methods/mode.rb +18 -1
  186. data/lib/carray/methods/nunique.rb +11 -6
  187. data/lib/carray/methods/repeat.rb +1 -20
  188. data/lib/carray/methods/resize.rb +21 -23
  189. data/lib/carray/methods/snap.rb +34 -12
  190. data/lib/carray/methods/unique.rb +11 -2
  191. data/lib/carray/methods/value_counts.rb +4 -11
  192. data/lib/carray/record_order.rb +208 -0
  193. data/lib/carray/rng.rb +3 -0
  194. data/lib/carray/runtime.rb +65 -17
  195. data/lib/carray/segment_iterator.rb +1221 -0
  196. data/lib/carray/serialize.rb +214 -30
  197. data/lib/carray/slab_iterator.rb +101 -27
  198. data/lib/carray/stack.rb +10 -4
  199. data/lib/carray/string.rb +105 -5
  200. data/lib/carray/string_operation_extension.rb +12 -4
  201. data/lib/carray/struct.rb +137 -58
  202. data/lib/carray/struct_builder.rb +124 -17
  203. data/lib/carray/time.rb +1222 -186
  204. data/lib/carray/window_iterator.rb +242 -81
  205. data/lib/carray.rb +9 -2
  206. data/yard-stubs/ca_obj_array.rb +4 -4
  207. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  208. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  209. data/yard-stubs/ca_obj_fake.rb +1 -1
  210. data/yard-stubs/ca_obj_field.rb +5 -1
  211. data/yard-stubs/ca_obj_grid.rb +21 -10
  212. data/yard-stubs/ca_obj_refer.rb +5 -4
  213. data/yard-stubs/ca_obj_roll.rb +2 -3
  214. data/yard-stubs/ca_obj_shift.rb +5 -1
  215. data/yard-stubs/ca_obj_stride.rb +10 -7
  216. data/yard-stubs/ca_obj_tile.rb +1 -1
  217. data/yard-stubs/ca_obj_transpose.rb +1 -1
  218. data/yard-stubs/ca_obj_window.rb +9 -5
  219. data/yard-stubs/carray_access.rb +11 -5
  220. data/yard-stubs/carray_attribute.rb +0 -5
  221. data/yard-stubs/carray_broadcast.rb +1 -1
  222. data/yard-stubs/carray_cast.rb +88 -43
  223. data/yard-stubs/carray_conversion.rb +15 -2
  224. data/yard-stubs/carray_copy.rb +12 -5
  225. data/yard-stubs/carray_core.rb +3 -3
  226. data/yard-stubs/carray_count.rb +9 -0
  227. data/yard-stubs/carray_element.rb +10 -4
  228. data/yard-stubs/carray_generate.rb +21 -1
  229. data/yard-stubs/carray_lazy.rb +5 -1
  230. data/yard-stubs/carray_loop.rb +4 -1
  231. data/yard-stubs/carray_mask.rb +60 -12
  232. data/yard-stubs/carray_math.rb +52 -14
  233. data/yard-stubs/carray_median_percentile.rb +15 -8
  234. data/yard-stubs/carray_memory_view.rb +40 -23
  235. data/yard-stubs/carray_order.rb +61 -29
  236. data/yard-stubs/carray_random.rb +13 -4
  237. data/yard-stubs/carray_scatter.rb +21 -9
  238. data/yard-stubs/carray_segment.rb +72 -0
  239. data/yard-stubs/carray_slab.rb +10 -2
  240. data/yard-stubs/carray_sort.rb +49 -8
  241. data/yard-stubs/carray_test.rb +15 -3
  242. data/yard-stubs/carray_utils.rb +0 -26
  243. metadata +19 -5
  244. data/ext/ca_for_each_element.h +0 -239
  245. data/ext/carray_kernels_map.c +0 -3467
  246. data/lib/carray/arrow_tensor.rb +0 -401
  247. data/lib/carray/table.rb +0 -108
@@ -1,15 +1,21 @@
1
1
  # CAFrame CSV input/output (memo §11.2, §6-2).
2
2
 
3
3
  require "carray/frame/csv_parser"
4
+ require "carray/inspect" # CArray::Inspector.face_label, for the column list
4
5
 
5
6
  class CAFrame
6
7
  # 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).
8
+ # §3.7); every column is built raw as a CAString of the cell strings, all
9
+ # over one object array (§4.2). Casting is a separate step: +types:+ takes
10
+ # anything +cast+ takes as a map -- { "temp" => :float64 }, :infer, or a
11
+ # map with +default:+ -- and casts the frame with it on load, which is the
12
+ # same as calling +cast+ afterwards. Broken cells become UNDEF
13
+ # automatically (parse-mask, §6-2).
14
+ #
15
+ # CAFrame.from_csv("obs.csv", types: :infer)
16
+ # CAFrame.from_csv("obs.csv", types: { default: :infer, "code" => :int32, "id" => nil })
17
+ # CAFrame.from_csv("obs.csv", types: { default: :float64, "station" => nil })
18
+ # CAFrame.from_csv("obs.csv", types: { "station" => :categorical })
13
19
  #
14
20
  # +source+ is a path, or an open IO -- anything answering +gets+, which a
15
21
  # StringIO is. So CSV that is already in memory does not have to go to a
@@ -30,67 +36,253 @@ class CAFrame
30
36
  # strip: trim spaces from unquoted fields (default false, RFC spacing)
31
37
  # +encoding+: open-mode encoding for a path (default "bom|utf-8", strips a
32
38
  # 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.
39
+ # the IO's own encoding governs and a BOM is the caller's. To
40
+ # read a file in another encoding, name it and the one to
41
+ # transcode to: "CP932:UTF-8" for a CSV written by Excel in
42
+ # Japanese.
34
43
  # parser: a callable source -> [headers, rows] to inject another parser
35
44
  # (e.g. the stdlib +csv+, or a typed-table source); when given,
36
- # sep/quote/strip/encoding and any block are that parser's concern.
45
+ # sep/quote/strip/encoding and any block are that parser's concern,
46
+ # and header:/data:/column_names:/columns:/attrs: are refused.
47
+ # on_error: what a +types:+ cast does with a cell that does not read
48
+ # (:mask default, :warn, :raise); see +cast+.
49
+ #
50
+ # Where the header and the data are is given by the index of the line,
51
+ # from 0, as in File.readlines (an error names the line from 1, as an
52
+ # editor numbers it):
53
+ # header: the line of the column names (default 0), or false for none
54
+ # data: the first line of the data (default the line after the
55
+ # header), or a Range of lines; a record that starts in it is
56
+ # read whole
57
+ # column_names: the names, for a file with none or to replace them; the
58
+ # header then defaults to none
59
+ # columns: the columns to read, by name or by index from 0 (Integers
60
+ # and Ranges), in the order the frame has them; the others are
61
+ # read past without a String made for their fields. Without a
62
+ # header the columns are named c0, c1, ..., and those names
63
+ # select too. A Hash selects by its keys and names the columns
64
+ # with its values: { 2 => "code", "c7" => "area" }; types: and
65
+ # missing: then use the new names
66
+ # attrs: attribute keys for the lines right below the header, one
67
+ # key per line: field j of a line becomes that attribute of
68
+ # column j (an empty field gives none). The data then starts
69
+ # below them. What to_csv(attrs:) writes, it reads back
37
70
  #
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+.
71
+ # CAFrame.from_csv("obs.csv", header: 2) # a title above
72
+ # CAFrame.from_csv("obs.csv", header: 0, data: 3) # units on lines 1-2
73
+ # CAFrame.from_csv("big.csv", data: 1..100) # the first 100 lines
74
+ # CAFrame.from_csv("raw.csv", column_names: %w[date temp rh])
75
+ # CAFrame.from_csv("wide.csv", columns: %w[date temp])
76
+ # CAFrame.from_csv("raw.csv", header: false, columns: { 0 => "date", 3 => "temp" })
77
+ # CAFrame.from_csv("obs.csv", attrs: [:units]) # units on line 1
41
78
  #
42
- # CAFrame.from_csv("obs.csv") do
43
- # skip 2; header; skip 1; body
44
- # end
79
+ # A block, given the reader, reads in any other order with +skip+ /
80
+ # +header+ / +header(name)+ / +column_names+ / +columns+ / +data+ (see
81
+ # CSVReader):
82
+ #
83
+ # CAFrame.from_csv("obs.csv") { |r| r.skip 2; r.header; r.skip 1; r.data }
45
84
  #
46
85
  # A missing field -- an unquoted empty one, or a cell a short row never
47
86
  # reached -- is UNDEF in the frame, whether or not the column is cast by
48
87
  # +types:+. A quoted empty field ("") is the empty string, which is a value.
49
88
  # So the mask +to_csv+ writes comes back as a mask.
50
89
  #
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,
55
- sep: ",", quote: '"', strip: false,
90
+ # Files that spell "missing" some other way say so with +missing:+. A field
91
+ # whose text is one of the given tokens is UNDEF too, before +types:+
92
+ # casts, so a -999 sentinel never reaches the column as a number:
93
+ #
94
+ # CAFrame.from_csv("obs.csv", missing: ["-999", "///"]) # every column
95
+ # CAFrame.from_csv("obs.csv", missing: { "temp" => "-999" }) # one column
96
+ # CAFrame.from_csv("obs.csv", missing: { default: "-999", "id" => [] })
97
+ #
98
+ # Tokens are Strings and are compared with the field's text as read (after
99
+ # +strip:+, quoted or not), so "-999" does not match "-999.0". In a Hash,
100
+ # +:default+ gives the tokens for every column and a column name gives that
101
+ # column's own, which replace the default ([] or "" for none); without
102
+ # +:default+ the columns it does not name keep only the empty field. As in
103
+ # +to_csv+, "" means the empty field, so a quoted "" is still a value.
104
+ #
105
+ # Without +types:+, the columns are handed to the frame as CABlock views
106
+ # over one backing object array (§3.6 view-by-default). With +types:+, the
107
+ # body is read without making a String per cell: a column cast to a number
108
+ # is read from the text directly, and a column left as text becomes its own
109
+ # CAString. Casting a column materializes it, and +copy+ gives an
110
+ # independent frame.
111
+ def self.from_csv(source, types: nil, on_error: :mask, missing: nil,
112
+ header: nil, data: nil, column_names: nil, columns: nil,
113
+ attrs: nil, sep: ",", quote: '"', strip: false,
56
114
  encoding: "bom|utf-8", parser: nil, &block)
57
- names, rows =
115
+ layout = { header: header, data: data, column_names: column_names,
116
+ columns: columns, attrs: attrs }.compact
117
+ if block && !layout.empty?
118
+ raise ArgumentError, "from_csv: give #{layout.keys.map { |k| "#{k}:" }.join(', ')} " \
119
+ "or a reading block, not both"
120
+ end
121
+ # The parser reads the header, the data and the columns itself.
122
+ if parser && !layout.empty?
123
+ raise ArgumentError, "from_csv: give #{layout.keys.map { |k| "#{k}:" }.join(', ')} " \
124
+ "or parser:, not both"
125
+ end
126
+ if block && block.arity != 1
127
+ raise ArgumentError, "from_csv: the reading block takes the reader as its " \
128
+ "parameter, as in from_csv(path) { |r| r.skip 2; r.header; r.data }"
129
+ end
130
+ missing = missing_tokens(missing) if missing
131
+ # With types:, the body is read into CAConstString columns, so a column
132
+ # cast to a number never becomes Strings; the text columns left over
133
+ # become CAString below. Without types: every column stays text, so the
134
+ # body goes straight to Strings. missing: masks text in place, which
135
+ # wants the Strings first, so it takes that path too.
136
+ read_text_as_const_string = !types.nil? && missing.nil?
137
+ names, rows, column_attrs =
58
138
  if parser
59
139
  parser.call(source)
60
140
  elsif source.respond_to?(:gets)
61
- read_csv(source, sep: sep, quote: quote, strip: strip, &block)
141
+ with_encoding_hint("open the IO in the file's encoding, as in " \
142
+ "File.open(path, \"r:<file encoding>:UTF-8\")") do
143
+ read_csv(source, sep: sep, quote: quote, strip: strip, layout: layout,
144
+ read_text_as_const_string: read_text_as_const_string, &block)
145
+ end
62
146
  else
63
- File.open(source, "r:#{encoding}") do |io|
64
- read_csv(io, sep: sep, quote: quote, strip: strip, &block)
147
+ with_encoding_hint("it was opened as #{encoding.inspect}; pass the " \
148
+ "file's encoding, as in " \
149
+ "encoding: \"<file encoding>:UTF-8\"") do
150
+ File.open(source, "r:#{encoding}") do |io|
151
+ read_csv(io, sep: sep, quote: quote, strip: strip, layout: layout,
152
+ read_text_as_const_string: read_text_as_const_string, &block)
153
+ end
65
154
  end
66
155
  end
67
156
 
68
157
  frame = build_frame(names, rows)
69
- frame.cast(types) if types
158
+ mask_missing_tokens(frame, missing) if missing
159
+ frame.cast(types, on_error: on_error, option_name: :types) unless types.nil?
160
+ convert_const_string_columns_to_castring(frame)
161
+ column_attrs&.each_with_index do |attrs, j|
162
+ frame[frame.column_names[j]].set_attrs(attrs) unless attrs.empty?
163
+ end
70
164
  frame
71
165
  end
72
166
 
167
+ # A text column from_csv reads is a CAString, whichever way the body was
168
+ # read: the string operations (strip!, gsub!, ...) write through to it.
169
+ def self.convert_const_string_columns_to_castring(frame)
170
+ frame.column_names.each do |name|
171
+ col = frame[name]
172
+ next unless col.is_a?(CAConstString)
173
+ frame[name] = col.to_string
174
+ end
175
+ end
176
+ private_class_method :convert_const_string_columns_to_castring
177
+
178
+ # Split a +missing:+ argument, as from_csv and to_csv both take it, into the
179
+ # setting for every column and a Hash of per-column settings that replace
180
+ # it. A Hash says :default for every column and a column name for one;
181
+ # column names are Strings, so the Symbol cannot collide with one. A
182
+ # setting that is not a Hash is the default.
183
+ def self.split_missing(missing, verb)
184
+ return [missing, {}] unless missing.is_a?(Hash)
185
+ default = nil
186
+ per_column = {}
187
+ missing.each do |key, value|
188
+ case key
189
+ when :default then default = value
190
+ else
191
+ per_column[CAFrame.__send__(:column_key, key, verb, "missing: a column name",
192
+ hint: ":default is the only Symbol key")] = value
193
+ end
194
+ end
195
+ [default, per_column]
196
+ end
197
+
198
+ # The read side of missing:, checked before the file is read: each setting
199
+ # becomes an Array of token Strings.
200
+ def self.missing_tokens(missing)
201
+ default, per_column = split_missing(missing, "from_csv")
202
+ tokens = lambda do |value|
203
+ list = Array(value)
204
+ bad = list.reject { |t| t.is_a?(String) }
205
+ unless bad.empty?
206
+ raise ArgumentError,
207
+ "missing: tokens are matched against a field's text, so give them " \
208
+ "as Strings (#{bad.map { |t| t.to_s.inspect }.join(', ')}, not " \
209
+ "#{bad.map(&:inspect).join(', ')})"
210
+ end
211
+ # "" is the empty field, which is missing already; as on the write side
212
+ # it adds nothing, so a quoted "" stays the empty string.
213
+ list.reject(&:empty?)
214
+ end
215
+ [tokens.(default), per_column.transform_values(&tokens)]
216
+ end
217
+
218
+ def self.mask_missing_tokens(frame, missing)
219
+ default, per_column = missing
220
+ per_column.each_key { |name| frame[name] } # KeyError for a name the file lacks
221
+ frame.column_names.each do |name|
222
+ col = frame[name]
223
+ per_column.fetch(name, default).each { |t| col[:eq, t] = UNDEF }
224
+ end
225
+ end
226
+
227
+ # The attribute keys an +attrs:+ argument names, as Strings: an Array of
228
+ # Symbols or Strings, none twice.
229
+ def self.csv_attr_keys(attrs, verb)
230
+ unless attrs.is_a?(Array)
231
+ raise TypeError, "#{verb}: attrs: takes an Array of attribute keys " \
232
+ "(got #{attrs.inspect})"
233
+ end
234
+ keys = attrs.map do |key|
235
+ unless key.is_a?(Symbol) || key.is_a?(String)
236
+ raise TypeError, "#{verb}: attrs: an attribute key is a Symbol or String " \
237
+ "(got #{key.inspect})"
238
+ end
239
+ key.to_s
240
+ end
241
+ # names-check: attribute keys, not column names
242
+ dup = keys.tally.find { |_, n| n > 1 }
243
+ raise ArgumentError, "#{verb}: attrs: names #{dup.first.inspect} twice" if dup
244
+ keys
245
+ end
246
+
247
+ private_class_method :split_missing, :missing_tokens, :mask_missing_tokens,
248
+ :csv_attr_keys
249
+
73
250
  # Drive the reading-control DSL over one open IO and hand back
74
- # [names, rows]. Shared by the path and the IO source, so the two cannot
75
- # 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
251
+ # [names, rows, column_attrs]. Shared by the path and the IO source, so the
252
+ # two cannot come to read a file differently.
253
+ def self.read_csv (io, sep:, quote:, strip:, layout:, read_text_as_const_string: false, &block)
254
+ reader = CSVReader.new(io, sep: sep, quote: quote, strip: strip,
255
+ read_text_as_const_string: read_text_as_const_string)
256
+ reader.reporting do
257
+ block ? block.call(reader) : reader.layout(**layout)
83
258
  end
84
- reader.result
259
+ names, rows = reader.result
260
+ [names, rows, reader.column_attrs(reader.file_ncol)]
85
261
  end
86
262
 
87
263
  private_class_method :read_csv
88
264
 
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).
265
+ # Re-raise a failure to decode the input with a line saying how to name the
266
+ # file's encoding. The error and its class are kept; only the message grows.
267
+ # Which encoding the file is in cannot be told from its bytes, so the hint
268
+ # names the option, not a value.
269
+ def self.with_encoding_hint(hint)
270
+ yield
271
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError,
272
+ Encoding::CompatibilityError, ArgumentError => e
273
+ raise if e.is_a?(ArgumentError) && !e.message.include?("invalid byte sequence")
274
+ if e.is_a?(Encoding::CompatibilityError)
275
+ # The text decoded; the separator or quote is in another encoding.
276
+ raise e.class, "#{e.message} -- sep: and quote: are matched against the " \
277
+ "text as read, so read the file transcoded to UTF-8, as in " \
278
+ "encoding: \"<file encoding>:UTF-8\"", e.backtrace
279
+ end
280
+ raise e.class, "#{e.message} -- the CSV is not in the encoding it was " \
281
+ "read as: #{hint}", e.backtrace
282
+ end
283
+
284
+ private_class_method :with_encoding_hint
285
+
94
286
  # Write the frame as CSV. CSV is a flat table of scalar cells, so this is the
95
287
  # text form of the same all-scalar subset +to_ca+ requires (§11.9): every
96
288
  # column must be 1-D. Unlike +to_ca+ it does not promote to a common data type --
@@ -110,36 +302,125 @@ class CAFrame
110
302
  # csv = df.to_csv # get a String
111
303
  #
112
304
  # 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)
305
+ # (default true); +index+ writes the index column (default true);
306
+ # +encoding+ transcodes the text before it is written or returned (default
307
+ # nil leaves it as built, UTF-8). A character the encoding cannot hold
308
+ # raises Encoding::UndefinedConversionError rather than being dropped. A
309
+ # String cell in another encoding is transcoded to UTF-8; one with bytes
310
+ # that are not valid in it, or with no encoding (ASCII-8BIT), raises. Each
311
+ # names the row and the column of the cell.
312
+ #
313
+ # df.to_csv("out.csv", encoding: "CP932") # for Excel in Japanese
314
+ #
315
+ # +missing:+ writes a masked cell as that String instead of an empty field,
316
+ # for a reader that expects a sentinel; +from_csv(missing:)+ with the same
317
+ # String reads it back as UNDEF. A Hash sets it per column the way
318
+ # +from_csv+ does: +:default+ for every column, and a column name (or the
319
+ # index's axis name) for one, replacing the default; "" is the empty field.
320
+ # A cell whose value would be written as the same text raises, since the
321
+ # file could not tell the two apart.
322
+ #
323
+ # df.to_csv("out.csv", missing: "-999")
324
+ # df.to_csv("out.csv", missing: { default: "-999", "comment" => "" })
325
+ #
326
+ # +attrs:+ writes, below the name row, one line per attribute key given,
327
+ # in that order: field j is +attr(key)+ of column j (the index's for the
328
+ # index column). A column without the attribute gets an empty field, an
329
+ # empty String a quoted "", and a number its +to_s+.
330
+ # +from_csv(attrs:)+ with the same keys reads them back, as Strings.
331
+ #
332
+ # df.to_csv("out.csv", attrs: [:units, :long_name])
333
+ def to_csv(path = nil, sep: ",", quote: '"', header: true, index: true,
334
+ encoding: nil, missing: nil, attrs: [])
335
+ default, per_column = CAFrame.send(:split_missing, missing, "to_csv")
336
+ [default, *per_column.values].each do |text|
337
+ next if text.nil? || text.is_a?(String)
338
+ raise ArgumentError, "to_csv: missing: is the String written for a masked " \
339
+ "cell (got #{text.inspect})"
340
+ end
341
+ attr_keys = attrs.nil? ? [] : CAFrame.send(:csv_attr_keys, attrs, "to_csv")
342
+ if !header && !attr_keys.empty?
343
+ raise ArgumentError, "to_csv: attrs: writes lines below the name row, " \
344
+ "so it needs header: true"
345
+ end
115
346
  nd = @columns.find { |_, c| c.ndim != 1 }
116
347
  if nd
117
348
  raise ArgumentError,
118
349
  "to_csv needs all-scalar (1-D) columns; #{nd.first.inspect} is " \
119
350
  "#{nd.last.ndim}-D — export it per column or via to_records + JSON"
120
351
  end
352
+ # A record cell has no text form that reads back; its members do.
353
+ rec = @columns.find { |_, c| c.is_a?(CARecord) }
354
+ if rec
355
+ name, col = rec
356
+ member = col.members.first
357
+ raise ArgumentError,
358
+ "to_csv cannot write the record column #{name.inspect}; write its " \
359
+ "members as columns, e.g. df[#{"#{name}_#{member}".inspect}] = " \
360
+ "df[#{name.inspect}][#{member.inspect}]"
361
+ end
121
362
 
122
363
  names = []
123
364
  formatted = []
365
+ written = []
124
366
  if index && @index
125
367
  names << @axis_name
126
368
  formatted << format_csv_column(@index)
369
+ written << @index
127
370
  end
128
371
  @columns.each do |name, col|
129
372
  names << name
130
373
  formatted << format_csv_column(col)
374
+ written << col
375
+ end
376
+ attr_rows = attr_keys.map do |key|
377
+ written.each_with_index.map { |col, j| csv_attr_text(col, key, names[j]) }
378
+ end
379
+ unknown = per_column.keys - names
380
+ raise KeyError, "to_csv: missing: names no column #{unknown.first.inspect}" unless unknown.empty?
381
+ formatted.each_with_index do |fcol, j|
382
+ token = per_column.fetch(names[j], default)
383
+ next if token.nil? || token.empty? # the empty field, as without missing:
384
+ i = fcol.index(token)
385
+ if i
386
+ raise ArgumentError,
387
+ "to_csv: row #{i} of #{names[j].inspect} is written as " \
388
+ "#{token.inspect}, the text given for a masked cell"
389
+ end
390
+ fcol.map! { |text| text.nil? ? token : text }
131
391
  end
132
392
 
133
- out = +""
134
- if header
135
- out << names.map { |t| quote_csv_field(t, sep, quote) }.join(sep) << "\n"
393
+ out = begin
394
+ csv_text(names, attr_rows, formatted, header, sep, quote)
395
+ rescue Encoding::CompatibilityError
396
+ nil
136
397
  end
137
- @nrow.times do |i|
138
- out << formatted.map { |fcol| quote_csv_field(fcol[i], sep, quote) }.join(sep) << "\n"
398
+ # A cell in another encoding, or with bytes that are not text, is found
399
+ # and named here, after the text was built in one pass, so a frame of
400
+ # UTF-8 text pays nothing for the check.
401
+ unless out && out.encoding == Encoding::UTF_8 && out.valid_encoding?
402
+ names = names.each_with_index.map { |t, j| csv_text_in_utf8(t, "the name of column #{j}") }
403
+ attr_rows = attr_rows.each_with_index.map do |row, k|
404
+ row.each_with_index.map do |t, j|
405
+ csv_text_in_utf8(t, "attribute #{attr_keys[k].inspect} of #{names[j].inspect}")
406
+ end
407
+ end
408
+ formatted = formatted.each_with_index.map do |fcol, j|
409
+ fcol.each_with_index.map { |t, i| csv_text_in_utf8(t, "row #{i} of #{names[j].inspect}") }
410
+ end
411
+ out = csv_text(names, attr_rows, formatted, header, sep, quote)
412
+ end
413
+
414
+ if encoding
415
+ out = begin
416
+ out.encode(encoding)
417
+ rescue Encoding::UndefinedConversionError => e
418
+ raise csv_unwritable_cell(names, attr_keys, attr_rows, formatted, encoding) || e
419
+ end
139
420
  end
140
421
 
141
422
  if path
142
- File.write(path, out)
423
+ File.binwrite(path, out)
143
424
  self
144
425
  else
145
426
  out
@@ -177,19 +458,22 @@ class CAFrame
177
458
 
178
459
  # +to_s+ is the whole frame, +inspect+ the middle-elided one -- so +puts df+
179
460
  # 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
461
+ # same summary line it always had (nrow, column data types, index), so the
181
462
  # table under it needs no row-count footer.
182
463
  def to_s
183
464
  to_table(rows: nil)
184
465
  end
185
466
 
186
- # The summary line (nrow, variable data types, index) followed by the
467
+ # The summary line (nrow, column data types, index) followed by the
187
468
  # middle-elided table.
188
469
  # @return [String]
189
470
  def inspect
190
- parts = @columns.map { |k, v| "#{k}:#{v.data_type}#{v.ndim > 1 ? v.shape[1..].inspect : ''}" }
471
+ parts = @columns.map do |k, v|
472
+ type = CArray::Inspector.type_label(v)
473
+ "#{k}:#{type}#{v.ndim > 1 ? v.shape[1..].inspect : ''}"
474
+ end
191
475
  idx = @index ? " index=#{@axis_name.inspect}" : ""
192
- head = "#<CAFrame nrow=#{@nrow} vars=[#{parts.join(', ')}]#{idx}>"
476
+ head = "#<CAFrame nrow=#{@nrow} columns=[#{parts.join(', ')}]#{idx}>"
193
477
  # The table counts the index as a column, so a frame whose only data is its
194
478
  # index has one to show. Gate on the same thing render_table does.
195
479
  return head if @columns.empty? && @index.nil?
@@ -232,7 +516,7 @@ class CAFrame
232
516
  out << table_row(widths.map { |w| "-" * w }, widths, aligns) << "\n"
233
517
  body.each { |cells| out << table_row(cells, widths, aligns) << "\n" }
234
518
  if footer && positions.size - positions.count(nil) < @nrow
235
- out << "(#{plural(@nrow, 'row')}, #{plural(@columns.size, 'variable')})\n"
519
+ out << "(#{plural(@nrow, 'row')}, #{plural(@columns.size, 'column')})\n"
236
520
  end
237
521
  out
238
522
  end
@@ -263,9 +547,11 @@ class CAFrame
263
547
  0xF900..0xFAFF, 0xFE10..0xFE19, 0xFE30..0xFE6F, 0xFF00..0xFF60,
264
548
  0xFFE0..0xFFE6, 0x1F300..0x1F64F, 0x1F900..0x1F9FF, 0x20000..0x3FFFD,
265
549
  ].freeze
550
+ Ractor.make_shareable(WIDE_CHAR_RANGES)
266
551
  private_constant :WIDE_CHAR_RANGES
267
552
 
268
553
  COMBINING_RANGES = [0x0300..0x036F, 0x1AB0..0x1AFF, 0x20D0..0x20F0].freeze
554
+ Ractor.make_shareable(COMBINING_RANGES)
269
555
  private_constant :COMBINING_RANGES
270
556
 
271
557
  private def display_width(text)
@@ -285,17 +571,31 @@ class CAFrame
285
571
  "#{n} #{noun}#{n == 1 ? '' : 's'}"
286
572
  end
287
573
 
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)
574
+ # Render one N-D cell as a bracketed list. Each element is shown as a
575
+ # scalar cell of its kind would be -- floats rounded to +precision+, a
576
+ # masked element as the table's missing marker -- except that a String
577
+ # keeps its quotes, so the list stays readable when one holds a comma.
578
+ private def format_nested_cell(v, precision)
291
579
  case v
292
- when Array then "[" + v.map { |x| format_nested_cell(x) }.join(", ") + "]"
293
- else UNDEF.equal?(v) || v.nil? ? "_" : v.inspect
580
+ when Array then "[" + v.map { |x| format_nested_cell(x, precision) }.join(", ") + "]"
581
+ when String then v.inspect
582
+ else format_scalar_cell(v, precision)
294
583
  end
295
584
  end
296
585
 
297
586
  private def format_table_cell(col, i, precision)
298
587
  e = elem_at(col, i)
588
+ if e.is_a?(CArray)
589
+ # An N-D cell renders its elements, and a masked element among them
590
+ # takes the same marker a masked scalar does -- UNDEF's own inspect
591
+ # would put a second spelling of "missing" in the same table.
592
+ format_nested_cell(e.to_a, precision)
593
+ else
594
+ format_scalar_cell(e, precision)
595
+ end
596
+ end
597
+
598
+ private def format_scalar_cell(e, precision)
299
599
  if UNDEF.equal?(e) || e.nil?
300
600
  "_"
301
601
  elsif e.is_a?(Float) && precision
@@ -303,11 +603,6 @@ class CAFrame
303
603
  # sets the column width for every other row and makes the table hard
304
604
  # to read. precision: nil prints the value as Ruby renders it.
305
605
  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
606
  elsif e.is_a?(String)
312
607
  e
313
608
  elsif e.respond_to?(:iso8601)
@@ -318,6 +613,15 @@ class CAFrame
318
613
  end
319
614
 
320
615
  private def format_csv_column(col)
616
+ # A float32 value goes out as its own shortest decimal ("0.1"), which
617
+ # from_csv reads back as the same value, not as the double it widens
618
+ # to ("0.10000000149011612"); cmplx64 has that in each part.
619
+ if !col.face? && (col.data_type == CA_FLOAT32 || col.data_type == CA_CMPLX64)
620
+ col = col.__widen_keeping_shortest_decimal__
621
+ end
622
+ # A boolean goes out as 0 / 1, as it does in every other serialization;
623
+ # from_csv reads it back with types: { name => :boolean }.
624
+ col = col.to_type(:uint8) if !col.face? && col.data_type == CA_BOOLEAN
321
625
  col.to_a.map do |e|
322
626
  if UNDEF.equal?(e) || e.nil?
323
627
  nil
@@ -331,43 +635,166 @@ class CAFrame
331
635
  end
332
636
  end
333
637
 
638
+ private def csv_text(names, attr_rows, formatted, header, sep, quote)
639
+ out = +""
640
+ if header
641
+ out << names.map { |t| quote_csv_field(t, sep, quote) }.join(sep) << "\n"
642
+ end
643
+ attr_rows.each do |row|
644
+ out << row.map { |t| quote_csv_field(t, sep, quote) }.join(sep) << "\n"
645
+ end
646
+ @nrow.times do |i|
647
+ out << formatted.map { |fcol| quote_csv_field(fcol[i], sep, quote) }.join(sep) << "\n"
648
+ end
649
+ out
650
+ end
651
+
652
+ # The field +to_csv(attrs:)+ writes for attribute +key+ of +col+: nil for
653
+ # none (the empty field), the String itself, or a scalar's to_s.
654
+ private def csv_attr_text(col, key, name)
655
+ value = col.attr(key)
656
+ case value
657
+ when nil, String then value
658
+ when Array, Hash
659
+ raise ArgumentError, "to_csv: attribute #{key.inspect} of #{name.inspect} is " \
660
+ "#{value.class}, which a CSV field cannot hold"
661
+ else value.to_s
662
+ end
663
+ end
664
+
665
+ # The text of a cell as UTF-8, the encoding the CSV is built in: text in
666
+ # another encoding is transcoded; bytes that are not valid in their
667
+ # encoding, or have none (ASCII-8BIT), raise naming the cell.
668
+ private def csv_text_in_utf8(text, where)
669
+ return text if text.nil?
670
+ enc = text.encoding
671
+ unless text.valid_encoding?
672
+ raise Encoding::InvalidByteSequenceError,
673
+ "to_csv: #{where} (#{csv_excerpt(text)}) is not valid #{enc}"
674
+ end
675
+ return text if enc == Encoding::UTF_8 || (enc.ascii_compatible? && text.ascii_only?)
676
+ if enc == Encoding::BINARY
677
+ raise Encoding::CompatibilityError,
678
+ "to_csv: #{where} (#{csv_excerpt(text)}) is bytes with no encoding " \
679
+ "(ASCII-8BIT); give it one with force_encoding"
680
+ end
681
+ text.encode(Encoding::UTF_8)
682
+ rescue Encoding::UndefinedConversionError
683
+ raise Encoding::UndefinedConversionError,
684
+ "to_csv: #{where} (#{csv_excerpt(text)}) has a character with no UTF-8 form in #{enc}"
685
+ end
686
+
687
+ # The start of a cell's text, quoted, for an error. Text is shown as it
688
+ # is, whatever the locale (inspect would escape it where the locale is not
689
+ # UTF-8); control characters and bytes that are not text are escaped.
690
+ private def csv_excerpt(text)
691
+ head = text[0, 20]
692
+ return head.dump unless head.valid_encoding? && head.encoding != Encoding::BINARY
693
+ head = head.encode(Encoding::UTF_8)
694
+ '"' + head.gsub(/[\x00-\x1f\x7f"\\]/) { |c| c.dump[1..-2] } + '"'
695
+ rescue EncodingError
696
+ text[0, 20].dump
697
+ end
698
+
699
+ # The error for the first cell (or name) +encoding+ cannot hold, or nil.
700
+ private def csv_unwritable_cell(names, attr_keys, attr_rows, formatted, encoding)
701
+ cells = names.each_with_index.map { |t, j| [t, "the name of column #{j}"] }
702
+ attr_rows.each_with_index do |row, k|
703
+ row.each_with_index do |t, j|
704
+ cells << [t, "attribute #{attr_keys[k].inspect} of #{names[j].inspect}"] if t
705
+ end
706
+ end
707
+ formatted.each_with_index do |fcol, j|
708
+ fcol.each_with_index { |t, i| cells << [t, "row #{i} of #{names[j].inspect}"] if t }
709
+ end
710
+ cells.each do |text, where|
711
+ text.encode(encoding)
712
+ rescue Encoding::UndefinedConversionError => e
713
+ return Encoding::UndefinedConversionError.new(
714
+ "to_csv: #{where} (#{csv_excerpt(text)}): #{csv_excerpt(e.error_char)} (U+#{format("%04X", e.error_char.ord)}) cannot be written in #{encoding}")
715
+ end
716
+ nil
717
+ end
718
+
334
719
  private def quote_csv_field(text, sep, quote)
335
720
  return "" if text.nil?
721
+ # With a separator longer than one character, a value can end in part of
722
+ # it ("a:" before "::"), and the reader would find the separator inside
723
+ # the value; such a value is quoted too. A value of spaces and tabs is
724
+ # quoted so that it is neither a blank line the reader skips (a header of
725
+ # one column) nor a field strip: empties.
336
726
  if text.empty? || text.include?(sep) || text.include?(quote) ||
337
- text.include?("\n") || text.include?("\r")
727
+ text.include?("\n") || text.include?("\r") || text.each_byte.all? { |b| b == 0x20 || b == 0x09 } ||
728
+ (sep.size > 1 && (text + sep).index(sep) != text.size)
338
729
  quote + text.gsub(quote, quote * 2) + quote
339
730
  else
340
731
  text
341
732
  end
342
733
  end
343
734
 
735
+ # Build a frame from parsed [names, rows]. When names is nil (headerless and
736
+ # no column_names) positional names "c0".."cN" are generated from the widest
737
+ # row. Rows are squared off to the column count (short rows padded with nil,
738
+ # over-long rows raise), one 2-D object array is bulk-filled, and each column
739
+ # is a view into it (§3.6).
344
740
  def self.build_frame(names, rows)
345
- ncol = names ? names.size : (rows.map(&:size).max || 0)
741
+ ncol = if names then names.size
742
+ elsif rows.is_a?(CArray) then rows.shape[1]
743
+ else rows.map(&:size).max || 0
744
+ end
346
745
  names ||= Array.new(ncol) { |j| "c#{j}" }
746
+ # A frame holds one column per name, so a repeated one would keep only
747
+ # the last of its columns.
748
+ CAFrame.__send__(:refuse_repeated_names, names, "from_csv", "the header row names",
749
+ hint: "pass header: false for the default column names " \
750
+ "(c0, c1, ...), or name the columns with column_names:")
347
751
 
348
752
  cols = {}
349
- if rows.empty?
350
- names.each { |name| cols[name] = CArray.object(0) }
753
+ # Columns the C reader built as CAConstStrings over the text, for
754
+ # cast to read without a String per cell.
755
+ if rows.is_a?(CSVReader::ConstStringColumns)
756
+ names.zip(rows.columns) { |name, col| cols[name] = col }
351
757
  return new(cols)
352
758
  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
759
+ # A table the C reader built: one row per record, missing cells masked.
760
+ table = rows if rows.is_a?(CArray) && rows.elements > 0
761
+ if table.nil? && (rows.is_a?(CArray) || rows.empty?)
762
+ names.each { |name| cols[name] = CArray.string(CArray.object(0)) }
763
+ return new(cols)
360
764
  end
361
- table = CArray.object(rows.size, ncol) { rows }
765
+ table ||= table_of_rows(rows, ncol)
766
+ # Each column is a CAString over its view of the table: the text, with
767
+ # the string operations (strip!, gsub, extract, ...) at hand, writing
768
+ # through to the same cells.
769
+ names.each_with_index { |name, j| cols[name] = CArray.string(table[nil, j]) }
770
+ new(cols)
771
+ end
772
+ private_class_method :build_frame
773
+
774
+ def self.table_of_rows(rows, ncol)
362
775
  # A missing field is UNDEF, not a Ruby nil sitting in a cell. The
363
776
  # tokenizer says missing with nil (an unquoted empty field; a quoted
364
777
  # one is the empty string and stays a value), and an object array will
365
778
  # hold that nil quite happily -- so a column read without `types:` used
366
779
  # 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)
780
+ # the mask a to_csv had written did not survive the trip back. A row
781
+ # holding a nil gets UNDEF in its place before the table is built (an
782
+ # UNDEF in the rows becomes a masked cell), so a row with nothing
783
+ # missing costs no pass over its cells. Short rows are padded, and a
784
+ # row with a nil is changed, on a copy: the rows may be a parser:
785
+ # callable's own arrays.
786
+ rows = rows.each_with_index.map do |r, i|
787
+ if r.size > ncol
788
+ raise ArgumentError,
789
+ "row #{i + 1} has #{r.size} fields, expected #{ncol}"
790
+ end
791
+ r = r + Array.new(ncol - r.size) if r.size < ncol
792
+ # compact tests for nil without calling == on every cell, as
793
+ # include?(nil) would.
794
+ r = r.map { |cell| cell.nil? ? UNDEF : cell } if r.compact.size < ncol
795
+ r
796
+ end
797
+ CArray.object(rows.size, ncol) { rows }
371
798
  end
372
- private_class_method :build_frame
799
+ private_class_method :table_of_rows
373
800
  end