carray 3.0.2 → 3.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +568 -0
- data/README.md +6 -6
- data/carray.gemspec +1 -1
- data/ext/ca_axis_descriptor.h +27 -24
- data/ext/ca_axis_dispatch.c +220 -0
- data/ext/ca_axis_group.c +201 -123
- data/ext/ca_binop_dispatch.c +46 -52
- data/ext/ca_binop_dispatch.h +1 -1
- data/ext/ca_categorical_iterator.c +249 -183
- data/ext/ca_compare.c +7 -12
- data/ext/ca_for_buffer.h +21 -68
- data/ext/ca_group_iter.c +38 -23
- data/ext/ca_kernel_iterator.c +1765 -2076
- data/ext/ca_kernel_iterator.h +584 -566
- data/ext/ca_monop_dispatch.c +86 -63
- data/ext/ca_monop_dispatch.h +5 -0
- data/ext/ca_obj_array.c +223 -56
- data/ext/ca_obj_bincmp.c +172 -138
- data/ext/ca_obj_binop.c +116 -230
- data/ext/ca_obj_bitarray.c +40 -26
- data/ext/ca_obj_bitfield.c +100 -68
- data/ext/ca_obj_block.c +19 -9
- data/ext/ca_obj_byte_swap.c +45 -46
- data/ext/ca_obj_const_string.c +269 -44
- data/ext/ca_obj_face.c +612 -89
- data/ext/ca_obj_face.h +111 -47
- data/ext/ca_obj_fake.c +36 -79
- data/ext/ca_obj_farray.c +7 -3
- data/ext/ca_obj_field.c +33 -8
- data/ext/ca_obj_fixlen_string.c +23 -24
- data/ext/ca_obj_grid.c +541 -210
- data/ext/ca_obj_meld.c +331 -111
- data/ext/ca_obj_moncmp.c +80 -65
- data/ext/ca_obj_monop.c +177 -149
- data/ext/ca_obj_object.c +664 -248
- data/ext/ca_obj_record.c +63 -23
- data/ext/ca_obj_reduce.c +18 -16
- data/ext/ca_obj_refer.c +140 -58
- data/ext/ca_obj_remap.c +85 -24
- data/ext/ca_obj_repeat.c +24 -9
- data/ext/ca_obj_roll.c +121 -16
- data/ext/ca_obj_select.c +61 -58
- data/ext/ca_obj_select_axis.c +73 -107
- data/ext/ca_obj_shift.c +32 -102
- data/ext/ca_obj_stack.c +372 -122
- data/ext/ca_obj_stride.c +348 -140
- data/ext/ca_obj_string.c +88 -22
- data/ext/ca_obj_tile.c +38 -21
- data/ext/ca_obj_time.c +45 -31
- data/ext/ca_obj_timedelta.c +43 -29
- data/ext/ca_obj_transpose.c +17 -6
- data/ext/ca_obj_triop.c +125 -141
- data/ext/ca_obj_window.c +330 -44
- data/ext/ca_op_ipower.c +24 -11
- data/ext/ca_op_powi.h +57 -30
- data/ext/ca_pow5_table.h +656 -0
- data/ext/ca_sort_kernels.h +70 -0
- data/ext/ca_sweep_engine.c +104 -81
- data/ext/ca_sweep_engine.h +7 -20
- data/ext/ca_triop_dispatch.c +4 -1
- data/ext/ca_triop_dispatch.h +5 -2
- data/ext/caframe_csv_reader.c +542 -0
- data/ext/carray.h +241 -92
- data/ext/carray_access.c +608 -276
- data/ext/carray_address_basis.c +494 -97
- data/ext/carray_attribute.c +129 -4
- data/ext/carray_bincount.c +39 -7
- data/ext/carray_broadcast.c +56 -19
- data/ext/carray_build_flags.h +1 -1
- data/ext/carray_call_cfunc.c +108 -28
- data/ext/carray_cast.c +1576 -223
- data/ext/carray_cast_func.rb +77 -24
- data/ext/carray_class.c +4 -3
- data/ext/carray_conversion.c +208 -75
- data/ext/carray_copy.c +82 -24
- data/ext/carray_core.c +646 -162
- data/ext/carray_count.c +171 -88
- data/ext/carray_element.c +94 -60
- data/ext/carray_factorize.c +670 -324
- data/ext/carray_generate.c +260 -83
- data/ext/carray_histogram.c +209 -106
- data/ext/carray_hold.c +14 -16
- data/ext/carray_index_classifier.c +422 -243
- data/ext/carray_internal.h +692 -3
- data/ext/carray_kernels_bincmp.c +7 -0
- data/ext/carray_kernels_binop.c +487 -120
- data/ext/carray_kernels_init.c +653 -50
- data/ext/carray_kernels_moncmp.c +15 -8
- data/ext/carray_kernels_monop.c +99 -43
- data/ext/carray_kernels_reduce_aggregate.c +3925 -6112
- data/ext/carray_kernels_reduce_boolean.c +112 -36
- data/ext/carray_kernels_reduce_cumulative.c +2127 -6903
- data/ext/carray_kernels_reduce_extreme.c +3032 -2892
- data/ext/carray_kernels_reduce_variance.c +439 -500
- data/ext/carray_kernels_scan.c +1097 -488
- data/ext/carray_kernels_search.c +3787 -2176
- data/ext/carray_kernels_sort.c +1200 -920
- data/ext/carray_kernels_triop.c +479 -7
- data/ext/carray_key_windows.c +216 -0
- data/ext/carray_lazy.c +967 -153
- data/ext/carray_loop.c +81 -65
- data/ext/carray_mask.c +910 -324
- data/ext/carray_math_kernel.h +1 -0
- data/ext/carray_median_percentile.c +299 -100
- data/ext/carray_memory_view.c +872 -758
- data/ext/carray_operator.c +1171 -364
- data/ext/carray_order.c +505 -133
- data/ext/carray_partition.c +112 -65
- data/ext/carray_random.c +327 -239
- data/ext/carray_round.c +273 -0
- data/ext/carray_scatter.c +346 -78
- data/ext/carray_segment.c +257 -0
- data/ext/carray_slab.c +249 -86
- data/ext/carray_sort.c +224 -227
- data/ext/carray_sort_kernel.c +8 -1
- data/ext/carray_struct.c +118 -39
- data/ext/carray_test.c +193 -69
- data/ext/carray_time_text.c +1182 -0
- data/ext/carray_undef.c +4 -0
- data/ext/carray_utils.c +579 -113
- data/ext/extconf.rb +27 -2
- data/ext/mk_call_cfunc.rb +19 -4
- data/ext/mk_pow5_table.rb +35 -0
- data/ext/mkkernel.rb +1202 -1014
- data/ext/ruby_carray.c +17 -5
- data/ext/version.h +4 -4
- data/ext/version.rb +4 -2
- data/lib/carray/attribute.rb +160 -17
- data/lib/carray/autoload_carray.rb +23 -27
- data/lib/carray/autoload_method_extension.rb +20 -5
- data/lib/carray/axis_group.rb +360 -63
- data/lib/carray/basics.rb +162 -28
- data/lib/carray/bincount_nd.rb +18 -2
- data/lib/carray/block_iterator.rb +231 -58
- data/lib/carray/boolean_reduce.rb +34 -42
- data/lib/carray/categorical.rb +242 -52
- data/lib/carray/categorical_iterator.rb +437 -827
- data/lib/carray/complex.rb +41 -34
- data/lib/carray/conditional.rb +150 -14
- data/lib/carray/const_string.rb +59 -4
- data/lib/carray/construct.rb +15 -11
- data/lib/carray/data_type_extension.rb +1 -0
- data/lib/carray/distance_limit.rb +77 -0
- data/lib/carray/fixlen_string.rb +7 -1
- data/lib/carray/frame/column_verbs.rb +70 -0
- data/lib/carray/frame/concat.rb +157 -6
- data/lib/carray/frame/convert.rb +82 -7
- data/lib/carray/frame/csv_parser.rb +688 -87
- data/lib/carray/frame/describe.rb +80 -0
- data/lib/carray/frame/frame.rb +369 -45
- data/lib/carray/frame/group.rb +631 -90
- data/lib/carray/frame/io.rb +506 -79
- data/lib/carray/frame/join.rb +411 -62
- data/lib/carray/frame/records.rb +116 -19
- data/lib/carray/frame/reshape.rb +455 -0
- data/lib/carray/frame/sort.rb +30 -18
- data/lib/carray/frame/verbs.rb +868 -61
- data/lib/carray/frame/windows.rb +117 -0
- data/lib/carray/frame.rb +4 -0
- data/lib/carray/fuse_source.rb +118 -2
- data/lib/carray/fusion.rb +257 -42
- data/lib/carray/histogram.rb +6 -1
- data/lib/carray/inspect.rb +54 -13
- data/lib/carray/iterator.rb +99 -9
- data/lib/carray/key_windows.rb +620 -0
- data/lib/carray/lazy.rb +216 -193
- data/lib/carray/mask_gap_fill.rb +112 -92
- data/lib/carray/math.rb +32 -19
- data/lib/carray/meld_reduce.rb +52 -21
- data/lib/carray/methods/align_addr.rb +8 -4
- data/lib/carray/methods/bin.rb +26 -18
- data/lib/carray/methods/bincount.rb +18 -2
- data/lib/carray/methods/bit_string.rb +15 -4
- data/lib/carray/methods/choose.rb +16 -3
- data/lib/carray/methods/composition.rb +18 -10
- data/lib/carray/methods/factorize.rb +2 -2
- data/lib/carray/methods/gather_nd.rb +25 -5
- data/lib/carray/methods/insert_block.rb +9 -14
- data/lib/carray/methods/is_between.rb +59 -0
- data/lib/carray/methods/is_in.rb +140 -10
- data/lib/carray/methods/join.rb +2 -7
- data/lib/carray/methods/lag.rb +49 -0
- data/lib/carray/methods/locate_addr.rb +182 -45
- data/lib/carray/methods/mode.rb +18 -1
- data/lib/carray/methods/nunique.rb +11 -6
- data/lib/carray/methods/repeat.rb +1 -20
- data/lib/carray/methods/resize.rb +21 -23
- data/lib/carray/methods/snap.rb +34 -12
- data/lib/carray/methods/unique.rb +11 -2
- data/lib/carray/methods/value_counts.rb +4 -11
- data/lib/carray/record_order.rb +208 -0
- data/lib/carray/rng.rb +3 -0
- data/lib/carray/runtime.rb +65 -17
- data/lib/carray/segment_iterator.rb +1221 -0
- data/lib/carray/serialize.rb +214 -30
- data/lib/carray/slab_iterator.rb +101 -27
- data/lib/carray/stack.rb +10 -4
- data/lib/carray/string.rb +105 -5
- data/lib/carray/string_operation_extension.rb +12 -4
- data/lib/carray/struct.rb +137 -58
- data/lib/carray/struct_builder.rb +124 -17
- data/lib/carray/time.rb +1222 -186
- data/lib/carray/window_iterator.rb +242 -81
- data/lib/carray.rb +9 -2
- data/yard-stubs/ca_obj_array.rb +4 -4
- data/yard-stubs/ca_obj_bitarray.rb +7 -0
- data/yard-stubs/ca_obj_bitfield.rb +23 -12
- data/yard-stubs/ca_obj_fake.rb +1 -1
- data/yard-stubs/ca_obj_field.rb +5 -1
- data/yard-stubs/ca_obj_grid.rb +21 -10
- data/yard-stubs/ca_obj_refer.rb +5 -4
- data/yard-stubs/ca_obj_roll.rb +2 -3
- data/yard-stubs/ca_obj_shift.rb +5 -1
- data/yard-stubs/ca_obj_stride.rb +10 -7
- data/yard-stubs/ca_obj_tile.rb +1 -1
- data/yard-stubs/ca_obj_transpose.rb +1 -1
- data/yard-stubs/ca_obj_window.rb +9 -5
- data/yard-stubs/carray_access.rb +11 -5
- data/yard-stubs/carray_attribute.rb +0 -5
- data/yard-stubs/carray_broadcast.rb +1 -1
- data/yard-stubs/carray_cast.rb +88 -43
- data/yard-stubs/carray_conversion.rb +15 -2
- data/yard-stubs/carray_copy.rb +12 -5
- data/yard-stubs/carray_core.rb +3 -3
- data/yard-stubs/carray_count.rb +9 -0
- data/yard-stubs/carray_element.rb +10 -4
- data/yard-stubs/carray_generate.rb +21 -1
- data/yard-stubs/carray_lazy.rb +5 -1
- data/yard-stubs/carray_loop.rb +4 -1
- data/yard-stubs/carray_mask.rb +60 -12
- data/yard-stubs/carray_math.rb +52 -14
- data/yard-stubs/carray_median_percentile.rb +15 -8
- data/yard-stubs/carray_memory_view.rb +40 -23
- data/yard-stubs/carray_order.rb +61 -29
- data/yard-stubs/carray_random.rb +13 -4
- data/yard-stubs/carray_scatter.rb +21 -9
- data/yard-stubs/carray_segment.rb +72 -0
- data/yard-stubs/carray_slab.rb +10 -2
- data/yard-stubs/carray_sort.rb +49 -8
- data/yard-stubs/carray_test.rb +15 -3
- data/yard-stubs/carray_utils.rb +0 -26
- metadata +19 -5
- data/ext/ca_for_each_element.h +0 -239
- data/ext/carray_kernels_map.c +0 -3467
- data/lib/carray/arrow_tensor.rb +0 -401
- data/lib/carray/table.rb +0 -108
data/lib/carray/frame/io.rb
CHANGED
|
@@ -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
|
|
8
|
-
# (§4.2
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
393
|
+
out = begin
|
|
394
|
+
csv_text(names, attr_rows, formatted, header, sep, quote)
|
|
395
|
+
rescue Encoding::CompatibilityError
|
|
396
|
+
nil
|
|
136
397
|
end
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
|
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}
|
|
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, '
|
|
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
|
|
289
|
-
#
|
|
290
|
-
|
|
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
|
|
293
|
-
|
|
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
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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 :
|
|
799
|
+
private_class_method :table_of_rows
|
|
373
800
|
end
|