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
@@ -1,10 +1,6 @@
1
1
  module IOStreams
2
2
  class Path < IOStreams::Stream
3
- # Stream option names whose values must not be displayed, such as `passphrase` or `signer_passphrase`.
4
- SENSITIVE_OPTION = /pass(phrase|word)|secret/i
5
- private_constant :SENSITIVE_OPTION
6
-
7
- attr_accessor :path
3
+ attr_reader :path
8
4
 
9
5
  def initialize(path)
10
6
  raise(ArgumentError, "Path cannot be nil") if path.nil?
@@ -13,20 +9,30 @@ module IOStreams
13
9
  @path = path.frozen? ? path : path.dup.freeze
14
10
  @io_stream = nil
15
11
  @builder = nil
12
+ @root_name = nil
16
13
  end
17
14
 
15
+ # A copy of a root path is not a root path, see #root!, so that it can be changed.
16
+ def initialize_copy(source)
17
+ super
18
+ @root_name = nil
19
+ end
20
+
21
+ # Returns [IOStreams::Path] a new path with the elements joined to this path, created with the same arguments as
22
+ # this path, such as the credentials of an SFTP path, but without the streams, options, format and schema set on
23
+ # this one. Without any elements it returns this path itself.
24
+ #
18
25
  # If elements already contains the current path then it is used as is without
19
26
  # adding the current path for a second time
20
27
  def join(*elements)
21
28
  return self if elements.empty?
22
29
 
23
30
  elements = elements.collect(&:to_s)
24
- relative = ::File.join(*elements)
31
+ relative = Utils::PathName.join(*elements)
25
32
 
26
- new_path = dup
27
- new_path.builder = nil
28
- new_path.path = contains?(relative) ? relative : ::File.join(path, relative)
29
- new_path
33
+ new_path = dup
34
+ new_path.reset_builder
35
+ new_path.replace_path(contains?(relative) ? relative : Utils::PathName.join(path, relative))
30
36
  end
31
37
 
32
38
  def relative?
@@ -103,18 +109,6 @@ module IOStreams
103
109
  raise NotImplementedError
104
110
  end
105
111
 
106
- # Cleanup an incomplete write to the target "file" if the copy fails.
107
- # rubocop:disable-next Lint/SuppressedException
108
- def copy_from(source, **args)
109
- super
110
- rescue StandardError => e
111
- begin
112
- delete
113
- rescue NotImplementedError
114
- end
115
- raise(e)
116
- end
117
-
118
112
  # Moves the file by copying it to the new path and then deleting the current path.
119
113
  # Returns [IOStreams::Path] the target path.
120
114
  #
@@ -128,7 +122,8 @@ module IOStreams
128
122
  target
129
123
  end
130
124
 
131
- # Returns [IOStreams::Path] the directory for this file.
125
+ # Returns [IOStreams::Path] the directory for this file, a new path created with the same arguments as this path,
126
+ # but without the streams, options, format and schema set on this one, see #join.
132
127
  #
133
128
  # If `path` does not include a directory name then "." is returned.
134
129
  #
@@ -139,10 +134,9 @@ module IOStreams
139
134
  # IOStreams.path("test").directory #=> "."
140
135
  # IOStreams.path(".profile").directory #=> "."
141
136
  def directory
142
- new_path = dup
143
- new_path.builder = nil
144
- new_path.path = ::File.dirname(path)
145
- new_path
137
+ new_path = dup
138
+ new_path.reset_builder
139
+ new_path.replace_path(Utils::PathName.dirname(path))
146
140
  end
147
141
 
148
142
  # When path is a file, deletes this file.
@@ -209,17 +203,85 @@ module IOStreams
209
203
  path == other.path
210
204
  end
211
205
 
