carray 3.0.1 → 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 (229) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +824 -0
  3. data/README.md +5 -6
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_dispatch.c +258 -4
  6. data/ext/ca_axis_group.c +368 -184
  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 +90 -41
  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 +960 -245
  14. data/ext/ca_kernel_iterator.h +352 -181
  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 +194 -42
  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 +5 -4
  23. data/ext/ca_obj_byte_swap.c +40 -40
  24. data/ext/ca_obj_const_string.c +180 -50
  25. data/ext/ca_obj_face.c +428 -62
  26. data/ext/ca_obj_face.h +107 -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 +39 -26
  31. data/ext/ca_obj_grid.c +181 -124
  32. data/ext/ca_obj_meld.c +235 -55
  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 +551 -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 +88 -75
  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 -123
  46. data/ext/ca_obj_stride.c +267 -35
  47. data/ext/ca_obj_string.c +25 -22
  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 +157 -29
  54. data/ext/ca_op_ipower.c +25 -13
  55. data/ext/ca_op_powi.h +57 -30
  56. data/ext/ca_pow5_table.h +656 -0
  57. data/ext/ca_rng_normal.h +42 -0
  58. data/ext/ca_rng_xoshiro256pp.h +105 -0
  59. data/ext/ca_sort_kernels.h +70 -0
  60. data/ext/ca_sweep_engine.c +385 -198
  61. data/ext/ca_sweep_engine.h +31 -23
  62. data/ext/ca_triop_dispatch.c +4 -1
  63. data/ext/ca_triop_dispatch.h +5 -2
  64. data/ext/caframe_csv_reader.c +463 -0
  65. data/ext/carray.h +90 -28
  66. data/ext/carray_access.c +487 -231
  67. data/ext/carray_address_basis.c +731 -0
  68. data/ext/carray_attribute.c +45 -3
  69. data/ext/carray_bincount.c +32 -2
  70. data/ext/carray_broadcast.c +49 -17
  71. data/ext/carray_call_cfunc.c +667 -511
  72. data/ext/carray_cast.c +1556 -247
  73. data/ext/carray_cast_func.rb +34 -21
  74. data/ext/carray_conversion.c +197 -73
  75. data/ext/carray_copy.c +84 -38
  76. data/ext/carray_core.c +591 -119
  77. data/ext/carray_count.c +148 -96
  78. data/ext/carray_element.c +18 -27
  79. data/ext/carray_factorize.c +604 -291
  80. data/ext/carray_generate.c +76 -40
  81. data/ext/carray_histogram.c +152 -94
  82. data/ext/carray_hold.c +2 -1
  83. data/ext/carray_index_classifier.c +63 -96
  84. data/ext/carray_internal.h +400 -1
  85. data/ext/carray_lazy.c +834 -79
  86. data/ext/carray_loop.c +88 -66
  87. data/ext/carray_mask.c +705 -324
  88. data/ext/carray_math_kernel.h +1 -0
  89. data/ext/carray_median_percentile.c +263 -63
  90. data/ext/carray_memory_view.c +254 -248
  91. data/ext/carray_operator.c +1093 -363
  92. data/ext/carray_order.c +133 -69
  93. data/ext/carray_partition.c +102 -56
  94. data/ext/carray_random.c +582 -204
  95. data/ext/carray_scatter.c +308 -65
  96. data/ext/carray_segment.c +257 -0
  97. data/ext/carray_slab.c +183 -70
  98. data/ext/carray_sort.c +235 -240
  99. data/ext/carray_sort_kernel.c +8 -1
  100. data/ext/carray_struct.c +99 -21
  101. data/ext/carray_test.c +167 -64
  102. data/ext/carray_time_text.c +1182 -0
  103. data/ext/carray_utils.c +363 -98
  104. data/ext/extconf.rb +27 -2
  105. data/ext/mk_call_cfunc.rb +103 -118
  106. data/ext/mk_pow5_table.rb +35 -0
  107. data/ext/mkkernel.rb +1349 -851
  108. data/ext/ruby_carray.c +17 -2
  109. data/ext/version.h +4 -4
  110. data/ext/version.rb +4 -2
  111. data/lib/carray/attribute.rb +39 -0
  112. data/lib/carray/autoload_carray.rb +24 -3
  113. data/lib/carray/autoload_method_extension.rb +18 -0
  114. data/lib/carray/axis_group.rb +245 -20
  115. data/lib/carray/basics.rb +95 -25
  116. data/lib/carray/bincount_nd.rb +6 -0
  117. data/lib/carray/block_iterator.rb +200 -54
  118. data/lib/carray/boolean_reduce.rb +21 -29
  119. data/lib/carray/categorical.rb +244 -59
  120. data/lib/carray/categorical_iterator.rb +391 -757
  121. data/lib/carray/complex.rb +36 -29
  122. data/lib/carray/conditional.rb +94 -7
  123. data/lib/carray/const_string.rb +177 -27
  124. data/lib/carray/construct.rb +40 -0
  125. data/lib/carray/data_type_extension.rb +3 -0
  126. data/lib/carray/data_type_limits.rb +91 -0
  127. data/lib/carray/fixlen_string.rb +1 -1
  128. data/lib/carray/frame/concat.rb +157 -6
  129. data/lib/carray/frame/convert.rb +13 -7
  130. data/lib/carray/frame/csv_parser.rb +590 -76
  131. data/lib/carray/frame/describe.rb +79 -0
  132. data/lib/carray/frame/frame.rb +205 -35
  133. data/lib/carray/frame/group.rb +199 -40
  134. data/lib/carray/frame/io.rb +452 -60
  135. data/lib/carray/frame/join.rb +134 -46
  136. data/lib/carray/frame/records.rb +54 -19
  137. data/lib/carray/frame/reshape.rb +448 -0
  138. data/lib/carray/frame/sort.rb +26 -2
  139. data/lib/carray/frame/verbs.rb +726 -56
  140. data/lib/carray/frame.rb +2 -0
  141. data/lib/carray/fuse_source.rb +111 -2
  142. data/lib/carray/fusion.rb +229 -38
  143. data/lib/carray/inspect.rb +71 -20
  144. data/lib/carray/iterator.rb +157 -2
  145. data/lib/carray/lazy.rb +54 -67
  146. data/lib/carray/mask_gap_fill.rb +66 -50
  147. data/lib/carray/math.rb +32 -19
  148. data/lib/carray/meld_reduce.rb +40 -7
  149. data/lib/carray/methods/align_addr.rb +8 -4
  150. data/lib/carray/methods/bincount.rb +2 -2
  151. data/lib/carray/methods/bit_string.rb +14 -3
  152. data/lib/carray/methods/composition.rb +16 -10
  153. data/lib/carray/methods/discovery_along.rb +74 -0
  154. data/lib/carray/methods/factorize.rb +50 -0
  155. data/lib/carray/methods/gather_nd.rb +13 -2
  156. data/lib/carray/methods/insert_block.rb +9 -14
  157. data/lib/carray/methods/is_in.rb +130 -6
  158. data/lib/carray/methods/join.rb +1 -1
  159. data/lib/carray/methods/locate_addr.rb +235 -21
  160. data/lib/carray/methods/mask_duplicates.rb +35 -1
  161. data/lib/carray/methods/mode.rb +18 -1
  162. data/lib/carray/methods/nunique.rb +22 -1
  163. data/lib/carray/methods/repeat.rb +91 -0
  164. data/lib/carray/methods/resize.rb +21 -23
  165. data/lib/carray/methods/snap.rb +34 -12
  166. data/lib/carray/methods/unique.rb +52 -3
  167. data/lib/carray/record_order.rb +208 -0
  168. data/lib/carray/rng.rb +86 -0
  169. data/lib/carray/runtime.rb +14 -11
  170. data/lib/carray/segment_iterator.rb +990 -0
  171. data/lib/carray/serialize.rb +197 -27
  172. data/lib/carray/slab_iterator.rb +109 -40
  173. data/lib/carray/stack.rb +10 -4
  174. data/lib/carray/string_operation_extension.rb +9 -4
  175. data/lib/carray/struct.rb +142 -51
  176. data/lib/carray/struct_builder.rb +110 -14
  177. data/lib/carray/table.rb +2 -2
  178. data/lib/carray/time.rb +535 -105
  179. data/lib/carray/window_iterator.rb +259 -82
  180. data/lib/carray.rb +4 -2
  181. data/yard-stubs/ca_obj_array.rb +4 -4
  182. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  183. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  184. data/yard-stubs/ca_obj_block.rb +2 -7
  185. data/yard-stubs/ca_obj_fake.rb +1 -1
  186. data/yard-stubs/ca_obj_field.rb +1 -1
  187. data/yard-stubs/ca_obj_refer.rb +3 -3
  188. data/yard-stubs/ca_obj_roll.rb +0 -1
  189. data/yard-stubs/ca_obj_stride.rb +10 -7
  190. data/yard-stubs/ca_obj_transpose.rb +1 -1
  191. data/yard-stubs/ca_obj_window.rb +11 -4
  192. data/yard-stubs/carray_access.rb +6 -5
  193. data/yard-stubs/carray_broadcast.rb +2 -2
  194. data/yard-stubs/carray_cast.rb +78 -43
  195. data/yard-stubs/carray_conversion.rb +13 -1
  196. data/yard-stubs/carray_copy.rb +8 -3
  197. data/yard-stubs/carray_core.rb +3 -83
  198. data/yard-stubs/carray_count.rb +16 -2
  199. data/yard-stubs/carray_lazy.rb +210 -1
  200. data/yard-stubs/carray_loop.rb +4 -1
  201. data/yard-stubs/carray_mask.rb +34 -10
  202. data/yard-stubs/carray_math.rb +1499 -4
  203. data/yard-stubs/carray_median_percentile.rb +26 -8
  204. data/yard-stubs/carray_order.rb +43 -82
  205. data/yard-stubs/carray_scatter.rb +10 -3
  206. data/yard-stubs/carray_segment.rb +72 -0
  207. data/yard-stubs/carray_slab.rb +11 -8
  208. data/yard-stubs/carray_sort.rb +50 -8
  209. data/yard-stubs/carray_test.rb +10 -0
  210. data/yard-stubs/carray_utils.rb +0 -26
  211. metadata +19 -19
  212. data/ext/ca_for_each_element.h +0 -239
  213. data/ext/carray_build_flags.h +0 -3
  214. data/ext/carray_config.h +0 -28
  215. data/ext/carray_kernels_bincmp.c +0 -4446
  216. data/ext/carray_kernels_binop.c +0 -11001
  217. data/ext/carray_kernels_init.c +0 -1131
  218. data/ext/carray_kernels_map.c +0 -3467
  219. data/ext/carray_kernels_moncmp.c +0 -2097
  220. data/ext/carray_kernels_monop.c +0 -18313
  221. data/ext/carray_kernels_reduce_aggregate.c +0 -25837
  222. data/ext/carray_kernels_reduce_boolean.c +0 -330
  223. data/ext/carray_kernels_reduce_cumulative.c +0 -14593
  224. data/ext/carray_kernels_reduce_extreme.c +0 -16948
  225. data/ext/carray_kernels_reduce_variance.c +0 -3910
  226. data/ext/carray_kernels_scan.c +0 -3693
  227. data/ext/carray_kernels_search.c +0 -32138
  228. data/ext/carray_kernels_sort.c +0 -10626
  229. data/ext/carray_kernels_triop.c +0 -1392
