tzinfo 2.0.2 → 2.0.5

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.
@@ -14,11 +14,20 @@ module TZInfo
14
14
 
15
15
  # Reads compiled zoneinfo TZif (\0, 2 or 3) files.
16
16
  class ZoneinfoReader #:nodoc:
17
+ # The year to generate transitions up to.
18
+ #
19
+ # @private
20
+ GENERATE_UP_TO = Time.now.utc.year + 100
21
+ private_constant :GENERATE_UP_TO
22
+
17
23
  # Initializes a new {ZoneinfoReader}.
18
24
  #
25
+ # @param posix_tz_parser [PosixTimeZoneParser] a {PosixTimeZoneParser}
26
+ # instance to use to parse POSIX-style TZ strings.
19
27
  # @param string_deduper [StringDeduper] a {StringDeduper} instance to use
20
- # when deduping abbreviations.
21
- def initialize(string_deduper)
28
+ # to dedupe abbreviations.
29
+ def initialize(posix_tz_parser, string_deduper)
30
+ @posix_tz_parser = posix_tz_parser
22
31
  @string_deduper = string_deduper
23
32
  end
24
33
 
@@ -163,6 +172,171 @@ module TZInfo
163
172
  first_offset_index
164
173
  end
165
174
 
175
+ # Determines if the offset from a transition matches the offset from a
176
+ # rule. This is a looser match than equality, not requiring that the
177
+ # base_utc_offset and std_offset both match (which have to be derived for
178
+ # transitions, but are known for rules.
179
+ #
180
+ # @param offset [TimezoneOffset] an offset from a transition.
181
+ # @param rule_offset [TimezoneOffset] an offset from a rule.
182
+ # @return [Boolean] whether the offsets match.
183
+ def offset_matches_rule?(offset, rule_offset)
184
+ offset.observed_utc_offset == rule_offset.observed_utc_offset &&
185
+ offset.dst? == rule_offset.dst? &&
186
+ offset.abbreviation == rule_offset.abbreviation
187
+ end
188
+
189
+ # Apply the rules from the TZ string when there were no defined
190
+ # transitions. Checks for a matching offset. Returns the rules-based
191
+ # constant offset or generates transitions from 1970 until 100 years into
192
+ # the future (at the time of loading zoneinfo_reader.rb).
193
+ #
194
+ # @param file [IO] the file being processed.
195
+ # @param first_offset [TimezoneOffset] the first offset included in the
196
+ # file that would normally apply without the rules.
197
+ # @param rules [Object] a {TimezoneOffset} specifying a constant offset or
198
+ # {AnnualRules} instance specfying transitions.
199
+ # @return [Object] either a {TimezoneOffset} or an `Array` of
200
+ # {TimezoneTransition}s.
201
+ # @raise [InvalidZoneinfoFile] if the first offset does not match the
202
+ # rules.
203
+ def apply_rules_without_transitions(file, first_offset, rules)
204
+ if rules.kind_of?(TimezoneOffset)
205
+ unless offset_matches_rule?(first_offset, rules)
206
+ raise InvalidZoneinfoFile, "Constant offset POSIX-style TZ string does not match constant offset in file '#{file.path}'."
207
+ end
208
+ rules
209
+ else
210
+ transitions = 1970.upto(GENERATE_UP_TO).flat_map {|y| rules.transitions(y) }
211
+ first_transition = transitions[0]
212
+
213
+ unless offset_matches_rule?(first_offset, first_transition.previous_offset)
214
+ # Not transitioning from the designated first offset.
215
+
216
+ if offset_matches_rule?(first_offset, first_transition.offset)
217
+ # Skip an unnecessary transition to the first offset.
218
+ transitions.shift
219
+ else
220
+ # The initial offset doesn't match the ongoing rules. Replace the
221
+ # previous offset of the first transition.
222
+ transitions[0] = TimezoneTransition.new(first_transition.offset, first_offset, first_transition.timestamp_value)
223
+ end
224
+ end
225
+
226
+ transitions
227
+ end
228
+ end
229
+
230
+ # Finds an offset that is equivalent to the one specified in the given
231
+ # `Array`. Matching is performed with {TimezoneOffset#==}.
232
+ #
233
+ # @param offsets [Array<TimezoneOffset>] an `Array` to search.
234
+ # @param offset [TimezoneOffset] the offset to search for.
235
+ # @return [TimezoneOffset] the matching offset from `offsets` or `nil`
236
+ # if not found.
237
+ def find_existing_offset(offsets, offset)
238
+ offsets.find {|o| o == offset }
239
+ end
240
+
241
+ # Returns a new AnnualRules instance with standard and daylight savings
242
+ # offsets replaced with equivalents from an array. This reduces the memory
243
+ # requirement for loaded time zones by reusing offsets for rule-generated
244
+ # transitions.
245
+ #
246
+ # @param offsets [Array<TimezoneOffset>] an `Array` to search for
247
+ # equivalent offsets.
248
+ # @param annual_rules [AnnualRules] the {AnnualRules} instance to check.
249
+ # @return [AnnualRules] either a new {AnnualRules} instance with either
250
+ # the {AnnualRules#std_offset std_offset} or {AnnualRules#dst_offset
251
+ # dst_offset} replaced, or the original instance if no equivalent for
252
+ # either {AnnualRules#std_offset std_offset} or {AnnualRules#dst_offset
253
+ # dst_offset} could be found.
254
+ def replace_with_existing_offsets(offsets, annual_rules)
255
+ existing_std_offset = find_existing_offset(offsets, annual_rules.std_offset)
256
+ existing_dst_offset = find_existing_offset(offsets, annual_rules.dst_offset)
257
+ if existing_std_offset || existing_dst_offset
258
+ AnnualRules.new(existing_std_offset || annual_rules.std_offset, existing_dst_offset || annual_rules.dst_offset,
259
+ annual_rules.dst_start_rule, annual_rules.dst_end_rule)
260
+ else
261
+ annual_rules
262
+ end
263
+ end
264
+
265
+ # Validates the offset indicated to be observed by the rules before the
266
+ # first generated transition against the offset of the last defined
267
+ # transition.
268
+ #
269
+ # Fix the last defined transition if it differ on just base/std offsets
270
+ # (which are derived). Raise an error if the observed UTC offset or
271
+ # abbreviations differ.
272
+ #
273
+ # @param file [IO] the file being processed.
274
+ # @param last_defined [TimezoneTransition] the last defined transition in
275
+ # the file.
276
+ # @param first_rule_offset [TimezoneOffset] the offset the rules indicate
277
+ # is observed prior to the first rules generated transition.
278
+ # @return [TimezoneTransition] the last defined transition (either the
279
+ # original instance or a replacement).
280
+ # @raise [InvalidZoneinfoFile] if the offset of {last_defined} and
281
+ # {first_rule_offset} do not match.
282
+ def validate_and_fix_last_defined_transition_offset(file, last_defined, first_rule_offset)
283
+ offset_of_last_defined = last_defined.offset
284
+
285
+ if offset_of_last_defined == first_rule_offset
286
+ last_defined
287
+ else
288
+ if offset_matches_rule?(offset_of_last_defined, first_rule_offset)
289
+ # The same overall offset, but differing in the base or std
290
+ # offset (which are derived). Correct by using the rule.
291
+ TimezoneTransition.new(first_rule_offset, last_defined.previous_offset, last_defined.timestamp_value)
292
+ else
293
+ raise InvalidZoneinfoFile, "The first offset indicated by the POSIX-style TZ string did not match the final defined offset in file '#{file.path}'."
294
+ end
295
+ end
296
+ end
297
+
298
+ # Apply the rules from the TZ string when there were defined
299
+ # transitions. Checks for a matching offset with the last transition.
300
+ # Redefines the last transition if required and if the rules don't
301
+ # specific a constant offset, generates transitions until 100 years into
302
+ # the future (at the time of loading zoneinfo_reader.rb).
303
+ #
304
+ # @param file [IO] the file being processed.
305
+ # @param transitions [Array<TimezoneTransition>] the defined transitions.
306
+ # @param offsets [Array<TimezoneOffset>] the offsets used by the defined
307
+ # transitions.
308
+ # @param rules [Object] a {TimezoneOffset} specifying a constant offset or
309
+ # {AnnualRules} instance specfying transitions.
310
+ # @raise [InvalidZoneinfoFile] if the first offset does not match the
311
+ # rules.
312
+ # @raise [InvalidZoneinfoFile] if the previous offset of the first
313
+ # generated transition does not match the offset of the last defined
314
+ # transition.
315
+ def apply_rules_with_transitions(file, transitions, offsets, rules)
316
+ last_defined = transitions[-1]
317
+
318
+ if rules.kind_of?(TimezoneOffset)
319
+ transitions[-1] = validate_and_fix_last_defined_transition_offset(file, last_defined, rules)
320
+ else
321
+ last_year = last_defined.local_end_at.to_time.year
322
+
323
+ if last_year <= GENERATE_UP_TO
324
+ rules = replace_with_existing_offsets(offsets, rules)
325
+
326
+ generated = rules.transitions(last_year).find_all do |t|
327
+ t.timestamp_value > last_defined.timestamp_value && !offset_matches_rule?(last_defined.offset, t.offset)
328
+ end
329
+
330
+ generated += (last_year + 1).upto(GENERATE_UP_TO).flat_map {|y| rules.transitions(y) }
331
+
332
+ unless generated.empty?
333
+ transitions[-1] = validate_and_fix_last_defined_transition_offset(file, last_defined, generated[0].previous_offset)
334
+ transitions.concat(generated)
335
+ end
336
+ end
337
+ end
338
+ end
339
+
166
340
  # Parses a zoneinfo file and returns either a {TimezoneOffset} that is
