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,250 @@
1
+ module IOStreams
2
+ class Builder
3
+ # What v2.2 reports about the streams, options and format of a builder as deprecation warnings: those that
4
+ # IOStreams v3.0 removes or rejects, and those whose results change in v3.0, with what to use instead. It is part
5
+ # of the Builder, which includes it, since the Builder owns the settings that it reports on.
6
+ module Deprecations
7
+ # A format set with #format= that is not the format of the text that the streams read, see #text_format, such as
8
+ # `:psv` for a spreadsheet, whose rows are read as CSV, is deprecated, since v3.0 raises ArgumentError when its
9
+ # lines, rows or records are read, rather than read them in that format. Reports a deprecation warning when they are
10
+ # read, as v3.0 raises then, whichever way the streams were set.
11
+ #
12
+ # So is reading them in the format that the file name names, such as `:psv` for `report.psv.xlsx`, without a format
13
+ # set, since v3.0 reads them in the format of the text whatever the file name, so that `format(:csv)` reads them
14
+ # the same in both versions.
15
+ #
16
+ # Neither is looked for while deprecation warnings are off, see `IOStreams::Deprecation.on?`, since finding the
17
+ # format of the text would build the pipeline once more for each read.
18
+ def warn_text_format
19
+ return unless Deprecation.on?
20
+
21
+ format = self.format
22
+ return if format.nil?
23
+
24
+ text_format = self.text_format
25
+ return if text_format.nil? || format == text_format
26
+
27
+ if @set_format
28
+ Deprecation.warn("format(#{format.inspect}) is deprecated when the streams read the rows as " \
29
+ "#{text_format.inspect}, such as those of a spreadsheet, since v3.0 raises ArgumentError for " \
30
+ "it, set format(#{text_format.inspect}) instead, or no format")
31
+ else
32
+ Deprecation.warn("the format #{format.inspect} that the file name names is deprecated when the streams read " \
33
+ "the rows as #{text_format.inspect}, such as those of a spreadsheet, since v3.0 reads them as " \
34
+ "#{text_format.inspect} whatever the file name, set format(#{text_format.inspect}) to read " \
35
+ "them so in both versions")
36
+ end
37
+ end
38
+
39
+ # An encoding set with #encoding is deprecated when the rows and records of a spreadsheet are read, see
40
+ # `IOStreams::Stream#warn_cell_rows`, which are its cells rather than lines of text, since v3.0 raises ArgumentError
41
+ # for it then. Its lines still take it. Without it, a schema reads the text of the cells as UTF-8, see #utf8_text?,
42
+ # as v3.0 does.
43
+ def warn_cells_encoding
44
+ return if @encoding.nil?
45
+
46
+ Deprecation.warn("#encoding is deprecated when the rows and records of a spreadsheet are read, which are its " \
47
+ "cells rather than lines of text, since v3.0 raises ArgumentError for it, so remove it, and " \
48
+ "give its columns a schema, with which v2.2 reads the text of its cells as UTF-8; " \
49
+ "each(:line) still takes it")
50
+ end
51
+
52
+ # Returns [Array(Array<Symbol>, String, Hash|nil)|nil] the methods that the stream that the application's block
53
+ # receives in v3.0 answers, its name for a deprecation warning, such as "the :gz stream", and what replaces each
54
+ # method that v3.0 removes, when the stream says, see `IOStreams::Reader.v3_stream_advice`, when the stream that it
55
+ # receives in v2.2 answers others, otherwise nil. The stream closest to the application decides, with its options,
56
+ # see `IOStreams::Reader.v3_stream_methods`. Without any streams, the block receives the IO that they would read or
57
+ # write, which the supplied source describes, see `IOStreams::Stream#v3_source` and `IOStreams::Stream#v3_target`.
58
+ #
59
+ # Parameters:
60
+ # type: [:reader|:writer]
61
+ # source: [Array(Array<Symbol>, String)|nil]
62
+ # The methods and the name of the IO that the streams read or write in v3.0, when it differs from the one in
63
+ # v2.2, otherwise nil.
64
+ #
65
+ # Yields, only when the stream closest to the application asks and is the only stream, to ask whether the streams
66
+ # read a regular local file in v3.0. The block answers true or false.
67
+ #
68
+ # Asked before the streams are opened, so it raises nothing, not even for a stream without a class of the type, which
69
+ # raises as before once it is opened.
70
+ def v3_stream(type, source)
71
+ streams = pipeline
72
+ name, options = streams.first
73
+ return source if name.nil?
74
+
75
+ klass = IOStreams.extensions[name]&.public_send(:"#{type}_class")
76
+ return unless klass.respond_to?(:v3_stream_methods)
77
+
78
+ methods = klass.v3_stream_methods(options) { streams.size == 1 && yield }
79
+ return unless methods
80
+
81
+ [methods, "the #{name.inspect} stream", (klass.v3_stream_advice if klass.respond_to?(:v3_stream_advice))]
82
+ end
83
+
84
+ # Returns [Symbol|nil] the format that the rows and records of this file are read and written in, when IOStreams
85
+ # v3.0 reads and writes them in its `:text` format instead, as the text of each line: when no format was set, so
86
+ # that it is taken from a file name whose format extension is `.text`, see `IOStreams::Tabular.text_file_format`,
87
+ # unless a stream reads the lines in a format of its own, such as the CSV of a spreadsheet, which v3.0 reads them
88
+ # in, whatever format the file name names. Returns nil otherwise.
89
+ def text_file_format
90
+ return if format_set? || file_name.nil? || text_format
91
+
92
+ Tabular.text_file_format(file_name)
93
+ end
94
+
95
+ # Returns [Symbol|nil] the tabular format of the data that a copy without a mode reads from these streams, or
96
+ # writes to them, when IOStreams v3.0 converts the rows of such a copy between it and another format, see
97
+ # `IOStreams::Stream#warn_copy_format`: the format of the text that the streams read, see #text_format, such as
98
+ # `:csv` for a spreadsheet, otherwise #format. Returns nil when v3.0 copies the data as it is: when the format is
99
+ # not known, such as for an IO without a file name or a `.txt` file, when the data is read and written as it is
100
+ # stored, see #raw, for a file that v3.0 reads in its `:text` format, see #text_file_format, and for a format whose
101
+ # rows are Ruby objects rather than lines of text, such as `:array` and `:hash`, see `IOStreams::Tabular.text_rows?`.
102
+ def copy_format
103
+ return if raw? || text_file_format
104
+
105
+ format = text_format || self.format
106
+ format if format && Tabular.text_rows?(format)
107
+ end
108
+
109
+ private
110
+
111
+ # Reports a deprecation warning for supplying `:none`, see #raw, or for setting the encode stream, see #encoding,
112
+ # which names the method that the application called.
113
+ def warn_deprecated_stream(method, stream)
114
+ case stream
115
+ when :none
116
+ Deprecation.warn("#{method}(:none) is deprecated, use #raw to read or write the data as it is stored, " \
117
+ "or #file_name to read a file that is not named for its format")
118
+ when :encode
119
+ # Like any other stream set with #stream, stream(:encode, ...) stops the streams being taken from the file
120
+ # name, which #encoding keeps.
121
+ if method == "stream"
122
+ Deprecation.warn("stream(:encode, ...) is deprecated, use #encoding instead, which keeps the streams that " \
123
+ "the file name implies, #raw to read or write the data as it is stored, or #file_name to " \
124
+ "read a file that is not named for its format")
125
+ else
126
+ Deprecation.warn("#{method}(:encode, ...) is deprecated, use #encoding instead")
127
+ end
128
+ end
129
+ end
130
+
131
+ # Reports a deprecation warning for each option of the stream that IOStreams 3.0 raises ArgumentError for when it is
132
+ # set with #option, #stream or #option_or_stream, whether or not the file name includes the stream, so that it is
133
+ # reported wherever the code runs, rather than only where a file name includes the stream: one that the reader or
134
+ # the writer of the stream replaces, with the option that replaces it, see
135
+ # `IOStreams::Deprecation::OptionNames#replaced_option_names`, and one that neither of them takes in IOStreams 3.0,
136
+ # such as a misspelled one, see #v3_option_names. An option of the other direction is not reported, since v3.0
137
+ # accepts it, and ignores it, nor are the options of the encode stream, which is deprecated itself, see
138
+ # #warn_deprecated_stream, or of `:none`, which takes none.
139
+ def warn_deprecated_options(stream, options)
140
+ return if options.empty? || %i[encode none].include?(stream)
141
+
142
+ classes = v3_stream_classes(stream)
143
+ replaced = classes.each_with_object({}) do |klass, names|
144
+ names.merge!(klass.replaced_option_names) if klass.respond_to?(:replaced_option_names)
145
+ end.slice(*options.keys)
146
+ replaced.each_pair { |name, replacement| Deprecation.warn("#{name}: is deprecated, use #{replacement}: instead") }
147
+
148
+ known = v3_option_names(classes)
149
+ names = known ? options.keys - replaced.keys - known : []
150
+ return if names.empty?
151
+
152
+ words = Deprecation::Words.new(names)
153
+ Deprecation.warn("#{words.keywords} #{words.are} deprecated for a #{stream.inspect} stream, since neither its " \
154
+ "reader nor its writer takes #{words.them}, so correct or remove #{words.them}")
155
+ end
156
+
157
+ # Returns [Array<Class|Module>] the classes that read and write the stream in IOStreams 3.0, as far as they are
158
+ # known: the reader and the writer registered for it, and, in place of a writer that it does not have, the stand-in
159
+ # for the one that IOStreams 3.0 adds, when its reader names one, such as for `.xlsx`, see
160
+ # `IOStreams::Xlsx::Reader.v3_writer`. None when the stream is not registered.
161
+ def v3_stream_classes(stream)
162
+ extension = IOStreams.extensions[stream]
163
+ return [] unless extension
164
+
165
+ reader = extension.reader_class
166
+ writer = extension.writer_class
167
+ writer ||= reader.v3_writer if reader.respond_to?(:v3_writer)
168
+ [reader, writer].compact
169
+ end
170
+
171
+ # Returns [Array<Symbol>|nil] the options that IOStreams 3.0 accepts when they are set for a stream of the supplied
172
+ # classes, see #v3_stream_classes: those that its reader or its writer takes, or nil when they are not known, since
173
+ # a class does not declare them, as one that an application registers need not.
174
+ def v3_option_names(classes)
175
+ names = classes.map { |klass| v3_class_option_names(klass) }
176
+ names.flatten.uniq unless names.empty? || names.include?(nil)
177
+ end
178
+
179
+ # Returns [Array<Symbol>|nil] the options that the class takes in IOStreams 3.0, see
180
+ # `IOStreams::Deprecation::OptionNames#known_option_names`, or those that it declares when it does not answer them,
181
+ # as a class that an application registers need not, or nil when it does neither.
182
+ def v3_class_option_names(klass)
183
+ return klass.known_option_names if klass.respond_to?(:known_option_names)
184
+
185
+ klass.option_names if klass.respond_to?(:option_names)
186
+ end
187
+
188
+ # Reports a deprecation warning, when the streams are opened, for those that are deprecated, or whose results
189
+ # change in IOStreams v3.0.
190
+ def warn_deprecated_streams
191
+ warn_encode_extension
192
+ warn_temp_file_streams
193
+ warn_extension_options
194
+ end
195
+
196
+ # A file name ending in `.encode` is deprecated, since file names do not name the encode stream, see #encoding.
197
+ # Once the encode stream is set, with #encoding or `option(:encode, ...)`, the file name adds nothing to it, so it
198
+ # is only reported when the file name is what applies the encode stream.
199
+ def warn_encode_extension
200
+ return if streams || @encoding || options&.key?(:encode)
201
+ return if file_name.nil? || !parse_extensions.include?(:encode)
202
+
203
+ Deprecation.warn("a file name ending in .encode is deprecated, since file names do not name the encode " \
204
+ "stream, use #encoding instead")
205
+ end
206
+
207
+ # The path of `IOStreams.temp_file` reads and writes its data as it is stored, see #no_streams, while IOStreams v3.0
208
+ # applies the streams that its file name implies, so reading or writing it is deprecated while it has no streams of
209
+ # its own, and its file name implies some, other than `.encode`, which v3.0 no longer applies, see #raw, which reads
210
+ # and writes the data as it is stored on both.
211
+ def warn_temp_file_streams
212
+ return unless @temp_file && streams.empty? && file_name
213
+ return if (parse_extensions - [:encode]).empty?
214
+
215
+ Deprecation.warn("reading or writing the path of IOStreams.temp_file is deprecated when its extension names " \
216
+ "streams, such as .gz, since v3.0 applies them, where v2.2 reads and writes the data as it is " \
217
+ "stored: call #raw on the path to read and write the data as it is stored")
218
+ end
219
+
220
+ # The options set for another extension of the format of an extension in the file name, such as
221
+ # `option(:pgp, ...)` for a `.gpg` file, are ignored, while IOStreams v3.0 applies the options of a format to each of
222
+ # its extensions, and then those set for the extension in the file name, which take precedence. So they are
223
+ # deprecated unless those of the extension itself set each of them too, with which both apply the same options.
224
+ def warn_extension_options
225
+ return if streams || options.nil? || file_name.nil?
226
+
227
+ parse_extensions.each do |stream|
228
+ own = options[stream] || {}
229
+ options.each_pair do |name, opts|
230
+ next if name == stream || (opts.keys - own.keys).empty? || !same_format?(name, stream)
231
+
232
+ Deprecation.warn("option(:#{name}, ...) is deprecated for a file name with the extension .#{stream}, which " \
233
+ "v2.2 does not apply it to, since v3.0 applies the options of a format to each of its " \
234
+ "extensions: set them with option(:#{stream}, ...)")
235
+ end
236
+ end
237
+ end
238
+
239
+ # Returns [true|false] whether the extensions are of the same format, whose reader and writer they share, such as
240
+ # `.pgp` and `.gpg`, or `.gz` and `.gzip`.
241
+ def same_format?(extension, other)
242
+ registered = IOStreams.extensions[extension]
243
+ other = IOStreams.extensions[other]
244
+ return false if registered.nil? || other.nil?
245
+
246
+ registered.reader_class == other.reader_class && registered.writer_class == other.writer_class
247
+ end
248
+ end
249
+ end
250
+ end
@@ -1,86 +1,192 @@
1
+ require "io_streams/builder/deprecations"
2
+
1
3
  module IOStreams