@@ -4,62 +4,244 @@ 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 })
17
+ #
18
+ # +source+ is a path, or an open IO -- anything answering +gets+, which a
19
+ # StringIO is. So CSV that is already in memory does not have to go to a
20
+ # temporary file first:
21
+ #
22
+ # CAFrame.from_csv("obs.csv") # a path
23
+ # CAFrame.from_csv(StringIO.new(body)) # text already in hand
24
+ # File.open("obs.csv") { |io| CAFrame.from_csv(io) }
25
+ #
26
+ # A String is always read as a path, never as CSV text: guessing between the
27
+ # two by looking for a newline is the kind of guess that is right until it is
28
+ # not, and StringIO says which one you meant in six characters. An IO is read
29
+ # from where it is and left open -- the caller opened it and closes it.
13
30
  #
14
31
  # Parsing uses the built-in fast tokenizer (CSVParser). Options:
15
32
  # sep: field separator (default ",")
16
33
  # quote: quote character (default '"')
17
34
  # strip: trim spaces from unquoted fields (default false, RFC spacing)
18
- # +encoding+: IO open-mode encoding (default "bom|utf-8", strips a BOM)
19
- # parser: a callable path -> [headers, rows] to inject another parser
35
+ # +encoding+: open-mode encoding for a path (default "bom|utf-8", strips a
36
+ # BOM). It has nothing to open when +source+ is an IO, so there
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.
41
+ # parser: a callable source -> [headers, rows] to inject another parser
20
42
  # (e.g. the stdlib +csv+, or a typed-table source); when given,