167
341
  # constantly observed or an `Array` of {TimezoneTransition}s.
168
342
  #
@@ -171,7 +345,7 @@ module TZInfo
171
345
  # {TimezoneTransition}s.
172
346
  # @raise [InvalidZoneinfoFile] if the file is not a valid zoneinfo file.
173
347
  def parse(file)
174
- magic, version, ttisgmtcnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt =
348
+ magic, version, ttisutccnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt =
175
349
  check_read(file, 44).unpack('a4 a x15 NNNNNN')
176
350
 
177
351
  if magic != 'TZif'
@@ -180,11 +354,11 @@ module TZInfo
180
354
 
181
355
  if version == '2' || version == '3'
182
356
  # Skip the first 32-bit section and read the header of the second 64-bit section
183
- file.seek(timecnt * 5 + typecnt * 6 + charcnt + leapcnt * 8 + ttisgmtcnt + ttisstdcnt, IO::SEEK_CUR)
357
+ file.seek(timecnt * 5 + typecnt * 6 + charcnt + leapcnt * 8 + ttisstdcnt + ttisutccnt, IO::SEEK_CUR)
184
358
 
185
359
  prev_version = version
186
360
 
187
- magic, version, ttisgmtcnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt =
361
+ magic, version, ttisutccnt, ttisstdcnt, leapcnt, timecnt, typecnt, charcnt =
188
362
  check_read(file, 44).unpack('a4 a x15 NNNNNN')
