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
data/docs/upgrading.md CHANGED
@@ -3,19 +3,411 @@ layout: default
3
3
  title: Upgrading
4
4
  heading: Upgrading IOStreams
5
5
  description: >-
6
- What changes when upgrading IOStreams to v2.1 or v2.0, and the security
7
- settings to review in your application when upgrading.
6
+ What changes when upgrading IOStreams to v2.2, v2.1 or v2.0, how to prepare
7
+ for v3.0, and the security settings to review in your application when upgrading.
8
8
  ---
9
9
 
10
10
  This page covers the changes that may need updates to your application when upgrading IOStreams,
11
11
  and the security issues to check. For every change in each release, see the
12
12
  [CHANGELOG](https://github.com/reidmorrison/iostreams/blob/main/CHANGELOG.md).
13
13
 
14
+ ## Upgrading to v2.2
15
+
16
+ v2.2 prepares applications for v3.0, and changes no existing behavior, apart from its deprecation warnings and the
17
+ [changes in behavior](#changes-in-behavior) below. It adds the methods that replace those that v3.0 deprecates or
18
+ removes, and reports a deprecation warning when the application uses one of those, so that it can move to the
19
+ replacement while it still runs v2.2, and then upgrade to v3.0 with fewer changes:
20
+
21
+ * `#raw` reads and writes the data as it is stored, in place of `stream(:none)`, see [Raw](streams#raw).
22
+ * `#encoding` sets the encoding of the text, in place of `option(:encode, ...)` and `stream(:encode, ...)`, see
23
+ [Character encoding](extensions#character-encoding).
24
+ * `#schema` describes the rows and records of a file, as v3.0 does: the type of each field, the columns that the file
25
+ must and can have, and the position of each field of a fixed width file, in place of `#format_options` and the
26
+ column options of each call, see [Schema](formats#schema). A schema in v2.2 reads and writes as one in v3.0 does,
27
+ apart from the differences that v3.0 makes to every file, such as its encoding, see
28
+ [Schemas in IOStreams 2.2 and 3.0](formats#schemas-in-iostreams-22-and-30).
29
+ * `IOStreams.tabular_formats` returns the formats that `#format` accepts.
30
+
31
+ What is deprecated still works as before. Apart from the [changes in behavior](#changes-in-behavior) below, the only
32
+ change to existing behavior is the deprecation warnings themselves, which Ruby shows only while `Warning[:deprecated]` is on: it is off by default, so production stays
33
+ quiet, but Minitest and test-unit turn it on, so most test runs show them on `$stderr`, including for the calls
34
+ that another gem makes, such as Rocket Job 7.0, see [Seeing deprecation warnings](#seeing-deprecation-warnings).
35
+
36
+ To prepare for v3.0: upgrade to v2.2, run your tests, which show the deprecation warnings, see below, and move each
37
+ use to what replaces it. Then read the changes that v3.0 makes in its own
38
+ [upgrading guide](https://github.com/reidmorrison/iostreams/blob/main/docs/upgrading.md#upgrading-to-v30), since it
39
+ also changes behavior that cannot be reported as a deprecation, such as reading text as UTF-8 by default.
40
+
41
+ ### Seeing deprecation warnings
42
+
43
+ IOStreams reports the use of a deprecated method or option as a Ruby deprecation warning, the same way that Ruby
44
+ reports its own, which names the line of your application that used it, and what to use instead:
45
+
46
+ ~~~
47
+ app/jobs/export_job.rb:12: warning: IOStreams: stream(:none) is deprecated, use #raw to read or write the data as it is stored, or #file_name to read a file that is not named for its format
48
+ ~~~
49
+
50
+ Ruby only shows deprecation warnings while `Warning[:deprecated]` is true, which it is not by default, so production
51
+ stays quiet without any configuration:
52
+
53
+ | Where | Deprecation warnings |
54
+ |-------|----------------------|
55
+ | Tests run with Minitest or test-unit | On, since both turn them on |
56
+ | Tests run with RSpec | Off, unless run with `rspec --warnings`, or `Warning[:deprecated] = true` is set in `spec_helper.rb` |
57
+ | Development | Off, unless `RUBYOPT=-W:deprecated` is set in the environment, or `Warning[:deprecated] = true` in code that only runs in development, such as `config/environments/development.rb` |
58
+ | Production | Off |
59
+
60
+ Rails settings such as `config.active_support.deprecation` only apply to the deprecation warnings of Rails, not to
61
+ those that Ruby reports.
62
+
63
+ Ruby writes each warning to `$stderr`. To send them elsewhere, such as to your log, or to raise them in your tests so
64
+ that each use is found, extend `Warning` with your own `warn`:
65
+
66
+ ~~~ruby
67
+ module IOStreamsDeprecations
68
+ def warn(message, *, **)
69
+ return super unless message.include?("IOStreams: ")
70
+
71
+ Rails.logger.warn(message.chomp)
72
+ end
73
+ end
74
+ Warning.extend(IOStreamsDeprecations)
75
+ ~~~
76
+
77
+ ### Deprecated, and what replaces it
78
+
79
+ Each of these still works as before in v2.2, and reports a deprecation warning:
80
+
81
+ | Deprecated | Use instead |
82
+ |------------|-------------|
83
+ | `stream(:none)` | `#raw` to read or write the data as it is stored, or `#file_name` to read a file that is not named for its format, such as `file_name("data.csv")` for a CSV file named `data.txt` |
84
+ | `option(:encode, ...)` and `option_or_stream(:encode, ...)` | `#encoding`, with the same options and the same results, see [Character encoding](extensions#character-encoding) |
85
+ | `stream(:encode, ...)` | `#encoding`, with the same options. Like any stream set with `#stream`, `stream(:encode, ...)` stops the streams being taken from the file name, while `#encoding` keeps them, so `IOStreams.path("data.csv.gz").encoding("BINARY")` reads the decompressed data. To read or write the data as it is stored, as `stream(:encode, encoding: "BINARY")` does for such a file name, use `#raw`, and to read a file that is not named for its format, set the file name that names its format with `#file_name`, then `#encoding` |
86
+ | `embedded_within:` with `each(:array)` or `each(:hash)` | `#format`, since the format of the file decides where the lines of its rows and records are split, such as `format(:psv)` for values that are not quoted |
87
+ | `#format_options`, and `format_options:` supplied to each call | `#schema`, which gives the `width` of each field of a fixed width file, see [Moving a layout to a schema](#moving-a-layout-to-a-schema) |
88
+ | `columns:`, `allowed_columns:`, `required_columns:`, `skip_unknown:` and `cleanse_header:`, even when they are nil, since v3.0 raises `ArgumentError` for them whatever their value | `#schema`, which describes the columns of the file on its path, see [Moving the column options to a schema](#moving-the-column-options-to-a-schema) |
89
+
90
+ `IOStreams.enforce_column_restrictions=` is deprecated too, see below, since it only applies to the column options.
91
+ v2.2 still follows it, so keep setting it to true, as [v2.1 recommends](#column-restrictions-on-uploaded-files),
92
+ until the column options are replaced with a schema, which applies to every input, and then remove it.
93
+
94
+ v3.0 removes these, or raises `ArgumentError` for them, so in v2.2 each still works as before, and reports a
95
+ deprecation warning:
96
+
97
+ | Deprecated | Use instead |
98
+ |------------|-------------|
99
+ | `format:`, `file_name:`, `original_file_name:` or `default_format:` supplied to `#each`, `#reader`, `#writer` or a copy, such as `each(:hash, format: :json)`, even when they are nil | `#format`, which sets the format on the path, so that every reader and writer of the path uses it: `format(:json).each(:hash)`. A file name supplied to a call only chooses the format, when the path has none, and not the streams, as `#file_name` does, so set the format that it names instead, such as `format(:json)` for `file_name: "data.json"`. `default_format:` only applies when the path has no format, so set it only then: `path.format(:json) unless path.format`. The rows of a spreadsheet are read as CSV, so set `format(:csv)` on its path |
100
+ | `allowed_columns:`, `required_columns:` or `skip_unknown:` supplied to `#writer` or a copy, which ignores them, even when they are nil | A schema, which describes the columns that are written, such as with `fieldsMatch: "subset"` to skip the other keys of each record |
101
+ | Options supplied to `#writer` when the stream is a line, row or record writer that is already open, such as the one that `writer(:line)` yields, handed to `IOStreams.stream`, which writes with its own settings, and ignores them, even when they are nil, other than a nil `delimiter:`, and the streams, encoding, format and schema set on such a stream, other than the format and schema of the rows or records written to a line writer, which apply | Set them where that writer is opened, or remove them |
102
+ | Options supplied to the `:stream` mode, the default of `#reader` and `#writer`, to a copy without a `mode:`, or to a copy with `convert: false`, including `mode:` | Remove them, since they are ignored |
103
+ | The Zip writer's `zip_file_name`, even when it is nil | `entry_file_name` |
104
+ | An option of `.bz2` that neither reading nor writing takes, which BZip2 ignores | Remove it |
105
+ | An option that neither the reader nor the writer of a stream takes, such as a misspelled one, set with `#option`, `#stream` or `#option_or_stream`, also when the file name does not include the stream, such as `IOStreams.path("data.csv").option(:pgp, recipent: "partner@example.org")`, which is ignored | Correct or remove it, since v3.0 raises `ArgumentError` for it when it is set |
106
+ | A file name ending in `.encode` | `#encoding`, which applies the encode stream whatever the file name, so the file need not be renamed |
107
+ | `#path=` | `#join` or `#directory`, which return a new path without the streams, options, format and schema set on the path, which `#path=` keeps, so set them again, or `IOStreams.path` with the new name |
108
+ | `#builder=` | `IOStreams.path`, to start again without the streams and options set with `#stream` or `#option` |
109
+ | A url part or S3 argument that a path ignores, reported when the path is created: an S3 url with a fragment, which an unencoded `#` starts, a port, a user name or password, or without a bucket, an SFTP url with a query or a fragment, an SFTP or HTTP url without a host, and `region:`, `access_key_id:` or `secret_access_key:` with an `Aws::S3::Client` as `client:` | Percent-encode a `#` in a key or name as `%23`, and a `?` in an SFTP name as `%3F`; supply the endpoint of a service that implements the S3 API with `client: {endpoint: "https://host:port"}`, S3 credentials with `access_key_id:` and `secret_access_key:`, and SFTP ssh options with `ssh_options:`; name the bucket or host; and supply the region and credentials to `Aws::S3::Client.new`, or `client:` as a Hash of its options |
110
+ | An option of an S3 path, or a name in the query of its url, that no S3 request takes, such as a misspelled one, or that the path sets itself, such as `key:` or `bucket:`, which it overrides, reported when the path is created | Correct or remove it, and percent-encode a `?` in a key as `%3F`. The options of the S3 requests that a path makes, such as `acl:`, are still accepted |
111
+ | `IOStreams.enforce_column_restrictions=` and `IOStreams.enforce_column_restrictions?` | A schema, whose `fieldsMatch` and `constraints` apply to every input, including the keys of each JSON record, see [Moving the column options to a schema](#moving-the-column-options-to-a-schema). The setting only applies to the column options, so keep it until they are replaced with a schema, and then remove it |
112
+ | The methods of a Struct, other than `reader_class` and `writer_class`, on the extension that `IOStreams.extensions` returns for a built-in extension, such as `IOStreams.extensions[:gz].to_a`, and the setters of every registered extension, such as `writer_class=`, also once it is no longer registered, such as the one that `IOStreams.deregister_extension` returns | `reader_class` and `writer_class`, which the format that v3.0 returns for a built-in extension, such as `IOStreams::Gzip`, answers too, and `IOStreams.register_extension` to change a registered extension, which v3.0 freezes |
113
+ | `IOStreams.register_extension(:encode, ...)`, in any case | Register the reader and writer under another name, and apply them with `#stream`, and set the encoding of the text with `#encoding`, since v3.0 builds in the encode stream |
114
+ | `IOStreams.deregister_extension(:encode)`, after which `#encoding` raises `ArgumentError` | Remove it: v3.0 applies the encode stream only where `#encoding` sets it, and does not deregister it |
115
+ | `IOStreams.register_extension(:none, ...)`, in any case | Register the stream under another name, since v3.0 reserves `:none` |
116
+ | A name that ends with a line break, such as `"xz\n"`, supplied to `IOStreams.register_extension` or `IOStreams.deregister_extension` | The name without the line break, since v3.0 raises `ArgumentError` for it |
117
+ | A name that is not in lower case, such as `:XZ`, supplied to `IOStreams.register_extension` or `IOStreams.deregister_extension` | The name in lower case, such as `:xz`, as v3.0 registers it, which matches the file names that end in `.xz`, in any case, where `:XZ` matched none |
118
+ | Ruby 3.2, reported once, as IOStreams is loaded, so only while deprecation warnings are on as it is loaded, such as with `RUBYOPT=-W:deprecated` | Ruby 3.3 or later, which v3.0 requires: it cannot be installed on Ruby 3.2 |
119
+ | `#io_stream` | The IO that you supply to `IOStreams.stream`, which the stream reads and writes |
120
+ | Changing a root path in place, such as `IOStreams.root.stream(:gz)` or `IOStreams.add_root(:default, "/data").option(...)`, which changes the root for the whole process. v3.0 keeps its own frozen copy of the root, so changing a path no longer changes the root: changing the path that `IOStreams.add_root` returns, which is that copy, raises `FrozenError`, a path supplied to it is not the root, and `IOStreams.root`, `IOStreams.roots` and `IOStreams.join` without elements return copies of it | A copy of the root, such as `IOStreams.root.dup.stream(:gz)`, or a path joined to it, such as `IOStreams.join("a.csv.gz").option(:gz, level: 9)`, which never has the streams, options, encoding, format or schema of the root, and for a named root, such as `:uploads`, `IOStreams.root(:uploads).dup` or `IOStreams.join("a.csv.gz", root: :uploads)` |
121
+ | `#move_to` onto an existing directory, which moves the file into it, and returns the directory | The file within the directory, such as `path.move_to(IOStreams.path("/archive").join(path.basename))`, which returns the file |
122
+ | A pattern supplied to `#each_child` or `#children` that is not a String, such as a `Pathname` | Its String, such as `pattern.to_s` |
123
+ | A format other than `:csv` set on a spreadsheet, such as `format(:psv)` on an `.xlsx` path, or named by its file name, such as `report.psv.xlsx`, when its lines, rows or records are read. v3.0 raises `ArgumentError` for a format that is set, and reads the rows as CSV whatever the file name | `format(:csv)`, which reads the rows as CSV in both versions, or no format on a path whose file name names none, since a spreadsheet is read as CSV |
124
+ | `delimiter:` supplied to `#each`, `#reader` or a copy of the rows and records of a spreadsheet, and an encoding set with `#encoding` when they are read, which v3.0 raises `ArgumentError` for, since they are its cells rather than lines of text | Neither: remove them. With a schema, v2.2 reads the text of the cells as UTF-8, as v3.0 does. Its lines, `each(:line)`, still take both |
125
+ | A method of the stream that a reader block receives for `.gz`, `.zip`, `.bz2` or `.enc`, or for PGP within another stream or from an IO that is not a local file, without `verify_first: true`, other than `#read`, `#readpartial`, `#gets`, `#readline`, `#readlines`, `#each_line`, `#each` and `#eof?`, such as `#rewind`, `#pos`, `#lineno` or `#map` of a `Zlib::GzipReader`. Only the calls that the application makes itself are reported, including through a `SimpleDelegator`, `DelegateClass` or `Forwardable` that wraps the stream | Those methods, or `each(:line)`. Open the reader again to read the data from the start. Where the stream must be a `File`, copy the data into a local file first, such as with `IOStreams.temp_file` and `#copy_to`, and read that file |
126
+ | A method of the `File` that a reader block receives for an S3 or HTTP(S) path without streams, or read with `#raw`, other than those of an IO that read, such as `#rewind`, `#size` or `#path` | The same, copying the object into a local file with `copy_to(temp, convert: false)` |
127
+ | A method of the IO that a writer block receives when writing PGP, other than `#write`, `#<<`, `#print`, `#puts`, `#printf` and `#flush`, such as `#fileno` | Those methods. Where it must be an IO, write a local file first, such as with `IOStreams.temp_file`, and copy it with `#copy_to` |
128
+ | A method of the `File` that a writer block receives for an S3 path without streams, other than `#write`, `#<<`, `#print`, `#puts`, `#printf` and `#flush`, such as `#path` or `#size` | Those methods. Where it must be a `File`, write a local file first, such as with `IOStreams.temp_file`, and copy it to the S3 path with `#copy_to` |
129
+ | `#encoding` or `#cleaner` of the stream that a reader or writer block receives when an encoding is set | The settings of the path: `path.encoding`, or `Encoding.find(path.encoding)` for an `Encoding`, and `path.setting(:encode)[:cleaner]` |
130
+
131
+ v3.0 changes the result of these calls, so in v2.2 each still gives the result that it gave before, and reports a
132
+ deprecation warning that names another way to write it, which gives the same result in v2.2 and v3.0:
133
+
134
+ | Deprecated | Use instead |
135
+ |------------|-------------|
136
+ | `IOStreams::Pgp.export(email: nil)` without a `key_id:`, and `IOStreams::Pgp.set_trust` without an `email:` or `key_id:`, which raise `IOStreams::Pgp::Failure`, where v3.0 raises `ArgumentError` | The email or key id of the key |
137
+ | The rows and records of a file named `.text`, such as `ids.text`, which are read and written as CSV, where v3.0 reads and writes them in its `:text` format, as the text of each line | `format(:csv)`, or the format of an extension before `.text`, such as `format(:json)` for `people.json.text` |
138
+ | `each(:array)` and `reader(:array)` of JSON without a schema, which yield each record as a Hash, where v3.0 yields each row as an Array, after a header row of the keys of the first record | `each(:hash)` to read the records, or a schema, with which v2.2 reads the rows as v3.0 does |
139
+ | Records read without a schema, by `each(:hash)`, `reader(:hash)` or a copy in the `:hash` mode, from a header row whose columns are the same key, such as `a,a,b`, or `A` and `a`, which are cleansed to the same key, and rows written to JSON under an `IOStreams::Row::HeaderRow` whose columns are the same key, each record of which holds the value of only the last of them, where v3.0 raises `IOStreams::Errors::InvalidHeader` | Columns of names of their own, or `each(:array)`, which reads every column, in both |
140
+ | A copy without a `mode:`, by `#copy_from` or `#copy_to`, between two formats, such as `people.csv` into `people.json` or `people.psv`, which copies the text of the source as it is, where v3.0 converts its rows into the format of the target, as the `:array` mode does | `mode: :array` to convert the rows, or `mode: :hash` when either file is JSON, whose rows are records, or `mode: :line` to copy the text as it is |
141
+ | The rows and records of a spreadsheet read without a type for each column, by `#each`, `#reader` or a copy in the `:array` or `:hash` mode: without a schema, or with a field of type `any`, or with `extraColumns: "keep"`, which read the text of each cell, such as `"1001.0"` without a schema, or `"1001"` with one, where v3.0 reads its value, as Excel typed it, such as the `Float` `1001.0`, a `Date` or `true` | A schema that gives each column that is read a type, such as `{name: "id", type: "integer"}`, which reads `1001` in both, `{name: "zip", type: "string"}` for the text of a column such as `01234`, or of a number, such as `2134`, which both read as the number that the cell holds, rather than `2134.0`, `{name: "ok", type: "boolean"}`, `{name: "at", type: "datetime"}` for a cell of a date and time, which both read at the time of its clock in UTC, or `{name: "opens", type: "string"}` for a cell of a time of day, which both read as the time of its clock, such as `09:30:00` |
142
+ | A `Time` or `DateTime` that is not at midnight, by its own clock, such as `Time.now`, converted by a `date` field of a schema, which drops its time of day, where v3.0 raises `IOStreams::Errors::InvalidValue` for it, while it takes one at midnight as its date. It is reported once for each field | A `Date`, converted in the time zone that the time belongs to, such as `time.to_date`, or the type `datetime` for the field, to keep its time |
143
+ | An SFTP or HTTP(S) url without a path, such as `sftp://host` or `https://host?a=1`, to which v3.0 adds a `/`, which changes its `#to_s` and `#path`, and the directory that SFTP `#each_child` lists, from the login directory to `/` | The url with the `/`, such as `sftp://host/` or `https://host/?a=1` |
144
+ | A name that starts with `/` joined to an S3 bucket, such as `IOStreams.path("s3://bucket").join("/exports/a.csv")`, which is the key `/exports/a.csv`, where v3.0 joins it as the key `exports/a.csv` | The name without the leading `/`, or for an object whose key starts with `/`, its url, such as `IOStreams.path("s3://bucket//exports/a.csv")` |
145
+ | A local file name of `~`, or starting with `~/`, which is within a directory called `~` in the current directory, where v3.0 reads it within the home directory | `File.expand_path("~/data/a.csv")` for the home directory, or `./~/data/a.csv` for the directory called `~` |
146
+ | A `file://` url, such as `file:///data/a.csv` or `file://~/a.csv`, which is a file name relative to the current directory, where v3.0 reads it as the local file that it names, such as `/data/a.csv`, or within the home directory | The name of the file, such as `/data/a.csv`, or `File.expand_path("~/a.csv")` for the home directory |
147
+ | A `Pathname` supplied to `IOStreams.new`, `#copy_from`, `#copy_to` or `#move_to`, which read and write it as an IO, where v3.0 takes it as the name of a file, applying the streams of its file name, or to `IOStreams.stream`, which raises `ArgumentError` for it in v3.0 | `pathname.to_s` or `IOStreams.path(pathname)`, and `#raw` on the path to read or write the data as it is stored |
148
+ | Reading or writing the path of `IOStreams.temp_file` when its extension names streams, such as `.csv.gz`, which reads and writes the data as it is stored, where v3.0 applies those streams | `#raw` on the path, to read and write the data as it is stored |
149
+ | An option set for another extension of the format of the file, such as `option(:pgp, ...)` for a `.gpg` file, `option(:gz, ...)` for `.gzip`, or `option(:xlsx, ...)` for `.xlsm`, which is ignored, where v3.0 applies it | The option of the extension in the file name, such as `option(:gpg, ...)` |
150
+
151
+ ### Moving a layout to a schema
152
+
153
+ `#format_options` is deprecated. It still works, and still reads and writes the file with its layout, as before, so
154
+ no change is needed yet. A schema gives the type of each field of every format, and can be held in a JSON file, see
155
+ [Schema](formats#schema). Each column of a layout is a field of the schema:
156
+
157
+ | Layout | Schema |
158
+ |--------|--------|
159
+ | `key:` | `name` |
160
+ | `size:` | `width` |
161
+ | `size: :remainder` | `width: "remainder"` |
162
+ | A column without a `key:`, a filler | `start` on the field after it, or the `width` of the schema at the end of the line |
163
+ | `type: :string` | `type: "string"`, with `truncate: true` unless the layout has `truncate: false` |
164
+ | `type: :integer` | `type: "integer"` |
165
+ | `type: :float` and `decimals:` | `type: "number"`, with `floatNumber: true` and `decimals:`, without `decimals` for a column with `size: :remainder`, which writes the digits that the value has |
166
+ | A record written with keys that the layout does not have, which are skipped | `fieldsMatch: "subset"` |
167
+
168
+ ~~~ruby
169
+ # Before (deprecated)
170
+ path.format(:fixed).format_options(
171
+ layout: [
172
+ {size: 23, key: "name"},
173
+ {size: 2},
174
+ {size: 5, key: "zip", type: :integer}
175
+ ]
176
+ )
177
+
178
+ # After
179
+ path.format(:fixed).schema(
180
+ fieldsMatch: "subset",
181
+ fields: [
182
+ {name: "name", type: "string", width: 23, truncate: true},
183
+ {name: "zip", type: "integer", width: 5, start: 26}
184
+ ]
185
+ )
186
+ ~~~
187
+
188
+ `IOStreams::Schema.from_layout` returns the schema of a layout as a Hash, such as
189
+ `IOStreams::Schema.from_layout(layout: layout)`, with `truncate: false` too when the layout has it. The columns of the
190
+ layout can have String keys, such as a layout read from JSON. A column after the column with `size: :remainder` is
191
+ left out, since the layout never reads it. It is only for moving off a layout, since v3.0 removes it, with
192
+ `#format_options`: convert each layout with it once, such as in a console, and save the schema that it returns, such
193
+ as in a JSON file, or as a Hash in the code, which `#schema` then sets, rather than call it each time a file is read
194
+ or written, which breaks once the application upgrades to v3.0. It reports no deprecation warning.
195
+
196
+ The keys of the records read are the names of the fields, so a layout with Symbol keys, such as `key: :name`, reads
197
+ Symbol keys, as does a schema whose field is named with that Symbol. A schema saved as a JSON file, or in a database
198
+ such as MongoDB, names its fields with Strings, so when every key of the layout is a Symbol, `from_layout` adds
199
+ `keyType: "symbol"`, which keeps the keys of its records Symbols, see
200
+ [Keys of the records read](formats#keys-of-the-records-read). Without `fieldsMatch: "subset"`, a schema has
201
+ `"exact"`, so that writing a record with a key that is not a field raises `IOStreams::Errors::InvalidHeader`, rather
202
+ than skip it as a layout does. A schema and a layout cannot both be set on a path: reading or writing it raises
203
+ `ArgumentError`.
204
+
205
+ The schema reads and writes the file as v3.0 does, which is not always as the layout does, so check that it reads
206
+ the files of the application before it replaces the layout. What reads differently:
207
+
208
+ * The `width` of each field counts the characters of the line, which is read as UTF-8, while the layout counts its
209
+ bytes. So a line with a character that is not ASCII, such as `é`, raises `IOStreams::Errors::InvalidLineLength`
210
+ when the program that wrote it counted bytes, and text that is not valid UTF-8, such as Latin-1, raises
211
+ `IOStreams::Errors::InvalidEncoding`. To count bytes, set the encoding of the file: `encoding("ISO-8859-1")` for
212
+ Latin-1, whose values are then ISO-8859-1 strings, or the name of another single-byte code page, or
213
+ `encoding("BINARY")` to read binary strings, as the layout does, such as for UTF-8 text whose sizes count bytes. See
214
+ [Schemas in IOStreams 2.2 and 3.0](formats#schemas-in-iostreams-22-and-30).
215
+ * An `integer` or `number` field that holds no number, such as `N/A` or `1,234.50`, raises
216
+ `IOStreams::Errors::InvalidValue`, where the layout reads it as `0`, or as the number that its leading digits make,
217
+ such as `12` for `12-` and `1.0` for `1,234.50`. A fraction in an `integer` field, such as `12.5`, raises too, where
218
+ the layout truncates it, while a whole number with a decimal point, such as `12.0`, is still read as `12`.
219
+ * A `number` field reads `NaN` and `Inf`, which the layout writes for a `Float` that is not finite, as `Float::NAN`
220
+ and `Float::INFINITY`, where the layout reads them as `0.0`.
221
+ * A `number` field with `decimals` rounds the number read to them, such as `12.35` for `12.346` with `decimals: 2`,
222
+ where the layout reads all of its digits.
223
+ * A line whose fields are all blank, even where a filler holds text, is a blank row, `nil`, which a copy skips, where
224
+ the layout reads a record of blank values, such as `{name: "", zip: nil}`.
225
+ * A `string` field of only spaces is `nil`, a missing value, where the layout reads empty text, `""`, see
226
+ [Types](formats#types).
227
+ * `each(:array)` yields the names of the fields first, as an `IOStreams::Row::HeaderRow`, and then an Array of the
228
+ values of each line, where the layout yields a Hash for each line, as `each(:hash)` does.
229
+
230
+ What writes differently:
231
+
232
+ * An `integer` or `number` field raises `IOStreams::Errors::InvalidValue` for a value that is not a number, such as
233
+ `"abc"`, or text of only spaces, such as `" "`, and an `integer` field for a fraction, such as `12.7`, where the
234
+ layout writes zeros, or truncates the fraction.
235
+ * A `string` field writes a `BigDecimal` as `12.5`, and a `Time` in ISO 8601, such as `2026-10-13T09:30:00Z`, where
236
+ the layout writes their `to_s`, such as `0.125e2` and `2026-10-13 09:30:00 UTC`. It raises
237
+ `IOStreams::Errors::InvalidValue` for a `Hash` or an `Array`, where the layout writes their `to_s`: to write their
238
+ JSON text, such as `{"a":1}`, give the field the type `object` or `array`.
239
+ * A `number` field with `decimals` writes a negative number that rounds to zero, such as `-0.001` with `decimals: 2`,
240
+ as zero, without a minus sign, where the layout writes one, such as `-0000.00`.
241
+ * A `number` field that takes the remainder of the line, without `decimals`, writes a whole number without a decimal
242
+ point, such as `12`, where the layout writes `12.0`.
243
+ * A `number` field writes infinity as `INF`, where the layout writes `Inf`, and a `string` field writes a `Float` that
244
+ is not finite as `NaN`, `INF` or `-INF`, where the layout writes `NaN`, `Infinity` or `-Infinity`.
245
+ * A filler is written as spaces, also where the layout gives it a `type:` of `:integer` or `:float`, which writes
246
+ zeros.
247
+ * A record whose keys are Strings, such as `"name"`, is written to fields named with Symbols, such as `:name`, and
248
+ the other way around, where the layout writes spaces for each of its columns, or zeros for a number.
249
+ * A column after the column with `size: :remainder` is not written, since it is not in the schema.
250
+
251
+ ### Moving the column options to a schema
252
+
253
+ `columns:`, `cleanse_header:`, `allowed_columns:`, `required_columns:` and `skip_unknown:` are deprecated. They still
254
+ work without a schema, so no change is needed yet. A [schema](formats#schema) describes the columns of the file on its
255
+ path instead, so that every reader and writer of the path, including a copy, checks them the same way. Its fields
256
+ name the keys of the records read, and each column of the header row is matched to a field whatever its case, spaces
257
+ and punctuation, or to one of the `aliases` of a field, see [Matching columns to fields](formats#matching-columns-to-fields).
258
+ So the keys no longer depend on how the cleansing rule changes a column, such as `Amount ($)` and `Amount (€)`, which
259
+ are both `amount_`.
260
+
261
+ To keep the keys of the records that the code reads, name each field as that key. The names that `allowed_columns:`
262
+ and `required_columns:` list already are, since they were compared to the columns once cleansed, such as `first_name`
263
+ for the column `First Name`, so use them as the names of the fields: the schema matches each column to the field
264
+ named as the column was cleansed. With `cleanse_header: false`, they are the columns as they are in the file, so use
265
+ them as they are.
266
+
267
+ With a schema, `columns:`, `allowed_columns:` and `required_columns:` raise `ArgumentError`, unless they are nil, and
268
+ so does `cleanse_header:`, even when it is nil. `skip_unknown:` only applies to `allowed_columns:`, so it is accepted.
269
+ A nil one, and `skip_unknown:`, have no effect with a schema, and report a deprecation warning, since v3.0 raises
270
+ `ArgumentError` for them, so remove them. The property of a schema that replaces each one:
271
+
272
+ | Option | With a schema |
273
+ |--------|---------------|
274
+ | `columns:` | The fields are the columns, in order: with `header: false` to read a file of a format with a header row, such as CSV, `fieldsMatch: "partial"` to read JSON, and `fieldsMatch: "subset"` to write. A `nil` column, which was not read, is a field with `skip: true`, see [Field properties](formats#field-properties) |
275
+ | `allowed_columns:` with `skip_unknown: false` | `fieldsMatch: "superset"`, which accepts a file without some of the fields, as `allowed_columns:` does, or `"equal"` when `required_columns:` lists every allowed column |
276
+ | `allowed_columns:` with `skip_unknown: true` | `fieldsMatch: "partial"`, which accepts a file without some of the fields, as `allowed_columns:` does, or `"subset"` when `required_columns:` lists every allowed column |
277
+ | `required_columns:` | `constraints: {required: true}` with `allowBlank: true` on each field, which needs the column but not a value, as `required_columns:` did, or a `fieldsMatch` that needs every field. Without `allowBlank`, a blank value raises too |
278
+ | `required_columns:` without `allowed_columns:`, which also read the other columns | Also `fieldsMatch: "subset"`, `extraColumns: "keep"` and `keyType: "cleansed"`, which keep the other columns, keyed as `cleanse_header` cleansed them, see [Keeping the other columns](formats#keeping-the-other-columns) |
279
+ | `cleanse_header:` | The keys of each record are the names of the fields, which the columns are matched to, or with `keyType: "cleansed"` the names once cleansed, as `cleanse_header: true` cleansed the columns, see [Keys of the records read](formats#keys-of-the-records-read) |
280
+ | `cleanse_header: false` without the other options, which read every column as it is written | `fieldsMatch: "subset"`, `extraColumns: "keep"` and no fields |
281
+
282
+ ~~~ruby
283
+ # Before (deprecated)
284
+ path.each(:hash, allowed_columns: %w[first_name zip_code], required_columns: %w[zip_code]) do |record|
285
+ p record
286
+ end
287
+
288
+ # After
289
+ path.schema(
290
+ fieldsMatch: "partial",
291
+ fields: [
292
+ {name: "first_name"},
293
+ {name: "zip_code", constraints: {required: true}, allowBlank: true}
294
+ ]
295
+ ).each(:hash) do |record|
296
+ p record
297
+ end
298
+
299
+ # Both read "First Name,Zip Code,Notes\nJack,12345,x\nJill,,y\n" as:
300
+ # {"first_name" => "Jack", "zip_code" => "12345"}
301
+ # {"first_name" => "Jill", "zip_code" => nil}
302
+ ~~~
303
+
304
+ Reading a CSV file without a header row:
305
+
306
+ ~~~ruby
307
+ # Before (deprecated)
308
+ IOStreams.path("no_header.csv").each(:hash, columns: ["name", "address", "zip"]) do |record|
309
+ p record
310
+ end
311
+
312
+ # After
313
+ path = IOStreams.path("no_header.csv").schema(
314
+ header: false,
315
+ fields: [
316
+ {name: "name"},
317
+ {name: "address"},
318
+ {name: "zip"}
319
+ ]
320
+ )
321
+ path.each(:hash) do |record|
322
+ p record
323
+ end
324
+ ~~~
325
+
326
+ Writing only some of the keys of each record, in a fixed order:
327
+
328
+ ~~~ruby
329
+ # Before (deprecated)
330
+ path = IOStreams.path("sample.csv")
331
+ path.writer(:hash, columns: ["name", "zip"]) do |io|
332
+ io << {"name" => "Jack Jones", "address" => "Somewhere", "zip" => 12345}
333
+ end
334
+
335
+ # After
336
+ path = IOStreams.path("sample.csv").schema(
337
+ fieldsMatch: "subset",
338
+ fields: [
339
+ {name: "name"},
340
+ {name: "zip"}
341
+ ]
342
+ )
343
+ path.writer(:hash) do |io|
344
+ io << {"name" => "Jack Jones", "address" => "Somewhere", "zip" => 12345}
345
+ end
346
+ ~~~
347
+
348
+ With a schema, `writer(:array)` writes the names of the fields as the header row, and takes each row as the values of
349
+ the fields, in order, as `columns:` did.
350
+
351
+ A schema reads the same records as the options it replaces, named as above, as it does in v3.0, except that:
352
+
353
+ * A column is matched to a field whatever its case, spaces and punctuation, so a column that `allowed_columns:` did
354
+ not list once cleansed, such as `FirstName` for `first_name`, is read.
355
+ * Two columns that are the same field, such as `Email` and `email`, or `Amount ($)` and `Amount (€)` for the field
356
+ `amount_`, raise `IOStreams::Errors::InvalidHeader`, where the value of the last one was kept.
357
+ * The keys of each record are in the order of the fields, rather than of the columns of the file.
358
+ * `each(:array)` yields a header row of the names of the fields, an `IOStreams::Row::HeaderRow`, and each row holds
359
+ only the values of the fields, where the column options left the header row and the rows as they are in the file.
360
+ * A value after the last column, which is not blank, raises `IOStreams::Errors::InvalidHeader` unless `fieldsMatch`
361
+ is `"subset"` or `"partial"`, where it was dropped.
362
+ * The text read is UTF-8, see [Schemas in IOStreams 2.2 and 3.0](formats#schemas-in-iostreams-22-and-30).
363
+ * Empty text, such as a quoted `""` in CSV or `""` in JSON, is a missing value, `nil`, where the column options read
364
+ it as it is, see [Types](formats#types). Writing works the same way: empty text is written as a missing value, an
365
+ empty value in CSV and `null` in JSON, where the column options wrote `""`.
366
+ * `IOStreams.enforce_column_restrictions` does not apply, since a schema applies to every input. So a schema applies
367
+ its fields to what the column options were not applied to, while `enforce_column_restrictions` is false, the
368
+ default: the keys of JSON records, the `columns:` of a file without a header row, and a header row read with
369
+ `cleanse_header: false`. One schema for the files of every format, such as uploads whose format is detected from
370
+ their file names, applies to each of them, where `allowed_columns:` restricted the columns of a CSV file but not the
371
+ keys of a JSON one. The names given to `columns:` were not cleansed, while a field named as one is matched to its
372
+ column loosely.
373
+ * A name that the cleansing of v2.1 named without its letters that are not ASCII, such as `prnom` for the column
374
+ `Prénom`, which an application may have kept in `allowed_columns:`, is not the field of that column, since a schema
375
+ cleanses it to `prénom`, which IOStreams 3.0 also does without a schema. With `fieldsMatch: "subset"` or `"partial"`
376
+ the column is skipped without an error, so name the field `prénom`, or keep the name and give the field the column
377
+ as an alias, such as `{name: "prnom", aliases: ["Prénom"]}`.
378
+ * A `Time` written to a field without a type is written in ISO 8601, such as `2026-10-13T09:30:00.123456Z`, where
379
+ `columns:` wrote its `to_s`, `2026-10-13 09:30:00 UTC`. A `datetime` field with
380
+ `format: "%Y-%m-%d %H:%M:%S UTC"` writes the text that `to_s` wrote.
381
+ * A key of a record written that is the field only once compared loosely, such as `LastName` for `last_name`, is
382
+ written, where `columns:` wrote an empty value, and so is a Symbol key for a field named with a String, such as
383
+ `:name` for `"name"`, where a layout wrote spaces.
384
+
385
+ Every use of the options has a schema form, see the table above, including `required_columns:` without
386
+ `allowed_columns:` and `cleanse_header: false` on its own, which read every column, with `extraColumns: "keep"`, and
387
+ `columns:` with `nil` for a column to skip, with `skip: true`. The options still work, so keep them until the schema
388
+ has been checked against the files of the application, since it reads some of them differently, see above.
389
+
390
+ ### Changes in behavior
391
+
392
+ - A failed copy no longer deletes a target that it did not write. `#copy_from`, `#copy_to` and `#move_to` open the
393
+ source before the target, as v3.0 does, and an error raised before the target is opened, such as for a source that
394
+ does not exist, or a mode that a copy does not convert with, leaves an existing target as it was, where v2.1 deleted
395
+ it, and a failed copy to S3 keeps the existing object. A copy that fails part way still leaves no part of the file,
396
+ see [When a copy fails](copy_files#when-a-copy-fails).
397
+ - A mode that a copy does not convert with, such as `copy_from(source, mode: :stream)`, raises `ArgumentError` before
398
+ the source or the target is opened, with a message that starts with v2.1's, `Invalid mode: :stream`, and then lists
399
+ the modes.
400
+ - A copy of a path, made with `dup`, has its own streams and options, so that changing it does not change the path
401
+ that it was copied from, and a copy with `convert: false` no longer changes the streams of the source or the
402
+ target. A copy with `convert: false` from a source with options set with `#option` copies it, where v2.1 raised
403
+ `ArgumentError`.
404
+
14
405
  ## Upgrading to v2.1
15
406
 
16
407
  v2.1 is a security release, and is backward compatible except for `IOStreams::Pgp.delete_keys`,
17
408
  described below. Most applications upgrade without any code changes. The changes that would break
18
- existing code are postponed to v3.0, and log a warning in v2.1. See [Coming in v3.0](#coming-in-v30).
409
+ existing code are postponed to v3.0, see [Coming in v3.0](#coming-in-v30), which says which of them
410
+ log a warning.
19
411
 
20
412
  ### Changes that may need code changes
21
413
 
@@ -99,7 +491,7 @@ IOStreams.path("s3://my-bucket-name/uploads").join(untrusted_name)
99
491
 
100
492
  A username and password supplied in an HTTP or SFTP url remain part of it, so `#to_s` returns
101
493
  them, as does any log or error message that includes the path. Supply them with the `username:`
102
- and `password:` arguments instead:
494
+ and `password:` arguments instead, and log `#display_name`, which never includes them:
103
495
 
104
496
  ~~~ruby
105
497
  # Returns the password from #to_s
@@ -160,41 +552,96 @@ IOStreams.enforce_column_restrictions = true
160
552
  ~~~
161
553
 
162
554
  See [Column restrictions apply to every input](#column-restrictions-apply-to-every-input) for what
163
- it changes.
555
+ it changes. v2.2 deprecates this setting, with the column options that it applies to: a schema applies to every input
556
+ instead, see [Moving the column options to a schema](#moving-the-column-options-to-a-schema).
164
557
 
165
558
  ## Coming in v3.0
166
559
 
167
- These changes are postponed to v3.0 since they could break existing code. Each one logs a warning
168
- in v2.1 via `IOStreams.logger` when it would change the result, so check your logs for them.
560
+ These changes are postponed to v3.0 since they could break existing code. v2.1 and v2.2 log a warning
561
+ via `IOStreams.logger` when the column restrictions or the BZip2 options would change the result, so
562
+ check your logs for them, and v2.2 reports PGP `export` without an email or key id as deprecated.
563
+ v3.0 makes many more changes, see its
564
+ [upgrading guide](https://github.com/reidmorrison/iostreams/blob/main/docs/upgrading.md#upgrading-to-v30), and
565
+ [Upgrading to v2.2](#upgrading-to-v22) for the deprecations that v2.2 reports to prepare for it. v3.0 also changes
566
+ what the encode stream reads and writes, which v2.2 cannot report, see
567
+ [What `#encoding` reads and writes in v3.0](#what-encoding-reads-and-writes-in-v30).
169
568
 
170
569
  ### Column restrictions apply to every input
171
570
 
172
- When reading records, `allowed_columns`, `required_columns` and `skip_unknown` will apply to every
173
- input. In v2.1 they are ignored for JSON and `:hash` input, when `columns:` is supplied, and with
174
- `cleanse_header: false`.
571
+ v3.0 removes the column options of each call, `columns:`, `allowed_columns:`, `required_columns:`,
572
+ `skip_unknown:` and `cleanse_header:`, and `IOStreams.enforce_column_restrictions=` with them, which
573
+ v2.2 deprecates. A schema describes the columns of the file instead, and its `fieldsMatch` and
574
+ `constraints` apply to every input: a header row, the columns of a file without one, and the keys of
575
+ each JSON record. In v2.1 and v2.2 the column options are ignored for JSON and `:hash` input, when
576
+ `columns:` is supplied, and with `cleanse_header: false`, unless `IOStreams.enforce_column_restrictions`
577
+ is true, so renaming an upload from `.csv` to `.json` bypasses them.
175
578
 
176
- When either `allowed_columns` or `required_columns` is set, JSON keys will be cleansed the same way
177
- as a header row, for example `"Name"` becomes `"name"`. Unknown keys will be skipped, or raise
178
- `IOStreams::Errors::InvalidHeader` when `skip_unknown: false`, and a record that is missing a
179
- required column will raise `IOStreams::Errors::InvalidHeader`.
180
-
181
- To prepare: set `IOStreams.enforce_column_restrictions = true`, and check that the JSON files you
182
- read with these options have the expected keys. See [Header options](formats#header-options).
579
+ To prepare: describe the columns with a schema, see
580
+ [Moving the column options to a schema](#moving-the-column-options-to-a-schema), and check that it reads
581
+ the files of the application as the column options did. Until then, keep
582
+ `IOStreams.enforce_column_restrictions = true` where the column options restrict what an upload can set,
583
+ since v2.2 still applies it, see [Header options](formats#header-options).
183
584
 
184
585
  ### BZip2 options are strict
185
586
 
186
- The BZip2 reader and writer ignore any option they do not accept, and log a warning. In v3.0 it will
187
- raise `ArgumentError`, like every other stream. They accept `autoclose`, `first_only` and `small` when
587
+ The BZip2 reader and writer ignore any option they do not accept, and log a warning. In v3.0 an option
588
+ that neither of them takes, such as a misspelled one, raises `ArgumentError` when it is set, like that of
589
+ every other stream, while an option of the other direction, such as `block_size` when reading, is
590
+ accepted, and ignored, as v2.1 and v2.2 do. They accept `autoclose`, `first_only` and `small` when
188
591
  reading, and `autoclose`, `block_size` and `work_factor` when writing.
189
592
 
190
- To prepare: remove the option, or use a separate path for reading and for writing. See
191
- [Reading and writing need separate options](streams#reading-and-writing-need-separate-options).
593
+ To prepare: remove an option that neither of them takes, which v2.2 reports as deprecated when it is
594
+ set, see [Deprecated, and what replaces it](#deprecated-and-what-replaces-it).
192
595
 
193
596
  ### PGP `export` without an email or key id
194
597
 
195
598
  `IOStreams::Pgp.export(email: nil)` without a `key_id:` raises `IOStreams::Pgp::Failure`, as it did
196
- before v2.1. In v3.0 it will raise `ArgumentError`. Calling it without either argument already raises
197
- `ArgumentError`.
599
+ before v2.1, and so does `IOStreams::Pgp.set_trust` without an `email:` or `key_id:`, since gpg rejects
600
+ the empty user id that it is asked for. In v3.0 they raise `ArgumentError`, so v2.2 reports them as
601
+ deprecated. Calling `export` without either argument already raises `ArgumentError`.
602
+
603
+ To prepare: supply the email or key id of the key, or rescue `ArgumentError` as well as
604
+ `IOStreams::Pgp::Failure`.
605
+
606
+ ### What `#encoding` reads and writes in v3.0
607
+
608
+ `#encoding` sets the encode stream with the same options, and the same results, as `option(:encode, ...)`,
609
+ which it replaces. v3.0 changes what that stream reads and writes, so these results of `#encoding` change
610
+ on upgrading, while the call stays the same, which v2.2 cannot report as deprecated. Each says what gives
611
+ the same result in v2.2 and v3.0, where something does:
612
+
613
+ - **`replace:` keeps the valid characters.** v2.2 takes the data read as binary, so `replace:` replaces
614
+ each byte that is not ASCII, even of a valid character: `encoding("UTF-8", replace: "")` reads
615
+ `José,\xFF` as `Jos,`, while v3.0 takes the data as text in the encoding, and only replaces what is not
616
+ valid in it, `José,`. So does `encoding("Windows-1252", replace: "")` for Windows-1252 text. To keep
617
+ only ASCII on both, use `encoding("US-ASCII", replace: "")`. To keep the valid UTF-8 characters on
618
+ both, read with `encoding("BINARY")`, and replace the others yourself, such as with
619
+ `line.force_encoding("UTF-8").scrub("")`.
620
+ - **Text that is not valid in the encoding raises `IOStreams::Errors::InvalidEncoding`**, an
621
+ `Encoding::UndefinedConversionError`, on every read, naming its line. v2.2 raises `ArgumentError` for
622
+ lines, rows and records, and `Encoding::UndefinedConversionError` for `#read`, and a write raises
623
+ `Encoding::UndefinedConversionError` on both. To prepare, rescue
624
+ `ArgumentError, Encoding::UndefinedConversionError`.
625
+ - **A byte order mark is removed** from the start of the data, so the first line, or the first value of
626
+ the first row, no longer starts with `"\uFEFF"`, which v2.2 keeps with `encoding("UTF-8")`. To get the
627
+ same on both, remove it yourself, such as with `line.delete_prefix("\uFEFF")`.
628
+ - **`read(length)` returns whole characters**, at least one, so that a character is not split between
629
+ reads. v2.2 returns `length` bytes, which can end within a character, in the same String for each
630
+ read. To read the same bytes on both, read without `#encoding`, or with `encoding("BINARY")`.
631
+ - **Text written to a `StringIO` keeps the encoding that is set.** v3.0 writes the bytes of the
632
+ encoding, such as of Windows-1252 for `IOStreams.stream(io).encoding("Windows-1252")`, while in v2.2 a
633
+ `StringIO` of a UTF-8 String, such as `StringIO.new`, converts them back to UTF-8. Supply a `StringIO`
634
+ of a binary String, `StringIO.new("".b)`, which holds the bytes of the encoding on both.
635
+ - **`#read` of the whole file takes the data as text in the encoding**, as reading lines does. v2.2
636
+ converts it into the encoding instead: from binary for most files, so that `encoding("UTF-8").read`
637
+ raises `Encoding::UndefinedConversionError` for any character that is not ASCII, and from
638
+ `Encoding.default_external` for a `.gz` or `.enc` file, so that `encoding("Windows-1252").read` converts
639
+ UTF-8 text into Windows-1252, and raises for Windows-1252 text. To get the same on both, read the lines,
640
+ rows or records, or `#read` without `#encoding`, and call `force_encoding` on the String, such as
641
+ `read.force_encoding("Windows-1252")`.
642
+
643
+ See the [upgrading guide](https://github.com/reidmorrison/iostreams/blob/main/docs/upgrading.md#text-and-encodings)
644
+ of v3.0 for the other changes to text, such as reading it as UTF-8 by default.
198
645
 
199
646
  ## Upgrading to v2.0
200
647