easy_time 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require 'yard'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ namespace :spec do
8
+ desc "run tests with code coverage"
9
+ task :coverage do
10
+ sh "CODE_COVERAGE=1 bundle exec rake spec"
11
+ end
12
+ end
13
+
14
+ YARD::Rake::YardocTask.new do |t|
15
+ t.options += ['--title', "EasyTime #{EasyTime::VERSION} Documentation"]
16
+ t.stats_options = ['--list-undoc']
17
+ end
18
+
19
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "easy_time"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/easy_time.gemspec ADDED
@@ -0,0 +1,63 @@
1
+ require_relative 'lib/easy_time/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "easy_time"
5
+ spec.version = EasyTime::VERSION
6
+ spec.authors = ["Alan Stebbens"]
7
+ spec.email = ["aks@stebbens.org"]
8
+
9
+ spec.summary = %q{Easy auto-conversion of most date and time values with tolerant-comparisons}
10
+ spec.description =
11
+ <<~'DESC'
12
+
13
+ A class that wraps the Time class and makes it easy to work with most
14
+ known time values, including various time strings, automatically
15
+ converting them to Time values, and perform tolerant comparisons.
16
+ Several time classes, and the String class, are extended with the
17
+ ".easy_time" method to perform an auto-conversion. A tolerant comparison
18
+ allows for times from differing systems to be compared, even when the
19
+ systems are out of sync, using the relationship operators and methods
20
+ like "newer?", "older?", "same?" and "between?". A tolerant comparison
21
+ for equality is where the difference of two values is less than the
22
+ tolerance value (1 minute by default). The tolerance can be configured,
23
+ even set to zero. Finally, all of the Time class and instance methods
24
+ are available on the EasyTime class and instances.
25
+
26
+ DESC
27
+ spec.homepage = 'https://github.com/aks/easy_time'
28
+ spec.license = "MIT"
29
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
30
+
31
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
32
+
33
+ spec.metadata["homepage_uri"] = spec.homepage
34
+ spec.metadata["source_code_uri"] = "https://github.com/aks/easy_time"
35
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
36
+
37
+ # Specify which files should be added to the gem when it is released.
38
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
39
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
40
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
41
+ end
42
+ spec.bindir = "bin"
43
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
44
+ spec.require_paths = ["lib"]
45
+
46
+ spec.add_development_dependency "bundler", "~> 2.1.4"
47
+ spec.add_development_dependency "fuubar", ">= 2.5.0"
48
+ spec.add_development_dependency "guard"
49
+ spec.add_development_dependency "guard-rspec"
50
+ spec.add_development_dependency "guard-yard"
51
+ spec.add_development_dependency "pry-byebug"
52
+ spec.add_development_dependency "rake"
53
+ spec.add_development_dependency "rspec"
54
+ spec.add_development_dependency "rspec_junit"
55
+ spec.add_development_dependency "rspec_junit_formatter"
56
+ spec.add_development_dependency "redcarpet"
57
+ spec.add_development_dependency "rubocop", ">= 0.82.0"
58
+ spec.add_development_dependency "simplecov"
59
+ spec.add_development_dependency "terminal-notifier-guard" if /Darwin/.match?(`uname -a`.strip)
60
+ spec.add_development_dependency "yard", ">= 0.9.24"
61
+
62
+ spec.add_dependency "activesupport"
63
+ end
data/lib/easy_time.rb ADDED
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'date'
5
+ require 'active_support'
6
+ require 'active_support/duration'
7
+ require 'active_support/time_with_zone'
8
+ require 'active_support/core_ext/numeric/time'
9
+
10
+ require 'easy_time/version'
11
+ require 'easy_time/convert'
12
+
13
+ # Are you tired of having to deal with many kinds of date and time objects?
14
+ #
15
+ # Are you frustrated that comparing timestamps from different systems yields
16
+ # incorrect results? _(Were you surprised to learn that, despite really good
17
+ # time sync sources, many systems aren't actually synced all that closely in
18
+ # time?)_
19
+ #
20
+ # Well, then, give EasyTime a try!
21
+ #
22
+ # `EasyTime` accepts most of the well-known date and time objects, including
23
+ # `RFC2822`, `HTTPDate`, `XMLSchema`, and `ISO8601` strings and provides
24
+ # comparisons that have an adjustable tolerance. With `EasyTime` methods, you
25
+ # can reliably compare two timestamps and determine which one is "newer", "older"
26
+ # or the "same" withing a configurable tolerance. The default comparison
27
+ # tolerance is 1.minute.
28
+ #
29
+ # In other words, if you have a time-stamp from an `ActiveRecord` object that is
30
+ # a few seconds different from a related object obtained from a 3rd-party system,
31
+ # (eg: AWS S3), then logically, from an application perspective, these two
32
+ # objects could be considered having the "same" time-stamp.
33
+ #
34
+ # This is quite useful when one is trying to keep state synchronized between
35
+ # different systems. How does one know if an object is "newer" or "older" than
36
+ # that from another system? If the system time from the connected systems varies
37
+ # by a few or more seconds, then comparisons needs to have some tolerance.
38
+ #
39
+ # Having a tolerant comparison makes "newness" and "oldness" checks easier to
40
+ # manage across systems with possibly varying time sources.
41
+ #
42
+ # `EasyTime` objects are just like Time objects, except:
43
+ #
44
+ # - they auto-convert most date and time objects to Time objects
45
+ # - they provide configurable tolerant comparisons between two time objects
46
+ #
47
+ # Even if you decide to set the configurable comparison tolerance to zero
48
+ # _(which disables it)_, the auto-type conversion of most date and time objects
49
+ # makes time and date comparisons and arithmetic very easy.
50
+ #
51
+ # Finally, this module adds an new instance method to the familiar date and
52
+ # time classes, to easily convert from the object to the corresponding
53
+ # `EasyTime` object:
54
+ #
55
+ # time.easy_time
56
+ #
57
+ # The conversion to an `EasyTime` can also be provided with a tolerance value:
58
+ #
59
+ # time.easy_time(tolerance: 5.seconds)
60
+ #
61
+ # These are the currently known date and time classes the values of which will
62
+ # be automatically converted to an `EasyTime` value with tolerant comparisons:
63
+ #
64
+ # Date
65
+ # Time
66
+ # EasyTime
67
+ # DateTime
68
+ # ActiveSupport::Duration
69
+ # ActiveSupport::TimeWithZone
70
+ # String
71
+ #
72
+ # The String values are examined and parsed into a `Time` value. If a string
73
+ # cannot be parsed, the `new` and `convert` methods return a nil.
74
+ #
75
+ class EasyTime
76
+ include Comparable
77
+
78
+ # we define a default tolerance below. This causes time value differences
79
+ # less than this to be considered "equal". This allows for time comparisons
80
+ # between values from different systems where the clock sync might not be
81
+ # very accurate.
82
+ #
83
+ # If this default tolerance is not desired, it can be overridden with an
84
+ # explicit tolerance setting in the singleton class instance:
85
+ #
86
+ # EasyTime.comparison_tolerance = 0
87
+
88
+ DEFAULT_TIME_COMPARISON_TOLERANCE = 1.minute
89
+
90
+ class << self
91
+ # @example These comparison methods observe the comparison tolerance
92
+ #
93
+ # EasyTime.newer?(t1, t2, tolerance: nil) # => true if t1 > t2
94
+ # EasyTime.older?(t1, t2, tolerance: nil) # => true if t1 < t2
95
+ # EasyTime.same?(t1, t2, tolerance: nil) # => true if t1 == t2
96
+ # EasyTime.compare(t1, t2, tolerance: nil) # => -1, 0, 1 (or nil)
97
+ #
98
+ # By default, the `tolerance` is nil, which means that any previously
99
+ # configured instance comparison tolerance value is used, if it is set,
100
+ # otherwise, the class comparison tolerance is used, if it set, otherwise
101
+ # the default `DEFAULT_TIME_COMPARISON_TOLERANCE` is used.
102
+
103
+ # @param time1 [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] a time value
104
+ # @param time2 [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] another time value
105
+ # @param tolerance [Integer] seconds of tolerance _(optional)_
106
+ # @return [Boolean] true if `time1` > `time2`, using a tolerant comparison
107
+
108
+ def newer?(time1, time2, tolerance: nil)
109
+ compare(time1, time2, tolerance: tolerance).positive?
110
+ end
111
+
112
+ # @param time1 [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] a time value
113
+ # @param time2 [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] another time value
114
+ # @param tolerance [Integer] seconds of tolerance _(optional)_
115
+ # @return [Boolean] true if `time1` > `time2`, using a tolerant comparison
116
+
117
+ def older?(time1, time2, tolerance: nil)
118
+ compare(time1, time2, tolerance: tolerance).negative?
119
+ end
120
+
121
+ # @param time1 [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] a time value
122
+ # @param time2 [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] another time value
123
+ # @param tolerance [Integer] seconds of tolerance _(optional)_
124
+ # @return [Boolean] true if `time1` > `time2`, using a tolerant comparison
125
+
126
+ def same?(time1, time2, tolerance: nil)
127
+ compare(time1, time2, tolerance: tolerance).zero?
128
+ end
129
+
130
+
131
+ # @overload between?(time1, t_min, t_max, tolerance: nil)
132
+ # @param time1 [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] a time value
133
+ # @param t_min [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] the minimum time
134
+ # @param t_max [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] the maximum time
135
+ # @return [Boolean] true if `t_min <= time1 <= t_max`, using tolerant comparisons
136
+ #
137
+ # @overload between?(time1, time_range, tolerance: nil)
138
+ # @param time1 [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] a time value
139
+ # @param time_range [Range] a range `(t_min..t_max)` of time values
140
+ # @return [Boolean] true if `time_range.min <= time1 <= time_range.max`, using tolerant comparisons
141
+
142
+ def between?(time1, t_arg, t_max=nil, tolerance: nil)
143
+ if t_arg.is_a?(Range)
144
+ t_min = t_arg.min
145
+ t_max = t_arg.max
146
+ else
147
+ t_min = t_arg
148
+ end
149
+ compare(time1, t_min, tolerance: tolerance) >= 0 &&
150
+ compare(time1, t_max, tolerance: tolerance) <= 0
151
+ end
152
+
153
+ # @param time1 [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] a time value
154
+ # @param time2 [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] another time value
155
+ # @param tolerance [Integer] seconds of tolerance _(optional)_
156
+ # @return [Integer] one of [-1, 0, 1] if `time1` <, ==, or > than `time2`,
157
+ # or nil if `time2` cannot be converted to a `Time` value.
158
+
159
+ def compare(time1, time2, tolerance: nil)
160
+ new(time1, tolerance: tolerance) <=> time2
161
+ end
162
+
163
+ attr_writer :comparison_tolerance
164
+
165
+ # Class methods to set the class-level comparison tolerance
166
+ #
167
+ # @example
168
+ # EasyTime.comparison_tolerance = 0 # turns off any tolerance
169
+ # EasyTime.comparison_tolerance = 5.seconds # makes times within 5 seconds the "same"
170
+ # EasyTime.comparison_tolerance = 1.minute # the default
171
+
172
+ # @return [Integer] the number of seconds of tolerance to use for "equality" tests
173
+ def comparison_tolerance
174
+ @tolerance || DEFAULT_TIME_COMPARISON_TOLERANCE
175
+ end
176
+
177
+ # @param time_string [String] a time string in one of the many known Time string formats
178
+ # @return [EasyTime]
179
+ def parse(time_string)
180
+ new(parse_string(time_string))
181
+ end
182
+
183
+ def method_missing(name, *args, &block)
184
+ if Time.respond_to?(name)
185
+ value = Time.send(name, *args, &block)
186
+ is_a_time?(value) ? new(value) : value
187
+ else
188
+ super
189
+ end
190
+ end
191
+
192
+ def respond_to_missing?(name, include_all=false)
193
+ Time.respond_to?(name, include_all)
194
+ end
195
+
196
+ # @param value [Anything] value to test as a time-like object
197
+ # @return [Boolean] true if value is one the known Time classes, or responds to :acts_like_time?
198
+ def is_a_time?(value)
199
+ case value
200
+ when Integer, ActiveSupport::Duration
201
+ false
202
+ when Date, Time, DateTime, ActiveSupport::TimeWithZone, EasyTime
203
+ true
204
+ else
205
+ value.respond_to?(:acts_like_time?) && value.acts_like_time?
206
+ end
207
+ end
208
+ end
209
+
210
+ attr_accessor :time
211
+ attr_reader :other_time
212
+ attr_writer :comparison_tolerance
213
+
214
+ delegate :to_s, :inspect, to: :time
215
+
216
+ def initialize(*time, tolerance: nil)
217
+ @time = time.presence && convert(time.size == 1 ? time.first : time)
218
+ @comparison_tolerance = tolerance
219
+ end
220
+
221
+ # if there is no instance value, default to the class value
222
+ def comparison_tolerance
223
+ @comparison_tolerance || self.class.comparison_tolerance
224
+ end
225
+
226
+ # returns a _new_ EasyTime value with the tolerance set to value
227
+ #
228
+ # @example Example:
229
+ #
230
+ # t1 = EasyTime.new(some_time)
231
+ # t1.with_tolerance(2.seconds) <= some_other_time
232
+ #
233
+ # @param value [Integer] a number of seconds to use as the comparison tolerance
234
+ # @return [EasyTime] a new EasyTime value with the given tolerance
235
+
236
+ def with_tolerance(value)
237
+ dup.tap { |time| time.comparison_tolerance = value }
238
+ end
239
+
240
+ # @example Comparison examples
241
+ # time1 = EasyTime.new(a_time, tolerance: nil)
242
+ # time1.newer?(time2)
243
+ # time1.older?(time2)
244
+ # time1.same?(time2)
245
+ # time1.compare(time2) # => -1, 0, 1
246
+
247
+ # @param time2 [String,Date,Time,DateTime,Duration,Array<Integer>] another time value
248
+ # @return [Boolean] true if `self` > `time2`
249
+
250
+ def newer?(time2)
251
+ self > time2
252
+ end
253
+
254
+ # @param time2 [String,Date,Time,DateTime,Duration,Array<Integer>] another time value
255
+ # @return [Boolean] true if `self` < `time2`
256
+
257
+ def older?(time2)
258
+ self < time2
259
+ end
260
+
261
+ # @param time2 [String,Date,Time,DateTime,Duration,Array<Integer>] another time value
262
+ # @return [Boolean] true if `self` == `time2`
263
+
264
+ def same?(time2)
265
+ self == time2
266
+ end
267
+ alias eql? same?
268
+
269
+ # @param time2 [String,Date,Time,DateTime,Duration,Array<Integer>] another time value
270
+ # @return [Boolean] true if `self` != `time2`
271
+
272
+ def different?(time2)
273
+ self != time2
274
+ end
275
+
276
+ # @param time2 [String,Date,Time,DateTime,Duration,Array<Integer>] another time value
277
+ # @return [Integer] one of the values: [-1, 0, 1] if `self` [<, ==, >] `time2`,
278
+ # or nil if `time2` cannot be converted to a `Time` value
279
+
280
+ def compare(time2, tolerance: nil)
281
+ self.comparison_tolerance = tolerance if tolerance
282
+ self <=> time2
283
+ end
284
+
285
+ # compare with automatic type-conversion and tolerance
286
+ # @return [Integer] one of [-1, 0, 1] or nil
287
+
288
+ def <=>(other)
289
+ diff = self - other # note: this has a side-effect of setting @other_time
290
+ if diff && diff.abs.to_i <= comparison_tolerance.to_i
291
+ 0
292
+ elsif diff
293
+ time <=> other_time
294
+ end
295
+ end
296
+
297
+ # compare a time against a min and max date pair, or against a time Range value.
298
+ # @overload between?(t_min, t_max, tolerance: nil)
299
+ # @param t_min [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] the minimum time
300
+ # @param t_max [Date,Time,DateTime,EasyTime,Duration,String,Array<Integer>] the maximum time
301
+ # @param tolerance [Integer] the optional amount of seconds of tolerance to use in the comparison
302
+ # @return [Boolean] true if `t_min <= self.time <= t_max`, using tolerant comparisons
303
+ #
304
+ # @overload between?(time_range, tolerance: nil)
305
+ # @param time_range [Range] a range `(t_min..t_max)` of time values
306
+ # @param tolerance [Integer] the optional amount of seconds of tolerance to use in the comparison
307
+ # @return [Boolean] true if `time_range.min <= self.time <= time_range.max`, using tolerant comparisons
308
+
309
+ def between?(t_arg, t_max = nil)
310
+ if t_arg.is_a?(Range)
311
+ t_min = t_arg.min
312
+ t_max = t_arg.max
313
+ else
314
+ t_min = t_arg
315
+ end
316
+ compare(t_min) >= 0 && compare(t_max) <= 0
317
+ end
318
+
319
+ # @param duration [Integer] seconds to add to the EasyTime value
320
+ # @return [EasyTime] updated date and time value
321
+ def +(duration)
322
+ dup.tap { |eztime| eztime.time += duration }
323
+ end
324
+
325
+ # Subtract a value from an EasyTime. If the value is an integer, it is treated
326
+ # as seconds. If the value is any of the Date, DateTime, Time, EasyTime, or a String-
327
+ # formatted date/time, it is subtracted from the EasyTime value resulting in an integer
328
+ # duration.
329
+ # @param other [Date,Time,DateTime,EasyTime,Duration,String,Integer]
330
+ # a date/time value, a duration, or an Integer
331
+ # @return [EasyTime,Integer] updated time _(time - duration)_ or duration _(time - time)_
332
+ def -(other)
333
+ @other_time = convert(other, false)
334
+ if is_a_time?(other_time)
335
+ time - other_time
336
+ elsif other_time
337
+ dup.tap { |eztime| eztime.time -= other_time }
338
+ end
339
+ end
340
+
341
+ def acts_like_time?
342
+ true
343
+ end
344
+
345
+ private
346
+
347
+ def convert(datetime, coerce = true)
348
+ self.class.convert(datetime, coerce)
349
+ end
350
+
351
+ # intercept any time methods so they can wrap the time-like result in a new EasyTime object.
352
+ def method_missing(name, *args, &block)
353
+ if time.respond_to?(name)
354
+ value = time.send(name, *args, &block)
355
+ is_a_time?(value) ? dup.tap { |eztime| eztime.time = value } : value
356
+ else
357
+ super
358
+ end
359
+ end
360
+
361
+ def respond_to_missing?(method_name, include_all=false)
362
+ time.respond_to?(name, include_all)
363
+ end
364
+
365
+ def is_a_time?(value)
366
+ self.class.is_a_time?(value)
367
+ end
368
+ end
369
+
370
+ # Extend the known date and time classes _(including EasyTime itself!)_
371
+ module EasyTimeExtensions
372
+ def easy_time(tolerance: nil)
373
+ EasyTime.new(self, tolerance: tolerance)
374
+ end
375
+ end
376
+
377
+ [EasyTime, Date, Time, DateTime, ActiveSupport::Duration, ActiveSupport::TimeWithZone, String].each do |klass|
378
+ klass.include(EasyTimeExtensions)
379
+ end
380
+
381
+ # end of EasyTime