iostreams 2.1.0 → 2.2.0

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/docs/config.md +13 -9
  3. data/docs/copy_files.md +20 -2
  4. data/docs/extensions.md +18 -4
  5. data/docs/formats.md +644 -16
  6. data/docs/path.md +18 -2
  7. data/docs/streams.md +54 -9
  8. data/docs/tutorial.md +14 -2
  9. data/docs/upgrading.md +470 -23
  10. data/lib/io_streams/builder/deprecations.rb +250 -0
  11. data/lib/io_streams/builder.rb +256 -39
  12. data/lib/io_streams/bzip2/reader.rb +18 -2
  13. data/lib/io_streams/bzip2/writer.rb +11 -2
  14. data/lib/io_streams/deprecation/methods.rb +51 -0
  15. data/lib/io_streams/deprecation/once.rb +27 -0
  16. data/lib/io_streams/deprecation/option_names.rb +26 -0
  17. data/lib/io_streams/deprecation/words.rb +42 -0
  18. data/lib/io_streams/deprecation.rb +259 -0
  19. data/lib/io_streams/encode/reader.rb +54 -3
  20. data/lib/io_streams/encode/writer.rb +19 -2
  21. data/lib/io_streams/encode.rb +99 -0
  22. data/lib/io_streams/errors.rb +51 -0
  23. data/lib/io_streams/gzip/reader.rb +11 -0
  24. data/lib/io_streams/io_streams.rb +365 -35
  25. data/lib/io_streams/line/reader.rb +2 -1
  26. data/lib/io_streams/line/utf8_reader.rb +23 -0
  27. data/lib/io_streams/path.rb +125 -35
  28. data/lib/io_streams/paths/file.rb +72 -9
  29. data/lib/io_streams/paths/http.rb +48 -2
  30. data/lib/io_streams/paths/matcher.rb +6 -3
  31. data/lib/io_streams/paths/s3.rb +180 -9
  32. data/lib/io_streams/paths/sftp.rb +47 -3
  33. data/lib/io_streams/paths.rb +23 -0
  34. data/lib/io_streams/pgp/reader.rb +18 -0
  35. data/lib/io_streams/pgp/writer.rb +16 -0
  36. data/lib/io_streams/pgp.rb +16 -2
  37. data/lib/io_streams/reader.rb +44 -0
  38. data/lib/io_streams/record/reader.rb +59 -68
  39. data/lib/io_streams/record/writer.rb +17 -10
  40. data/lib/io_streams/row/header_row.rb +21 -0
  41. data/lib/io_streams/row/reader.rb +52 -15
  42. data/lib/io_streams/row/writer.rb +64 -13
  43. data/lib/io_streams/schema/columns.rb +456 -0
  44. data/lib/io_streams/schema/constraints.rb +99 -0
  45. data/lib/io_streams/schema/field.rb +967 -0
  46. data/lib/io_streams/schema/json_number.rb +56 -0
  47. data/lib/io_streams/schema/keys.rb +78 -0
  48. data/lib/io_streams/schema/pattern.rb +257 -0
  49. data/lib/io_streams/schema/source.rb +31 -0
  50. data/lib/io_streams/schema.rb +616 -0
  51. data/lib/io_streams/stream.rb +836 -67
  52. data/lib/io_streams/symmetric_encryption/reader.rb +8 -0
  53. data/lib/io_streams/tabular/header.rb +29 -0
  54. data/lib/io_streams/tabular/header_mapping.rb +332 -0
  55. data/lib/io_streams/tabular/mapping.rb +165 -0
  56. data/lib/io_streams/tabular/parser/array.rb +5 -0
  57. data/lib/io_streams/tabular/parser/base.rb +24 -0
  58. data/lib/io_streams/tabular/parser/fixed.rb +197 -0
  59. data/lib/io_streams/tabular/parser/fixed_fields.rb +160 -0
  60. data/lib/io_streams/tabular/parser/hash.rb +10 -0
  61. data/lib/io_streams/tabular/parser/json.rb +17 -0
  62. data/lib/io_streams/tabular/parser/json_fields.rb +132 -0
  63. data/lib/io_streams/tabular/schema_mapping.rb +361 -0
  64. data/lib/io_streams/tabular.rb +344 -30
  65. data/lib/io_streams/utils.rb +441 -6
  66. data/lib/io_streams/version.rb +1 -1
  67. data/lib/io_streams/writer.rb +27 -0
  68. data/lib/io_streams/xlsx/cells.rb +127 -0
  69. data/lib/io_streams/xlsx/reader.rb +56 -0
  70. data/lib/io_streams/xlsx/schema_reader.rb +90 -0
  71. data/lib/io_streams/xlsx/v3_writer.rb +16 -0
  72. data/lib/io_streams/zip/reader.rb +7 -0
  73. data/lib/io_streams/zip/writer.rb +24 -2
  74. data/lib/iostreams.rb +21 -17
  75. metadata +28 -2
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IOStreams
4
+ class Tabular
5
+ # Maps the columns of a file to the fields of its schema, see `IOStreams::Tabular::Mapping`: the columns of its header
6
+ # row, or the keys of each record of a format of keyed records, such as JSON, are matched to the fields once, see
7
+ # `IOStreams::Schema::Columns`, which converts the values of each row and record read to the types of the fields, by
8
+ # their names, and renders those written. The columns of the rows of a file without a header row are the fields, in
9
+ # order, see `IOStreams::Schema#header?`, including those of a format whose records supply their own keys, whose rows
10
+ # are the values of every field of each record.
11
+ #
12
+ # The column options of a file without a schema do not apply, since the schema describes the columns, see
13
+ # `IOStreams::Tabular.new`. The header keeps the header row as it was read, so that another Tabular with the same
14
+ # schema, such as one that reads the rest of the file elsewhere, matches the same columns with
15
+ # `read_header(header.columns)`.
16
+ #
17
+ # Its text is UTF-8, as IOStreams 3.0 reads and writes the text of every file by default: a line read as binary,
18
+ # as a file is read without an encoding, or a row already read, is taken as UTF-8, unless the text is already in the
19
+ # encoding that the application chose, see `decoded:`, and so is binary text written, such as the values of rows read
20
+ # from a file without a schema, see `IOStreams::Utils.utf8`, unless it is not valid UTF-8 and the application chose
21
+ # an encoding, which encodes it instead, see #written_row.
22
+ class SchemaMapping < Mapping
23
+ # Parameters:
24
+ # schema: [IOStreams::Schema]
25
+ # The fields of the rows and records.
26
+ #
27
+ # decoded: [true|false]
28
+ # Whether the text is in the encoding that the application chose, rather than taken as UTF-8: with an encoding
29
+ # set on the stream, or with #raw, see `IOStreams::Builder#utf8_text?`. Then the lines read are taken as they
30
+ # are, rather than a binary line as UTF-8, see `IOStreams::Utils.utf8`, and binary text written that is not
31
+ # valid UTF-8 is left as it is, for that encoding, see #written_row. The lines that `IOStreams::Stream` reads
32
+ # without such an encoding are already UTF-8, see `IOStreams::Line::Utf8Reader`, as a caller of
33
+ # `IOStreams::Tabular` hands them, so they are only checked.
34
+ # Default: false
35
+ #
36
+ # See `IOStreams::Tabular::Mapping#initialize` for the others.
37
+ #
38
+ # Raises ArgumentError when the schema describes a file without a header row, of a format with one, such as CSV,
39
+ # that it cannot describe, see `IOStreams::Schema#check_fields_in_order`.
40
+ def initialize(schema, header:, parser:, format:, decoded: false)
41
+ super(header: header, parser: parser, format: format)
42
+ @schema = schema
43
+ @decoded = decoded
44
+ @columns = schema.columns
45
+ @requires_header = parser.requires_header? && schema.header?
46
+ # Whether each row is a record whose keys are its columns, such as JSON, rather than the values in the order of
47
+ # the columns, see `IOStreams::Tabular::Parser::Base.keyed?`, and the form in which the format writes the values
48
+ # of the fields.
49
+ @keyed = parser.class.keyed?
50
+ @form = parser.class.value_form
51
+ # The parsers render a row by a header, see `IOStreams::Tabular::Header#to_array`, which leaves a row whose
52
+ # values the schema has already arranged as it is, since it has no columns.
53
+ @arranged = Header.new.freeze
54
+ # The columns of the rows of a file without a header row are the fields, in order: of a format with one whose
55
+ # schema says that it has none, see `IOStreams::Schema#check_fields_in_order`, or of a format without one, such
56
+ # as fixed width, or JSON, whose rows are the values of the fields of its records.
57
+ if parser.requires_header?
58
+ @columns.match_fields unless schema.header?
59
+ else
60
+ @columns.match_in_order
61
+ end
62
+ # Whether the names of the fields have been read as the header row of the rows of a file without one, see
63
+ # #rows_header?.
64
+ @names_read = false
65
+ end
66
+
67
+ # Returns [true|false] whether the file has a header row: when its format has one, such as CSV, unless the schema
68
+ # says that the file has none, see `IOStreams::Schema#header?`.
69
+ def requires_header?
70
+ @requires_header
71
+ end
72
+
73
+ # Returns [true|false] whether the header row is still to be read, see #read_header: until its columns are
74
+ # matched to the fields, for a file with a header row.
75
+ def header?
76
+ @requires_header && !@columns.matched?
77
+ end
78
+
79
+ # Returns [true|false] whether the rows read start with a header row, see `IOStreams::Tabular#rows_header?`: the
80
+ # header row of a file with one, until it is read, see #header?, or for a file without one, the names of the
81
+ # fields, which are the columns of its rows, until they are read, see #read_row, so that the rows of every file
82
+ # start with a header row.
83
+ def rows_header?
84
+ @requires_header ? header? : !@names_read
85
+ end
86
+
87
+ # Returns [false] the rows read are Arrays, see `IOStreams::Tabular#record_rows?`, after a header row of the names
88
+ # of the fields of a file without one, such as JSON, see #rows_header?.
89
+ def record_rows?
90
+ false
91
+ end
92
+
93
+ # Returns [Array<String|Symbol>] the names of the fields of a file without a header row, which are the columns of
94
+ # its rows, see `IOStreams::Tabular#headerless_columns`.
95
+ # Returns nil for a file with a header row.
96
+ def headerless_columns
97
+ @columns.names if @columns.matched? && !@requires_header
98
+ end
99
+
100
+ # Returns [nil] the key of each value of a record is its own field, since two columns of the same field raise
101
+ # `IOStreams::Errors::InvalidHeader`, see `IOStreams::Tabular#repeated_key`.
102
+ def repeated_key
103
+ nil
104
+ end
105
+
106
+ # Returns [Array] the header row parsed from the line, once its columns are matched to the fields, see
107
+ # `IOStreams::Schema::Columns#match`.
108
+ # Returns nil when the line is blank, or the file has no header row, see #requires_header?, such as with a schema
109
+ # whose `header` is false, whose lines are all rows.
110
+ def parse_header(line)
111
+ match_header(line)&.first
112
+ end
113
+
114
+ # Matches the columns of the header row to the fields, see #parse_header.
115
+ #
116
+ # Returns [Array<String|Symbol>] the names of the fields of its columns, in the order of the fields.
117
+ # Returns nil when the line is blank, or the file has no header row.
118
+ #
119
+ # Raises ArgumentError for `cleanse` that is supplied, other than nil, which leaves the header row as it is, since
120
+ # the keys of the records are the names of the fields, which its columns are matched to instead.
121
+ def read_header(line, cleanse: CLEANSE_BY_DEFAULT)
122
+ unless cleanse.nil? || cleanse.equal?(CLEANSE_BY_DEFAULT)
123
+ raise(ArgumentError, "cleanse: cannot be supplied with a schema, since the keys of its records are the names " \
124
+ "of its fields, which the columns of the header row are matched to, see " \
125
+ "IOStreams::Schema#field_for")
126
+ end
127
+
128
+ match_header(line)&.last
129
+ end
130
+
131
+ # Returns [Array] the values of the fields of the line, converted to their types, in the order of the fields: of
132
+ # its columns, or, for a format whose records supply their own keys, such as JSON, of every field, nil for a key
133
+ # that its record does not have, see `IOStreams::Schema::Columns#read_row`.
134
+ # Returns nil if the line is blank.
135
+ def row_parse(line)
136
+ values = parse(line)
137
+ return if values.nil?
138
+
139
+ values.is_a?(::Hash) ? @columns.read_row(values) : @columns.row(values)
140
+ end
141
+
142
+ # Returns [Hash] the record of the line, converted to the types of the fields, by the names of the fields. The
143
+ # record of a row already read, an Array, is by the columns of the rows, see #row_parse.
144
+ # Returns nil if the line is blank.
145
+ def record_parse(line)
146
+ values = parse(line)
147
+ return if values.nil?
148
+
149
+ values.is_a?(::Hash) ? @columns.read_hash(values) : @columns.record(values)
150
+ end
151
+
152
+ # Returns [String|Array|Hash] the line that the parser renders for the row or record, with the value of each field
153
+ # in the form of its field that the format writes, see `IOStreams::Schema::Field#render`, in the order of the
154
+ # fields, see `IOStreams::Schema::Columns#render`.
155
+ #
156
+ # Returns nil for a blank row, which is written as a blank line, unless the schema has a field that is required,
157
+ # which raises `IOStreams::Errors::InvalidValue`, since the row has no value for it.
158
+ def render(row)
159
+ return @columns.render_blank if IOStreams::Utils.blank?(row)
160
+
161
+ @parser.render(@columns.render(written_row(row), @form, @keyed), @arranged)
162
+ end
163
+
164
+ # Returns [String] the header row of the file: the names of its fields.
165
+ # Returns nil when the file has no header row.
166
+ def render_header
167
+ return unless @requires_header
168
+
169
+ @parser.render(@schema.fields.map { |field| field.name.to_s }, @arranged)
170
+ end
171
+
172
+ # Yields [Array|nil] each row of the line of a file of rows: the header row, while it is expected, see #header?,
173
+ # as the names of the fields of its columns, see #read_header, otherwise the row, see #row_parse, or nil for a
174
+ # blank line. The rows of a file without a header row, such as fixed width, JSON, or a schema whose `header` is
175
+ # false, start with the names of the fields, before the row of the first line, since they are the columns of its
176
+ # rows.
177
+ #
178
+ # The header row is an `IOStreams::Row::HeaderRow`, so that a writer of rows takes it as the header row of the
179
+ # rows after it, see `IOStreams::Row::Writer#<<`.
180
+ #
181
+ # Returns the result of the block.
182
+ def read_row(line)
183
+ # Once the header row is read, every line is a row, so that is asked first.
184
+ return yield(row_parse(line)) unless rows_header?
185
+ return yield(as_header_row(read_header(line))) if header?
186
+
187
+ @names_read = true
188
+ yield as_header_row(@columns.names)
189
+ yield row_parse(line)
190
+ end
191
+
192
+ # Yields [Hash|nil] the record of the line of a file of records, see #record_parse, or nil for a blank line, unless
193
+ # the line is the header row, while it is expected, see #header?, which it reads instead, see #read_header, since
194
+ # it names the keys of the records after it.
195
+ def read_record_line(line)
196
+ if header?
197
+ read_header(line)
198
+ nil
199
+ else
200
+ yield record_parse(line)
201
+ end
202
+ end
203
+
204
+ # Returns nil: no column options apply, since the schema describes the columns instead.
205
+ def restrict_columns
206
+ nil
207
+ end
208
+
209
+ # Yields [String] the header line that a writer of rows or records writes before the first one, the names of the
210
+ # fields, when the file has a header row, see #requires_header?, so that every row written is the values of the
211
+ # fields, in order, as for a file without a header row.
212
+ def start_rows
213
+ yield render_header if @requires_header
214
+ end
215
+
216
+ alias start_records start_rows
217
+
218
+ # Yields [String|nil] the line of the row, see #render.
219
+ #
220
+ # Returns the result of the block.
221
+ def render_row(row)
222
+ yield render(row)
223
+ end
224
+
225
+ alias render_record render_row
226
+
227
+ # Takes the columns of the rows written after them from a header row written before them, such as the
228
+ # `IOStreams::Row::HeaderRow` that `IOStreams::Stream#each(:array)` yields, or the header row of a file without a
229
+ # schema that is copied, see `IOStreams::Tabular#copy_row`, once they are matched to the fields, see
230
+ # `IOStreams::Schema::Columns#match_written`, without a line, since the header row of the file is the names of the
231
+ # fields, which the schema writes, see #start_rows, or the file has none. Its binary text is taken as UTF-8, see
232
+ # #utf8_row, whatever the encoding of this stream, since the header row is read from another file, and the names
233
+ # of the fields are matched in Unicode.
234
+ #
235
+ # Returns nil.
236
+ def render_header_row(columns)
237
+ @columns.match_written(utf8_row(columns))
238
+ nil
239
+ end
240
+
241
+ # Raises ArgumentError: the header row is not cleansed with a schema, since the keys of the records are the names
242
+ # of the fields, which the columns of the header row are matched to instead, as for `cleanse_header:`, see
243
+ # `IOStreams::Tabular.new`. The header keeps the header row as it was read, see #read_header.
244
+ def cleanse_header!
245
+ raise(ArgumentError, "The header row cannot be cleansed with a schema, since the keys of its records are the " \
246
+ "names of its fields, which the columns of the header row are matched to, see " \
247
+ "IOStreams::Schema#field_for")
248
+ end
249
+
250
+ # Yields [IOStreams::Row::HeaderRow] the names of the fields of a file without a header row, which are the columns
251
+ # of its rows, see #headerless_columns, which a copy writes before the first line, see
252
+ # `IOStreams::Tabular#start_copy`, so that the target has them even when the file has no lines. #read_row then
253
+ # does not yield them again.
254
+ def start_copy
255
+ return if @requires_header || @names_read
256
+
257
+ @names_read = true
258
+ yield as_header_row(@columns.names)
259
+ end
260
+
261
+ # Yields [Array|nil] each row of the line that a copy writes, see `IOStreams::Tabular#copy_row`, as #read_row
262
+ # yields it, whose header row is an `IOStreams::Row::HeaderRow`.
263
+ alias copy_row read_row
264
+
265
+ private
266
+
267
+ # Returns [Array(Array, Array)] the header row parsed from the line, which the header keeps as it was read, and the
268
+ # names of the fields of its columns, once they are matched to the fields, or nil when the line is blank, or the
269
+ # format has no header row.
270
+ def match_header(line)
271
+ return unless @requires_header
272
+
273
+ columns = parse(line)
274
+ return if columns.nil?
275
+
276
+ names = @columns.match(columns)
277
+ header.columns = columns
278
+ [columns, names]
279
+ end
280
+
281
+ # Returns [Array|Hash] the values of the line, which the parser parses, or a row already read, an Array, as it is,
282
+ # since the parsers of keyed records, such as JSON, do not accept one without a schema. Its binary text is taken
283
+ # as UTF-8, see `IOStreams::Utils.utf8`, whether it is a line or a row already read, unless the text is in the
284
+ # encoding that the application chose, see #initialize.
285
+ #
286
+ # Returns nil when it is blank, the same way in every format: a line that is blank, see
287
+ # `IOStreams::Utils.blank_text?`, or a row or record whose every value is blank, see `IOStreams::Utils.blank_row?`,
288
+ # such as a line of CSV of only commas, or a JSON record of `null`, `{}`, or whose every value is null.
289
+ def parse(line)
290
+ return if line.nil?
291
+
292
+ if line.is_a?(String)
293
+ line = IOStreams::Utils.utf8(line) unless @decoded
294
+ return if IOStreams::Utils.blank_text?(line)
295
+ elsif !@decoded
296
+ line = utf8_row(line)
297
+ end
298
+
299
+ values = line.is_a?(::Array) ? line : @parser.parse(line)
300
+ values unless IOStreams::Utils.blank_row?(values)
301
+ end
302
+
303
+ # Returns [Array|Hash] the row or record, whose text that is binary and not ASCII is taken as UTF-8, see
304
+ # `IOStreams::Utils.utf8`, such as that of a row read from a file without a schema, which is binary, or the row
305
+ # itself when it has none.
306
+ def utf8_row(row)
307
+ case row
308
+ when ::Array
309
+ row.any? { |value| binary_text?(value) } ? row.map { |value| utf8_text(value) } : row
310
+ when ::Hash
311
+ return row unless row.any? { |key, value| binary_text?(key) || binary_text?(value) }
312
+
313
+ row.to_h { |key, value| [utf8_text(key), utf8_text(value)] }
314
+ else
315
+ row
316
+ end
317
+ end
318
+
319
+ def utf8_text(value)
320
+ binary_text?(value) ? IOStreams::Utils.utf8(value) : value
321
+ end
322
+
323
+ # Returns [Array|Hash] the row or record written, see #render, whose binary text is taken as UTF-8, see #utf8_row.
324
+ # With an encoding set on the stream, see #initialize, which encodes the text written, binary text that is not
325
+ # valid UTF-8 is left as it is, for that encoding, rather than raise, as text read with it is taken as it is.
326
+ def written_row(row)
327
+ return utf8_row(row) unless @decoded
328
+
329
+ case row
330
+ when ::Array
331
+ row.any? { |value| binary_text?(value) } ? row.map { |value| written_text(value) } : row
332
+ when ::Hash
333
+ return row unless row.any? { |key, value| binary_text?(key) || binary_text?(value) }
334
+
335
+ row.to_h { |key, value| [written_text(key), written_text(value)] }
336
+ else
337
+ row
338
+ end
339
+ end
340
+
341
+ # Returns [Object] the value written with an encoding set on the stream, see #written_row: binary text as UTF-8
342
+ # when it is valid UTF-8, otherwise as it is.
343
+ def written_text(value)
344
+ return value unless binary_text?(value)
345
+
346
+ text = value.dup.force_encoding(::Encoding::UTF_8)
347
+ text.valid_encoding? ? text : value
348
+ end
349
+
350
+ def binary_text?(value)
351
+ value.is_a?(String) && value.encoding == ::Encoding::BINARY && !value.ascii_only?
352
+ end
353
+
354
+ # Returns [IOStreams::Row::HeaderRow] the columns of a header row that #read_row yields, or nil when there are none,
355
+ # such as for a blank line.
356
+ def as_header_row(columns)
357
+ IOStreams::Row::HeaderRow.new(columns) if columns
358
+ end
359
+ end
360
+ end
361
+ end