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
@@ -57,94 +57,85 @@ module IOStreams
57
57
  # false:
58
58
  # Raises Tabular::InvalidHeader when a column is supplied that is not in the whitelist.
59
59
  #
60
+ # cleanse_header: [true|false]
61
+ # Whether to cleanse the header row read, see `IOStreams::Tabular::Header#cleanse!`, which the tabular that it
62
+ # creates does, see `IOStreams::Tabular.new`.
63
+ # With a schema it raises ArgumentError, even when nil, since the fields of the schema name the keys of the
64
+ # records instead.
65
+ # Default: cleanses it.
66
+ #
67
+ # tabular: [IOStreams::Tabular]
68
+ # The tabular that parses each line, such as one with a schema, see `IOStreams::Stream#schema`, in place of the
69
+ # other options, which create it.
70
+ #
71
+ # report_repeated_key: [true|false]
72
+ # Whether to report a deprecation warning, before the first record, when two columns of the header row are the
73
+ # same key, see #warn_repeated_key, which `IOStreams::Stream#each` and `#reader` ask for, rather than another
74
+ # gem that creates a reader, which its users cannot change.
75
+ # Default: false
76
+ #
60
77
  # Note:
61
78
  # * `allowed_columns`, `required_columns` and `skip_unknown` only apply to every input, including JSON records,
62
79
  # supplied `columns` and `cleanse_header: false`, when `IOStreams.enforce_column_restrictions?` is true.
63
- def initialize(line_reader, cleanse_header: true, original_file_name: nil, **args)
80
+ def initialize(line_reader, original_file_name: nil, tabular: nil, report_repeated_key: false, **args)
64
81
  unless line_reader.respond_to?(:each)
65
82
  raise(ArgumentError, "Stream must be a IOStreams::Line::Reader or implement #each")
66
83
  end
67
84
 
68
- @tabular = IOStreams::Tabular.new(file_name: original_file_name, **args)
69
- @line_reader = line_reader
70
- @cleanse_header = cleanse_header
71
- @warned = false
85
+ if tabular && (original_file_name || !args.empty?)
86
+ raise(ArgumentError, "Supply either tabular: or the options that create it, not both")
87
+ end
72
88
 
73
- # Supplied columns take the place of a header row, so apply the allowed and required columns to them.
74
- restrict_columns if restricted? && !@tabular.header?
75
- end
89
+ @tabular = tabular || IOStreams::Tabular.new(file_name: original_file_name, **args)
90
+ @line_reader = line_reader
91
+ @report_repeated_key = report_repeated_key
76
92
 
77
- def each
78
- @line_reader.each do |line|
79
- if @tabular.header?
80
- @tabular.parse_header(line)
81
- if @cleanse_header
82
- cleanse_columns
83
- elsif restricted?
84
- restrict_columns
85
- end
86
- else
87
- yield restrict(@tabular.record_parse(line))
88
- end
89
- end
93
+ # Supplied columns take the place of a header row, so apply the allowed and required columns to them.
94
+ @tabular.restrict_columns
90
95
  end
91
96
 
92
- private
97
+ # Yields each record, after the header row, which names their keys, see `IOStreams::Tabular#read_record_line`.
98
+ def each(&block)
99
+ return each_reporting(&block) if @report_repeated_key
93
100
 
94
- def restricted?
95
- @tabular.header.restricted?
101
+ @line_reader.each { |line| @tabular.read_record_line(line, &block) }
96
102
  end
97
103
 
98
- def cleanse_columns
99
- @tabular.header.cleanse!(rename: @cleanse_header)
104
+ # Writes each record to the writer, see `IOStreams::Record::Writer`, as a copy with a schema does, see
105
+ # `IOStreams::Stream#copy_from`. A blank line, or a JSON line of null, which #each yields as nil, is not a record,
106
+ # so it is not written.
107
+ #
108
+ # Returns the result of #each.
109
+ def copy_to(writer)
110
+ each { |record| writer << record unless record.nil? }
100
111
  end
101
112
 
