fat_core 0.0.1
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.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.rspec +2 -0
- data/.yardopts +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +46 -0
- data/Rakefile +1 -0
- data/fat_core.gemspec +29 -0
- data/lib/fat_core.rb +19 -0
- data/lib/fat_core/array.rb +14 -0
- data/lib/fat_core/date.rb +757 -0
- data/lib/fat_core/enumerable.rb +7 -0
- data/lib/fat_core/hash.rb +33 -0
- data/lib/fat_core/kernel.rb +9 -0
- data/lib/fat_core/latex_eruby.rb +11 -0
- data/lib/fat_core/nil.rb +9 -0
- data/lib/fat_core/numeric.rb +87 -0
- data/lib/fat_core/period.rb +410 -0
- data/lib/fat_core/range.rb +192 -0
- data/lib/fat_core/string.rb +184 -0
- data/lib/fat_core/symbol.rb +17 -0
- data/lib/fat_core/version.rb +3 -0
- data/spec/lib/date_spec.rb +320 -0
- data/spec/lib/kernel_spec.rb +11 -0
- data/spec/lib/numeric_spec.rb +34 -0
- data/spec/lib/period_spec.rb +294 -0
- data/spec/lib/range_spec.rb +246 -0
- data/spec/lib/string_spec.rb +128 -0
- data/spec/spec_helper.rb +23 -0
- metadata +178 -0
@@ -0,0 +1,33 @@
|
|
1
|
+
class Hash
|
2
|
+
# Return all keys in hash that have a value == to the given value or have an
|
3
|
+
# Enumerable value that includes the given value.
|
4
|
+
def keys_with_value(val)
|
5
|
+
result = []
|
6
|
+
each_pair do |k, v|
|
7
|
+
if self[k] == val || (v.respond_to?(:include?) && v.include?(val))
|
8
|
+
result << k
|
9
|
+
end
|
10
|
+
end
|
11
|
+
result
|
12
|
+
end
|
13
|
+
|
14
|
+
# Remove from the hash all keys that have values == to given value or that
|
15
|
+
# include the given value if the hash has an Enumerable for a value
|
16
|
+
def delete_with_value(v)
|
17
|
+
keys_with_value(v).each do |k|
|
18
|
+
delete(k)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def remap_keys(key_map = {})
|
23
|
+
new_hash = {}
|
24
|
+
each_pair do |key, val|
|
25
|
+
if key_map.has_key?(key)
|
26
|
+
new_hash[key_map[key]] = val
|
27
|
+
else
|
28
|
+
new_hash[key] = val
|
29
|
+
end
|
30
|
+
end
|
31
|
+
new_hash
|
32
|
+
end
|
33
|
+
end
|
data/lib/fat_core/nil.rb
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
class Numeric
|
2
|
+
def signum
|
3
|
+
if self > 0
|
4
|
+
1
|
5
|
+
elsif self < 0
|
6
|
+
-1
|
7
|
+
else
|
8
|
+
0
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def commas(places = nil)
|
13
|
+
# By default, use zero places for whole numbers; four places for
|
14
|
+
# numbers containing a fractional part to 4 places.
|
15
|
+
if places.nil?
|
16
|
+
if self.modulo(1).round(4) > 0.0
|
17
|
+
places = 4
|
18
|
+
else
|
19
|
+
places = 0
|
20
|
+
end
|
21
|
+
end
|
22
|
+
group(places, ',')
|
23
|
+
end
|
24
|
+
|
25
|
+
def group(places = 0, delim = ',')
|
26
|
+
# Return number as a string with embedded commas
|
27
|
+
# for nice printing; round to places places after
|
28
|
+
# the decimal
|
29
|
+
|
30
|
+
# Only convert to string numbers with exponent unless they are
|
31
|
+
# less than 1 (to ensure that really small numbers round to 0.0)
|
32
|
+
if self.abs > 1.0 && self.to_s =~ /e/
|
33
|
+
return self.to_s
|
34
|
+
end
|
35
|
+
|
36
|
+
str = self.to_f.round(places).to_s
|
37
|
+
|
38
|
+
# Break the number into parts
|
39
|
+
str =~ /^(-)?(\d*)((\.)?(\d*))?$/
|
40
|
+
neg = $1 || ''
|
41
|
+
whole = $2
|
42
|
+
frac = $5
|
43
|
+
|
44
|
+
# Pad out the fractional part with zeroes to the right
|
45
|
+
n_zeroes = [places - frac.length, 0].max
|
46
|
+
frac += "0" * n_zeroes if n_zeroes > 0
|
47
|
+
|
48
|
+
# Place the commas in the whole part only
|
49
|
+
whole = whole.reverse
|
50
|
+
whole.gsub!(/([0-9]{3})/, "\\1#{delim}")
|
51
|
+
whole.gsub!(/#{Regexp.escape(delim)}$/, '')
|
52
|
+
whole.reverse!
|
53
|
+
if frac.nil? || places <= 0
|
54
|
+
return neg + whole
|
55
|
+
else
|
56
|
+
return neg + whole + '.' + frac
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Return an integer type, but only if the fractional part of self
|
61
|
+
# is zero
|
62
|
+
def int_if_whole
|
63
|
+
self.floor == self ? self.floor : self
|
64
|
+
end
|
65
|
+
|
66
|
+
def secs_to_hms
|
67
|
+
frac = self % 1
|
68
|
+
mins, secs = self.divmod(60)
|
69
|
+
hrs, mins = mins.divmod(60)
|
70
|
+
if frac.round(5) > 0.0
|
71
|
+
"%02d:%02d:%02d.%d" % [hrs, mins, secs, frac.round(5) * 100]
|
72
|
+
else
|
73
|
+
"%02d:%02d:%02d" % [hrs, mins, secs]
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Allow erb documents can directly interpolate numbers
|
78
|
+
def tex_quote
|
79
|
+
to_s
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
class BigDecimal
|
84
|
+
def inspect
|
85
|
+
to_s
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,410 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
class Period
|
3
|
+
include Enumerable
|
4
|
+
include Comparable
|
5
|
+
|
6
|
+
attr_accessor :first, :last
|
7
|
+
|
8
|
+
def initialize(first, last)
|
9
|
+
case first
|
10
|
+
when String
|
11
|
+
begin
|
12
|
+
first = Date.parse(first)
|
13
|
+
rescue ArgumentError => ex
|
14
|
+
if ex.message =~ /invalid date/
|
15
|
+
raise ArgumentError, "you gave an invalid date '#{first}'"
|
16
|
+
else
|
17
|
+
raise
|
18
|
+
end
|
19
|
+
end
|
20
|
+
when Date
|
21
|
+
first = first
|
22
|
+
else
|
23
|
+
raise ArgumentError, "use Date or String to initialize Period"
|
24
|
+
end
|
25
|
+
|
26
|
+
case last
|
27
|
+
when String
|
28
|
+
begin
|
29
|
+
last = Date.parse(last)
|
30
|
+
rescue ArgumentError => ex
|
31
|
+
if ex.message =~ /invalid date/
|
32
|
+
raise ArgumentError, "you gave an invalid date '#{last}'"
|
33
|
+
else
|
34
|
+
raise
|
35
|
+
end
|
36
|
+
end
|
37
|
+
when Date
|
38
|
+
last = last
|
39
|
+
else
|
40
|
+
raise ArgumentError, "use Date or String to initialize Period"
|
41
|
+
end
|
42
|
+
|
43
|
+
@first = first
|
44
|
+
@last = last
|
45
|
+
if @first > @last
|
46
|
+
raise ArgumentError, "Period's first date is later than its last date"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Possibly useful class method to take an array of periods and join all the
|
51
|
+
# contiguous ones, then return an array of the disjoint periods not
|
52
|
+
# contiguous to one another. An array of periods with no gaps should return
|
53
|
+
# an array of only one period spanning all the given periods.
|
54
|
+
|
55
|
+
# Return an array of periods that represent the concatenation of all
|
56
|
+
# adjacent periods in the given periods.
|
57
|
+
# def self.meld_periods(*periods)
|
58
|
+
# melded_periods = []
|
59
|
+
# while (this_period = periods.pop)
|
60
|
+
# melded_periods.each do |mp|
|
61
|
+
# if mp.overlaps?(this_period)
|
62
|
+
# melded_periods.delete(mp)
|
63
|
+
# melded_periods << mp.union(this_period)
|
64
|
+
# break
|
65
|
+
# elsif mp.contiguous?(this_period)
|
66
|
+
# melded_periods.delete(mp)
|
67
|
+
# melded_periods << mp.join(this_period)
|
68
|
+
# break
|
69
|
+
# end
|
70
|
+
# end
|
71
|
+
# end
|
72
|
+
# melded_periods
|
73
|
+
# end
|
74
|
+
|
75
|
+
# TO_DATE = Period.new(Date::BOT, Date.current)
|
76
|
+
# FOREVER = Period.new(Date::BOT, Date::EOT)
|
77
|
+
|
78
|
+
def each
|
79
|
+
d = first
|
80
|
+
while d <= last
|
81
|
+
yield d
|
82
|
+
d = d + 1.day
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def self.chunk_syms
|
87
|
+
[:day, :week, :biweek, :semimonth, :month, :bimonth,
|
88
|
+
:quarter, :year, :irregular]
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.chunk_sym_to_days(sym)
|
92
|
+
case sym
|
93
|
+
when :day
|
94
|
+
1
|
95
|
+
when :week
|
96
|
+
7
|
97
|
+
when :biweek
|
98
|
+
14
|
99
|
+
when :semimonth
|
100
|
+
15
|
101
|
+
when :month
|
102
|
+
30
|
103
|
+
when :bimonth
|
104
|
+
60
|
105
|
+
when :quarter
|
106
|
+
90
|
107
|
+
when :year
|
108
|
+
365
|
109
|
+
when :irregular
|
110
|
+
30
|
111
|
+
else
|
112
|
+
raise ArgumentError, "unknown chunk sym '#{sym}'"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# The largest number of days possible in each chunk
|
117
|
+
def self.chunk_sym_to_max_days(sym)
|
118
|
+
case sym
|
119
|
+
when :semimonth
|
120
|
+
16
|
121
|
+
when :month
|
122
|
+
31
|
123
|
+
when :bimonth
|
124
|
+
62
|
125
|
+
when :quarter
|
126
|
+
92
|
127
|
+
when :year
|
128
|
+
366
|
129
|
+
else
|
130
|
+
chunk_sym_to_days(sym)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# This is only used for inferring statement frequency based on the number
|
135
|
+
# of days between statements, so it will not consider all possible chunks,
|
136
|
+
# only :year, :quarter, :month, and :week. And distinguishing between
|
137
|
+
# :semimonth and :biweek is impossible anyway. Since statement dates can
|
138
|
+
# bounce around quite a bit in my experience, this is really fuzzy. For
|
139
|
+
# example, one of my banks does monthly statements "around" the 10th of
|
140
|
+
# the month, but the 10th can get pushed off by holidays, weekends, etc.,
|
141
|
+
# so a "quarter" here is much broader than the calendar definition. Ditto
|
142
|
+
# for the others, but since statements are most likely monthly, we default
|
143
|
+
# to :month.
|
144
|
+
def self.days_to_chunk_sym(days)
|
145
|
+
case days
|
146
|
+
when 356..376
|
147
|
+
:year
|
148
|
+
when 86..96
|
149
|
+
:quarter
|
150
|
+
when 26..33
|
151
|
+
:month
|
152
|
+
when 7
|
153
|
+
:week
|
154
|
+
when 1
|
155
|
+
:day
|
156
|
+
else
|
157
|
+
:irregular
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def size
|
162
|
+
to_range.size
|
163
|
+
end
|
164
|
+
|
165
|
+
def length
|
166
|
+
size
|
167
|
+
end
|
168
|
+
|
169
|
+
def to_range
|
170
|
+
(first..last)
|
171
|
+
end
|
172
|
+
|
173
|
+
def ==(other)
|
174
|
+
first == other.first && last == other.last
|
175
|
+
end
|
176
|
+
|
177
|
+
def <=>(other)
|
178
|
+
[first, size] <=> [other.first, other.size]
|
179
|
+
end
|
180
|
+
|
181
|
+
def to_s
|
182
|
+
if first.beginning_of_year? && last.end_of_year? && first.year == last.year
|
183
|
+
"#{first.year}"
|
184
|
+
elsif first.beginning_of_quarter? &&
|
185
|
+
last.end_of_quarter? &&
|
186
|
+
first.year == last.year &&
|
187
|
+
first.quarter == last.quarter
|
188
|
+
"#{first.year}-#{first.quarter}Q"
|
189
|
+
elsif first.beginning_of_month? &&
|
190
|
+
last.end_of_month? &&
|
191
|
+
first.year == last.year &&
|
192
|
+
first.month == last.month
|
193
|
+
"#{first.year}-%02d" % first.month
|
194
|
+
else
|
195
|
+
"#{first.iso} to #{last.iso}"
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
# Allow erb documents can directly interpolate ranges
|
200
|
+
def tex_quote
|
201
|
+
"#{first.iso}--#{last.iso}"
|
202
|
+
end
|
203
|
+
|
204
|
+
# Days in period
|
205
|
+
def size
|
206
|
+
(last - first + 1).to_i
|
207
|
+
end
|
208
|
+
|
209
|
+
def length
|
210
|
+
size
|
211
|
+
end
|
212
|
+
|
213
|
+
def subset_of?(other)
|
214
|
+
to_range.subset_of?(other.to_range)
|
215
|
+
end
|
216
|
+
|
217
|
+
def proper_subset_of?(other)
|
218
|
+
to_range.proper_subset_of?(other.to_range)
|
219
|
+
end
|
220
|
+
|
221
|
+
def superset_of?(other)
|
222
|
+
to_range.superset_of?(other.to_range)
|
223
|
+
end
|
224
|
+
|
225
|
+
def proper_superset_of?(other)
|
226
|
+
to_range.proper_superset_of?(other.to_range)
|
227
|
+
end
|
228
|
+
|
229
|
+
def overlaps?(other)
|
230
|
+
self.to_range.overlaps?(other.to_range)
|
231
|
+
end
|
232
|
+
|
233
|
+
def intersection(other)
|
234
|
+
self.to_range.intersection(other.to_range)
|
235
|
+
end
|
236
|
+
alias_method :&, :intersection
|
237
|
+
|
238
|
+
def union(other)
|
239
|
+
self.to_range.union(other.to_range)
|
240
|
+
end
|
241
|
+
alias_method :+, :union
|
242
|
+
|
243
|
+
def difference(other)
|
244
|
+
self.to_range.difference(other.to_range)
|
245
|
+
end
|
246
|
+
alias_method :-, :difference
|
247
|
+
|
248
|
+
# returns the chunk sym represented by the period
|
249
|
+
def chunk_sym
|
250
|
+
if first.beginning_of_year? && last.end_of_year? &&
|
251
|
+
(365..366) === last - first + 1
|
252
|
+
:year
|
253
|
+
elsif first.beginning_of_quarter? && last.end_of_quarter? &&
|
254
|
+
(90..92) === last - first + 1
|
255
|
+
:quarter
|
256
|
+
elsif first.beginning_of_bimonth? && last.end_of_bimonth? &&
|
257
|
+
(58..62) === last - first + 1
|
258
|
+
:bimonth
|
259
|
+
elsif first.beginning_of_month? && last.end_of_month? &&
|
260
|
+
(28..31) === last - first + 1
|
261
|
+
:month
|
262
|
+
elsif first.beginning_of_semimonth? && last.end_of_semimonth &&
|
263
|
+
(13..16) === last - first + 1
|
264
|
+
:semimonth
|
265
|
+
elsif first.beginning_of_biweek? && last.end_of_biweek? &&
|
266
|
+
last - first + 1 == 14
|
267
|
+
:biweek
|
268
|
+
elsif first.beginning_of_week? && last.end_of_week? &&
|
269
|
+
last - first + 1 == 7
|
270
|
+
:week
|
271
|
+
elsif first == last
|
272
|
+
:day
|
273
|
+
else
|
274
|
+
:irregular
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
# Name for a period not necessarily ending on calendar boundaries. For
|
279
|
+
# example, in reporting reconciliation, we want the period from Feb 11,
|
280
|
+
# 2014, to March 10, 2014, be called the 'Month ending March 10, 2014,'
|
281
|
+
# event though the period is not a calendar month. Using the stricter
|
282
|
+
# Period#chunk_sym, would not allow such looseness.
|
283
|
+
def chunk_name
|
284
|
+
case Period.days_to_chunk_sym(length)
|
285
|
+
when :year
|
286
|
+
'Year'
|
287
|
+
when :quarter
|
288
|
+
'Quarter'
|
289
|
+
when :bimonth
|
290
|
+
'Bi-month'
|
291
|
+
when :month
|
292
|
+
'Month'
|
293
|
+
when :semimonth
|
294
|
+
'Semi-month'
|
295
|
+
when :biweek
|
296
|
+
'Bi-week'
|
297
|
+
when :week
|
298
|
+
'Week'
|
299
|
+
when :day
|
300
|
+
'Day'
|
301
|
+
else
|
302
|
+
'Period'
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
def contains?(date)
|
307
|
+
self.to_range.cover?(date)
|
308
|
+
end
|
309
|
+
|
310
|
+
def overlaps?(other)
|
311
|
+
self.to_range.overlaps?(other.to_range)
|
312
|
+
end
|
313
|
+
|
314
|
+
# Return whether any of the Periods that are within self overlap one
|
315
|
+
# another
|
316
|
+
def has_overlaps_within?(periods)
|
317
|
+
self.to_range.has_overlaps_within?(periods.map{ |p| p.to_range})
|
318
|
+
end
|
319
|
+
|
320
|
+
def spanned_by?(periods)
|
321
|
+
to_range.spanned_by?(periods.map { |p| p.to_range })
|
322
|
+
end
|
323
|
+
|
324
|
+
def gaps(periods)
|
325
|
+
to_range.gaps(periods.map { |p| p.to_range }).
|
326
|
+
map { |r| Period.new(r.first, r.last)}
|
327
|
+
end
|
328
|
+
|
329
|
+
# Return an array of Periods wholly-contained within self in chunks of
|
330
|
+
# size, defaulting to monthly chunks. Partial chunks at the beginning and
|
331
|
+
# end of self are not included unless partial_first or partial_last,
|
332
|
+
# respectively, are set true. The last chunk can be made to extend beyond
|
333
|
+
# the end of self to make it a whole chunk if round_up_last is set true,
|
334
|
+
# in which case, partial_last is ignored.
|
335
|
+
def chunks(size: :month, partial_first: false, partial_last: false, round_up_last: false)
|
336
|
+
size = size.to_sym
|
337
|
+
result = []
|
338
|
+
chunk_start = first.dup
|
339
|
+
while chunk_start <= last
|
340
|
+
case size
|
341
|
+
when :year
|
342
|
+
unless partial_first
|
343
|
+
until chunk_start.beginning_of_year?
|
344
|
+
chunk_start += 1.day
|
345
|
+
end
|
346
|
+
end
|
347
|
+
chunk_end = chunk_start.end_of_year
|
348
|
+
when :quarter
|
349
|
+
unless partial_first
|
350
|
+
until chunk_start.beginning_of_quarter?
|
351
|
+
chunk_start += 1.day
|
352
|
+
end
|
353
|
+
end
|
354
|
+
chunk_end = chunk_start.end_of_quarter
|
355
|
+
when :bimonth
|
356
|
+
unless partial_first
|
357
|
+
until chunk_start.beginning_of_bimonth?
|
358
|
+
chunk_start += 1.day
|
359
|
+
end
|
360
|
+
end
|
361
|
+
chunk_end = (chunk_start.end_of_month + 1.day).end_of_month
|
362
|
+
when :month
|
363
|
+
unless partial_first
|
364
|
+
until chunk_start.beginning_of_month?
|
365
|
+
chunk_start += 1.day
|
366
|
+
end
|
367
|
+
end
|
368
|
+
chunk_end = chunk_start.end_of_month
|
369
|
+
when :semimonth
|
370
|
+
unless partial_first
|
371
|
+
until chunk_start.beginning_of_semimonth?
|
372
|
+
chunk_start += 1.day
|
373
|
+
end
|
374
|
+
end
|
375
|
+
chunk_end = chunk_start.end_of_semimonth
|
376
|
+
when :biweek
|
377
|
+
unless partial_first
|
378
|
+
until chunk_start.beginning_of_biweek?
|
379
|
+
chunk_start += 1.day
|
380
|
+
end
|
381
|
+
end
|
382
|
+
chunk_end = chunk_start.end_of_biweek
|
383
|
+
when :week
|
384
|
+
unless partial_first
|
385
|
+
until chunk_start.beginning_of_week?
|
386
|
+
chunk_start += 1.day
|
387
|
+
end
|
388
|
+
end
|
389
|
+
chunk_end = chunk_start.end_of_week
|
390
|
+
when :day
|
391
|
+
chunk_end = chunk_start
|
392
|
+
else
|
393
|
+
chunk_end = last
|
394
|
+
end
|
395
|
+
if chunk_end <= last
|
396
|
+
result << Period.new(chunk_start, chunk_end)
|
397
|
+
else
|
398
|
+
if round_up_last
|
399
|
+
result << Period.new(chunk_start, chunk_end)
|
400
|
+
elsif partial_last
|
401
|
+
result << Period.new(chunk_start, last)
|
402
|
+
else
|
403
|
+
break
|
404
|
+
end
|
405
|
+
end
|
406
|
+
chunk_start = result.last.last + 1.day
|
407
|
+
end
|
408
|
+
result
|
409
|
+
end
|
410
|
+
end
|