drain 0.2.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +5 -6
- data/LICENSE.txt +1 -1
- data/README.md +1 -1
- data/lib/dr/base/bool.rb +6 -3
- data/lib/dr/base/graph.rb +21 -18
- data/lib/dr/base/uri.rb +201 -0
- data/lib/dr/base/utils.rb +26 -2
- data/lib/dr/formatter/simple_formatter.rb +154 -0
- data/lib/dr/parse/date_parse.rb +157 -0
- data/lib/dr/parse/simple_keywords.rb +56 -0
- data/lib/dr/parse/simple_parser.rb +18 -0
- data/lib/dr/parse/time_parse.rb +1 -1
- data/lib/dr/ruby_ext/core_modules.rb +86 -5
- data/lib/dr/version.rb +1 -1
- data/test/helper.rb +2 -2
- data/test/test_date_parse.rb +25 -0
- data/test/test_simple_keywords.rb +36 -0
- metadata +9 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5c577f08df5d0dd378a335824250d97008d3eb24896d101ecc1ed51a2281a1f0
|
4
|
+
data.tar.gz: f35b4fd99afb4870ddb44991b776ef8e5621739d7d83d7ba4d9ae851b2713df1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a889f20d3499a98ddc6da50f8cc14201881cf695271b250730ebc26024e657f2af2f7764f5e078979029559dd1dea3745a60a9df9c40ae8d5fc64f7903948a1e
|
7
|
+
data.tar.gz: 1a95a1661230282f9339258e1b6a241bd4fe2e503fbb9f43ea20c220e220a093903356cd8e78fd36e62899189b919a5a4ee50a3261951fdfde1bceebb91cbae1
|
data/.travis.yml
CHANGED
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
data/lib/dr/base/bool.rb
CHANGED
@@ -1,15 +1,16 @@
|
|
1
1
|
module DR
|
2
2
|
module Bool
|
3
3
|
extend(self)
|
4
|
-
def to_bool(el, default=nil)
|
4
|
+
def to_bool(el, default=nil, allow_nil: true, string_fallback: true)
|
5
5
|
case el
|
6
6
|
when String
|
7
7
|
string=el.chomp
|
8
8
|
return true if string =~ (/(true|t|yes|y|1)$/i)
|
9
9
|
return false if string.empty? || string =~ (/(false|f|no|n|0)$/i)
|
10
|
-
|
10
|
+
return el if string_fallback
|
11
|
+
when Integer
|
11
12
|
return ! (el == 0)
|
12
|
-
when Process::Status
|
13
|
+
when ::Process::Status
|
13
14
|
exitstatus=el.exitstatus
|
14
15
|
return exitstatus == 0
|
15
16
|
else
|
@@ -18,7 +19,9 @@ module DR
|
|
18
19
|
#we don't return !!el because we don't want nil to be false but to
|
19
20
|
#give an error
|
20
21
|
end
|
22
|
+
return el if string_fallback and el.is_a?(Symbol)
|
21
23
|
return default unless default.nil?
|
24
|
+
return nil if el.nil? and allow_nil
|
22
25
|
raise ArgumentError.new("Invalid value for Boolean: \"#{el}\"")
|
23
26
|
end
|
24
27
|
end
|
data/lib/dr/base/graph.rb
CHANGED
@@ -83,24 +83,24 @@ module DR
|
|
83
83
|
def inspect
|
84
84
|
"#{self.class}: #{to_s(show_attr: true)}"+(graph.nil? ? "" : " (#{graph})")
|
85
85
|
end
|
86
|
-
|
87
|
-
|
86
|
+
# output like a graph
|
87
|
+
def to_graph(indent_level: 0, show_attr: true, out: [])
|
88
88
|
margin = ''
|
89
89
|
0.upto(indent_level/STEP-1) { |p| margin += (p==0 ? ' ' : '|') + ' '*(STEP - 1) }
|
90
90
|
margin += '|' + '-'*(STEP - 2)
|
91
|
-
|
91
|
+
out << margin + "#{to_s(show_attr: show_attr)}"
|
92
92
|
@children.each do |child|
|
93
|
-
|
93
|
+
child.to_graph(indent_level: indent_level+STEP, show_attr: show_attr, out: out)
|
94
94
|
end
|
95
|
-
return
|
95
|
+
return out
|
96
96
|
end
|
97
|
-
def to_dot
|
98
|
-
|
97
|
+
def to_dot(out: [])
|
98
|
+
out << "\""+name+"\""
|
99
99
|
@children.each do |child|
|
100
|
-
|
101
|
-
|
100
|
+
out << "\"#{@name}\" -> \"#{child.name}\""
|
101
|
+
child.to_dot(out: out)
|
102
102
|
end
|
103
|
-
return
|
103
|
+
return out
|
104
104
|
end
|
105
105
|
end
|
106
106
|
|
@@ -109,6 +109,7 @@ module DR
|
|
109
109
|
include Enumerable
|
110
110
|
def initialize(*nodes, attributes: {}, infos: nil)
|
111
111
|
@nodes=[]
|
112
|
+
# a node can be a Hash or a Node
|
112
113
|
build(*nodes, attributes: {}, infos: infos)
|
113
114
|
end
|
114
115
|
def each(&b)
|
@@ -214,30 +215,32 @@ module DR
|
|
214
215
|
@nodes.select{ |n| n.children.length == 0}.sort
|
215
216
|
end
|
216
217
|
|
218
|
+
# allow a hash too
|
217
219
|
def |(graph)
|
220
|
+
graph=Graph.new(graph) unless Graph===graph
|
218
221
|
build(*graph.all, recursive: false)
|
219
222
|
end
|
220
223
|
def +(graph)
|
221
224
|
clone.|(graph)
|
222
225
|
end
|
223
226
|
|
224
|
-
def dump(mode: :graph, nodes_list: :roots, show_attr: true, **
|
227
|
+
def dump(mode: :graph, nodes_list: :roots, show_attr: true, out: [], **_opts)
|
225
228
|
n=case nodes_list
|
226
229
|
when :roots; roots
|
227
230
|
when :all; all
|
228
231
|
when Symbol; nodes.select {|n| n.attributes[:nodes_list]}
|
229
232
|
else nodes_list.to_a
|
230
233
|
end
|
231
|
-
sout = ""
|
232
234
|
case mode
|
233
|
-
when :graph; n.each do |node|
|
234
|
-
when :list; n.each do |i|
|
235
|
+
when :graph; n.each do |node| node.to_graph(show_attr: show_attr, out: out) end
|
236
|
+
when :list; n.each do |i| out << "- #{i}" end
|
235
237
|
when :dot;
|
236
|
-
|
237
|
-
|
238
|
-
|
238
|
+
out << "digraph gems {"
|
239
|
+
#out << n.map {|node| node.to_dot}.inject(:+).uniq!.join("\n")
|
240
|
+
n.map {|node| node.to_dot(out: out)}
|
241
|
+
out << "}"
|
239
242
|
end
|
240
|
-
return
|
243
|
+
return out
|
241
244
|
end
|
242
245
|
|
243
246
|
def to_nodes(*nodes)
|
data/lib/dr/base/uri.rb
ADDED
@@ -0,0 +1,201 @@
|
|
1
|
+
require "uri"
|
2
|
+
require "delegate"
|
3
|
+
|
4
|
+
module URI
|
5
|
+
# From https://github.com/packsaddle/ruby-uri-ssh_git
|
6
|
+
module Ssh
|
7
|
+
extend self
|
8
|
+
# @example
|
9
|
+
# url = URI::SshGit.parse('git@github.com:packsaddle/ruby-uri-ssh_git.git')
|
10
|
+
# #=> #<URI::SshGit::Generic git@github.com:packsaddle/ruby-uri-ssh_git.git>
|
11
|
+
# url.scheme #=> nil
|
12
|
+
# url.userinfo #=> 'git'
|
13
|
+
# url.user #=> 'git'
|
14
|
+
# url.password #=> nil
|
15
|
+
# url.host #=> 'github.com'
|
16
|
+
# url.port #=> nil
|
17
|
+
# url.registry #=> nil
|
18
|
+
# url.path #=> 'packsaddle/ruby-uri-ssh_git.git'
|
19
|
+
# url.opaque #=> nil
|
20
|
+
# url.query #=> nil
|
21
|
+
# url.fragment #=> nil
|
22
|
+
# @see http://docs.ruby-lang.org/en/2.2.0/URI/Generic.html
|
23
|
+
# @param url [String] git repository url via ssh protocol
|
24
|
+
# @return [Generic] parsed object
|
25
|
+
protected def internal_parse(uri_string)
|
26
|
+
host_part, path_part = uri_string&.split(':', 2)
|
27
|
+
# There may be no user, so reverse the split to make sure host always
|
28
|
+
# is !nil if host_part was !nil.
|
29
|
+
host, userinfo = host_part&.split('@', 2)&.reverse
|
30
|
+
Generic.build(userinfo: userinfo, host: host || uri_string, path: path_part)
|
31
|
+
end
|
32
|
+
|
33
|
+
# @param url [String] git repository-ish url
|
34
|
+
# @return [URI::Generic] if url starts ssh
|
35
|
+
# @return [URI::HTTPS] if url starts https
|
36
|
+
# @return [URI::SshGit] if url is ssh+git e.g git@example.com:schacon/ticgit.git
|
37
|
+
def parse(url, force: false)
|
38
|
+
(ssh_git_url?(url) || force)? URI::Ssh.internal_parse(url) : URI.parse(url)
|
39
|
+
end
|
40
|
+
|
41
|
+
## From: https://github.com/packsaddle/ruby-git_clone_url
|
42
|
+
# @param url [String] git repository-ish url
|
43
|
+
# @return [Boolean] true if url is git via ssh protocol
|
44
|
+
def ssh_git_url?(url)
|
45
|
+
!generic_url?(url)
|
46
|
+
end
|
47
|
+
|
48
|
+
# @param url [String] git repository-ish url
|
49
|
+
# @return [Boolean] true if url is https, ssh protocol
|
50
|
+
def generic_url?(url)
|
51
|
+
match = %r{\A(\w*)://}.match(url)
|
52
|
+
!match.nil?
|
53
|
+
end
|
54
|
+
|
55
|
+
class Generic < ::URI::Generic
|
56
|
+
# check_host returns `false` for 'foo_bar'
|
57
|
+
# but in ssh config this can be a valid host
|
58
|
+
def check_host(v)
|
59
|
+
return true
|
60
|
+
end
|
61
|
+
# @example
|
62
|
+
# Generic.build(
|
63
|
+
# userinfo: 'git',
|
64
|
+
# host: 'github.com',
|
65
|
+
# path: 'packsaddle/ruby-uri-ssh_git.git'
|
66
|
+
# ).to_ssh
|
67
|
+
# #=> 'git@github.com:packsaddle/ruby-uri-ssh_git.git'
|
68
|
+
#
|
69
|
+
# @return [String] git repository url via ssh protocol
|
70
|
+
def to_ssh(show_path: true)
|
71
|
+
str = ''
|
72
|
+
str << "#{user}@" if user && !user.empty?
|
73
|
+
str << "#{host}"
|
74
|
+
str << ":#{path}" if path and show_path
|
75
|
+
str
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
module DR
|
82
|
+
module MailToHelper
|
83
|
+
# TODO: wrap to= to add user= and host=
|
84
|
+
end
|
85
|
+
|
86
|
+
module URIlikeWrapper
|
87
|
+
def to_h
|
88
|
+
h = { uri: uri }
|
89
|
+
components = uri.component
|
90
|
+
components += %i[user password] if components.include?(:userinfo)
|
91
|
+
components.each do |m|
|
92
|
+
v = uri.public_send(m)
|
93
|
+
v && h[m] = v
|
94
|
+
end
|
95
|
+
h
|
96
|
+
end
|
97
|
+
|
98
|
+
def to_json(_state = nil)
|
99
|
+
to_h.to_json
|
100
|
+
end
|
101
|
+
|
102
|
+
def to_public
|
103
|
+
pub = dup
|
104
|
+
pub.password = nil
|
105
|
+
pub.to_s
|
106
|
+
end
|
107
|
+
|
108
|
+
# uri=u2.merge(uri) does not work if uri is absolute
|
109
|
+
def reverse_merge(u2)
|
110
|
+
# return self unless uri.scheme
|
111
|
+
u2 = u2.clone
|
112
|
+
u2 = self.class.new(u2) unless u2.is_a?(self.class)
|
113
|
+
if opaque.nil? == u2.opaque.nil?
|
114
|
+
u2.soft_merge(self)
|
115
|
+
else
|
116
|
+
self
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# merge(u2) replace self by u2 if u2 is aboslute
|
121
|
+
# soft_merge looks at each u2 components
|
122
|
+
def soft_merge(u2)
|
123
|
+
# we want automatic unescaping of u2 components
|
124
|
+
u2 = self.class.new(u2) unless u2.is_a?(self.class)
|
125
|
+
# only merge if we are both opaque or path like
|
126
|
+
if opaque.nil? == u2.opaque.nil?
|
127
|
+
components = uri.component
|
128
|
+
if components.include?(:userinfo)
|
129
|
+
components += %i[user password]
|
130
|
+
components.delete(:userinfo)
|
131
|
+
end
|
132
|
+
components.each do |m|
|
133
|
+
# path returns "" by default but we don't want to merge in this case
|
134
|
+
if u2.respond_to?(m) && (v = u2.public_send(m)) && !((v == "") && (m == :path))
|
135
|
+
uri.public_send(:"#{m}=", v)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
self
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
class URIWrapper < SimpleDelegator
|
144
|
+
def uri
|
145
|
+
__getobj__
|
146
|
+
end
|
147
|
+
|
148
|
+
def uri=(uri)
|
149
|
+
__setobj__(transform_uri(uri))
|
150
|
+
end
|
151
|
+
|
152
|
+
include URIlikeWrapper
|
153
|
+
|
154
|
+
def self.parse(s)
|
155
|
+
new(URI.parse(s))
|
156
|
+
end
|
157
|
+
|
158
|
+
def self.get_uri_object(uri)
|
159
|
+
uri = self.parse(uri.to_s) unless uri.is_a?(URI)
|
160
|
+
uri
|
161
|
+
end
|
162
|
+
|
163
|
+
private def transform_uri(uri)
|
164
|
+
# wrap the components around escape/unescape
|
165
|
+
uri = self.class.get_uri_object(uri)
|
166
|
+
if uri.is_a?(URI)
|
167
|
+
components = uri.component
|
168
|
+
components += %i[user password] if components.include?(:userinfo)
|
169
|
+
components.each do |m|
|
170
|
+
uri.define_singleton_method(m) do
|
171
|
+
r = super()
|
172
|
+
r && r.is_a?(String) ? URI.unescape(r) : r
|
173
|
+
end
|
174
|
+
uri.define_singleton_method(:"#{m}=") do |v|
|
175
|
+
begin
|
176
|
+
super(v && v.is_a?(String) ? URI.escape(v) : v)
|
177
|
+
rescue URI::InvalidURIError => e
|
178
|
+
warn "#{e} in (#{self}).#{m}=#{v}"
|
179
|
+
# require 'pry'; binding.pry
|
180
|
+
end
|
181
|
+
end
|
182
|
+
uri.extend(MailToHelper) if uri.is_a?(URI::MailTo)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
uri
|
186
|
+
end
|
187
|
+
|
188
|
+
# recall that '//user@server' is an uri while 'user@server' is just a path
|
189
|
+
def initialize(uri)
|
190
|
+
super
|
191
|
+
self.uri = uri
|
192
|
+
end
|
193
|
+
|
194
|
+
class Ssh < URIWrapper
|
195
|
+
def self.parse(s)
|
196
|
+
new(URI::Ssh.parse(s))
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
end
|
data/lib/dr/base/utils.rb
CHANGED
@@ -5,10 +5,10 @@ module DR
|
|
5
5
|
case format.to_s
|
6
6
|
when "json"
|
7
7
|
require 'json'
|
8
|
-
return pretty_print(string.to_json)
|
8
|
+
return pretty_print(string.to_json, pretty: pretty)
|
9
9
|
when "yaml"
|
10
10
|
require "yaml"
|
11
|
-
return pretty_print(string.to_yaml)
|
11
|
+
return pretty_print(string.to_yaml, pretty: pretty)
|
12
12
|
end
|
13
13
|
if pretty.to_s=="color"
|
14
14
|
begin
|
@@ -24,5 +24,29 @@ module DR
|
|
24
24
|
puts string
|
25
25
|
end
|
26
26
|
end
|
27
|
+
|
28
|
+
# stolen from active support
|
29
|
+
def to_camel_case(s)
|
30
|
+
s.sub(/^[a-z\d]*/) { |match| match.capitalize }.
|
31
|
+
gsub(/(?:_|(\/))([a-z\d]*)/i) {"#{$1}#{$2.capitalize}"}.
|
32
|
+
gsub("/", "::")
|
33
|
+
end
|
34
|
+
def to_snake_case(s)
|
35
|
+
# convert from caml case to snake_case
|
36
|
+
s.gsub(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2').
|
37
|
+
gsub(/([a-z\d])([A-Z])/,'\1_\2').downcase
|
38
|
+
end
|
39
|
+
|
40
|
+
def rsplit(s, sep, num=nil)
|
41
|
+
if num.nil? or num==0
|
42
|
+
s.split(sep)
|
43
|
+
else
|
44
|
+
components=s.split(sep)
|
45
|
+
components+=[nil]*[(num-components.length), 0].max
|
46
|
+
a=components[0..(components.length-num)]
|
47
|
+
b=components[(components.length-num+1)..(components.length-1)]
|
48
|
+
return [a.join(sep), *b]
|
49
|
+
end
|
50
|
+
end
|
27
51
|
end
|
28
52
|
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
module DR
|
2
|
+
class Formatter
|
3
|
+
module Helpers
|
4
|
+
def localize(msg, lang: :en, **_opts)
|
5
|
+
case msg
|
6
|
+
when Hash
|
7
|
+
Array(lang).each do |l|
|
8
|
+
if msg.key?(l)
|
9
|
+
yield(msg[l]) if block_given?
|
10
|
+
return msg[l]
|
11
|
+
end
|
12
|
+
end
|
13
|
+
else
|
14
|
+
msg
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def wrap(content, pre:nil, post:nil)
|
19
|
+
return content if content.nil? or content.empty?
|
20
|
+
pre.to_s+content.to_s+post.to_s
|
21
|
+
end
|
22
|
+
|
23
|
+
def join(*args, pre: "", post: "", pre_item: "", post_item: "", join: :auto, **_opts)
|
24
|
+
args=Array(args)
|
25
|
+
list=args.compact.map {|i| wrap(i, pre: pre_item, post: post_item)}.delete_if {|i| i.empty?}
|
26
|
+
r=list.shift
|
27
|
+
list.each do |s|
|
28
|
+
if join==:auto
|
29
|
+
if r[-1]=="\n" or s[1]=="\n"
|
30
|
+
r+=s
|
31
|
+
else
|
32
|
+
r+=" "+s
|
33
|
+
end
|
34
|
+
else
|
35
|
+
r+=join+s
|
36
|
+
end
|
37
|
+
end
|
38
|
+
r=pre+r+post unless r.nil? or r.empty?
|
39
|
+
r
|
40
|
+
end
|
41
|
+
end
|
42
|
+
extend Helpers
|
43
|
+
|
44
|
+
attr_accessor :opts, :meta
|
45
|
+
def initialize(meta={}, **opts)
|
46
|
+
@meta=meta
|
47
|
+
@opts=opts
|
48
|
+
end
|
49
|
+
|
50
|
+
def localize(msg, **opts)
|
51
|
+
self.class.localize(msg, **@opts.merge(opts))
|
52
|
+
end
|
53
|
+
|
54
|
+
def join(*args, **opts)
|
55
|
+
opts=@opts.merge(opts)
|
56
|
+
args=Array(args).map {|i| try_expand_symbol(i,**opts)}
|
57
|
+
self.class.localize(*args, **opts)
|
58
|
+
end
|
59
|
+
|
60
|
+
private def metainfo_from_symbol(sym, meta: @meta, **opts)
|
61
|
+
return sym if opts[:meta_symbol]==:never
|
62
|
+
content=case meta
|
63
|
+
when Hash
|
64
|
+
warn "#{sym} not found in #{meta}" unless meta.key?(sym)
|
65
|
+
meta[sym]
|
66
|
+
when Proc
|
67
|
+
meta.call(sym, **opts)
|
68
|
+
else
|
69
|
+
sym
|
70
|
+
end
|
71
|
+
if block_given?
|
72
|
+
yield content, **opts
|
73
|
+
else
|
74
|
+
content
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
private def get_symbol(sym)
|
79
|
+
case sym
|
80
|
+
when Symbol
|
81
|
+
return sym
|
82
|
+
when String
|
83
|
+
return sym[1...sym.length].to_sym if sym[0] == ':'
|
84
|
+
end
|
85
|
+
nil
|
86
|
+
end
|
87
|
+
private def try_get_symbol(sym,**opts)
|
88
|
+
if (key=get_symbol(sym))
|
89
|
+
expand_symbol(key,**opts)
|
90
|
+
else
|
91
|
+
sym
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def expand(msg, **opts)
|
96
|
+
recursive=opts[:recursive]
|
97
|
+
#if recursive is :first, then we only expand once ore
|
98
|
+
if recursive.is_a?(Integer)
|
99
|
+
opts[:recursive]=recursive-1
|
100
|
+
recursive=false if recursive <= 0
|
101
|
+
end
|
102
|
+
case msg
|
103
|
+
when Hash
|
104
|
+
Array(opts[:merge]).each do |key|
|
105
|
+
if msg.key?(key)
|
106
|
+
msg=msg.merge(msg[key])
|
107
|
+
msg.delete(key)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
# we localize after merging potential out types
|
111
|
+
localize(msg, **opts) do |lmsg|
|
112
|
+
# localization do not count as a recursive step
|
113
|
+
return expand(lmsg, **opts)
|
114
|
+
end
|
115
|
+
if recursive
|
116
|
+
msg_exp={}
|
117
|
+
msg.each do |k,v|
|
118
|
+
msg_exp[k]=expand(v,**opts)
|
119
|
+
end
|
120
|
+
#expand may have introduced nil values
|
121
|
+
clean_nil=opts.fetch(:clean_nil,true)
|
122
|
+
msg_exp.delete_if {|_k,v| v==nil} if clean_nil
|
123
|
+
msg_exp
|
124
|
+
else
|
125
|
+
msg
|
126
|
+
end
|
127
|
+
when Symbol
|
128
|
+
opts[:symbol]||=:never
|
129
|
+
msg=metainfo_from_symbol(msg,**opts)
|
130
|
+
recursive ? expand(msg, **opts) : msg
|
131
|
+
when Array
|
132
|
+
msg=msg.map {|i| expand(i,**opts)} if recursive
|
133
|
+
opts[:join] ? join(msg, **opts) : msg
|
134
|
+
when String
|
135
|
+
(nmsg=try_get_symbol(msg,**opts)) and return nmsg
|
136
|
+
if block_given?
|
137
|
+
yield(msg, **opts)
|
138
|
+
else
|
139
|
+
msg
|
140
|
+
end
|
141
|
+
when nil
|
142
|
+
nil
|
143
|
+
else
|
144
|
+
if block_given?
|
145
|
+
yield(msg, **opts)
|
146
|
+
else
|
147
|
+
#expand(msg.to_s,**opts)
|
148
|
+
msg
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
require 'dr/formatter/simple_formatter'
|
2
|
+
|
3
|
+
module DR
|
4
|
+
module DateRangeParser
|
5
|
+
extend self
|
6
|
+
#in: 2014-01-02 -> 2014-01-03, 2014-01-05, 2014-02 -> :now
|
7
|
+
#out: [[2014-01-02,2014-01-03],[2014-01-05],[2014-02,:now]]
|
8
|
+
def parse(date)
|
9
|
+
return date if date.kind_of?(self)
|
10
|
+
r=[]
|
11
|
+
dates = date.to_s.chomp.split(/,\s*/)
|
12
|
+
dates.each do |d|
|
13
|
+
r << d.split(/\s*->\s*/).map {|i| i == ":now" ? :now : i }
|
14
|
+
end
|
15
|
+
return DateRange.new(r)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
module DateOutput
|
20
|
+
extend self
|
21
|
+
#BUG: années bissextiles...
|
22
|
+
Months_end={1 => 31, 2 => 28, 3 => 31, 4 => 30,
|
23
|
+
5 => 31, 6 => 30, 7 => 31, 8 => 31,
|
24
|
+
9 => 30, 10 => 31, 11 => 30, 12 => 31}
|
25
|
+
|
26
|
+
# Convert a Date/string into a Time
|
27
|
+
def to_time(datetime, complete_date: :first, **opts)
|
28
|
+
require 'time'
|
29
|
+
return Time.now if datetime == :now
|
30
|
+
begin
|
31
|
+
fallback=Time.new(0) #supply the missing components
|
32
|
+
return Time.parse(datetime,fallback)
|
33
|
+
rescue ArgumentError
|
34
|
+
year,month,day,time=split_date(datetime)
|
35
|
+
case complete_date
|
36
|
+
when :first
|
37
|
+
month="01" if month == nil
|
38
|
+
day="01" if day == nil
|
39
|
+
time="00:00:00" if day == nil
|
40
|
+
when :last
|
41
|
+
month="12" if month == nil
|
42
|
+
day=Months_end[month.to_i].to_s if day == nil
|
43
|
+
time="23:59:59" if day == nil
|
44
|
+
end
|
45
|
+
return Time.parse("#{year}-#{month}-#{day}T#{time}",fallback)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
#ex: split 2014-07-28T19:26:20+0200 into year,month,day,time
|
50
|
+
def split_date(datetime)
|
51
|
+
datetime=Time.now.iso8601 if datetime == :now
|
52
|
+
date,time=datetime.to_s.split("T")
|
53
|
+
year,month,day=date.split("-")
|
54
|
+
return year,month,day,time
|
55
|
+
end
|
56
|
+
|
57
|
+
Months_names={en: {
|
58
|
+
1 => 'January', 2 => 'February', 3 => 'March',
|
59
|
+
4 => 'April', 5 => 'May', 6 => 'June',
|
60
|
+
7 => 'July', 8 => 'August', 9 => 'September',
|
61
|
+
10 => 'October', 11 => 'November', 12 => 'December'},
|
62
|
+
fr: {
|
63
|
+
1 => 'Janvier', 2 => 'Février', 3 => 'Mars',
|
64
|
+
4 => 'Avril', 5 => 'Mai', 6 => 'Juin',
|
65
|
+
7 => 'Juillet', 8 => 'Août', 9 => 'Septembre',
|
66
|
+
10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre'}}
|
67
|
+
|
68
|
+
private def abbr_month(month, lang: :en, **_opts)
|
69
|
+
return month if month.length <= 4
|
70
|
+
return month[0..2]+(lang==:en ? '.' : '')
|
71
|
+
end
|
72
|
+
|
73
|
+
# output_date_length: granularity :year/:month/:day/:all
|
74
|
+
# output_date: :num, :string, :abbr
|
75
|
+
def output_date(datetime, output_date: :abbr, output_date_length: :month,
|
76
|
+
**opts)
|
77
|
+
lang=opts[:lang]||:en
|
78
|
+
year,month,day,time=split_date(datetime)
|
79
|
+
month=nil if output_date_length==:year
|
80
|
+
day=nil if output_date_length==:month
|
81
|
+
time=nil if output_date_length==:day
|
82
|
+
return Formatter.localize({en: 'Present', fr: 'Présent'},**opts) if datetime==:now
|
83
|
+
r=year
|
84
|
+
case output_date
|
85
|
+
when :num
|
86
|
+
month.nil? ? (return r) : r+="-"+month
|
87
|
+
day.nil? ? (return r) : r+="-"+day
|
88
|
+
time.nil? ? (return r) : r+="T"+time
|
89
|
+
when :abbr,:string
|
90
|
+
return r if month.nil?
|
91
|
+
month_name=Months_names[lang][month.to_i]
|
92
|
+
month_name=abbr_month(month_name) if output_date==:abbr
|
93
|
+
r=month_name+" "+r
|
94
|
+
return r if day.nil?
|
95
|
+
r=day+" "+r
|
96
|
+
return r if time.nil?
|
97
|
+
r+=" "+time
|
98
|
+
end
|
99
|
+
r
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
class DateRange
|
104
|
+
extend DateRangeParser
|
105
|
+
extend DateOutput
|
106
|
+
|
107
|
+
attr_accessor :d, :t
|
108
|
+
def initialize(d)
|
109
|
+
@d=d
|
110
|
+
@t=d.map do |range|
|
111
|
+
case range.length
|
112
|
+
when 1
|
113
|
+
[DateRange.to_time(range[0], complete_date: :first),
|
114
|
+
DateRange.to_time(range[0], complete_date: :last)]
|
115
|
+
when 2
|
116
|
+
[DateRange.to_time(range[0], complete_date: :first),
|
117
|
+
DateRange.to_time(range[1], complete_date: :last)]
|
118
|
+
else
|
119
|
+
range.map {|i| DateRange.to_time(i)}
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
#sort_date_by :first or :last
|
125
|
+
def <=>(d2, sort_date_by: :last,**_opts)
|
126
|
+
d1=@t; d2=d2.t
|
127
|
+
sel=lambda do |d|
|
128
|
+
case sort_date_by
|
129
|
+
when :last
|
130
|
+
return d.map {|i| i.last}
|
131
|
+
when :first
|
132
|
+
return d.map {|i| i.first}
|
133
|
+
end
|
134
|
+
end
|
135
|
+
best=lambda do |d|
|
136
|
+
case sort_date_by
|
137
|
+
when :last
|
138
|
+
return d.max
|
139
|
+
when :first
|
140
|
+
return d.min
|
141
|
+
end
|
142
|
+
end
|
143
|
+
b1=best.call(sel.call(d1))
|
144
|
+
b2=best.call(sel.call(d2))
|
145
|
+
return b1 <=> b2
|
146
|
+
end
|
147
|
+
|
148
|
+
def to_s(join: ", ", range_join: " – ", **opts)
|
149
|
+
r=@d.map do |range|
|
150
|
+
range.map do |d|
|
151
|
+
DateRange.output_date(d,**opts)
|
152
|
+
end.join(range_join)
|
153
|
+
end.join(join)
|
154
|
+
r.empty? ? nil : r
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module DR
|
2
|
+
class SimpleKeywordsParser
|
3
|
+
attr_accessor :opts, :keywords
|
4
|
+
|
5
|
+
def initialize(hash, **opts)
|
6
|
+
@opts=opts
|
7
|
+
@keywords=hash
|
8
|
+
end
|
9
|
+
|
10
|
+
def keyword(name, &b)
|
11
|
+
@keywords[name]=b
|
12
|
+
end
|
13
|
+
|
14
|
+
def parse(msg, **opts)
|
15
|
+
opts=@opts.merge(opts)
|
16
|
+
sep=opts[:sep] || ','
|
17
|
+
# Warning: the delims must take only one char
|
18
|
+
delims= opts[:delims] || '()'
|
19
|
+
bdelim= delims[0]
|
20
|
+
edelim= delims[1] || bdelim
|
21
|
+
keywords=@keywords.keys
|
22
|
+
keywords_r="(?:"+keywords.map {|k| "(?:"+k+")"}.join("|")+")"
|
23
|
+
reg = %r{(?<kw>#{keywords_r})(?<re>#{'\\'+bdelim}(?:(?>[^#{'\\'+bdelim}#{'\\'+edelim}]+)|\g<re>)*#{'\\'+edelim})}
|
24
|
+
if (m=reg.match(msg))
|
25
|
+
arg=m[:re][1...m[:re].length-1]
|
26
|
+
arg=parse(arg, **opts)
|
27
|
+
args=arg.split(sep)
|
28
|
+
args=args.map {|a| a.strip} unless opts[:space]
|
29
|
+
key=keywords.find {|k| /#{k}/ =~ m[:kw]}
|
30
|
+
r=@keywords[key].call(*args).to_s
|
31
|
+
msg=m.pre_match+r+parse(m.post_match,**opts)
|
32
|
+
msg=keywords(msg,@keywords,**opts) if opts[:recursive]
|
33
|
+
end
|
34
|
+
return msg
|
35
|
+
end
|
36
|
+
# re = %r{
|
37
|
+
# (?<re>
|
38
|
+
# \(
|
39
|
+
# (?:
|
40
|
+
# (?> [^()]+ )
|
41
|
+
# |
|
42
|
+
# \g<re>
|
43
|
+
# )*
|
44
|
+
# \)
|
45
|
+
# )
|
46
|
+
# }x
|
47
|
+
#(?<re> name regexp/match
|
48
|
+
#\g<re> reuse regexp
|
49
|
+
#\k<re> reuse match
|
50
|
+
#(?: grouping without capturing
|
51
|
+
#(?> atomic grouping
|
52
|
+
#x whitespace does not count
|
53
|
+
# -> match balanced groups of parentheses
|
54
|
+
|
55
|
+
end
|
56
|
+
end
|
@@ -24,6 +24,24 @@ module DR
|
|
24
24
|
return name,value
|
25
25
|
end
|
26
26
|
|
27
|
+
# parse opt1=value1:opt2=value2...
|
28
|
+
def parse_options(options, arg_split:':', valuesep: '=', opt_default: true, keyed_sep: "/")
|
29
|
+
return {} unless options
|
30
|
+
parsed_options={}
|
31
|
+
options=options.split(arg_split) unless options.is_a?(Enumerable)
|
32
|
+
options.each do |optvalue|
|
33
|
+
opt,value=DR::SimpleParser.parse_namevalue(optvalue,sep: valuesep, default: opt_default)
|
34
|
+
parsed_options.set_keyed_value(opt,value, sep: keyed_sep)
|
35
|
+
end
|
36
|
+
return parsed_options
|
37
|
+
end
|
38
|
+
|
39
|
+
# parse name:opt1=value1:opt2=value2...
|
40
|
+
def parse_name_options(name, arg_split:':', **keywords)
|
41
|
+
name,*options=name.split(arg_split)
|
42
|
+
return name, parse_options(options, arg_split: arg_split, **keywords)
|
43
|
+
end
|
44
|
+
|
27
45
|
#takes a string as "name:value!option1=ploum!option2=plam,name2:value2!!globalopt=plim,globalopt2=plam!!globalopt3=plom,globalopt4=plim"
|
28
46
|
#and return the hash
|
29
47
|
#{values: {name: value, name2: value2},
|
data/lib/dr/parse/time_parse.rb
CHANGED
@@ -30,12 +30,12 @@ module DR
|
|
30
30
|
# #=> {:x=>{:y=>[4, 5, 6, 7, 8, 9]}, :z=>[7, 8, 9, "xyz"]}
|
31
31
|
#
|
32
32
|
# Adapted from active support
|
33
|
-
def deep_merge(other_hash, &block)
|
34
|
-
dup.deep_merge!(other_hash, &block)
|
33
|
+
def deep_merge(other_hash, **opts, &block)
|
34
|
+
dup.deep_merge!(other_hash, **opts, &block)
|
35
35
|
end
|
36
36
|
|
37
37
|
# Same as +deep_merge+, but modifies +self+.
|
38
|
-
def deep_merge!(other_hash, &block)
|
38
|
+
def deep_merge!(other_hash, append: :auto, &block)
|
39
39
|
return unless other_hash
|
40
40
|
other_hash.each_pair do |k,v|
|
41
41
|
tv = self[k]
|
@@ -43,17 +43,19 @@ module DR
|
|
43
43
|
when tv.is_a?(Hash) && v.is_a?(Hash)
|
44
44
|
self[k] = tv.deep_merge(v, &block)
|
45
45
|
when tv.is_a?(Array) && v.is_a?(Array)
|
46
|
-
if v.length > 0 && v.first.nil? then
|
46
|
+
if append==:auto and v.length > 0 && v.first.nil? then
|
47
47
|
#hack: if the array begins with nil, we append the new
|
48
48
|
#value rather than overwrite it
|
49
49
|
v.shift
|
50
50
|
self[k] += v
|
51
|
+
elsif append && append != :auto
|
52
|
+
self[k] += v
|
51
53
|
else
|
52
54
|
self[k] = block && tv ? block.call(k, tv, v) : v
|
53
55
|
end
|
54
56
|
when tv.nil? && v.is_a?(Array)
|
55
57
|
#here we still need to remove nil (see above)
|
56
|
-
if v.length > 0 && v.first.nil? then
|
58
|
+
if append==:auto and v.length > 0 && v.first.nil? then
|
57
59
|
v.shift
|
58
60
|
self[k]=v
|
59
61
|
else
|
@@ -66,6 +68,13 @@ module DR
|
|
66
68
|
self
|
67
69
|
end
|
68
70
|
|
71
|
+
def reverse_merge(other_hash)
|
72
|
+
other_hash.merge(self)
|
73
|
+
end
|
74
|
+
def reverse_deep_merge(other_hash)
|
75
|
+
other_hash.deep_merge(self)
|
76
|
+
end
|
77
|
+
|
69
78
|
#from a hash {key: [values]} produce a hash {value: [keys]}
|
70
79
|
#there is already Hash#invert using Hash#key which does that, but the difference here is that we flatten Enumerable values
|
71
80
|
#h={ploum: 2, plim: 2, plam: 3}
|
@@ -137,6 +146,78 @@ module DR
|
|
137
146
|
end until s==r
|
138
147
|
r
|
139
148
|
end
|
149
|
+
|
150
|
+
# Adapted from File activesupport/lib/active_support/core_ext/hash/slice.rb, line 22
|
151
|
+
# Note that ruby has Hash#slice, but if the key does not exist, we
|
152
|
+
# cannot configure a default
|
153
|
+
def slice_with_default(*keys, default: nil)
|
154
|
+
keys.each_with_object(::Hash.new) do |k, hash|
|
155
|
+
if has_key?(k) || default == :default_proc
|
156
|
+
hash[k] = self[k]
|
157
|
+
else
|
158
|
+
hash[k] = default
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def dig_with_default(*args, default: nil)
|
164
|
+
r=dig(*args)
|
165
|
+
return default if r.nil?
|
166
|
+
r
|
167
|
+
end
|
168
|
+
|
169
|
+
def has_keys?(*keys, key)
|
170
|
+
i=self
|
171
|
+
keys.each do |k|
|
172
|
+
i.key?(k) or return false
|
173
|
+
i=i[k]
|
174
|
+
end
|
175
|
+
i.key?(key)
|
176
|
+
end
|
177
|
+
|
178
|
+
def set_key(*keys, key, value)
|
179
|
+
i=self
|
180
|
+
keys.each do |k|
|
181
|
+
i.key?(k) or i[k]={}
|
182
|
+
i=i[k]
|
183
|
+
end
|
184
|
+
i[key]=value
|
185
|
+
# self
|
186
|
+
end
|
187
|
+
# like set_key, but only set the value if it does not exist
|
188
|
+
def add_key(*keys, key, value)
|
189
|
+
i=self
|
190
|
+
keys.each do |k|
|
191
|
+
i.key?(k) or i[k]={}
|
192
|
+
i=i[k]
|
193
|
+
end
|
194
|
+
i.key?(key) or i[key]=value
|
195
|
+
# self
|
196
|
+
i[key]
|
197
|
+
end
|
198
|
+
#like add_key, but consider the value is an Array and add to it
|
199
|
+
def add_to_key(*keys, key, value, overwrite: false, uniq: true, deep: false)
|
200
|
+
i=self
|
201
|
+
keys.each do |k|
|
202
|
+
i.key?(k) or i[k]={}
|
203
|
+
i=i[k]
|
204
|
+
end
|
205
|
+
if value.is_a?(Hash)
|
206
|
+
v=i[key] || {}
|
207
|
+
if deep
|
208
|
+
overwrite ? v.deep_merge!(value) : v=value.deep_merge(v)
|
209
|
+
else
|
210
|
+
overwrite ? v.merge!(value) : v=value.merge(v)
|
211
|
+
end
|
212
|
+
else
|
213
|
+
v=i[key] || []
|
214
|
+
v += Array(value)
|
215
|
+
v.uniq! if uniq
|
216
|
+
end
|
217
|
+
i[key]=v
|
218
|
+
# self
|
219
|
+
end
|
220
|
+
|
140
221
|
end
|
141
222
|
|
142
223
|
module UnboundMethod
|
data/lib/dr/version.rb
CHANGED
data/test/helper.rb
CHANGED
@@ -4,8 +4,8 @@ require 'minitest/autorun'
|
|
4
4
|
#require 'pry-rescue/minitest'
|
5
5
|
|
6
6
|
begin
|
7
|
-
require 'minitest/reporters'
|
8
|
-
Minitest::Reporters.use! Minitest::Reporters::DefaultReporter.new
|
7
|
+
# require 'minitest/reporters'
|
8
|
+
# Minitest::Reporters.use! Minitest::Reporters::DefaultReporter.new
|
9
9
|
#Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new
|
10
10
|
#Minitest::Reporters.use! Minitest::Reporters::ProgressReporter.new
|
11
11
|
rescue LoadError => error
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'helper'
|
2
|
+
require 'dr/parse/date_parse'
|
3
|
+
|
4
|
+
describe DR::DateRange do
|
5
|
+
before do
|
6
|
+
ENV['TZ']='GMT'
|
7
|
+
@daterange=DR::DateRange.parse("2014-01-02 -> 2014-01-03, 2014-01-05, 2014-02 -> :now")
|
8
|
+
end
|
9
|
+
|
10
|
+
it "Can parse dates" do
|
11
|
+
@daterange.d.must_equal [["2014-01-02", "2014-01-03"], ["2014-01-05"], ["2014-02", :now]]
|
12
|
+
end
|
13
|
+
|
14
|
+
it "Can output a date range" do
|
15
|
+
@daterange.to_s.must_equal "Jan. 2014 – Jan. 2014, Jan. 2014, Feb. 2014 – Present"
|
16
|
+
end
|
17
|
+
|
18
|
+
it "Can output a date range with full time information" do
|
19
|
+
@daterange.to_s(output_date_length: :all).must_equal "02 Jan. 2014 – 03 Jan. 2014, 05 Jan. 2014, Feb. 2014 – Present"
|
20
|
+
end
|
21
|
+
|
22
|
+
it "Has time information" do
|
23
|
+
@daterange.t[0].to_s.must_equal "[2014-01-02 00:00:00 +0000, 2014-01-03 00:00:00 +0000]".encode('US-ASCII')
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'helper'
|
2
|
+
require 'dr/parse/simple_keywords'
|
3
|
+
|
4
|
+
describe DR::SimpleKeywordsParser do
|
5
|
+
before do
|
6
|
+
@parser=DR::SimpleKeywordsParser.new({
|
7
|
+
'FOO' => lambda { |*args| "FOO: #{args}" },
|
8
|
+
'BAR' => lambda { |*args| "BAR: #{args}" },
|
9
|
+
})
|
10
|
+
end
|
11
|
+
|
12
|
+
it "Can parse keywords" do
|
13
|
+
@parser.parse("FOO(ploum, plam)").must_equal 'FOO: ["ploum", "plam"]'
|
14
|
+
end
|
15
|
+
|
16
|
+
it "Can preserver spaces" do
|
17
|
+
@parser.parse("FOO( ploum , plam )", space: true).must_equal "FOO: [\" ploum \", \" plam \"]"
|
18
|
+
end
|
19
|
+
|
20
|
+
it "Can change delimiters" do
|
21
|
+
@parser.parse("FOO[ ploum , plam ]", delims: '[]').must_equal "FOO: [\"ploum\", \"plam\"]"
|
22
|
+
end
|
23
|
+
|
24
|
+
it "Can have a one caracter delimiter" do
|
25
|
+
@parser.parse("FOO! ploum , plam !", delims: '!').must_equal "FOO: [\"ploum\", \"plam\"]"
|
26
|
+
end
|
27
|
+
|
28
|
+
it "Can parse keywords inside keywords" do
|
29
|
+
@parser.parse("FOO(ploum, BAR( foo, bar ))").must_equal "FOO: [\"ploum\", \"BAR: [\\\"foo\\\"\", \"\\\"bar\\\"]\"]"
|
30
|
+
end
|
31
|
+
|
32
|
+
it "Can add a keyword" do
|
33
|
+
@parser.keyword("PLOUM") { |a,b| a.to_i+b.to_i}
|
34
|
+
@parser.parse("Hello PLOUM(2,3)").must_equal 'Hello 5'
|
35
|
+
end
|
36
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: drain
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Damien Robert
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-
|
11
|
+
date: 2018-09-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: minitest
|
@@ -95,8 +95,12 @@ files:
|
|
95
95
|
- lib/dr/base/eruby.rb
|
96
96
|
- lib/dr/base/functional.rb
|
97
97
|
- lib/dr/base/graph.rb
|
98
|
+
- lib/dr/base/uri.rb
|
98
99
|
- lib/dr/base/utils.rb
|
100
|
+
- lib/dr/formatter/simple_formatter.rb
|
99
101
|
- lib/dr/parse.rb
|
102
|
+
- lib/dr/parse/date_parse.rb
|
103
|
+
- lib/dr/parse/simple_keywords.rb
|
100
104
|
- lib/dr/parse/simple_parser.rb
|
101
105
|
- lib/dr/parse/time_parse.rb
|
102
106
|
- lib/dr/ruby_ext.rb
|
@@ -110,9 +114,11 @@ files:
|
|
110
114
|
- test/helper.rb
|
111
115
|
- test/test_converter.rb
|
112
116
|
- test/test_core_ext.rb
|
117
|
+
- test/test_date_parse.rb
|
113
118
|
- test/test_drain.rb
|
114
119
|
- test/test_graph.rb
|
115
120
|
- test/test_meta.rb
|
121
|
+
- test/test_simple_keywords.rb
|
116
122
|
- test/test_simple_parser.rb
|
117
123
|
homepage: https://github.com/DamienRobert/drain#readme
|
118
124
|
licenses:
|
@@ -135,7 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
135
141
|
version: '0'
|
136
142
|
requirements: []
|
137
143
|
rubyforge_project:
|
138
|
-
rubygems_version: 2.7.
|
144
|
+
rubygems_version: 2.7.7
|
139
145
|
signing_key:
|
140
146
|
specification_version: 4
|
141
147
|
summary: Use a drain for a dryer ruby!
|