timeliness 0.1.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.
@@ -0,0 +1,221 @@
1
+ module Timeliness
2
+ module Formats
3
+
4
+ # Format tokens:
5
+ # y = year
6
+ # m = month
7
+ # d = day
8
+ # h = hour
9
+ # n = minute
10
+ # s = second
11
+ # u = micro-seconds
12
+ # ampm = meridian (am or pm) with or without dots (e.g. am, a.m, or a.m.)
13
+ # _ = optional space
14
+ # tz = Timezone abbreviation (e.g. UTC, GMT, PST, EST)
15
+ # zo = Timezone offset (e.g. +10:00, -08:00, +1000)
16
+ #
17
+ # All other characters are considered literal. You can embed regexp in the
18
+ # format but no guarantees that it will remain intact. If you don't use capture
19
+ # groups, dots or backslashes in the regexp, it may well work as expected.
20
+ # For special characters, use POSIX character classes for safety.
21
+ #
22
+ # Repeating tokens:
23
+ # x = 1 or 2 digits for unit (e.g. 'h' means an hour can be '9' or '09')
24
+ # xx = 2 digits exactly for unit (e.g. 'hh' means an hour can only be '09')
25
+ #
26
+ # Special Cases:
27
+ # yy = 2 or 4 digit year
28
+ # yyyy = exactly 4 digit year
29
+ # mmm = month long name (e.g. 'Jul' or 'July')
30
+ # ddd = Day name of 3 to 9 letters (e.g. Wed or Wednesday)
31
+ # u = microseconds matches 1 to 6 digits
32
+
33
+ @time_formats = [
34
+ 'hh:nn:ss',
35
+ 'hh-nn-ss',
36
+ 'h:nn',
37
+ 'h.nn',
38
+ 'h nn',
39
+ 'h-nn',
40
+ 'h:nn_ampm',
41
+ 'h.nn_ampm',
42
+ 'h nn_ampm',
43
+ 'h-nn_ampm',
44
+ 'h_ampm'
45
+ ]
46
+
47
+ @date_formats = [
48
+ 'yyyy-mm-dd',
49
+ 'yyyy/mm/dd',
50
+ 'yyyy.mm.dd',
51
+ 'm/d/yy',
52
+ 'd/m/yy',
53
+ 'm\d\yy',
54
+ 'd\m\yy',
55
+ 'd-m-yy',
56
+ 'dd-mm-yyyy',
57
+ 'd.m.yy',
58
+ 'd mmm yy'
59
+ ]
60
+
61
+ @datetime_formats = [
62
+ 'yyyy-mm-dd hh:nn:ss.u',
63
+ 'yyyy-mm-dd hh:nn:ss',
64
+ 'yyyy-mm-dd h:nn',
65
+ 'yyyy-mm-dd h:nn_ampm',
66
+ 'm/d/yy h:nn:ss',
67
+ 'm/d/yy h:nn_ampm',
68
+ 'm/d/yy h:nn',
69
+ 'd/m/yy hh:nn:ss',
70
+ 'd/m/yy h:nn_ampm',
71
+ 'd/m/yy h:nn',
72
+ 'dd-mm-yyyy hh:nn:ss',
73
+ 'dd-mm-yyyy h:nn_ampm',
74
+ 'dd-mm-yyyy h:nn',
75
+ 'ddd, dd mmm yyyy hh:nn:ss tz', # RFC 822
76
+ 'ddd, dd mmm yyyy hh:nn:ss zo', # RFC 822
77
+ 'ddd mmm d hh:nn:ss zo yyyy', # Ruby time string
78
+ 'yyyy-mm-ddThh:nn:ssZ', # ISO 8601 without zone offset
79
+ 'yyyy-mm-ddThh:nn:sszo' # ISO 8601 with zone offset
80
+ ]
81
+
82
+ # All tokens available for format construction. The token array is made of
83
+ # regexp and key for format component mapping, if any.
84
+ #
85
+ @format_tokens = {
86
+ 'ddd' => [ '\w{3,9}' ],
87
+ 'dd' => [ '\d{2}', :day ],
88
+ 'd' => [ '\d{1,2}', :day ],
89
+ 'mmm' => [ '\w{3,9}', :month ],
90
+ 'mm' => [ '\d{2}', :month ],
91
+ 'm' => [ '\d{1,2}', :month ],
92
+ 'yyyy' => [ '\d{4}', :year ],
93
+ 'yy' => [ '\d{4}|\d{2}', :year ],
94
+ 'hh' => [ '\d{2}', :hour ],
95
+ 'h' => [ '\d{1,2}', :hour ],
96
+ 'nn' => [ '\d{2}', :min ],
97
+ 'n' => [ '\d{1,2}', :min ],
98
+ 'ss' => [ '\d{2}', :sec ],
99
+ 's' => [ '\d{1,2}', :sec ],
100
+ 'u' => [ '\d{1,6}', :usec ],
101
+ 'ampm' => [ '[aApP]\.?[mM]\.?', :meridian ],
102
+ 'zo' => [ '[+-]\d{2}:?\d{2}', :offset ],
103
+ 'tz' => [ '[A-Z]{1,4}' ],
104
+ '_' => [ '\s?' ]
105
+ }
106
+
107
+ # Component argument values will be passed to the format method if matched in
108
+ # the time string. The key should match the key defined in the format tokens.
109
+ #
110
+ # The array consists of the position the value should be inserted in
111
+ # the time array, and the code to place in the time array.
112
+ #
113
+ # If the position is nil, then the value won't be put in the time array. If the
114
+ # code is nil, then just the raw value is used.
115
+ #
116
+ @format_components = {
117
+ :year => [ 0, 'unambiguous_year(year)'],
118
+ :month => [ 1, 'month_index(month)'],
119
+ :day => [ 2 ],
120
+ :hour => [ 3, 'full_hour(hour, meridian ||= nil)'],
121
+ :min => [ 4 ],
122
+ :sec => [ 5 ],
123
+ :usec => [ 6, 'microseconds(usec)'],
124
+ :offset => [ 7, 'offset_in_seconds(offset)'],
125
+ :meridian => [ nil ]
126
+ }
127
+
128
+ US_FORMAT_REGEXP = /\Am{1,2}[^m]/
129
+
130
+ class << self
131
+ attr_accessor :time_formats, :date_formats, :datetime_formats, :format_tokens, :format_components
132
+ attr_reader :date_format_set, :time_format_set, :datetime_format_set
133
+
134
+ # Adds new formats. Must specify format type and can specify a :before
135
+ # option to nominate which format the new formats should be inserted in
136
+ # front on to take higher precedence.
137
+ #
138
+ # Error is raised if format already exists or if :before format is not found.
139
+ #
140
+ def add_formats(type, *add_formats)
141
+ formats = send("#{type}_formats")
142
+ options = add_formats.last.is_a?(Hash) ? add_formats.pop : {}
143
+ before = options[:before]
144
+ raise "Format for :before option #{format} was not found." if before && !formats.include?(before)
145
+
146
+ add_formats.each do |format|
147
+ raise "Format #{format} is already included in #{type} formats" if formats.include?(format)
148
+
149
+ index = before ? formats.index(before) : -1
150
+ formats.insert(index, format)
151
+ end
152
+ compile_formats
153
+ end
154
+
155
+ # Delete formats of specified type. Error raised if format not found.
156
+ #
157
+ def remove_formats(type, *remove_formats)
158
+ remove_formats.each do |format|
159
+ unless send("#{type}_formats").delete(format)
160
+ raise "Format #{format} not found in #{type} formats"
161
+ end
162
+ end
163
+ compile_formats
164
+ end
165
+
166
+ # Removes US date formats so that ambigious dates are parsed as European format
167
+ #
168
+ def use_euro_formats
169
+ @date_format_set = FormatSet.compile(date_formats.select { |format| US_FORMAT_REGEXP !~ format })
170
+ @datetime_format_set = FormatSet.compile(datetime_formats.select { |format| US_FORMAT_REGEXP !~ format })
171
+ end
172
+
173
+ # Restores default to parse ambiguous dates as US format
174
+ #
175
+ def use_us_formats
176
+ @date_format_set = FormatSet.compile(date_formats)
177
+ @datetime_format_set = FormatSet.compile(datetime_formats)
178
+ end
179
+
180
+ def compile_formats
181
+ @sorted_token_keys = nil
182
+ @time_format_set = FormatSet.compile(time_formats)
183
+ @date_format_set = FormatSet.compile(date_formats)
184
+ @datetime_format_set = FormatSet.compile(datetime_formats)
185
+ end
186
+
187
+ def sorted_token_keys
188
+ @sorted_token_keys ||= format_tokens.keys.sort {|a,b| a.size <=> b.size }.reverse
189
+ end
190
+
191
+ # Returns format for type and other possible matching format set based on type
192
+ # and value length. Gives minor speed-up by checking string length.
193
+ def format_set(type, string)
194
+ case type
195
+ when :date
196
+ [ @date_format_set, @datetime_format_set ]
197
+ when :time
198
+ if string.length < 11
199
+ [ @time_format_set ]
200
+ else
201
+ [ @datetime_format_set, @time_format_set ]
202
+ end
203
+ when :datetime
204
+ if string.length < 11
205
+ [ @date_format_set, @datetime_format_set ]
206
+ else
207
+ [ @datetime_format_set, @date_format_set ]
208
+ end
209
+ else
210
+ if string.length < 11
211
+ [ @date_format_set, @time_format_set, @datetime_format_set ]
212
+ else
213
+ [ @datetime_format_set, @date_format_set, @time_format_set ]
214
+ end
215
+ end
216
+ end
217
+
218
+ end
219
+
220
+ end
221
+ end
@@ -0,0 +1,49 @@
1
+ module Timeliness
2
+ module Helpers
3
+
4
+ def full_hour(hour, meridian)
5
+ hour = hour.to_i
6
+ return hour if meridian.nil?
7
+ if meridian.delete('.').downcase == 'am'
8
+ raise if hour == 0 || hour > 12
9
+ hour == 12 ? 0 : hour
10
+ else
11
+ hour == 12 ? hour : hour + 12
12
+ end
13
+ end
14
+
15
+ def unambiguous_year(year)
16
+ if year.length <= 2
17
+ century = Time.now.year.to_s[0..1].to_i
18
+ century -= 1 if year.to_i >= Timeliness.ambiguous_year_threshold
19
+ year = "#{century}#{year.rjust(2,'0')}"
20
+ end
21
+ year.to_i
22
+ end
23
+
24
+ def month_index(month)
25
+ return month.to_i if month.to_i > 0
26
+ month.length > 3 ? month_names.index(month.capitalize) : abbr_month_names.index(month.capitalize)
27
+ end
28
+
29
+ def month_names
30
+ defined?(I18n) ? I18n.t('date.month_names') : Date::MONTHNAMES
31
+ end
32
+
33
+ def abbr_month_names
34
+ defined?(I18n) ? I18n.t('date.abbr_month_names') : Date::ABBR_MONTHNAMES
35
+ end
36
+
37
+ def microseconds(usec)
38
+ (".#{usec}".to_f * 1_000_000).to_i
39
+ end
40
+
41
+ def offset_in_seconds(offset)
42
+ sign = offset =~ /^-/ ? -1 : 1
43
+ parts = offset.scan(/\d\d/).map {|p| p.to_f }
44
+ parts[1] = parts[1].to_f / 60
45
+ (parts[0] + parts[1]) * sign * 3600
46
+ end
47
+
48
+ end
49
+ end
@@ -0,0 +1,94 @@
1
+ module Timeliness
2
+ module Parser
3
+
4
+ class << self
5
+
6
+ def parse(value, *args)
7
+ return value unless value.is_a?(String)
8
+
9
+ options = args.last.is_a?(Hash) ? args.pop : {}
10
+ type = args.first
11
+
12
+ time_array = _parse(value, type, options)
13
+ return nil if time_array.nil?
14
+
15
+ if type == :date
16
+ time_array[3..7] = nil
17
+ elsif type == :time
18
+ time_array[0..2] = current_date(options)
19
+ elsif type.nil?
20
+ dummy_date = current_date(options)
21
+ time_array[0] ||= dummy_date[0]
22
+ time_array[1] ||= dummy_date[1]
23
+ time_array[2] ||= dummy_date[2]
24
+ end
25
+ make_time(time_array[0..6], options[:zone])
26
+ end
27
+
28
+ def make_time(time_array, zone=nil)
29
+ return nil unless fast_date_valid_with_fallback(*time_array[0..2])
30
+
31
+ zone ||= Timeliness.default_timezone
32
+ case zone
33
+ when :utc, :local
34
+ time_with_datetime_fallback(zone, *time_array.compact)
35
+ when :current
36
+ Time.zone.local(*time_array)
37
+ else
38
+ Time.use_zone(zone) { Time.zone.local(*time_array) }
39
+ end
40
+ rescue ArgumentError, TypeError
41
+ nil
42
+ end
43
+
44
+ def _parse(string, type=nil, options={})
45
+ if options[:strict] && type
46
+ set = Formats.send("#{type}_format_set")
47
+ set.match(string, options[:format])
48
+ else
49
+ values = nil
50
+ Formats.format_set(type, string).find {|set| values = set.match(string, options[:format]) }
51
+ values
52
+ end
53
+ rescue
54
+ nil
55
+ end
56
+
57
+ private
58
+
59
+ def current_date(options)
60
+ now = if options[:now]
61
+ options[:now]
62
+ elsif options[:zone]
63
+ case options[:zone]
64
+ when :utc, :local
65
+ Time.now.send("get#{options[:zone]}")
66
+ when :current
67
+ Time.current
68
+ else
69
+ Time.use_zone(options[:zone]) { Time.current }
70
+ end
71
+ end
72
+ now ||= Timeliness.date_for_time_type
73
+ now.is_a?(Array) ? now[0..2] : Array(now).reverse[4..6]
74
+ end
75
+
76
+ # Taken from ActiveSupport and simplified
77
+ def time_with_datetime_fallback(utc_or_local, year, month=1, day=1, hour=0, min=0, sec=0, usec=0)
78
+ return nil if hour > 23 || min > 59 || sec > 59
79
+ ::Time.send(utc_or_local, year, month, day, hour, min, sec, usec)
80
+ rescue
81
+ offset = utc_or_local == :local ? (::Time.local(2007).utc_offset.to_r/86400) : 0
82
+ ::DateTime.civil(year, month, day, hour, min, sec, offset)
83
+ end
84
+
85
+ # Enforce strict date part validity which the Time class does not.
86
+ # Only does full date check if month and day are possibly invalid.
87
+ def fast_date_valid_with_fallback(year, month, day)
88
+ month < 13 && (day < 29 || Date.valid_civil?(year, month, day))
89
+ end
90
+
91
+ end
92
+
93
+ end
94
+ end
@@ -0,0 +1,3 @@
1
+ module Timeliness
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,36 @@
1
+ require 'rspec'
2
+
3
+ require 'active_support/time'
4
+ require 'timecop'
5
+ require 'timeliness'
6
+
7
+ module TimelinessHelpers
8
+ def parser
9
+ Timeliness::Parser
10
+ end
11
+
12
+ def formats
13
+ Timeliness::Formats
14
+ end
15
+
16
+ def parse(*args)
17
+ Timeliness::Parser.parse(*args)
18
+ end
19
+
20
+ def current_date(options={})
21
+ Timeliness::Parser.send(:current_date, options)
22
+ end
23
+
24
+ def should_parse(*args)
25
+ Timeliness::Parser.parse(*args).should_not be_nil
26
+ end
27
+
28
+ def should_not_parse(*args)
29
+ Timeliness::Parser.parse(*args).should be_nil
30
+ end
31
+ end
32
+
33
+ Rspec.configure do |c|
34
+ c.mock_with :rspec
35
+ c.include TimelinessHelpers
36
+ end
@@ -0,0 +1,106 @@
1
+ require 'spec_helper'
2
+
3
+ describe Timeliness::FormatSet do
4
+ context ".define_format_method" do
5
+ it "should define method which outputs date array with values in correct order" do
6
+ define_method_for('yyyy-mm-dd').call('2000', '1', '2').should == [2000,1,2,nil,nil,nil,nil]
7
+ end
8
+
9
+ it "should define method which outputs date array from format with different order" do
10
+ define_method_for('dd/mm/yyyy').call('2', '1', '2000').should == [2000,1,2,nil,nil,nil,nil]
11
+ end
12
+
13
+ it "should define method which outputs time array" do
14
+ define_method_for('hh:nn:ss').call('01', '02', '03').should == [nil,nil,nil,1,2,3,nil]
15
+ end
16
+
17
+ it "should define method which outputs time array with meridian 'pm' adjusted hour" do
18
+ define_method_for('hh:nn:ss ampm').call('01', '02', '03', 'pm').should == [nil,nil,nil,13,2,3,nil]
19
+ end
20
+
21
+ it "should define method which outputs time array with meridian 'am' unadjusted hour" do
22
+ define_method_for('hh:nn:ss ampm').call('01', '02', '03', 'am').should == [nil,nil,nil,1,2,3,nil]
23
+ end
24
+
25
+ it "should define method which outputs time array with microseconds" do
26
+ define_method_for('hh:nn:ss.u').call('01', '02', '03', '99').should == [nil,nil,nil,1,2,3,990000]
27
+ end
28
+
29
+ it "should define method which outputs datetime array with zone offset" do
30
+ define_method_for('yyyy-mm-dd hh:nn:ss.u zo').call('2001', '02', '03', '04', '05', '06', '99', '+10:00').should == [2001,2,3,4,5,6,990000,36000]
31
+ end
32
+ end
33
+
34
+ context "compiled regexp" do
35
+
36
+ context "for time formats" do
37
+ format_tests = {
38
+ 'hh:nn:ss' => {:pass => ['12:12:12', '01:01:01'], :fail => ['1:12:12', '12:1:12', '12:12:1', '12-12-12']},
39
+ 'hh-nn-ss' => {:pass => ['12-12-12', '01-01-01'], :fail => ['1-12-12', '12-1-12', '12-12-1', '12:12:12']},
40
+ 'h:nn' => {:pass => ['12:12', '1:01'], :fail => ['12:2', '12-12']},
41
+ 'h.nn' => {:pass => ['2.12', '12.12'], :fail => ['2.1', '12:12']},
42
+ 'h nn' => {:pass => ['2 12', '12 12'], :fail => ['2 1', '2.12', '12:12']},
43
+ 'h-nn' => {:pass => ['2-12', '12-12'], :fail => ['2-1', '2.12', '12:12']},
44
+ 'h:nn_ampm' => {:pass => ['2:12am', '2:12 pm', '2:12 AM', '2:12PM'], :fail => ['1:2am', '1:12 pm', '2.12am']},
45
+ 'h.nn_ampm' => {:pass => ['2.12am', '2.12 pm'], :fail => ['1:2am', '1:12 pm', '2:12am']},
46
+ 'h nn_ampm' => {:pass => ['2 12am', '2 12 pm'], :fail => ['1 2am', '1 12 pm', '2:12am']},
47
+ 'h-nn_ampm' => {:pass => ['2-12am', '2-12 pm'], :fail => ['1-2am', '1-12 pm', '2:12am']},
48
+ 'h_ampm' => {:pass => ['2am', '2 am', '12 pm'], :fail => ['1.am', '12 pm', '2:12am']},
49
+ }
50
+ format_tests.each do |format, values|
51
+ it "should correctly match times in format '#{format}'" do
52
+ regexp = compile_regexp(format)
53
+ values[:pass].each {|value| value.should match(regexp)}
54
+ values[:fail].each {|value| value.should_not match(regexp)}
55
+ end
56
+ end
57
+ end
58
+
59
+ context "for date formats" do
60
+ format_tests = {
61
+ 'yyyy/mm/dd' => {:pass => ['2000/02/01'], :fail => ['2000\02\01', '2000/2/1', '00/02/01']},
62
+ 'yyyy-mm-dd' => {:pass => ['2000-02-01'], :fail => ['2000\02\01', '2000-2-1', '00-02-01']},
63
+ 'yyyy.mm.dd' => {:pass => ['2000.02.01'], :fail => ['2000\02\01', '2000.2.1', '00.02.01']},
64
+ 'm/d/yy' => {:pass => ['2/1/01', '02/01/00', '02/01/2000'], :fail => ['2/1/0', '2.1.01']},
65
+ 'd/m/yy' => {:pass => ['1/2/01', '01/02/00', '01/02/2000'], :fail => ['1/2/0', '1.2.01']},
66
+ 'm\d\yy' => {:pass => ['2\1\01', '2\01\00', '02\01\2000'], :fail => ['2\1\0', '2/1/01']},
67
+ 'd\m\yy' => {:pass => ['1\2\01', '1\02\00', '01\02\2000'], :fail => ['1\2\0', '1/2/01']},
68
+ 'd-m-yy' => {:pass => ['1-2-01', '1-02-00', '01-02-2000'], :fail => ['1-2-0', '1/2/01']},
69
+ 'd.m.yy' => {:pass => ['1.2.01', '1.02.00', '01.02.2000'], :fail => ['1.2.0', '1/2/01']},
70
+ 'd mmm yy' => {:pass => ['1 Feb 00', '1 Feb 2000', '1 February 00', '01 February 2000'],
71
+ :fail => ['1 Fe 00', 'Feb 1 2000', '1 Feb 0']}
72
+ }
73
+ format_tests.each do |format, values|
74
+ it "should correctly match dates in format '#{format}'" do
75
+ regexp = compile_regexp(format)
76
+ values[:pass].each {|value| value.should match(regexp)}
77
+ values[:fail].each {|value| value.should_not match(regexp)}
78
+ end
79
+ end
80
+ end
81
+
82
+ context "for datetime formats" do
83
+ format_tests = {
84
+ 'ddd mmm d hh:nn:ss zo yyyy' => {:pass => ['Sat Jul 19 12:00:00 +1000 2008'], :fail => []},
85
+ 'yyyy-mm-ddThh:nn:ss(?:Z|zo)' => {:pass => ['2008-07-19T12:00:00+10:00', '2008-07-19T12:00:00Z'], :fail => ['2008-07-19T12:00:00Z+10:00']},
86
+ }
87
+ format_tests.each do |format, values|
88
+ it "should correctly match datetimes in format '#{format}'" do
89
+ regexp = compile_regexp(format)
90
+ values[:pass].each {|value| value.should match(regexp)}
91
+ values[:fail].each {|value| value.should_not match(regexp)}
92
+ end
93
+ end
94
+ end
95
+
96
+ end
97
+
98
+ def define_method_for(format)
99
+ Timeliness::FormatSet.compile([format]).method(:"format_#{format}")
100
+ end
101
+
102
+ def compile_regexp(format)
103
+ Timeliness::FormatSet.compile([format]).regexp
104
+ end
105
+
106
+ end