2
4
  # Build the streams that need to be applied to a path during reading or writing.
3
5
  class Builder
6
+ include Deprecations
7
+
4
8
  attr_accessor :file_name, :format_options
5
9
  attr_reader :streams, :options
6
10
 
11
+ # Returns [Hash|String|IOStreams::Stream|nil] the schema of the rows and records as it was set, see #schema=.
12
+ attr_reader :schema
13
+
7
14
  def initialize(file_name = nil)
8
15
  @file_name = file_name
9
16
  @streams = nil
10
17
  @options = nil
11
- @format = nil
18
+ @named_format = nil
19
+ @set_format = nil
12
20
  @format_option = nil
21
+ @encoding = nil
22
+ @raw = false
23
+ @temp_file = false
24
+ @schema = nil
25
+ @schema_source = nil
26
+ end
27
+
28
+ # A copy has its own streams, options and encoding, so that changing them does not change the original. It shares
29
+ # the schema, which #schema= replaces rather than changes, so that the copies read its file once, see
30
+ # `IOStreams::Schema::Source`.
31
+ def initialize_copy(source)
32
+ super
33
+ @streams = @streams&.transform_values(&:dup)
34
+ @options = @options&.transform_values(&:dup)
35
+ @encoding = @encoding&.dup
36
+ end
37
+
38
+ # Sets the schema of the rows and records, see `IOStreams::Stream#schema`: a Hash, which is checked now, or the path
39
+ # or url of a JSON file that holds one, which is read the first time that a reader or writer needs it, see
40
+ # `IOStreams::Schema::Source`, or nil to remove it.
41
+ #
42
+ # A copy of a Hash or String is held, which is frozen, so that the caller changing the object that it supplied
43
+ # changes neither #schema nor the schema that is read, and a copy of a path, so that changing its path does not
44
+ # change the schema.
45
+ #
46
+ # Raises ArgumentError when the schema is not a Hash, a String or a path, or is a Hash that is not a valid schema.
47
+ def schema=(schema)
48
+ schema = schema.is_a?(IOStreams::Stream) ? schema.dup : Utils.frozen_copy(schema)
49
+ @schema_source = schema.nil? ? nil : IOStreams::Schema::Source.new(schema)
50
+ @schema = schema
51
+ end
52
+
53
+ # Returns [IOStreams::Schema|nil] the schema of the rows and records, reading its file the first time, see #schema=,
54
+ # or nil when none is set.
55
+ def tabular_schema
56
+ @schema_source&.schema
13
57
  end