102
- # Apply the allowed and required columns to supplied columns, or to a header row read with
103
- # `cleanse_header: false`. Unless `IOStreams.enforce_column_restrictions?`, only warn when they would
104
- # change the columns.
105
- def restrict_columns
106
- return cleanse_columns if IOStreams.enforce_column_restrictions?
107
- return if @warned
113
+ private
108
114
 
109
- header = @tabular.header
110
- columns = header.columns
111
- changed = changed_by_restriction? do
112
- copy = IOStreams::Tabular::Header.new(
113
- columns: columns,
114
- allowed_columns: header.allowed_columns,
115
- required_columns: header.required_columns,
116
- skip_unknown: header.skip_unknown
117
- )
118
- copy.cleanse!(rename: @cleanse_header)
119
- copy.columns != columns
115
+ # Yields each record, as #each does, after reporting a deprecation warning before the first one when two columns
116
+ # of the header row are the same key, see #warn_repeated_key.
117
+ def each_reporting
118
+ @line_reader.each do |line|
119
+ @tabular.read_record_line(line) do |record|
120
+ warn_repeated_key if @report_repeated_key
121
+ yield record
122
+ end
120
123
  end
121
- warn_restriction if changed
122
- end
123
-
124
- # Formats such as JSON have no header row, so apply the allowed and required columns to each record's keys.
125
- # Unless `IOStreams.enforce_column_restrictions?`, only warn when they would change the record.
126
- def restrict(record)
127
- return record unless record.is_a?(Hash) && restricted? && @tabular.header.columns.nil?
128
- return @tabular.header.restrict_hash(record, rename: @cleanse_header) if IOStreams.enforce_column_restrictions?
129
- return record if @warned
130
-
131
- warn_restriction if changed_by_restriction? { @tabular.header.restrict_hash(record, rename: @cleanse_header) != record }
132
- record
133
- end
134
-
135
- def changed_by_restriction?
136
- yield
137
- rescue IOStreams::Errors::InvalidHeader
138
- true
139
124
  end
140
125
 
141
- # Warn once per reader, since the same columns usually apply to every record.
142
- def warn_restriction
143
- @warned = true
144
- IOStreams.logger&.warn(
145
- "allowed_columns and required_columns are not applied to this input, but would change the records read. " \
146
- "In v3.0 they will apply to every input. Set `IOStreams.enforce_column_restrictions = true` to apply them now."
147
- )
126
+ # Reports a deprecation warning, once, when two columns of the header row are the same key, see
127
+ # `IOStreams::Tabular#repeated_key`, such as a header row of `a,a,b`, or of `A` and `a`, which are cleansed to the
128
+ # same key, since each record holds the value of only the last of them, while IOStreams v3.0 raises
129
+ # `IOStreams::Errors::InvalidHeader` for them, rather than lose the others.
130
+ def warn_repeated_key
131
+ @report_repeated_key = false
132
+ key = @tabular.repeated_key
133
+ return if key.nil?
134
+
135
+ Deprecation.warn("records read from a header row whose columns are the same key, #{key.inspect}, are " \
136
+ "deprecated, since each record holds the value of only the last of them, and v3.0 raises " \
137
+ "IOStreams::Errors::InvalidHeader for them: rename the columns, or read the rows with " \
138
+ "each(:array), which holds every column")
148
139
  end
149
140
  end
150
141
  end
@@ -58,25 +58,32 @@ module IOStreams
58
58
  # #as_hash will skip these additional columns entirely as if they were not in the file at all.
59
59
  # false:
60
60
  # Raises Tabular::InvalidHeader when a column is supplied that is not in the whitelist.
61
- def initialize(line_writer, columns: nil, original_file_name: nil, **args)
61
+ #
62
+ # tabular: [IOStreams::Tabular]
63
+ # The tabular that renders each record, such as one with a schema, see `IOStreams::Stream#schema`, in place of
64
+ # the other options, which create it.
65
+ def initialize(line_writer, columns: nil, original_file_name: nil, tabular: nil, **args)
62
66
  raise(ArgumentError, "Stream must be a IOStreams::Line::Writer or implement #<<") unless line_writer.respond_to?(:<<)