189
363
 
190
364
  unless magic == 'TZif' && (version == prev_version)
@@ -229,6 +403,23 @@ module TZInfo
229
403
 
230
404
  abbrev = check_read(file, charcnt)
231
405
 
406
+ if using_64bit
407
+ # Skip to the POSIX-style TZ string.
408
+ file.seek(ttisstdcnt + ttisutccnt, IO::SEEK_CUR) # + leapcnt * 8, but leapcnt is checked above and guaranteed to be 0.
409
+ tz_string_start = check_read(file, 1)
410
+ raise InvalidZoneinfoFile, "Expected newline starting POSIX-style TZ string in file '#{file.path}'." unless tz_string_start == "\n"
411
+ tz_string = file.readline("\n").force_encoding(Encoding::UTF_8)
412
+ raise InvalidZoneinfoFile, "Expected newline ending POSIX-style TZ string in file '#{file.path}'." unless tz_string.chomp!("\n")
413
+
414
+ begin
415
+ rules = @posix_tz_parser.parse(tz_string)
416
+ rescue InvalidPosixTimeZone => e
417
+ raise InvalidZoneinfoFile, "Failed to parse POSIX-style TZ string in file '#{file.path}': #{e}"
418
+ end
419
+ else
420
+ rules = nil
421
+ end
422
+
232
423
  # Derive the offsets from standard time (std_offset).