14
58
 
15
59
  # Supply an option that is only applied once the file name extensions have been parsed.
16
60
  # Note:
17
61
  # - Cannot set both `stream` and `option`
62
+ #
63
+ # Setting the encode stream with `option(:encode, ...)` is deprecated, see #encoding, and reports a deprecation
64
+ # warning, see `IOStreams::Deprecation`.
18
65
  def option(stream, **options)
19
66
  stream = stream.to_sym unless stream.is_a?(Symbol)
20
- raise(ArgumentError, "Invalid stream: #{stream.inspect}") unless IOStreams.extensions.include?(stream)
21
- raise(ArgumentError, "Cannot call both #option and #stream on the same streams instance}") if @streams
22
- raise(ArgumentError, "Cannot call #option unless the `file_name` was already set}") unless file_name
23
-
24
- @options ||= {}
25
- if (opts = @options[stream])
26
- opts.merge!(options)
27
- else
28
- @options[stream] = options.dup
29
- end
67
+ raise_if_raw!("#option")
68
+ add_option(stream, options)
69
+ warn_deprecated_stream("option", stream)
70
+ warn_deprecated_options(stream, options)
30
71
  self
31
72
  end
32
73
 
74
+ # Setting the encode stream with `stream(:encode, ...)` is deprecated, see #encoding. Like any other stream set
75
+ # with `#stream`, it still stops the streams being taken from the file name, which #encoding keeps.
76
+ #
77
+ # Supplying `:none` is deprecated, see #raw. Since it does what #raw does, it can still be supplied after #raw.
78
+ #
79
+ # Each reports a deprecation warning, see `IOStreams::Deprecation`.
33
80
  def stream(stream, **options)