206
+ # Returns [Array<Symbol>] the options for creating a path of this class whose values must not be displayed,
207
+ # such as a password, see .redact_options. A path class with sensitive options overrides it.
208
+ def self.sensitive_option_names
209
+ []
210
+ end
211
+
212
+ # Returns [Hash] the supplied options for creating a path of this class, such as `password:`, with the value of
213
+ # each sensitive option replaced with "[FILTERED]", so that they can be displayed, for example by an application
214
+ # that stores them to create the path later. See .sensitive_option_names and `IOStreams::Utils.redact_options`.
215
+ def self.redact_options(options)
216
+ Utils.redact_options(options, sensitive_option_names)
217
+ end
218
+
219
+ # Returns [String|nil] the deprecation warning for the supplied name or url of a path of this class, as the
220
+ # application supplies it to `IOStreams.path`, when IOStreams v3.0 reads it as another file or url, which says what
221
+ # to supply instead, so that it names the same one on both, or nil when v3.0 reads it the same way. A path class
222
+ # whose names v3.0 reads differently overrides it.
223
+ def self.name_deprecation(_name)
224
+ nil
225
+ end
226
+
227
+ # Returns [String|nil] the deprecation warning for the supplied name or url, see .name_deprecation, only when
228
+ # IOStreams v3.0 reads it as another location, which the allowed paths compare, see `IOStreams.add_allowed_path`,
229
+ # rather than only spelling its url another way. A path class whose names v3.0 reads as another location overrides
230
+ # it.
231
+ def self.location_deprecation(_name)
232
+ nil
233
+ end
234
+
235
+ # Returns [true|false] whether the url has no path, such as `sftp://host`, or `https://host?a=1`, which IOStreams
236
+ # v3.0 reads with the path `/`, see `IOStreams::Paths::SFTP.name_deprecation`. Its text is checked rather than
237
+ # parsed, so that a pattern with characters that a url cannot hold, such as braces, can be checked too.
238
+ def self.url_without_path?(url)
239
+ url.match?(%r{\A[a-z][a-z0-9+.-]*://[^/?#]*(?:[?#]|\z)}i)
240
+ end
241
+ private_class_method :url_without_path?
242
+
243
+ # Shows the full name of the path, see #display_name, without any credentials, and the options of its streams
244
+ # without the values of sensitive options, such as a passphrase, see `IOStreams.redact_stream_options`.
212
245
  def inspect
213
- str = "#<#{self.class.name}:#{path}"
246
+ str = "#<#{self.class.name}:#{display_name}"
214
247
  str << " @builder=#{redact(builder.streams).inspect}" if builder.streams
215
248
  str << " @options=#{redact(builder.options).inspect}" if builder.options
216
249
  str << " pipeline=#{redact(pipeline).inspect}>"
217
250
  end
218
251
 
252
+ # Returns [String] the full name of this path, see #to_s, without any credentials, so that it can be logged
253
+ # or displayed. A user name, password or query in an SFTP or HTTP url is removed, since it can hold
254
+ # credentials, so unlike #to_s it cannot be used to create the path again. Each byte of a name that is not
255
+ # valid UTF-8 is shown as `\xHH`, see `IOStreams::Utils.display_text`.
256
+ #
257
+ # Example:
258
+ # IOStreams.path("sftp://jack:secret@example.org/data/a.csv").display_name
259
+ # # => "sftp://example.org/data/a.csv"
260
+ def display_name
261
+ Utils.display_text(to_s)
262
+ end
263
+
264
+ # Deprecated: a path can be used by many objects at once, such as a root path that file names are joined to, so
265
+ # changing it in place changes it for all of them. Use #join or #directory, which return a new path, without the
266
+ # streams, options, format and schema set on this one, or `IOStreams.path` with the new name. Reports a
267
+ # deprecation warning, see `IOStreams::Deprecation`.
268
+ def path=(path)
269
+ Deprecation.warn("#path= is deprecated, use #join or #directory, which return a new path, or IOStreams.path " \
270
+ "with the new name")
271
+ @path = path
272
+ end
273
+
219
274
  protected
220
275
 
276
+ # Returns [self] after setting its path in place, for IOStreams itself, see #path=.
277
+ def replace_path(path)
278
+ @path = path
279
+ self
280
+ end
281
+
221
282
  # Raises [IOStreams::Errors::AccessDenied] when allowed paths have been added, see `IOStreams.add_allowed_path`,
222
- # and this path is not within any of them.
283
+ # and this path is not within any of them, before #reader, #writer, `Stream#internal_reader` and
284
+ # `Stream#internal_writer` open it.
223
285
  def authorize!
224
286
  return if IOStreams.allowed_paths.empty? || (@permitted_path && @permitted_path == path)
225
287
 
@@ -275,7 +337,7 @@ module IOStreams
275
337
  def allowed_child?(child)
276
338
  return true if child.allowed?
277
339
 
278
- IOStreams.logger&.warn("Skipping #{child} since it is not within any of the allowed paths")
340
+ IOStreams.logger&.warn("Skipping #{child.display_name} since it is not within any of the allowed paths")
279
341
  false
280
342
  end
281
343
 
@@ -286,13 +348,41 @@ module IOStreams
286
348
  self
287
349
  end
288
350
 
351
+ # Makes this path the root path of the supplied name, which `IOStreams.add_root` adds, and the whole process shares,
352
+ # so that changing it in place reports a deprecation warning, see #warn_change. A copy of it is not a root path, see
353
+ # #initialize_copy.
354
+ # Returns self
355
+ def root!(name)
356
+ @root_name = name
357
+ self
358
+ end
359
+
360
+ # Returns [true|false] whether the streams of this path read a regular local file in v3.0: the file itself, or the
361
+ # temp file that it is downloaded into, as v2.2 reads, unless v3.0 reads it from another IO, see #v3_source, such as
362
+ # an S3 object, see `IOStreams::Stream#v3_reads_file?`.
363
+ def v3_reads_file?
364
+ v3_source.nil?
365
+ end
366
+
367
+ # Changing a root path in place, see #root!, such as with #stream, is deprecated, since it changes the root for the
368
+ # whole process. v3.0 keeps its own frozen copy of the root instead, so that changing a path no longer changes the
369
+ # root: the path that `IOStreams.add_root` returns is that copy, so changing it raises FrozenError, while a path
370
+ # supplied to `IOStreams.add_root` is not, and `IOStreams.root`, `IOStreams.roots` and `IOStreams.join` without
371
+ # elements return copies of it, so changing them changes only the copy. A copy of the root, or a path joined to it,
372
+ # can be changed in either version, which the warning names for the root that this path is.
373
+ def warn_change(method_name)
374
+ return unless @root_name
375
+
376
+ root, join = @root_name == :default ? ["", ""] : ["(#{@root_name.inspect})", ", root: #{@root_name.inspect}"]
377
+ Deprecation.warn("#{method_name} is deprecated on a root path, which the whole process shares, since v3.0 " \
378
+ "keeps its own frozen copy of the root, and returns copies of it, so changing this path no " \
379
+ "longer changes the root, change a copy of it instead, such as IOStreams.root#{root}.dup, or a " \
380
+ "path joined to it, such as IOStreams.join(\"a.csv\"#{join})")
381
+ end
382
+
289
383
  # Returns [Hash<Symbol:Hash>] the streams with the values of sensitive options replaced.
290
384
  def redact(streams)
291
- streams.transform_values do |options|
292
- next options unless options.is_a?(Hash)
293
-
294
- options.to_h { |name, value| [name, name.to_s.match?(SENSITIVE_OPTION) ? "[FILTERED]" : value] }
295
- end
385
+ IOStreams.redact_stream_options(streams)
296
386
  end
297
387
 
298
388
  def builder
@@ -3,13 +3,64 @@ require "fileutils"
3
3
  module IOStreams
4
4
  module Paths
5
5
  class File < IOStreams::Path
6
- attr_accessor :create_path
6
+ # A pattern supplied to #each_child that is not a String, such as a Pathname, is deprecated.
7
+ PATTERN_DEPRECATION = "a pattern that is not a String is deprecated for #each_child and #children, since v3.0 " \
8
+ "raises ArgumentError for it, supply a String instead, such as pattern.to_s".freeze
9
+ private_constant :PATTERN_DEPRECATION
10
+
11
+ # Moving a file onto an existing directory is deprecated, see #move_to.
12
+ MOVE_DEPRECATION = "#move_to onto an existing directory is deprecated, since v3.0 raises ArgumentError for it, " \
13
+ "supply the file within the directory instead, such as " \
14
+ "move_to(IOStreams.path(directory).join(path.basename))".freeze
15
+ private_constant :MOVE_DEPRECATION
16
+
17
+ attr_reader :create_path
7
18
 
8
19
  def initialize(file_name, create_path: true)
9
20
  @create_path = create_path
10
21
  super(file_name)
11
22
  end
12
23
 
24
+ # Sets whether writing the file creates its directory first, see #mkpath.
25
+ def create_path=(create_path)
26
+ warn_change("#create_path=")
27
+ @create_path = create_path
28
+ end
29
+
30
+ # Returns [String|nil] the deprecation warning for a name that IOStreams v3.0 reads as another file, see
31
+ # `IOStreams::Path.name_deprecation`, or nil for one that it reads the same way:
32
+ # - A name of `~`, or starting with `~/`, which is within a directory called `~` in the current directory, while
33
+ # v3.0 reads it within the home directory. A name such as `~user/a.csv` or `~$Book1.xlsx` is the same on both.
34
+ # - A `file://` url, such as `file:///data/a.csv` or `file://~/a.csv`, which is a file name relative to the current
35
+ # directory, within a directory called `file:`, while v3.0 reads it as the local file that it names, such as
36
+ # `/data/a.csv`, or within the home directory.
37
+ def self.name_deprecation(name)
38
+ if file_url?(name)
39
+ "a file:// url is deprecated, since v3.0 reads it as the local file that it names, such as /data/a.csv for " \
40
+ "file:///data/a.csv, or a file within the home directory for file://~/a.csv, rather than as a file name " \
41
+ "relative to the current directory: supply the name of the file instead, such as /data/a.csv, or " \
42
+ "File.expand_path(\"~/a.csv\") for the home directory"
43
+ elsif name == "~" || name.start_with?("~/")
44
+ "a local file name of ~, or starting with ~/, is deprecated, since v3.0 reads it within the home directory, " \
45
+ "rather than within a directory called ~ in the current directory: expand it with File.expand_path for " \
46
+ "the home directory, or start it with ./~/ for the directory called ~"
47
+ end
48
+ end
49
+
50
+ # Returns [String|nil] the deprecation warning for a name that IOStreams v3.0 reads as another file, see
51
+ # .name_deprecation, which is in another directory, and so another allowed path.
52
+ def self.location_deprecation(name)
53
+ name_deprecation(name)
54
+ end
55
+
56
+ # Returns [true|false] whether the name is a `file://` url, in any case, as IOStreams v3.0 takes it, see
57
+ # .name_deprecation. Its first bytes are compared, so that a name that is not valid in its encoding, such as a
58
+ # Latin-1 name, can be checked too.
59
+ def self.file_url?(name)
60
+ name.include?("://") && name.byteslice(0, 7).b.casecmp?("file://")
61
+ end
62
+ private_class_method :file_url?
63
+
13
64
  # Yields Paths within the current path.
14
65
  #
15
66
  # Examples:
@@ -88,20 +139,28 @@ module IOStreams
88
139
  # "**.rb" "./main.rb" false
89
140
  # "**.rb" "lib/song.rb" true
90
141
  # "*" "dave/.profile" true
142
+ #
143
+ # A pattern that is not a String, such as a Pathname, is deprecated, and reports a deprecation warning, see
144
+ # `IOStreams::Deprecation`.
91
145
  def each_child(pattern = "*", case_sensitive: false, directories: false, hidden: false)
92
146
  unless block_given?
93
147
  return to_enum(__method__, pattern,
94
148
  case_sensitive: case_sensitive, directories: directories, hidden: hidden)
95
149
  end
96
150
 
151
+ Deprecation.warn(PATTERN_DEPRECATION) unless pattern.is_a?(String)
97
152
  authorize!
98
153
 
154
+ # A pattern that is a Pathname is taken by its #to_path, as `::File.join` takes it, which v2.1 joined the pattern
155
+ # with: `IOStreams::Utils::PathName.join` does not take it on JRuby, where it joins the names by their bytes, and
156
+ # the case-insensitive retry below matches and downcases the pattern as a String.
157
+ pattern = pattern.to_path if pattern.respond_to?(:to_path)
158
+
99
159
  flags = 0
100
160
  flags |= ::File::FNM_CASEFOLD unless case_sensitive
101
161
  flags |= ::File::FNM_DOTMATCH if hidden
102
162
 
103
- # Dir.each_child("testdir") {|x| puts "Got #{x}" }
104
- full_pattern = ::File.join(path, pattern)
163
+ full_pattern = Utils::PathName.join(path, pattern)
105
164
 
106
165
  results = Dir.glob(full_pattern, flags)
107
166
 
@@ -111,7 +170,7 @@ module IOStreams
111
170
  if results.empty? && !case_sensitive && pattern.match?(/[A-Z]/)
112
171
  # Try converting the pattern to lowercase and re-matching
113
172
  lowercase_pattern = pattern.downcase
114
- lowercase_full_pattern = ::File.join(path, lowercase_pattern)
173
+ lowercase_full_pattern = Utils::PathName.join(path, lowercase_pattern)
115
174
  results = Dir.glob(lowercase_full_pattern, flags)
116
175
  end
117
176
 
@@ -127,12 +186,16 @@ module IOStreams
127
186
  #
128
187
  # Notes:
129
188
  # - Can copy across buckets.
189
+ # - A target that is an existing directory, which the file is moved into, as `mv` does, returning the directory, is
190
+ # deprecated, and reports a deprecation warning, see `IOStreams::Deprecation`: supply the file within it instead,
191
+ # such as `move_to(IOStreams.path("/archive").join(path.basename))`.
130
192
  def move_to(target_path)
131
193
  target = IOStreams.new(target_path)
132
194
  return super(target) unless target.is_a?(self.class)
133
195
 
134
196
  authorize!
135
197
  target.authorize!
198
+ Deprecation.warn(MOVE_DEPRECATION) if ::File.directory?(target.path)
136
199
  target.mkpath
137
200
  # In case the file is being moved across partitions
138
201
  FileUtils.move(path, target.to_s)
@@ -141,7 +204,7 @@ module IOStreams
141
204
 
142
205
  def mkpath
143
206
  authorize!
144
- dir = ::File.dirname(path)
207
+ dir = Utils::PathName.dirname(path)
145
208
  FileUtils.mkdir_p(dir)
146
209
  self
147
210
  end
@@ -193,13 +256,13 @@ module IOStreams
193
256
  # is appended to the real path of the part that does exist. It cannot contain `.` or `..`, since
194
257
  # what they refer to depends on directories that have not been created yet.
195
258
  def allowed_location
196
- existing = ::File.absolute_path?(path) ? path : ::File.join(Dir.pwd, path)
259
+ existing = ::File.absolute_path?(path) ? path : Utils::PathName.join(Dir.pwd, path)
197
260
  missing = []
198
261
  until present?(existing)
199
- parent = ::File.dirname(existing)
262
+ parent = Utils::PathName.dirname(existing)
200
263
  break if parent == existing
201
264
 
202
- missing.unshift(::File.basename(existing))
265
+ missing.unshift(Utils::PathName.basename(existing))
203
266
  existing = parent
204
267
  end
205
268
 
@@ -207,7 +270,7 @@ module IOStreams
207
270
  raise(Errors::AccessDenied, "Access denied to #{path}: '.' and '..' are not allowed after a missing directory")
208
271
  end
209
272
 
210
- ::File.join(::File.realpath(existing), *missing)
273
+ Utils::PathName.join(::File.realpath(existing), *missing)
211
274
  rescue SystemCallError => e
212
275
  raise(Errors::AccessDenied, "Access denied to #{path}: #{e.message}")
213
276
  end
@@ -6,6 +6,19 @@ module IOStreams
6
6
  class HTTP < IOStreams::Path
7
7
  attr_reader :username, :password, :http_redirect_count, :url
8
8
 
9
+ # The password, and the query `parameters`, which can hold an access key, see IOStreams::Path.redact_options.
10
+ def self.sensitive_option_names = %i[password parameters]
11
+
12
+ # Returns [String|nil] the deprecation warning for an HTTP(S) url without a path, such as `https://host`, or
13
+ # `https://host?a=1`, which IOStreams v3.0 reads with the path `/`, as `https://host/?a=1`, see
14
+ # `IOStreams::Path.name_deprecation`.
15
+ def self.name_deprecation(url)
16
+ return unless url_without_path?(url)
17
+
18
+ "an HTTP url without a path, such as https://host or https://host?a=1, is deprecated, since v3.0 adds a / " \
19
+ "after the host, https://host/?a=1, which changes its #to_s and #path: supply the url with the /"
20
+ end
21
+
9
22
  # Stream to/from a remote file over http(s).
10
23
  #
11
24
  # Parameters:
@@ -21,7 +34,8 @@ module IOStreams
21
34
  # SECURITY WARNING:
22
35
  # A username and password supplied in the url remain part of it, so `#to_s` and `#url`
23
36
  # return them, as does any log or error message that includes the path.
24
- # Supply them with the `username:` and `password:` arguments instead.
37
+ # Supply them with the `username:` and `password:` arguments instead,
38
+ # and log `#display_name`, which never includes them.
25
39
  #
26
40
  # username: [String]
27
41
  # When supplied, basic authentication is used with the username and password.
@@ -72,6 +86,7 @@ module IOStreams
72
86
  @maximum_file_size = maximum_file_size
73
87
  @url = parameters ? "#{url}?#{URI.encode_www_form(parameters)}" : url
74
88
  super(uri.path)
89
+ warn_url_without_host(uri)
75
90
  end
76
91
 
77
92
  # Does not support relative file names since there is no concept of current working directory
@@ -83,17 +98,48 @@ module IOStreams
83
98
  url
84
99
  end
85
100
 
101
+ # Returns [String] the url without the user name, password, query or fragment, which can hold credentials,
102
+ # such as the signature of a pre-signed url, or the `parameters:` supplied to this path.
103
+ def display_name
104
+ uri = URI.parse(url)
105
+ uri.user = nil
106
+ uri.query = nil
107
+ uri.fragment = nil
108
+ uri.to_s
109
+ end
110
+
86
111
  private
87
112
 
88
113
  attr_reader :allow_hosts, :maximum_file_size
89
114
 
115
+ # Reports a deprecation warning for a url without a host, such as `http:///a.csv`, which IOStreams 3.0 raises
116
+ # ArgumentError for when the path is created, so that a mistake in a configured url is reported wherever the
117
+ # code runs.
118
+ def warn_url_without_host(uri)
119
+ return unless uri.host.to_s.empty?
120
+
121
+ Deprecation.warn("the HTTP url #{display_name.inspect} is deprecated, since it names no host, name one, such " \
122
+ "as 'https://example.org/a.csv'")
123
+ end
124
+
125
+ # The methods of the IO that v3.0 reads the response from, see #v3_source: those of an IO that read, and #close.
126
+ V3_SOURCE_METHODS = [*IOStreams::Reader::V3_READ_METHODS, :close].freeze
127
+ private_constant :V3_SOURCE_METHODS
128
+
129
+ # Returns [Array(Array<Symbol>, String)] the methods of the IO that v3.0 reads the response from, in place of the
130
+ # temp File that v2.2 downloads it into, and its name, see `IOStreams::Stream#v3_source`: v3.0 reads the response as
131
+ # the application reads it, with an IO that only reads.
132
+ def v3_source
133
+ [V3_SOURCE_METHODS, "the IO of an HTTP path"]
134
+ end
135
+
90
136
  # Read a file using an http get.
91
137
  #
92
138
  # For example:
93
139
  # IOStreams.path('https://www5.fdic.gov/idasp/Offices2.zip').reader {|file| puts file.read}
94
140
  #
95
141
  # Read the file without unzipping and streaming the first file in the zip:
96
- # IOStreams.path('https://www5.fdic.gov/idasp/Offices2.zip').stream(:none).reader {|file| puts file.read}
142
+ # IOStreams.path('https://www5.fdic.gov/idasp/Offices2.zip').raw.reader {|file| puts file.read}
97
143
  #
98
144
  # Notes:
99
145
  # * Since Net::HTTP download only supports a push stream, the data is streamed into a tempfile first.
@@ -38,20 +38,23 @@ module IOStreams
38
38
 
39
39
  private
40
40
 
41
+ # The directory is created without reporting a deprecation warning for its name, see `IOStreams::Paths.build`,
42
+ # since it is derived from the pattern, whose name `IOStreams.each_child` reports instead, and since other gems
43
+ # create a matcher themselves, such as Rocket Job, whose users cannot change its lines that a warning would name.
41
44
  def extract_optimized_path(path, pattern)
42
45
  elements = pattern.split("/")
43
46
  index = elements.find_index { |e| e.match(MATCH_START_CHARS) }
44
47
  if index.nil?
45
48
  # No index means it has no pattern.
46
- @path = path.nil? ? IOStreams.path(pattern) : path.join(pattern)
49
+ @path = path.nil? ? Paths.build(pattern) : path.join(pattern)
47
50
  @pattern = nil
48
51
  elsif index.zero?
49
52
  # Cannot optimize path since the very first entry contains a wildcard
50
- @path = path || IOStreams.path
53
+ @path = path || Paths.build("")
51
54
  @pattern = pattern
52
55
  else
53
56
  new_path = elements[0..(index - 1)].join("/")
54
- @path = path.nil? ? IOStreams.path(new_path) : path.join(new_path)
57
+ @path = path.nil? ? Paths.build(new_path) : path.join(new_path)
55
58
  @pattern = elements[index..].join("/")
56
59
  end
57
60
  end