233
424
  first_offset_index = derive_offsets(transitions, offsets)
234
425
 
@@ -266,12 +457,16 @@ module TZInfo
266
457
 
267
458
 
268
459
  if transitions.empty?
269
- first_offset
460
+ if rules
461
+ apply_rules_without_transitions(file, first_offset, rules)
462
+ else
463
+ first_offset
464
+ end
270
465
  else
271
466
  previous_offset = first_offset
272
467
  previous_at = nil
273
468
 
274
- transitions.map do |t|
469
+ transitions = transitions.map do |t|
275
470
  offset = offsets[t[:offset]]
276
471
  at = t[:at]
277
472
  raise InvalidZoneinfoFile, "Transition at #{at} is not later than the previous transition at #{previous_at} in file '#{file.path}'." if previous_at && previous_at >= at
@@ -280,6 +475,9 @@ module TZInfo
280
475
  previous_at = at
281
476
  tt
282
477
  end
478
+
479
+ apply_rules_with_transitions(file, transitions, offsets, rules) if rules
480
+ transitions
283
481
  end
284
482
  end
285
483
  end
@@ -46,6 +46,22 @@ module TZInfo
46
46
  end
47
47
  alias isdst dst?
48
48
 
49
+ # An overridden version of `Time#getlocal` that clears the associated
50
+ # {TimezoneOffset} if the base implementation of `getlocal` returns a
51
+ # {TimeWithOffset}.
52
+ #
53
+ # @return [Time] a representation of the {TimeWithOffset} using either the
54
+ # local time zone or the given offset.
55
+ def getlocal(*args)
56
+ # JRuby < 9.3 returns a Time in all cases.
57
+ # JRuby >= 9.3 returns a Time when called with no arguments and a
58
+ # TimeWithOffset with a timezone_offset assigned when called with an
59
+ # offset argument.
60
+ result = super
61
+ result.clear_timezone_offset if result.kind_of?(TimeWithOffset)
62
+ result
63
+ end
64
+
49
65
  # An overridden version of `Time#gmtime` that clears the associated
50
66
  # {TimezoneOffset}.
51
67
  #
@@ -124,5 +140,15 @@ module TZInfo
124
140
  result.set_timezone_offset(o)
125
141
  end
126
142
  end
143
+
144
+ protected
145
+
146
+ # Clears the associated {TimezoneOffset}.
147
+ #
148
+ # @return [TimeWithOffset] `self`.
149
+ def clear_timezone_offset
150
+ @timezone_offset = nil
151
+ self
152
+ end
127
153
  end
128
154
  end
@@ -3,10 +3,11 @@
3
3
 
4
4
  module TZInfo
5
5
  # A time represented as an `Integer` number of seconds since 1970-01-01
6
- # 00:00:00 UTC (ignoring leap seconds), the fraction through the second
7
- # (sub_second as a `Rational`) and an optional UTC offset. Like Ruby's `Time`
8
- # class, {Timestamp} can distinguish between a local time with a zero offset
9
- # and a time specified explicitly as UTC.
6
+ # 00:00:00 UTC (ignoring leap seconds and using the proleptic Gregorian
7
+ # calendar), the fraction through the second (sub_second as a `Rational`) and
8
+ # an optional UTC offset. Like Ruby's `Time` class, {Timestamp} can
9
+ # distinguish between a local time with a zero offset and a time specified
10
+ # explicitly as UTC.
10
11
  class Timestamp