34
81
  stream = stream.to_sym unless stream.is_a?(Symbol)
35
- raise(ArgumentError, "Cannot call both #option and #stream on the same streams instance}") if @options
82
+ raise_if_raw!("#stream") unless stream == :none
83
+ add_stream(stream, options)
84
+ warn_deprecated_stream("stream", stream)
85
+ warn_deprecated_options(stream, options)
86
+ self
87
+ end
36
88
 
37
- # To prevent any streams from being applied supply a stream named `:none`
38
- if stream == :none
39
- @streams = {}
40
- return self
89
+ # Sets the encoding of the text that the application reads or writes, and the other options of the encode
90
+ # stream, see `IOStreams::Encode`, merging them with those already set.
91
+ #
92
+ # Unlike `#option`, it can be combined with `#stream`, and needs no file name. Unlike `#stream`, it does not stop
93
+ # the streams being taken from the file name. The encode stream is applied closest to the application, after
94
+ # every other stream when reading, with the options set by `#option` or `#stream` for `:encode`, if any, and
95
+ # these on top of them.
96
+ #
97
+ # Raises ArgumentError when neither an encoding nor any options are supplied, when the encoding is supplied both
98
+ # as an argument and as the `encoding:` option, or for an option that the encode stream does not take, or a value
99
+ # that it does not accept, see `IOStreams::Encode.check`. They are checked when they are set, since a writer that
100
+ # raised for them would have replaced its file. The options set with `#option` or `#stream` for `:encode` are
101
+ # deprecated, and are still only checked when the stream is opened, as before.
102
+ def encoding(encoding = nil, **options)
103
+ raise_if_raw!("#encoding")
104
+ if encoding
105
+ raise(ArgumentError, "Supply the encoding as an argument or as `encoding:`, not both") if options.key?(:encoding)
106
+
107
+ options = {encoding: encoding, **options}
41
108
  end