63
67
 
64
- @tabular = IOStreams::Tabular.new(columns: columns, file_name: original_file_name, **args)
68
+ if tabular && (columns || original_file_name || !args.empty?)
69
+ raise(ArgumentError, "Supply either tabular: or the options that create it, not both")
70
+ end
71
+
72
+ IOStreams::Tabular.check_write_options(args)
73
+ @tabular = tabular || IOStreams::Tabular.new(columns: columns, file_name: original_file_name, **args)
65
74
  @line_writer = line_writer
66
75
 
67
- # Render header line when `columns` is supplied.
68
- @line_writer << @tabular.render_header if columns && @tabular.requires_header?
76
+ # Render header line when `columns` is supplied, or with a schema the names of its fields, see
77
+ # `IOStreams::Tabular#start_records`.
78
+ @tabular.start_records { |line| @line_writer << line }
69
79
  end
70
80
 
81
+ # Writes the record, after the header line that the keys of the first record give, when neither the columns
82
+ # supplied nor a schema give it, see `IOStreams::Tabular#render_record`.
71
83
  def <<(hash)
72
84
  raise(ArgumentError, "#<< only accepts a Hash argument") unless hash.is_a?(Hash)
73
85
 
74
- if @tabular.header?
75
- # Extract header from the keys from the first row when not supplied above.
76
- @tabular.header.columns = hash.keys
77
- @line_writer << @tabular.render_header
78
- end
79
- @line_writer << @tabular.render(hash)
86
+ @tabular.render_record(hash) { |line| @line_writer << line }
80
87
  end
81
88
  end
82
89
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IOStreams
4
+ module Row
5
+ # The header row that `IOStreams::Stream#each(:array)` yields before the rows of a file, which names the column of
6
+ # each value of the rows after it. It is an Array, as every other row is, whose class says that it is the header
7
+ # row, so that a writer of rows takes it as the header row of the rows written after it, rather than as a row of
8
+ # values, see `IOStreams::Row::Writer#<<`, and so that code that reads the rows can tell it apart:
9
+ #
10
+ # path.each(:array) do |row|
11
+ # next if row.is_a?(IOStreams::Row::HeaderRow)
12
+ #
13
+ # process(row)
14
+ # end
15
+ #
16
+ # It is equal to an Array of the same columns, and the methods of an Array that return a new Array, such as `map`,
17
+ # return an Array rather than a header row, so that a row made from it is a row of values.
18
+ class HeaderRow < ::Array
19
+ end
20
+ end
21
+ end
@@ -28,27 +28,64 @@ module IOStreams
28
28
  # format: [Symbol]
29
29
  # :csv, :hash, :array, :json, :psv, :fixed
30
30
  #
31
- # For all other parameters, see Tabular::Header.new
32
- def initialize(line_reader, cleanse_header: true, original_file_name: nil, **args)
31
+ # cleanse_header: [true|false]
32
+ # Whether to cleanse the header row read, see `IOStreams::Tabular::Header#cleanse!`, which the tabular that it
33
+ # creates does, see `IOStreams::Tabular.new`.
34
+ # With a schema it raises ArgumentError, even when nil, since the fields of the schema name the keys of the
35
+ # records instead.
36
+ # Default: cleanses it.
37
+ #
38
+ # tabular: [IOStreams::Tabular]
39
+ # The tabular that parses each line, such as one with a schema, see `IOStreams::Stream#schema`, in place of the
40
+ # other options, which create it.
41
+ #
42
+ # For all other parameters, see IOStreams::Tabular.new
43
+ def initialize(line_reader, original_file_name: nil, tabular: nil, **args)
33
44
  unless line_reader.respond_to?(:each)
34
45
  raise(ArgumentError, "Stream must be a IOStreams::Line::Reader or implement #each")
35
46
  end
36
47
 
