iostreams 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/docs/config.md +13 -9
  3. data/docs/copy_files.md +20 -2
  4. data/docs/extensions.md +18 -4
  5. data/docs/formats.md +644 -16
  6. data/docs/path.md +18 -2
  7. data/docs/streams.md +54 -9
  8. data/docs/tutorial.md +14 -2
  9. data/docs/upgrading.md +470 -23
  10. data/lib/io_streams/builder/deprecations.rb +250 -0
  11. data/lib/io_streams/builder.rb +256 -39
  12. data/lib/io_streams/bzip2/reader.rb +18 -2
  13. data/lib/io_streams/bzip2/writer.rb +11 -2
  14. data/lib/io_streams/deprecation/methods.rb +51 -0
  15. data/lib/io_streams/deprecation/once.rb +27 -0
  16. data/lib/io_streams/deprecation/option_names.rb +26 -0
  17. data/lib/io_streams/deprecation/words.rb +42 -0
  18. data/lib/io_streams/deprecation.rb +259 -0
  19. data/lib/io_streams/encode/reader.rb +54 -3
  20. data/lib/io_streams/encode/writer.rb +19 -2
  21. data/lib/io_streams/encode.rb +99 -0
  22. data/lib/io_streams/errors.rb +51 -0
  23. data/lib/io_streams/gzip/reader.rb +11 -0
  24. data/lib/io_streams/io_streams.rb +365 -35
  25. data/lib/io_streams/line/reader.rb +2 -1
  26. data/lib/io_streams/line/utf8_reader.rb +23 -0
  27. data/lib/io_streams/path.rb +125 -35
  28. data/lib/io_streams/paths/file.rb +72 -9
  29. data/lib/io_streams/paths/http.rb +48 -2
  30. data/lib/io_streams/paths/matcher.rb +6 -3
  31. data/lib/io_streams/paths/s3.rb +180 -9
  32. data/lib/io_streams/paths/sftp.rb +47 -3
  33. data/lib/io_streams/paths.rb +23 -0
  34. data/lib/io_streams/pgp/reader.rb +18 -0
  35. data/lib/io_streams/pgp/writer.rb +16 -0
  36. data/lib/io_streams/pgp.rb +16 -2
  37. data/lib/io_streams/reader.rb +44 -0
  38. data/lib/io_streams/record/reader.rb +59 -68
  39. data/lib/io_streams/record/writer.rb +17 -10
  40. data/lib/io_streams/row/header_row.rb +21 -0
  41. data/lib/io_streams/row/reader.rb +52 -15
  42. data/lib/io_streams/row/writer.rb +64 -13
  43. data/lib/io_streams/schema/columns.rb +456 -0
  44. data/lib/io_streams/schema/constraints.rb +99 -0
  45. data/lib/io_streams/schema/field.rb +967 -0
  46. data/lib/io_streams/schema/json_number.rb +56 -0
  47. data/lib/io_streams/schema/keys.rb +78 -0
  48. data/lib/io_streams/schema/pattern.rb +257 -0
  49. data/lib/io_streams/schema/source.rb +31 -0
  50. data/lib/io_streams/schema.rb +616 -0
  51. data/lib/io_streams/stream.rb +836 -67
  52. data/lib/io_streams/symmetric_encryption/reader.rb +8 -0
  53. data/lib/io_streams/tabular/header.rb +29 -0
  54. data/lib/io_streams/tabular/header_mapping.rb +332 -0
  55. data/lib/io_streams/tabular/mapping.rb +165 -0
  56. data/lib/io_streams/tabular/parser/array.rb +5 -0
  57. data/lib/io_streams/tabular/parser/base.rb +24 -0
  58. data/lib/io_streams/tabular/parser/fixed.rb +197 -0
  59. data/lib/io_streams/tabular/parser/fixed_fields.rb +160 -0
  60. data/lib/io_streams/tabular/parser/hash.rb +10 -0
  61. data/lib/io_streams/tabular/parser/json.rb +17 -0
  62. data/lib/io_streams/tabular/parser/json_fields.rb +132 -0
  63. data/lib/io_streams/tabular/schema_mapping.rb +361 -0
  64. data/lib/io_streams/tabular.rb +344 -30
  65. data/lib/io_streams/utils.rb +441 -6
  66. data/lib/io_streams/version.rb +1 -1
  67. data/lib/io_streams/writer.rb +27 -0
  68. data/lib/io_streams/xlsx/cells.rb +127 -0
  69. data/lib/io_streams/xlsx/reader.rb +56 -0
  70. data/lib/io_streams/xlsx/schema_reader.rb +90 -0
  71. data/lib/io_streams/xlsx/v3_writer.rb +16 -0
  72. data/lib/io_streams/zip/reader.rb +7 -0
  73. data/lib/io_streams/zip/writer.rb +24 -2
  74. data/lib/iostreams.rb +21 -17
  75. metadata +28 -2
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module IOStreams
6
+ class Schema
7
+ # The text of a number, which JSON writes as it is, rather than through a Float, so that a BigDecimal keeps all of
8
+ # its digits, such as `12345678901234567.89`, and the digits after the decimal point that its field writes, such as
9
+ # `12.50`, see `IOStreams::Schema::Field#render`.
10
+ #
11
+ # The json gem writes the text that #to_json returns. It is a Numeric, whose #as_json is itself, so that
12
+ # ActiveSupport, which replaces how Ruby writes JSON, writes it as a number too: its encoder keeps a Numeric that
13
+ # #as_json returns, and then has the json gem write it.
14
+ class JsonNumber < Numeric
15
+ # Returns [JSON::Fragment|IOStreams::Schema::JsonNumber] the text of the number in the form that the json gem
16
+ # writes as it is: a `JSON::Fragment` from json 2.10, which its coder writes without calling back into Ruby,
17
+ # see `IOStreams::Tabular::Parser::Json`, otherwise an `IOStreams::Schema::JsonNumber`.
18
+ def self.fragment(text)
19
+ defined?(::JSON::Fragment) ? ::JSON::Fragment.new(text) : new(text)
20
+ end
21
+
22
+ # Returns [String] the text of the number.
23
+ attr_reader :text
24
+
25
+ def initialize(text)
26
+ super()
27
+ @text = text
28
+ freeze
29
+ end
30
+
31
+ # Returns [String] the number as JSON.
32
+ def to_json(*)
33
+ text
34
+ end
35
+
36
+ # Returns [IOStreams::Schema::JsonNumber] itself, a number, which ActiveSupport writes with #to_json.
37
+ def as_json(*)
38
+ self
39
+ end
40
+
41
+ # Returns [String] the text of the number.
42
+ def to_s
43
+ text
44
+ end
45
+
46
+ def ==(other)
47
+ other.is_a?(JsonNumber) && other.text == text
48
+ end
49
+ alias eql? ==
50
+
51
+ def hash
52
+ text.hash
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IOStreams
4
+ class Schema
5
+ # The keys of the records read with a schema: how each is formed from the name of a field, as `keyType` says, see
6
+ # `IOStreams::Schema`, and the key of each field of the schema.
7
+ class Keys
8
+ # The values of `keyType`.
9
+ TYPES = %w[string cleansed symbol cleansedSymbol].freeze
10
+
11
+ # Returns [String|nil] how the keys are formed from the names, see `keyType` in `IOStreams::Schema`, or nil when
12
+ # they are the names as they are given.
13
+ attr_reader :type
14
+
15
+ # Parameters:
16
+ # type: [String|nil]
17
+ # The `keyType` of the schema.
18
+ #
19
+ # fields: [Array<IOStreams::Schema::Field>]
20
+ # The fields of the schema.
21
+ #
22
+ # Raises ArgumentError for a type that it does not know, and when two fields have the same key, such as
23
+ # "First Name" and "first-name", which are both "first_name" with keyType "cleansed", since a record could not
24
+ # hold both.
25
+ def initialize(type, fields)
26
+ @type = check_type(type)
27
+ @keys = build(fields)
28
+ freeze
29
+ end
30
+
31
+ # Returns [String|Symbol] the key in the records read of the field or column with the supplied name, formed as
32
+ # `keyType` says: the name as it is given without one. A String is frozen, so that a record uses it as its key
33
+ # without copying it.
34
+ def key_for(name)
35
+ case @type
36
+ when nil then name.is_a?(String) ? -name : name
37
+ when "string" then -name.to_s
38
+ when "cleansed" then -IOStreams::Schema.cleansed(name)
39
+ when "symbol" then name.is_a?(Symbol) ? name : name.to_s.to_sym
40
+ else IOStreams::Schema.cleansed(name).to_sym
41
+ end
42
+ end
43
+
44
+ # Returns [String|Symbol] the key of the field in the records read, see #key_for.
45
+ def [](field)
46
+ @keys.fetch(field)
47
+ end
48
+
49
+ private
50
+
51
+ def check_type(type)
52
+ return if type.nil?
53
+ return type if TYPES.include?(type)
54
+
55
+ raise(ArgumentError, "The schema keyType must be one of #{TYPES[0..-2].map(&:inspect).join(', ')} and " \
56
+ "#{TYPES.last.inspect}: #{type.inspect}")
57
+ end
58
+
59
+ def build(fields)
60
+ keys = {}.compare_by_identity
61
+ named = {}
62
+ fields.each do |field|
63
+ key = key_for(field.name)
64
+ other = named[key]
65
+ if other
66
+ raise(ArgumentError, "The fields #{other.name.inspect} and #{field.name.inspect} both have the key " \
67
+ "#{key.inspect}, which keyType #{@type.inspect} forms from their names, but a record " \
68
+ "read holds each field by a key of its own")
69
+ end
70
+
71
+ named[key] = field
72
+ keys[field] = key
73
+ end
74
+ keys
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module IOStreams
6
+ class Schema
7
+ # The pattern of the text of a date or datetime field, its `format`, which Frictionless writes in the syntax of
8
+ # Python and C's strptime, see https://datapackage.org/standard/table-schema/, and which IOStreams reads and writes
9
+ # with Ruby's strptime and strftime, whose directives are mostly the same:
10
+ # %Y the year in exactly 4 digits, as Python reads it, such as "2026", where Ruby reads as many digits as there
11
+ # are, so that "26" would be the year 26. So does `%G`, the year of an ISO 8601 week.
12
+ # %y the year of the century in exactly 2 digits, as Python reads it, such as "05", where Ruby also reads "5".
13
+ # %f the microseconds, which Ruby does not have: 1 to 6 digits of the fraction of a second when reading, such as
14
+ # "5" for half a second, and 6 digits when writing, such as "500000".
15
+ # %H the hour, from 00 to 23, as Python reads it, where Ruby also reads 24, and `%S` the second, from 00 to 59,
16
+ # where Ruby also reads the leap second 60, see #parts.
17
+ #
18
+ # Nothing is read on the day that it is read, as Ruby's strptime reads a date that the text leaves out, or part of
19
+ # one, so that a file reads the same on every day. As Python reads them:
20
+ # - A pattern that reads a day of the month, `%d` or `%e`, without a year, `%Y`, `%y` or `%G`, such as `%m/%d`,
21
+ # raises ArgumentError when it is created, as Python's strptime does from 3.15, since it cannot read February 29.
22
+ # So does one that reads an ISO 8601 week, `%G` or `%V`, without the year, week and weekday that give its date,
23
+ # see #check_date.
24
+ # - Text without a year, such as that of `%H:%M`, `%m %H:%M` or `%I:%M %p`, is on 1900-01-01, in the month that it
25
+ # reads, and the day of the year of `%j` counts from 1900-01-01, while a weekday or a week, such as `%a` or `%U`,
26
+ # is ignored, see #default_date.
27
+ #
28
+ # Every other directive is Ruby's, which reads and writes the directives of Python the same way. A directive that
29
+ # reads several, such as `%F`, `%c` or Ruby's `%+`, reads its year as they do, see COMPOSITES. Ruby's own
30
+ # directives, such as `%N`, `%L`, `%s` and `%+`, still work, but other Frictionless tools do not read them, and `%s`
31
+ # reads whole seconds, so that the fraction after it, such as in `%s.%f`, is ignored.
32
+ class Pattern
33
+ # A directive of a pattern: `%%`, or `%` with its flags, width, modifier `E` or `O`, and letter, such as `%d`,
34
+ # `%-m`, `%:z` or `%EY`, whose letter is captured. A width does not start with `0`, which is a flag, so that the
35
+ # flags and the width never match the same characters, which would take time that grows with the square of their
36
+ # length.
37
+ DIRECTIVE = /%(?:%|[-_0^#:]*(?:[1-9]\d*)?[EO]?([a-zA-Z+]))/
38
+
39
+ # The directives that Ruby's strptime reads as a pattern of several, and those patterns, which a pattern is
40
+ # expanded with before it is read, so that the digits of the year in each one are checked, see DIGITS. Writing
41
+ # keeps the directive, which Ruby's strftime writes as that pattern, apart from `%+`, see WRITE_DIRECTIVES.
42
+ COMPOSITES = {
43
+ "%+" => "%a %b %e %H:%M:%S %Z %Y",
44
+ "%c" => "%a %b %e %H:%M:%S %Y",
45
+ "%D" => "%m/%d/%y",
46
+ "%F" => "%Y-%m-%d",
47
+ "%v" => "%e-%b-%Y",
48
+ "%x" => "%m/%d/%y"
49
+ }.freeze
50
+
51
+ # A directive with the modifier `E` or `O`, of the alternative form of a locale, which Ruby's strptime reads as the
52
+ # directive without it, such as `%EY` as `%Y`, see #expand.
53
+ MODIFIED = /\A%(?:E([cCxXyY])|O([deHImMSuUVwWy]))\z/
54
+
55
+ # The directive of Python's microseconds, which Ruby does not have.
56
+ MICROSECONDS = "%f"
57
+
58
+ # The directives that Ruby's strptime does not read, and those of Ruby that read them: the microseconds.
59
+ READ_DIRECTIVES = {MICROSECONDS => "%N"}.freeze
60
+
61
+ # The directives that Ruby's strftime of a Time does not write, and those of Ruby that write them: the
62
+ # microseconds, in 6 digits, and `%+`, the form of date(1), as the strftime of a Date writes it, with the offset
63
+ # of its zone, such as "Sat Oct 31 09:30:00 +00:00 2026".
64
+ WRITE_DIRECTIVES = {MICROSECONDS => "%6N", "%+" => "%a %b %e %H:%M:%S %:z %Y"}.freeze
65
+
66
+ # The directives whose digits Ruby counts differently than Python, and the digits that Python reads for each one.
67
+ DIGITS = {"%Y" => "\\d{4}", "%G" => "\\d{4}", "%y" => "\\d{2}", MICROSECONDS => "\\d{1,6}"}.freeze
68
+
69
+ # The letters of the directives that write a zone, see #zone?.
70
+ ZONES = %w[z Z +].freeze
71
+
72
+ # The year, month and day that Python's strptime gives the parts of a date that text without a year leaves out,
73
+ # see #default_date.
74
+ DEFAULT_DATE = [1900, 1, 1].freeze
75
+
76
+ # The parts of the text, as `Date._strptime` returns them, that give its year, with which Ruby's strptime reads its
77
+ # date as Python's does, see #dated?: the year of `%Y` or `%y`, that of an ISO 8601 week, `%G`, and the seconds
78
+ # since 1970 of `%s` and `%Q`.
79
+ YEAR_PARTS = %i[year cwyear seconds].freeze
80
+
81
+ # The letters of the directives that read a year, which a pattern that reads a day of the month needs, see
82
+ # #check_date: `%Y`, `%y` and `%G`.
83
+ YEARS = %w[Y y G].freeze
84
+
85
+ # The letters of the directives that read a day of the month, `%d` and `%e`.
86
+ DAYS = %w[d e].freeze
87
+
88
+ # The letters of the directives that read a weekday, `%a`, `%A`, `%u` and `%w`, which an ISO 8601 week needs.
89
+ WEEKDAYS = %w[a A u w].freeze
90
+
91
+ # The start of a pattern of Ruby's strptime that reads a number, after which a directive before it that reads as
92
+ # many digits as there are reads only its own number of them, see #checks.
93
+ NUMERIC = /\A(?:\d|%[EO]?[CDdeFGgHIjkLlMmNQRrSsTUuVvWwXxYy\d])/
94
+
95
+ # Parameters:
96
+ # pattern: [String]
97
+ # The pattern, such as "%d/%m/%Y", in the syntax of Python's strptime.
98
+ #
99
+ # Raises ArgumentError for a pattern that reads a day of the month without a year, or an ISO 8601 week without its
100
+ # year, week and weekday, see #check_date.
101
+ def initialize(pattern)
102
+ read = expand(pattern)
103
+ check_date(pattern, read.scan(DIRECTIVE).flatten.compact)
104
+ @write = -ruby(pattern, WRITE_DIRECTIVES)
105
+ @read = -ruby(read, READ_DIRECTIVES)
106
+ @checks = checks(read).freeze
107
+ @zone = pattern.scan(DIRECTIVE).flatten.compact.intersect?(ZONES)
108
+ freeze
109
+ end
110
+
111
+ # Returns [true|false] whether the pattern writes a zone, such as `%z`, `%:z` or `%Z`, so that a time written
112
+ # with it says its own offset from UTC.
113
+ def zone?
114
+ @zone
115
+ end
116
+
117
+ # Returns [String] the text of the Date or Time in the pattern.
118
+ def strftime(value)
119
+ value.strftime(@write)
120
+ end
121
+
122
+ # Returns [Hash] the parts of the text, as `Date._strptime` returns them, such as `{year: 2026, mon: 10}`.
123
+ # Returns nil when the pattern does not match all of the text, or a year does not have exactly 4 digits, or 2 for
124
+ # `%y`, or microseconds 1 to 6, or its time is not on a clock, see #clock?, as Python reads them.
125
+ def parts(text)
126
+ parts = Date._strptime(text, @read)
127
+ return if parts.nil? || parts.key?(:leftover) || !clock?(parts)
128
+
129
+ parts if @checks.all? { |prefix, digits| digits.match?(text, offset(text, prefix)) }
130
+ end
131
+
132
+ # Returns [Date] the date of the text, whose parts #parts returned: as Ruby's strptime reads it when the parts give
133
+ # its year, see #dated?, otherwise as Python's strptime gives it, see #default_date, such as 1900-07-01 for `07`
134
+ # with `%m`, rather than in the year that it is read, which Ruby's strptime gives it.
135
+ #
136
+ # Raises `Date::Error` for a date that does not exist, such as February 30.
137
+ def date(text, parts)
138
+ dated?(parts) ? Date.strptime(text, @read) : default_date(parts)
139
+ end
140
+
141
+ # Returns [DateTime] the time of the text, whose parts #parts returned: as Ruby's strptime reads it when the parts
142
+ # give its year, see #dated?, otherwise on the date that Python's strptime gives it, see #default_date, such as
143
+ # 1900-01-01 09:30 for `09:30` with `%H:%M`, rather than on the day that it is read, which Ruby's strptime gives it.
144
+ #
145
+ # Raises `Date::Error` for a date that does not exist, such as February 30.
146
+ def datetime(text, parts)
147
+ return DateTime.strptime(text, @read) if dated?(parts)
148
+
149
+ date = default_date(parts)
150
+ seconds = parts.fetch(:sec, 0) + parts.fetch(:sec_fraction, 0)
151
+ DateTime.new(date.year, date.month, date.day, parts.fetch(:hour, 0), parts.fetch(:min, 0), seconds,
152
+ Rational(parts.fetch(:offset, 0), 86_400))
153
+ end
154
+
155
+ private
156
+
157
+ # Raises ArgumentError, naming the pattern, when its directives, the letters of the supplied pattern of Ruby's
158
+ # strptime that it expands to, see #expand, cannot read every date as Python's strptime reads it, rather than read
159
+ # some in the year that they are read, as Ruby's strptime reads them, or raise for every value, as Python's does:
160
+ # - A day of the month, `%d` or `%e`, without a year, `%Y`, `%y` or `%G`, such as `%m/%d`, which Python's strptime
161
+ # raises ValueError for from 3.15, since the year that it gives text without one, 1900, is not a leap year, so
162
+ # that February 29 cannot be read.
163
+ # - An ISO 8601 week, see #check_week.
164
+ def check_date(pattern, letters)
165
+ if letters.intersect?(DAYS) && !letters.intersect?(YEARS)
166
+ raise(ArgumentError, "the format #{pattern.inspect} reads a day of the month without a year, which cannot " \
167
+ "read February 29, as Python's strptime raises for it: add a year, such as %Y")
168
+ end
169
+ check_week(pattern, letters) if letters.include?("G") || letters.include?("V")
170
+ end
171
+
172
+ # Raises ArgumentError, naming the pattern, for the directives of an ISO 8601 week that Python's strptime raises
173
+ # ValueError for: the year of a week, `%G`, with the day of the year, `%j`, or without the week, `%V`, and a
174
+ # weekday, `%a`, `%A`, `%u` or `%w`, which give its date together, and the week without the year of the week.
175
+ def check_week(pattern, letters)
176
+ if letters.include?("j")
177
+ raise(ArgumentError, "the format #{pattern.inspect} reads the day of the year, %j, with an ISO 8601 week, " \
178
+ "which Python's strptime raises for: read the year with %Y instead")
179
+ end
180
+ return if letters.include?("G") && letters.include?("V") && letters.intersect?(WEEKDAYS)
181
+
182
+ raise(ArgumentError, "the format #{pattern.inspect} reads an ISO 8601 week without its year, %G, its week, %V, " \
183
+ "or a weekday, such as %u, which give its date together, as Python's strptime requires: " \
184
+ "add them, or read the year with %Y, and a week with %U or %W, instead")
185
+ end
186
+
187
+ # Returns [true|false] whether the parts give the year of the date, see YEAR_PARTS, so that Ruby's strptime reads
188
+ # it as Python's does, rather than in the year that it is read: those of `%Y`, `%y` and `%s`, and of `%G`, which a
189
+ # pattern reads only with the week and weekday that complete its date, see #check_date.
190
+ def dated?(parts)
191
+ YEAR_PARTS.any? { |part| parts.key?(part) }
192
+ end
193
+
194
+ # Returns [Date] the date of parts without a year, see #dated?, as Python's strptime gives it: the day of the year
195
+ # of `%j`, counted from 1900-01-01, see DEFAULT_DATE, otherwise the first day of the month that they read, or of
196
+ # January, of 1900, since a pattern reads a day of the month only with a year, see #check_date. A weekday or a
197
+ # week, such as `%a` or `%U`, is ignored, as Python's strptime uses them only with a year.
198
+ def default_date(parts)
199
+ year, month, day = DEFAULT_DATE
200
+ return Date.new(year, month, day) + (parts[:yday] - 1) if parts.key?(:yday)
201
+
202
+ Date.new(year, parts.fetch(:mon, month), day)
203
+ end
204
+
205
+ # Returns [true|false] whether the time of the parts is on a clock, from 00:00:00 to 23:59:59, as Python's strptime
206
+ # reads it, for a date as for a time, rather than the hour 24 of `%H`, which `DateTime` moves into the next day, or
207
+ # a leap second, 60 of `%S`, which it reads as the second before it, and `Date` ignores. Ruby's strptime reads no
208
+ # minute of 60 or more.
209
+ def clock?(parts)
210
+ parts.fetch(:hour, 0) < 24 && parts.fetch(:sec, 0) < 60
211
+ end
212
+
213
+ # Returns [String] the pattern with each directive that reads several replaced with them, see COMPOSITES, and the
214
+ # modifier of each directive that has one removed, see MODIFIED, which Ruby's strptime reads the same way.
215
+ def expand(pattern)
216
+ pattern.gsub(DIRECTIVE) do |directive|
217
+ modified = MODIFIED.match(directive)
218
+ directive = "%#{modified[1] || modified[2]}" if modified
219
+ COMPOSITES.fetch(directive, directive)
220
+ end
221
+ end
222
+
223
+ # Returns [String] the pattern of Ruby's strptime or strftime of the pattern of Python, with each of the supplied
224
+ # directives replaced with its own, see READ_DIRECTIVES and WRITE_DIRECTIVES.
225
+ def ruby(pattern, directives)
226
+ pattern.gsub(DIRECTIVE) { |directive| directives.fetch(directive, directive) }
227
+ end
228
+
229
+ # Returns [Array(String, Regexp)] for each directive whose digits Ruby counts differently than Python, see DIGITS,
230
+ # the pattern of Ruby's strptime before it, which finds where its text starts, see #offset, and the digits that
231
+ # Python reads there. Ruby reads as many digits as there are unless the pattern reads a number right after it, so
232
+ # then those after the digits are not checked.
233
+ def checks(pattern)
234
+ checks = []
235
+ pattern.scan(DIRECTIVE) do
236
+ match = Regexp.last_match
237
+ digits = DIGITS[match[0]]
238
+ next unless digits
239
+
240
+ after = NUMERIC.match?(ruby(match.post_match, READ_DIRECTIVES)) ? "" : "(?!\\d)"
241
+ checks << [-ruby(match.pre_match, READ_DIRECTIVES), Regexp.new("\\G#{digits}#{after}")]
242
+ end
243
+ checks
244
+ end
245
+
246
+ # Returns [Integer] where the text of the directive after the prefix starts: after the text that the prefix reads.
247
+ def offset(text, prefix)
248
+ return 0 if prefix.empty?
249
+
250
+ parts = Date._strptime(text, prefix)
251
+ return text.length if parts.nil?
252
+
253
+ text.length - parts.fetch(:leftover, "").length
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IOStreams
4
+ class Schema
5
+ # The schema supplied to `IOStreams::Stream#schema`: a Hash, which is checked as soon as it is supplied, or the
6
+ # path or url of a JSON file, which is read the first time that a reader or writer needs it, so that configuring a
7
+ # path does not read the file, nor fail when the store is not available yet.
8
+ #
9
+ # The copies of a path share its source, so that they read the file once.
10
+ class Source
11
+ # Parameters:
12
+ # source: [Hash|String|IOStreams::Stream]
13
+ # The schema, or the path or url of a JSON file that holds it.
14
+ #
15
+ # Raises ArgumentError when the source is neither, or is a Hash that is not a valid schema.
16
+ def initialize(source)
17
+ unless source.is_a?(Hash) || source.is_a?(String) || source.is_a?(IOStreams::Stream)
18
+ raise(ArgumentError, "A schema must be a Hash, or the path or url of a JSON file: #{source.inspect}")
19
+ end
20
+
21
+ @source = source
22
+ @schema = Schema.new(source) if source.is_a?(Hash)
23
+ end
24
+
25
+ # Returns [IOStreams::Schema] the schema, reading its file the first time.
26
+ def schema
27
+ @schema ||= Schema.read(@source)
28
+ end
29
+ end
30
+ end
31
+ end