42
- raise(ArgumentError, "Invalid stream: #{stream.inspect}") unless IOStreams.extensions.include?(stream)
109
+ raise(ArgumentError, "Supply the encoding, or options for the encode stream") if options.empty?
43
110
 
44
- @streams ||= {}
45
- if (opts = @streams[stream])
46
- opts.merge!(options)
47
- else
48
- @streams[stream] = options.dup
49
- end
111
+ merged = (@encoding || {}).merge(options)
112
+ Encode.check(**merged)
113
+ @encoding = merged
50
114
  self
51
115
  end
52
116
 
53
- def option_or_stream(stream, **)
54
- if streams
55
- stream(stream, **)
56
- elsif file_name
57
- option(stream, **)
58
- else
59
- stream(stream, **)
60
- end
117
+ # Reads and writes the data as it is stored: applies no streams, neither those that the file name implies nor any
118
+ # already set with #stream or #option, and no encoding set with #encoding. For example, to download a zip file
119
+ # without unzipping it, or to write data that is already compressed.
120
+ #
121
+ # Since the data is as stored, setting a stream, option or encoding afterwards raises ArgumentError.
122
+ def raw
123
+ no_streams
124
+ @options = nil
125
+ @raw = true
126
+ self
127
+ end
128
+
129
+ # Returns [true|false] whether the data is read and written as it is stored, see #raw.
130
+ def raw?
131
+ @raw
132
+ end
133
+
134
+ # Returns [true|false] whether the text that the streams yield is taken as UTF-8: the text of a file with a
135
+ # schema, see #schema=, unless the application chose the encoding of its text, with an encode stream, whether set
136
+ # with #encoding, #option, #stream, or by a file name ending in `.encode`, or chose none with #raw, which reads the
137
+ # data as it is stored. Without a schema, the text is read as it always was.
138
+ def utf8_text?
139
+ !@schema.nil? && !raw? && !pipeline.key?(:encode)
140
+ end
141
+
142
+ # Applies no streams, including an encoding set with #encoding, as `stream(:none)` does, for IOStreams itself.
143
+ #
144
+ # `temp_file: true` for the path of `IOStreams.temp_file`, which reads and writes its data as it is stored, while
145
+ # IOStreams v3.0 applies the streams that its file name implies, so that reading or writing it reports a deprecation
146
+ # warning while they would apply, see #warn_temp_file_streams.
147
+ def no_streams(temp_file: false)
148
+ @streams = {}
149
+ @encoding = nil
150
+ @temp_file = temp_file
151
+ self
152
+ end
153
+
154
+ # Sets the options of a stream as #option does, unless streams were already set with #stream, or no file name is
155
+ # set, when it adds the stream as #stream does.
156
+ #
157
+ # Setting the encode stream with `option_or_stream(:encode, ...)` is deprecated, see #encoding, and reports a
158
+ # deprecation warning, see `IOStreams::Deprecation`.
159
+ def option_or_stream(stream, **options)
160
+ stream = stream.to_sym unless stream.is_a?(Symbol)
161
+ raise_if_raw!("#option_or_stream") unless stream == :none
162
+ streams || file_name.nil? ? add_stream(stream, options) : add_option(stream, options)
163
+ warn_deprecated_stream("option_or_stream", stream)
164
+ warn_deprecated_options(stream, options)
165
+ self
61
166
  end