37
- @tabular = IOStreams::Tabular.new(file_name: original_file_name, **args)
38
- @line_reader = line_reader
39
- @cleanse_header = cleanse_header
48
+ if tabular && (original_file_name || !args.empty?)
49
+ raise(ArgumentError, "Supply either tabular: or the options that create it, not both")
50
+ end
51
+
52
+ @tabular = tabular || IOStreams::Tabular.new(file_name: original_file_name, **args)
53
+ @line_reader = line_reader
40
54
  end
41
55
 
42
- def each
43
- @line_reader.each do |line|
44
- if @tabular.header?
45
- columns = @tabular.parse_header(line)
46
- @tabular.cleanse_header! if @cleanse_header
47
- yield columns
48
- else
49
- yield @tabular.row_parse(line)
50
- end
51
- end
56
+ # Returns [true|false] whether the first row that #each yields is a header row, which names the column of each
57
+ # value of the rows after it, see `IOStreams::Tabular#rows_header?`: the header row of the file, or with a schema,
58
+ # the names of its fields, of a file without one, such as fixed width or JSON. False when the columns were
59
+ # supplied, which name the values of the rows instead. Asked before #each.
60
+ def header?
61
+ @tabular.rows_header?
62
+ end
63
+
64
+ # Returns [true|false] whether the rows that #each yields are the records of the file, Hashes rather than Arrays,
65
+ # see `IOStreams::Tabular#record_rows?`, such as those of JSON without a schema.
66
+ def record_rows?
67
+ @tabular.record_rows?
68
+ end
69
+
70
+ # Yields each row, an Array, after the header row of a file with one, which it yields first, see
71
+ # `IOStreams::Tabular#read_row`. With a schema, the header row is an `IOStreams::Row::HeaderRow` of the names of
72
+ # the fields, also for a file without a header row, such as fixed width or JSON.
73
+ def each(&block)
74
+ @line_reader.each { |line| @tabular.read_row(line, &block) }
75
+ end
76
+
77
+ # Writes each row to the writer, see `IOStreams::Row::Writer`, as a copy with a schema does, see
78
+ # `IOStreams::Stream#copy_from`, telling it the column of each value of the rows: the header row of the file, or
79
+ # for a file without one, the names of the fields of its schema, is an `IOStreams::Row::HeaderRow`, which the writer
80
+ # takes as the header row of the rows after it, see `IOStreams::Row::Writer#<<`, so that a target with a schema
81
+ # matches its columns to the fields, rather than write it as a row of values. Tabular decides which row is the
82
+ # header row, see `IOStreams::Tabular#start_copy` and `IOStreams::Tabular#copy_row`. A blank line, which is not a
83
+ # row, is not written.
84
+ #
85
+ # Returns the result of the #each of the line reader.
86
+ def copy_to(writer)
87
+ @tabular.start_copy { |columns| writer << columns }
88
+ @line_reader.each { |line| @tabular.copy_row(line) { |row| writer << row unless row.nil? } }
52
89
  end
53
90
  end
54
91
  end
@@ -35,28 +35,79 @@ module IOStreams
35
35
  # format: [Symbol]
36
36
  # :csv, :hash, :array, :json, :psv, :fixed
37
37
  #
38
+ # tabular: [IOStreams::Tabular]
39
+ # The tabular that renders each row, such as one with a schema, see `IOStreams::Stream#schema`, in place of the
40
+ # other options, which create it.
41
+ #
42
+ # report_repeated_key: [true|false]
43
+ # Whether to report a deprecation warning when the header row of the rows written to a format whose rows are
44
+ # records, such as JSON, has two columns of the same key, see #warn_repeated_key, which
45
+ # `IOStreams::Stream#writer` asks for, rather than another gem that creates a writer, which its users cannot
46
+ # change.
47
+ # Default: false
48
+ #
38
49
  # For all other parameters, see Tabular::Header.new
39
- def initialize(line_writer, columns: nil, original_file_name: nil, **args)
50
+ def initialize(line_writer, columns: nil, original_file_name: nil, tabular: nil, report_repeated_key: false, **args)
40
51
  raise(ArgumentError, "Stream must be a IOStreams::Line::Writer or implement #<<") unless line_writer.respond_to?(:<<)
