tzinfo 2.0.2 → 2.0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -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