62
167
 
63
168
  # Return the options set for either a stream or option.
64
169
  def setting(stream)
170
+ return encode_setting if stream == :encode && @encoding
65
171
  return streams[stream] if streams
66
172
 
67
173
  options[stream] if options
68
174
  end
69
175
 
70
176
  def reader(io_stream, &)
177
+ warn_deprecated_streams
71
178
  execute(:reader, pipeline, io_stream, &)
72
179
  end
73
180
 
74
181
  def writer(io_stream, &)
182
+ warn_deprecated_streams
75
183
  execute(:writer, pipeline, io_stream, &)
76
184
  end
77
185
 
78
186
  # Returns [Hash<Symbol:Hash>] the pipeline of streams
79
187
  # with their options that will be applied when the reader or writer is invoked.
80
188
  def pipeline
81
- return streams.dup.freeze if streams
82
-
83
- build_pipeline.freeze
189
+ with_encoding(streams ? streams.dup : build_pipeline).freeze
84
190
  end
85
191
 
86
192
  # Removes the named stream from the current pipeline.
@@ -88,21 +194,43 @@ module IOStreams
88
194
  # Note: Any options must be set _before_ calling this method.
89
195
  def remove_from_pipeline(stream_name)
90
196
  @streams ||= build_pipeline
197
+ @encoding = nil if stream_name.to_sym == :encode
198
+ # The streams are now those that the application chose, see #warn_temp_file_streams.
199
+ @temp_file = false
91
200
  @streams.delete(stream_name.to_sym)