41
52
 
42
- @tabular = IOStreams::Tabular.new(columns: columns, file_name: original_file_name, **args)
43
- @line_writer = line_writer
53
+ if tabular && (columns || original_file_name || !args.empty?)
54
+ raise(ArgumentError, "Supply either tabular: or the options that create it, not both")
55
+ end
56
+
57
+ IOStreams::Tabular.check_write_options(args)
58
+ @tabular = tabular || IOStreams::Tabular.new(columns: columns, file_name: original_file_name, **args)
59
+ @line_writer = line_writer
60
+ @report_repeated_key = report_repeated_key
44
61
 
45
- # Render header line when `columns` is supplied.
46
- line_writer << @tabular.render_header if columns && @tabular.requires_header?
62
+ # Render header line when `columns` is supplied, or with a schema the names of its fields, see
63
+ # `IOStreams::Tabular#start_rows`.
64
+ @tabular.start_rows { |line| @line_writer << line }
47
65
  end
48
66
 
49
- # Supply a hash or an array to render
67
+ # Supply an array to render, otherwise it raises ArgumentError.
68
+ #
69
+ # Without a schema, the first row is the header row, unless the columns were supplied. With a schema, the header
70
+ # row of a file with one is the names of its fields, which is written before the rows, so every row written is
71
+ # the values of the fields, in order. A header row that `IOStreams::Stream#each(:array)` yields with a schema, an
72
+ # `IOStreams::Row::HeaderRow`, is written as the header row of the rows after it, see #write_header.
50
73
  def <<(array)
51
74
  raise(ArgumentError, "Must supply an Array") unless array.is_a?(Array)
75
+ return write_header(array) if array.is_a?(HeaderRow)
52
76
 
53
- if @tabular.header?
54
- # If header (columns) was not supplied as an argument, assume first line is the header.
55
- @tabular.header.columns = array
56
- @line_writer << @tabular.render_header
57
- else
58
- @line_writer << @tabular.render(array)
59
- end
77
+ @tabular.render_row(array) { |line| @line_writer << line }
78
+ end
79
+
80
+ # Writes the header row of the rows after it, such as that of the file that is copied, see
81
+ # `IOStreams::Row::Reader#copy_to`, so that the value of each column of the rows after it is written to its
82
+ # column, or to its field with a schema, whose columns it matches to the fields, without a line. Without a schema,
83
+ # as the first row, see #<<, unless the format has no header row, such as JSON, and the columns are not known,
84
+ # when it gives the columns of the rows after it, or the columns were supplied, which name them instead, see
85
+ # `IOStreams::Tabular#render_header_row`.
86
+ #
87
+ # Returns self, so that calls can be chained.
88
+ def write_header(columns)
89
+ @tabular.render_header_row(columns) { |line| @line_writer << line }
90
+ warn_repeated_key if @report_repeated_key
91
+ self
92
+ end
93
+
94
+ private
95
+
96
+ # Reports a deprecation warning when two columns of the header row are the same key, see
97
+ # `IOStreams::Tabular#repeated_key`, such as a header row of `a,a,b`, and the format writes each row as a record,
98
+ # see `IOStreams::Tabular#record_rows?`, such as JSON, since each record holds the value of only the last of them,
99
+ # while IOStreams v3.0 raises `IOStreams::Errors::InvalidHeader` for them, rather than lose the others. A format
100
+ # whose rows are Arrays, such as CSV, writes every column.
101
+ def warn_repeated_key
102
+ return unless @tabular.record_rows?
103
+
104
+ key = @tabular.repeated_key
105
+ return if key.nil?
106
+
107
+ Deprecation.warn("rows written to a format whose rows are records, such as JSON, under a header row whose " \
108
+ "columns are the same key, #{key.inspect}, are deprecated, since each record holds the value " \
109
+ "of only the last of them, and v3.0 raises IOStreams::Errors::InvalidHeader for them: rename " \
110
+ "the columns")
60
111
  end
61
112
  end
62
113
  end