21
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
59
+ #
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])
65
+ #
66
+ # A block, given the reader, reads in any other order with +skip+ /
67
+ # +header+ / +header(name)+ / +column_names+ / +columns+ / +data+ (see
68
+ # CSVReader):
22
69
  #
23
- # A block gives reading control for files with preamble lines, a units row,
24
- # or no header (memo §11.2), using +skip+ / +header+ / +column_names+ /
25
- # +body+ (see CSVReader). Without a block the default is +header+ then +body+.
70
+ # CAFrame.from_csv("obs.csv") { |r| r.skip 2; r.header; r.skip 1; r.data }
26
71
  #
27
- # CAFrame.from_csv("obs.csv") do
28
- # skip 2; header; skip 1; body
29
- # end
72
+ # A missing field -- an unquoted empty one, or a cell a short row never
73
+ # reached -- is UNDEF in the frame, whether or not the column is cast by
74
+ # +types:+. A quoted empty field ("") is the empty string, which is a value.
75
+ # So the mask +to_csv+ writes comes back as a mask.
30
76
  #
31
- # Columns are handed to the frame as CABlock views over one backing object
32
- # array (§3.6 view-by-default); casting a column materializes it, and +copy+
33
- # gives an independent frame.
34
- def self.from_csv(path, 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,
35
100
  sep: ",", quote: '"', strip: false,
36
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?
37
119
  names, rows =
38
120
  if parser
39
- parser.call(path)
121
+ parser.call(source)
122
+ elsif source.respond_to?(:gets)
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
40
128
  else
41
- File.open(path, "r:#{encoding}") do |io|
42
- reader = CSVReader.new(io, sep: sep, quote: quote, strip: strip)
43
- if block
44
- block.arity == 1 ? block.call(reader) : reader.instance_exec(&block)
45
- else
46
- reader.header
47
- reader.body
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)
48
135
  end