92
201
  end
93
202
 
94
203
  # Returns the tabular format if set, otherwise tries to autodetect the format if the file_name has been set
95
204
  # Returns [nil] if no format is set, or if it cannot be determined from the file_name
205
+ #
206
+ # The format that the file name names is kept once it is found, until a format is set, see #format=.
96
207
  def format
97
- @format ||= file_name ? Tabular.format_from_file_name(file_name) : nil
208
+ @set_format || (@named_format ||= file_name ? Tabular.format_from_file_name(file_name) : nil)
98
209
  end
99
210
 
211
+ # Sets the tabular format, or nil to detect it from the file name again, see #format.
100
212
  def format=(format)
101
213
  unless format.nil? || IOStreams::Tabular.registered_formats.include?(format)
102
214
  raise(ArgumentError, "Invalid format: #{format.inspect}")
103
215
  end
104
216
 
105
- @format = format
217
+ @set_format = format
218
+ @named_format = nil
219
+ end
220
+
221
+ # Returns [true|false] whether a format was set, see #format=, rather than taken from the file name, see #format.
222
+ def format_set?
223
+ !@set_format.nil?
224
+ end
225
+
226
+ # Returns [Symbol|nil] the tabular format of the text that the stream closest to the application reads, when that
227
+ # stream decides it, such as `:csv` for a spreadsheet, whose rows it reads as CSV, see
228
+ # `IOStreams::Xlsx::Reader.text_format`, otherwise nil, for a stream whose data is the text, such as gzip. The encode
229
+ # stream converts that text, so it is the stream after it.
230
+ def text_format
231
+ stream = pipeline.each_key.find { |name| name != :encode }
232
+ klass = stream && IOStreams.extensions[stream]&.reader_class
233
+ klass.text_format if klass.respond_to?(:text_format)
106
234
  end
107
235
 
108
236
  # Returns [String] the quote character within which field delimiters and newlines may be
@@ -117,8 +245,68 @@ module IOStreams
117
245
  format && IOStreams::Tabular.parser_class(format).quote_character
118
246
  end
119
247
 
248
+ # Returns [Hash<Symbol, Array<Symbol>>] the settings of this stream that apply to what it writes, other than the
249
+ # options of each call: `streams:` the names of the streams of its pipeline, see #pipeline, which #stream, #option,
250
+ # #encoding and the file name set, and `tabular:` the names of those of its rows and records that were set: `:format`,
251
+ # when it was set, see #format=, rather than taken from the file name, and `:schema`, see #schema=.
252
+ def writer_settings
253
+ tabular = []
254
+ tabular << :format if format_set?
255
+ tabular << :schema unless schema.nil?
256
+ {streams: pipeline.keys, tabular: tabular}
257
+ end
258
+
120
259
  private
121
260
 
