easy_time 0.1.2

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.
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