49
- reader.result
50
136
  end
51
137
  end
52
138
 
53
139
  frame = build_frame(names, rows)
54
- 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)
55
143
  frame
56
144
  end
57
145
 
58
- # Build a frame from parsed [names, rows]. When names is nil (headerless and
59
- # no column_names) positional names "c0".."cN" are generated from the widest
60
- # row. Rows are squared off to the column count (short rows padded with nil,
61
- # over-long rows raise), one 2-D object array is bulk-filled, and each column
62
- # is a view into it (§3.6).
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
+
210
+ # Drive the reading-control DSL over one open IO and hand back
211
+ # [names, rows]. Shared by the path and the IO source, so the two cannot
212
+ # come to read a file differently.
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)
218
+ end
219
+ reader.result
220
+ end
221
+
222
+ private_class_method :read_csv
223
+
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
+
63
245
  # Write the frame as CSV. CSV is a flat table of scalar cells, so this is the
64
246
  # text form of the same all-scalar subset +to_ca+ requires (§11.9): every
65
247
  # column must be 1-D. Unlike +to_ca+ it does not promote to a common data type --
@@ -79,14 +261,50 @@ class CAFrame
79
261
  # csv = df.to_csv # get a String
80
262
  #
81
263
  # Options: +sep+ / +quote+ mirror +from_csv+; +header+ writes the name row
82
- # (default true); +index+ writes the index column (default true).
83
- 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
84
292
  nd = @columns.find { |_, c| c.ndim != 1 }
85
293
  if nd
86
294
  raise ArgumentError,
87
295
  "to_csv needs all-scalar (1-D) columns; #{nd.first.inspect} is " \
88
296
  "#{nd.last.ndim}-D — export it per column or via to_records + JSON"
89
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
90
308
 
91
309
  names = []
92
310
  formatted = []
@@ -98,17 +316,46 @@ class CAFrame
98
316
  names << name
99
317
  formatted << format_csv_column(col)
100
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
101
332
 
102
- out = +""
103
- if header
104
- 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
105
337
  end
106
- @nrow.times do |i|
107
- 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
108
355
  end
109
356
 
110
357
  if path
111
- File.write(path, out)
358
+ File.binwrite(path, out)
112
359
  self
113
360
  else
114
361
  out
@@ -146,20 +393,22 @@ class CAFrame
146
393
 
147
394
  # +to_s+ is the whole frame, +inspect+ the middle-elided one -- so +puts df+
148
395
  # dumps everything and +p df+ stays a screenful. +inspect+ leads with the
149
- # 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
150
397
  # table under it needs no row-count footer.
151
398
  def to_s
152
399
  to_table(rows: nil)
153
400
  end
154
401
 