261
+ # Sets the options of a stream that is applied once the file name extensions have been parsed, see #option.
262
+ def add_option(stream, options)
263
+ raise(ArgumentError, "Invalid stream: #{stream.inspect}") unless IOStreams.extensions.include?(stream)
264
+ raise(ArgumentError, "Cannot call both #option and #stream on the same streams instance}") if @streams
265
+ raise(ArgumentError, "Cannot call #option unless the `file_name` was already set}") unless file_name
266
+
267
+ @options ||= {}
268
+ if (opts = @options[stream])
269
+ opts.merge!(options)
270
+ else
271
+ @options[stream] = options.dup
272
+ end
273
+ end
274
+
275
+ # Adds a stream, in place of the streams that the file name implies, see #stream.
276
+ def add_stream(stream, options)
277
+ raise(ArgumentError, "Cannot call both #option and #stream on the same streams instance}") if @options
278
+ # `:none` applies no streams, including an encoding set with #encoding. It is the deprecated form of #raw.
279
+ return no_streams if stream == :none
280
+ raise(ArgumentError, "Invalid stream: #{stream.inspect}") unless IOStreams.extensions.include?(stream)
281
+
282
+ @streams ||= {}
283
+ if (opts = @streams[stream])
284
+ opts.merge!(options)
285
+ else
286
+ @streams[stream] = options.dup
287
+ end
288
+ end
289
+
290
+ def raise_if_raw!(method_name)
291
+ return unless @raw
292
+
293
+ raise(ArgumentError, "Cannot call #{method_name} after #raw, which reads and writes the data as it is stored")
294
+ end
295
+
296
+ # Returns [Hash] the options of the encode stream set with #option or #stream, if any, and those of #encoding.
297
+ def encode_setting
298
+ setting = streams ? streams[:encode] : options&.[](:encode)
299
+ (setting || {}).merge(@encoding)
300
+ end
301
+
302
+ # Returns [Hash<Symbol:Hash>] the pipeline, with the encode stream first, closest to the application, when an
303
+ # encoding was set with #encoding.
304
+ def with_encoding(pipeline)
305
+ return pipeline unless @encoding
306
+
307
+ {encode: encode_setting}.merge(pipeline.except(:encode))
308
+ end
309
+
122
310
  def build_pipeline
123
311
  return {} unless file_name
124
312
 
@@ -139,7 +327,7 @@ module IOStreams
139
327
 
140
328
  # Returns the streams for the supplied file_name
141
329
  def parse_extensions
142
- parts = ::File.basename(file_name).split(".")
330
+ parts = Utils::PathName.basename(file_name).split(".")
143
331
  extensions = []
144
332
  while (extension = parts.pop)
145
333
  sym = extension.downcase.to_sym
@@ -169,9 +357,38 @@ module IOStreams
169
357
  end
170
358
 
171
359
  def open_stream(type, stream, io_stream, opts, &)
172
- klass = class_for_stream(type, stream)
360
+ klass = stream_class(type, stream)
173
361
  validate_options(type, stream, klass, opts)
174
- klass.open(io_stream, **opts, &)
362
+ klass.open(io_stream, **taken_options(type, stream, klass, opts), &)
363
+ end
364
+
365
+ # Returns [Class] the class that reads or writes the stream: the class registered for it, or, to read a file with a
366
+ # schema, see #schema=, the class that the registered reader answers for it, when it reads a file with a schema
367
+ # differently, such as the reader of a spreadsheet, see `IOStreams::Xlsx::Reader.schema_reader`.
368
+ def stream_class(type, stream)
369
+ klass = class_for_stream(type, stream)
370
+ return klass if type != :reader || @schema.nil? || !klass.respond_to?(:schema_reader)
371
+
372
+ klass.schema_reader
373
+ end
374
+
375
+ # Returns [Hash] the options of the stream that the class is opened with: all of them, unless the class takes any
376
+ # option, since it does not declare them, see #validate_options, while it answers those that it takes in IOStreams
377
+ # 3.0, as `IOStreams::Bzip2::Reader` does until v3.0, see #v3_class_option_names. It is then opened with those
378
+ # alone, and a warning is logged for each of the others, which it would ignore, see
379
+ # `IOStreams::Utils.warn_unknown_options`, naming the stream as it was set: one that the class of the other
380
+ # direction takes, which v3.0 accepts, and ignores, or one that neither takes, which v3.0 raises ArgumentError for.
381
+ def taken_options(type, stream, klass, opts)
382
+ known = v3_class_option_names(klass) if option_names(klass).nil?
383
+ return opts if known.nil?
384
+
385
+ unknown = opts.except(*known)
386
+ return opts if unknown.empty?
387
+
388
+ other = IOStreams.extensions[stream].public_send(type == :reader ? :writer_class : :reader_class)
389
+ other_names = (v3_class_option_names(other) if other) || []
390
+ Utils.warn_unknown_options(unknown, stream, type, known, other_names)
391
+ opts.slice(*known)
175
392
  end
176
393
 
177
394
  # Options are strict: an option the stream does not accept raises instead of being ignored.