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.
- checksums.yaml +4 -4
- data/docs/config.md +13 -9
- data/docs/copy_files.md +20 -2
- data/docs/extensions.md +18 -4
- data/docs/formats.md +644 -16
- data/docs/path.md +18 -2
- data/docs/streams.md +54 -9
- data/docs/tutorial.md +14 -2
- data/docs/upgrading.md +470 -23
- data/lib/io_streams/builder/deprecations.rb +250 -0
- data/lib/io_streams/builder.rb +256 -39
- data/lib/io_streams/bzip2/reader.rb +18 -2
- data/lib/io_streams/bzip2/writer.rb +11 -2
- data/lib/io_streams/deprecation/methods.rb +51 -0
- data/lib/io_streams/deprecation/once.rb +27 -0
- data/lib/io_streams/deprecation/option_names.rb +26 -0
- data/lib/io_streams/deprecation/words.rb +42 -0
- data/lib/io_streams/deprecation.rb +259 -0
- data/lib/io_streams/encode/reader.rb +54 -3
- data/lib/io_streams/encode/writer.rb +19 -2
- data/lib/io_streams/encode.rb +99 -0
- data/lib/io_streams/errors.rb +51 -0
- data/lib/io_streams/gzip/reader.rb +11 -0
- data/lib/io_streams/io_streams.rb +365 -35
- data/lib/io_streams/line/reader.rb +2 -1
- data/lib/io_streams/line/utf8_reader.rb +23 -0
- data/lib/io_streams/path.rb +125 -35
- data/lib/io_streams/paths/file.rb +72 -9
- data/lib/io_streams/paths/http.rb +48 -2
- data/lib/io_streams/paths/matcher.rb +6 -3
- data/lib/io_streams/paths/s3.rb +180 -9
- data/lib/io_streams/paths/sftp.rb +47 -3
- data/lib/io_streams/paths.rb +23 -0
- data/lib/io_streams/pgp/reader.rb +18 -0
- data/lib/io_streams/pgp/writer.rb +16 -0
- data/lib/io_streams/pgp.rb +16 -2
- data/lib/io_streams/reader.rb +44 -0
- data/lib/io_streams/record/reader.rb +59 -68
- data/lib/io_streams/record/writer.rb +17 -10
- data/lib/io_streams/row/header_row.rb +21 -0
- data/lib/io_streams/row/reader.rb +52 -15
- data/lib/io_streams/row/writer.rb +64 -13
- data/lib/io_streams/schema/columns.rb +456 -0
- data/lib/io_streams/schema/constraints.rb +99 -0
- data/lib/io_streams/schema/field.rb +967 -0
- data/lib/io_streams/schema/json_number.rb +56 -0
- data/lib/io_streams/schema/keys.rb +78 -0
- data/lib/io_streams/schema/pattern.rb +257 -0
- data/lib/io_streams/schema/source.rb +31 -0
- data/lib/io_streams/schema.rb +616 -0
- data/lib/io_streams/stream.rb +836 -67
- data/lib/io_streams/symmetric_encryption/reader.rb +8 -0
- data/lib/io_streams/tabular/header.rb +29 -0
- data/lib/io_streams/tabular/header_mapping.rb +332 -0
- data/lib/io_streams/tabular/mapping.rb +165 -0
- data/lib/io_streams/tabular/parser/array.rb +5 -0
- data/lib/io_streams/tabular/parser/base.rb +24 -0
- data/lib/io_streams/tabular/parser/fixed.rb +197 -0
- data/lib/io_streams/tabular/parser/fixed_fields.rb +160 -0
- data/lib/io_streams/tabular/parser/hash.rb +10 -0
- data/lib/io_streams/tabular/parser/json.rb +17 -0
- data/lib/io_streams/tabular/parser/json_fields.rb +132 -0
- data/lib/io_streams/tabular/schema_mapping.rb +361 -0
- data/lib/io_streams/tabular.rb +344 -30
- data/lib/io_streams/utils.rb +441 -6
- data/lib/io_streams/version.rb +1 -1
- data/lib/io_streams/writer.rb +27 -0
- data/lib/io_streams/xlsx/cells.rb +127 -0
- data/lib/io_streams/xlsx/reader.rb +56 -0
- data/lib/io_streams/xlsx/schema_reader.rb +90 -0
- data/lib/io_streams/xlsx/v3_writer.rb +16 -0
- data/lib/io_streams/zip/reader.rb +7 -0
- data/lib/io_streams/zip/writer.rb +24 -2
- data/lib/iostreams.rb +21 -17
- 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
|