155
- # The summary line (nrow, variable data types, index) followed by the
402
+ # The summary line (nrow, column data types, index) followed by the
156
403
  # middle-elided table.
157
404
  # @return [String]
158
405
  def inspect
159
406
  parts = @columns.map { |k, v| "#{k}:#{v.data_type}#{v.ndim > 1 ? v.shape[1..].inspect : ''}" }
160
407
  idx = @index ? " index=#{@axis_name.inspect}" : ""
161
- head = "#<CAFrame nrow=#{@nrow} vars=[#{parts.join(', ')}]#{idx}>"
162
- return head if @columns.empty?
408
+ head = "#<CAFrame nrow=#{@nrow} columns=[#{parts.join(', ')}]#{idx}>"
409
+ # The table counts the index as a column, so a frame whose only data is its
410
+ # index has one to show. Gate on the same thing render_table does.
411
+ return head if @columns.empty? && @index.nil?
163
412
  head + "\n" + render_table(head: 8, tail: 2, index: true, precision: 6,
164
413
  footer: false)
165
414
  end
@@ -199,7 +448,7 @@ class CAFrame
199
448
  out << table_row(widths.map { |w| "-" * w }, widths, aligns) << "\n"
200
449
  body.each { |cells| out << table_row(cells, widths, aligns) << "\n" }
201
450
  if footer && positions.size - positions.count(nil) < @nrow
202
- out << "(#{plural(@nrow, 'row')}, #{plural(@columns.size, 'variable')})\n"
451
+ out << "(#{plural(@nrow, 'row')}, #{plural(@columns.size, 'column')})\n"
203
452
  end
204
453
  out
205
454
  end
@@ -252,8 +501,31 @@ class CAFrame
252
501
  "#{n} #{noun}#{n == 1 ? '' : 's'}"
253
502
  end
254
503
 
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)
509
+ case v
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)
513
+ end
514
+ end
515
+
255
516
  private def format_table_cell(col, i, precision)
256
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)
257
529
  if UNDEF.equal?(e) || e.nil?
258
530
  "_"
259
531
  elsif e.is_a?(Float) && precision
@@ -261,8 +533,6 @@ class CAFrame
261
533
  # sets the column width for every other row and makes the table hard
262
534
  # to read. precision: nil prints the value as Ruby renders it.
263
535
  e.round(precision).to_s
264
- elsif e.is_a?(CArray)
265
- e.to_a.inspect
266
536
  elsif e.is_a?(String)
267
537
  e
268
538
  elsif e.respond_to?(:iso8601)
@@ -273,6 +543,15 @@ class CAFrame
273
543
  end
274
544
 
275
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
276
555
  col.to_a.map do |e|
277
556
  if UNDEF.equal?(e) || e.nil?
278
557
  nil
@@ -286,36 +565,149 @@ class CAFrame
286
565
  end
287
566
  end
288
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
+
289
628
  private def quote_csv_field(text, sep, quote)
290
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.
291
635
  if text.empty? || text.include?(sep) || text.include?(quote) ||
292
- 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)
293
638
  quote + text.gsub(quote, quote * 2) + quote
294
639
  else
295
640
  text
296
641
  end
297
642
  end
298
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).
299
649
  def self.build_frame(names, rows)
300
- 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
301
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
302
664
 
303
665
  cols = {}
304
- if rows.empty?
305
- 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 }
670
+ return new(cols)
671
+ 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)) }
306
676
  return new(cols)
307
677
  end
308
- rows.each_with_index do |r, i|
309
- if r.size < ncol
310
- r.concat(Array.new(ncol - r.size))
311
- elsif r.size > ncol
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)
688
+ # A missing field is UNDEF, not a Ruby nil sitting in a cell. The
689
+ # tokenizer says missing with nil (an unquoted empty field; a quoted
690
+ # one is the empty string and stays a value), and an object array will
691
+ # hold that nil quite happily -- so a column read without `types:` used
692
+ # to carry nil where the same column read with one carried UNDEF, and
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
312
701
  raise ArgumentError,
313
702
  "row #{i + 1} has #{r.size} fields, expected #{ncol}"
314
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
315
709
  end
316
- table = CArray.object(rows.size, ncol) { rows }
317
- names.each_with_index { |name, j| cols[name] = table[nil, j] }
318
- new(cols)
710
+ CArray.object(rows.size, ncol) { rows }
319
711
  end
320
- private_class_method :build_frame
712
+ private_class_method :table_of_rows
321
713
  end