11
12
  include Comparable
12
13
 
@@ -16,8 +17,8 @@ module TZInfo
16
17
  private_constant :JD_EPOCH
17
18
 
18
19
  class << self
19
- # Returns a new {Timestamp} representing the (Gregorian calendar) date and
20
- # time specified by the supplied parameters.
20
+ # Returns a new {Timestamp} representing the (proleptic Gregorian
21
+ # calendar) date and time specified by the supplied parameters.
21
22
  #
22
23
  # If `utc_offset` is `nil`, `:utc` or 0, the date and time parameters will
23
24
  # be interpreted as representing a UTC date and time. Otherwise the date
@@ -37,7 +38,7 @@ module TZInfo
37
38
  # specified offset, an offset from UTC specified as an `Integer` number
38
39
  # of seconds or the `Symbol` `:utc`).
39
40
  # @return [Timestamp] a new {Timestamp} representing the specified
40
- # (Gregorian calendar) date and time.
41
+ # (proleptic Gregorian calendar) date and time.
41
42
  # @raise [ArgumentError] if either of `year`, `month`, `day`, `hour`,
42
43
  # `minute`, or `second` is not an `Integer`.
43
44
  # @raise [ArgumentError] if `sub_second` is not a `Rational`, or the
@@ -84,7 +85,8 @@ module TZInfo
84
85
  # When called with a block, the {Timestamp} representation of `value` is
85
86
  # passed to the block. The block must then return a {Timestamp}, which
86
87
  # will be converted back to the type of the initial value. If the initial
87
- # value was a {Timestamp}, the block result will just be returned.
88
+ # value was a {Timestamp}, the block result will be returned. If the
89
+ # initial value was a `DateTime`, a Gregorian `DateTime` will be returned.
88
90
  #
89
91
  # The UTC offset of `value` can either be preserved (the {Timestamp}
90
92
  # representation will have the same UTC offset as `value`), ignored (the
@@ -396,11 +398,11 @@ module TZInfo
396
398
  end
397
399
  end
398
400
 
399
- # Converts this {Timestamp} to a `DateTime`.
401
+ # Converts this {Timestamp} to a Gregorian `DateTime`.
400
402
  #
401
- # @return [DateTime] a DateTime representation of this {Timestamp}. If the
402
- # UTC offset of this {Timestamp} is not specified, a UTC `DateTime` will
403
- # be returned.
403
+ # @return [DateTime] a Gregorian `DateTime` representation of this
404
+ # {Timestamp}. If the UTC offset of this {Timestamp} is not specified, a
405
+ # UTC `DateTime` will be returned.
404
406
  def to_datetime
405
407
  new_datetime
406
408
  end
@@ -408,7 +410,7 @@ module TZInfo
408
410
  # Converts this {Timestamp} to an `Integer` number of seconds since
409
411
  # 1970-01-01 00:00:00 UTC (ignoring leap seconds).
410
412
  #
411
- # @return [Integer] an Integer representation of this {Timestamp} (the
413
+ # @return [Integer] an `Integer` representation of this {Timestamp} (the
412
414
  # number of seconds since 1970-01-01 00:00:00 UTC ignoring leap seconds).
413
415
  def to_i
414
416
  value
@@ -492,7 +494,9 @@ module TZInfo
492
494
  #
493
495
  # @private
494
496
  def new_datetime(klass = DateTime)
495
- datetime = klass.jd(JD_EPOCH + ((@value.to_r + @sub_second) / 86400))
497
+ # Can't specify the start parameter unless the jd parameter is an exact number of days.
498
+ # Use #gregorian instead.
499
+ datetime = klass.jd(JD_EPOCH + ((@value.to_r + @sub_second) / 86400)).gregorian
496
500
  @utc_offset && @utc_offset != 0 ? datetime.new_offset(Rational(@utc_offset, 86400)) : datetime
497
501
  end
498
502