iparty 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +187 -0
- data/Rakefile +62 -0
- data/exe/iparty +10 -0
- data/lib/iparty/address.rb +146 -0
- data/lib/iparty/cli/application/actions.rb +56 -0
- data/lib/iparty/cli/application/appinfo.rb +107 -0
- data/lib/iparty/cli/application/irb_context.rb +41 -0
- data/lib/iparty/cli/application/options.rb +133 -0
- data/lib/iparty/cli/application.rb +258 -0
- data/lib/iparty/cli/colorize.rb +39 -0
- data/lib/iparty/cli/formatter.rb +169 -0
- data/lib/iparty/config.rb +141 -0
- data/lib/iparty/max_mind/database.rb +216 -0
- data/lib/iparty/max_mind/eager_reader.rb +33 -0
- data/lib/iparty/max_mind/lazy_reader.rb +47 -0
- data/lib/iparty/max_mind/result.rb +205 -0
- data/lib/iparty/max_mind.rb +93 -0
- data/lib/iparty/railtie.rb +22 -0
- data/lib/iparty/rake_task.rb +121 -0
- data/lib/iparty/version.rb +5 -0
- data/lib/iparty.rb +71 -0
- metadata +125 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IParty
|
|
4
|
+
module CLI
|
|
5
|
+
class Application
|
|
6
|
+
module Options
|
|
7
|
+
def default_options
|
|
8
|
+
{
|
|
9
|
+
debug: @argv.include?("--debug"),
|
|
10
|
+
stdin: false, # --stdin
|
|
11
|
+
colorize: true, # -m
|
|
12
|
+
summarize: true, # -a
|
|
13
|
+
resolve: false, # -r
|
|
14
|
+
action: :info, # -d
|
|
15
|
+
formatter: "pretty", # -f
|
|
16
|
+
lang: "en", # -l
|
|
17
|
+
only: [], # -o
|
|
18
|
+
except: [], # -e
|
|
19
|
+
|
|
20
|
+
# format latlong when summarizing (--no-a)
|
|
21
|
+
fmt_latlong: "https://www.google.com/maps?q=%f,%f",
|
|
22
|
+
|
|
23
|
+
# refresh stale mmdb files (:always, :missing, maxAge in seconds as Numeric)
|
|
24
|
+
mmdb_fetch_when: 14 * 24 * 60 * 60, # 14.days
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def loadrc
|
|
29
|
+
if @rc_disabled
|
|
30
|
+
puts "[iparty-debug] skipping rc (disabled)" if @opts[:debug]
|
|
31
|
+
return
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
unless @config_file.exist? && @config_file.readable?
|
|
35
|
+
puts "[iparty-debug] skipping rc (inaccessible)" if @opts[:debug]
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
puts "[iparty-debug] eval'ing rc #{@config_file}" if @opts[:debug]
|
|
40
|
+
instance_eval @config_file.read(encoding: "utf-8"), @config_file.to_s
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def require_resolv
|
|
44
|
+
require "resolv"
|
|
45
|
+
rescue LoadError
|
|
46
|
+
warn c("This iparty feature requires the resolv gem to be installed.", :red)
|
|
47
|
+
warn c("Resolution:", :yellow)
|
|
48
|
+
warn c(" gem install resolv", :blue)
|
|
49
|
+
exit 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# rubocop:disable Layout/SpaceInsideParens, Metrics/AbcSize -- readability
|
|
53
|
+
def init_optparse
|
|
54
|
+
OptionParser.new do |opts|
|
|
55
|
+
opts.summary_width = 38
|
|
56
|
+
opts.banner = "Usage: iparty <IP|host...> [options]"
|
|
57
|
+
|
|
58
|
+
expression_help = [
|
|
59
|
+
c("** matches .*", :cyan),
|
|
60
|
+
c(" * matches [^.]*", :cyan),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
opts.separator("\n# Application options")
|
|
64
|
+
opts.on("-a", "--[no-]all", "full non-summarized output") {|v| @opts[:summarize] = !v }
|
|
65
|
+
opts.on("-f", "--format <FORMATTER>", String, "formatter (pretty|json|off) or template string [default: #{@opts[:formatter]}]") {|v| @opts[:formatter] = v }
|
|
66
|
+
opts.on("-l", "--language <LANG>", String, "limit output to language (or all) [default: #{@opts[:lang]}]") {|v| @opts[:lang] = v }
|
|
67
|
+
opts.on("-r", "--[no-]resolve", "resolve hosts and include hostnames in data (requires resolv)") {|v| @opts[:resolv] = v }
|
|
68
|
+
opts.on("-o", "--only key,deep.key,*country*", Array, "list of key expressions (grep on full key)") {|v| @opts[:only] += v }
|
|
69
|
+
opts.on("-e", "--except key,deep.key,sub*", Array, "list of key expressions (grep_v on full key)", *expression_help) {|v| @opts[:except] += v }
|
|
70
|
+
opts.on( "--[no-]stdin", "read from stdin (space/line separated IPs or hosts)") {|v| @opts[:stdin] = v }
|
|
71
|
+
|
|
72
|
+
opts.separator("\n# (Custom) actions")
|
|
73
|
+
opts.on("-d", "--dispatch ACTION", String, "Dispatch given action, you may add your own") {|v| @opts[:action] = v.to_sym }
|
|
74
|
+
opts.on( "--irb", "IRB repl with iparty context and helpers") { @opts[:action] = :irb }
|
|
75
|
+
|
|
76
|
+
opts.separator("\n# MMDB actions")
|
|
77
|
+
opts.on( "--mmdb-status", "Show mmdb file status") { exit(appinfo_mmdb_status ? 0 : 1) }
|
|
78
|
+
opts.on( "--mmdb-fetch", "Fetch missing mmdb-editions") { ensure_mmdb_files! }
|
|
79
|
+
opts.on( "--mmdb-update", "Update all mmdb-editions") { ensure_mmdb_files!(:always) }
|
|
80
|
+
|
|
81
|
+
opts.separator("\n# General options")
|
|
82
|
+
opts.on("-h", "--help", "Shows this help") { @opts[:action] = :help }
|
|
83
|
+
opts.on("-v", "--version", "Shows version and mmdb info (and config with --debug)") { @opts[:action] = :appinfo }
|
|
84
|
+
opts.on("-m", "--[no-]monochrome", "Don't or do colorize output") {|v| @opts[:colorize] = !v }
|
|
85
|
+
opts.on( "--[no-]debug", "Enable debug, raise exceptions and print config with -v") {|v| @opts[:debug] = v }
|
|
86
|
+
opts.on( "--no-rc", "Do not eval config.rb")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
# rubocop:enable Layout/SpaceInsideParens, Metrics/AbcSize
|
|
90
|
+
|
|
91
|
+
def parse_options!
|
|
92
|
+
return @opts if @options_parsed
|
|
93
|
+
|
|
94
|
+
@options_parsed = true
|
|
95
|
+
@optparse.parse!(@argv)
|
|
96
|
+
require_resolv if @opts[:resolv]
|
|
97
|
+
@opts
|
|
98
|
+
rescue OptionParser::ParseError => ex
|
|
99
|
+
puts colorized_help_text, nil
|
|
100
|
+
@opts[:debug] ? raise(ex) : abort(c(ex.message, :red))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def colorized_help_text
|
|
104
|
+
@optparse.to_s.split("\n").map do |line|
|
|
105
|
+
if line.start_with?("Usage:")
|
|
106
|
+
words = line.split
|
|
107
|
+
[
|
|
108
|
+
colorize(words[0]),
|
|
109
|
+
colorize(words[1], :white),
|
|
110
|
+
colorize(words[2], :yellow),
|
|
111
|
+
colorize(words[3..].join(" "), :cyan),
|
|
112
|
+
].join(" ")
|
|
113
|
+
elsif line.start_with?("#")
|
|
114
|
+
colorize(line, :blue)
|
|
115
|
+
elsif line.strip.start_with?("-")
|
|
116
|
+
summary_width = @optparse.summary_indent.length + @optparse.summary_width
|
|
117
|
+
optstr = line[...summary_width]
|
|
118
|
+
optdesc = line[summary_width..]
|
|
119
|
+
optdesc&.gsub!(/(\[default: [^\]]+\])/){ colorize(_1, :black) }
|
|
120
|
+
|
|
121
|
+
[
|
|
122
|
+
colorize(optstr, :cyan),
|
|
123
|
+
colorize(optdesc),
|
|
124
|
+
].join
|
|
125
|
+
else
|
|
126
|
+
colorize(line)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "formatter"
|
|
6
|
+
require_relative "colorize"
|
|
7
|
+
require_relative "application/options"
|
|
8
|
+
require_relative "application/actions"
|
|
9
|
+
require_relative "application/appinfo"
|
|
10
|
+
require_relative "application/irb_context"
|
|
11
|
+
|
|
12
|
+
module IParty
|
|
13
|
+
module CLI
|
|
14
|
+
class Application
|
|
15
|
+
class Error < ArgumentError; end
|
|
16
|
+
class ActionNotFound < Application::Error; end
|
|
17
|
+
class UnknownFormatter < Application::Error; end
|
|
18
|
+
|
|
19
|
+
include Application::Options
|
|
20
|
+
include Application::Actions
|
|
21
|
+
include Application::Appinfo
|
|
22
|
+
include Colorize
|
|
23
|
+
|
|
24
|
+
class DefaultOut
|
|
25
|
+
def push *args
|
|
26
|
+
puts(args.flatten.reject{ _1 == Formatter::VOID_OUTPUT })
|
|
27
|
+
end
|
|
28
|
+
alias_method :<<, :push
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :opts, :config_path, :config_file, :out, :env, :argv, :argf
|
|
32
|
+
|
|
33
|
+
def initialize(env:, argv:, argf:, **opts)
|
|
34
|
+
@env = env
|
|
35
|
+
@argv = argv
|
|
36
|
+
@argf = argf
|
|
37
|
+
|
|
38
|
+
@config_path = Pathname.new(env.fetch("IPARTY_CFGDIR", "~/.iparty")).expand_path
|
|
39
|
+
@config_file = @config_path.join("config.rb")
|
|
40
|
+
@opts = default_options.merge(opts)
|
|
41
|
+
@optparse = init_optparse
|
|
42
|
+
@options_parsed = false
|
|
43
|
+
@rc_disabled = @argv.delete("--no-rc")
|
|
44
|
+
@out = DefaultOut.new
|
|
45
|
+
|
|
46
|
+
loadrc
|
|
47
|
+
yield(self) if block_given?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ensure_mmdb_files! fetch_when = @opts[:mmdb_fetch_when]
|
|
51
|
+
IParty::MaxMind.fetch_db_files!(fetch_when, verbose: true)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def stdin_select?
|
|
55
|
+
!$stdin.wait_readable(0).nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def read_from_stdin?
|
|
59
|
+
@opts[:stdin] || (@argv.empty? && stdin_select?)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def each_line_in_argf_as_addresses prompt: $stdin.tty?, ps1: "> "
|
|
63
|
+
index = 0
|
|
64
|
+
print ps1 if prompt
|
|
65
|
+
|
|
66
|
+
@argf.each_line do |line|
|
|
67
|
+
raise(Interrupt) if prompt && line.chomp.match?(/^(q|quit|exit)$/)
|
|
68
|
+
|
|
69
|
+
line.split(/\s+/).each do |chunk|
|
|
70
|
+
addresses = IParty.expand_hostnames(chunk)
|
|
71
|
+
yield(addresses, index)
|
|
72
|
+
index += addresses.length
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if prompt
|
|
76
|
+
print ps1
|
|
77
|
+
index = 0
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def each_address use_argf: read_from_stdin?, &block
|
|
83
|
+
if use_argf
|
|
84
|
+
each_line_in_argf_as_addresses do |addresses|
|
|
85
|
+
addresses.each(&block)
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
IParty.expand_hostnames(@argv).each(&block)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_formatter fmt = @opts[:formatter], **kw
|
|
93
|
+
if fmt.is_a?(CLI::Formatter)
|
|
94
|
+
fmt
|
|
95
|
+
elsif fmt.is_a?(Class)
|
|
96
|
+
fmt.new(self, **kw)
|
|
97
|
+
elsif fmt_class = CLI::Formatter.find_by_id(fmt)
|
|
98
|
+
fmt_class.new(self, argument: fmt, **kw)
|
|
99
|
+
else
|
|
100
|
+
raise UnknownFormatter, "unknown formatter: #{fmt}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def formatter
|
|
105
|
+
@_formatter ||= build_formatter(@opts[:formatter], colorize: @opts[:colorize])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def ip_to_data ip, colorize: false
|
|
109
|
+
ipp = IParty(ip)
|
|
110
|
+
|
|
111
|
+
data = {
|
|
112
|
+
type: ipp.type,
|
|
113
|
+
prefix: ipp.prefix,
|
|
114
|
+
address: ipp.to_s,
|
|
115
|
+
cidr: ipp.to_cidr,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# -r --resolve
|
|
119
|
+
data[:hostname] = Resolv.getnames(ip).join(" ") if @opts[:resolv]
|
|
120
|
+
|
|
121
|
+
# merge geo data
|
|
122
|
+
data.merge!(ipp.as_json)
|
|
123
|
+
|
|
124
|
+
# -l --language
|
|
125
|
+
replace_names_with_singular_for!(@opts[:lang].to_s, data) if @opts[:lang] && @opts[:lang].to_s != "all"
|
|
126
|
+
|
|
127
|
+
# -a --all
|
|
128
|
+
data = summarize(data, colorize: colorize) if @opts[:summarize]
|
|
129
|
+
|
|
130
|
+
# -e --except
|
|
131
|
+
# -o --only
|
|
132
|
+
onlyexcept_data!(data)
|
|
133
|
+
|
|
134
|
+
data
|
|
135
|
+
rescue StandardError => ex
|
|
136
|
+
@opts[:debug] ? raise(ex) : { error_class: ex.class, error: ex.message }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def replace_names_with_singular_for!(lang, data)
|
|
140
|
+
case data
|
|
141
|
+
when Hash
|
|
142
|
+
if (names = data.dig(:names)) && (name = names.dig(lang.to_sym) || names.dig(:en))
|
|
143
|
+
data[:name] = name
|
|
144
|
+
data.delete(:names)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
data.each_value { replace_names_with_singular_for!(lang, _1) }
|
|
148
|
+
when Array
|
|
149
|
+
data.each{ replace_names_with_singular_for!(lang, _1) }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def summarize data, colorize: @opts[:colorize]
|
|
154
|
+
with_color(colorize) do
|
|
155
|
+
latlong = [data.dig(:location, :latitude), data.dig(:location, :longitude)].compact
|
|
156
|
+
|
|
157
|
+
{
|
|
158
|
+
type: "#{data[:type]}[/#{data[:prefix]}]",
|
|
159
|
+
hostname: (data[:hostname] if data[:hostname] && !data[:hostname].empty?),
|
|
160
|
+
cidr: (data[:cidr] unless data[:cidr] == data[:address]),
|
|
161
|
+
network: summarize_network_detail(data),
|
|
162
|
+
name: data.dig(:annotations, :name),
|
|
163
|
+
tags: (data.dig(:annotations, :tags).join(" ") if data.dig(:annotations, :tags)&.any?),
|
|
164
|
+
location: summarize_location_detail(data),
|
|
165
|
+
time_zone: data.dig(:location, :time_zone),
|
|
166
|
+
latlong: (c((@opts[:fmt_latlong] || "%f, %f") % latlong, :magenta) unless latlong.empty?),
|
|
167
|
+
}.compact
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def summarize_asn_detail data
|
|
172
|
+
return unless asn_number = data.dig(:autonomous_system_number)
|
|
173
|
+
|
|
174
|
+
asn_org = data.dig(:autonomous_system_organization)
|
|
175
|
+
asn_detail = c("AS#{asn_number} #{c(asn_org, :cyan)}")
|
|
176
|
+
asn_detail unless decolorize(asn_detail).empty?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def summarize_network_detail data
|
|
180
|
+
network_detail = [c(data[:network], :blue), summarize_asn_detail(data)].compact.join(c(" -- ", :black))
|
|
181
|
+
network_detail unless decolorize(network_detail).empty?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def summarize_location_detail data
|
|
185
|
+
continent_name = data.dig(:continent, :name) || data.dig(:continent, :names, :en)
|
|
186
|
+
country_name = data.dig(:country, :name) || data.dig(:country, :names, :en)
|
|
187
|
+
country_name ||= data.dig(:registered_country, :name) || data.dig(:registered_country, :names, :en)
|
|
188
|
+
city_name = data.dig(:city, :name) || data.dig(:city, :names, :en)
|
|
189
|
+
|
|
190
|
+
location_detail = [
|
|
191
|
+
(c(continent_name, :green) if continent_name),
|
|
192
|
+
(c(country_name, :yellow) if country_name),
|
|
193
|
+
([c(data.dig(:postal, :code), :cyan), c(city_name, :blue)].compact.join(" ") if city_name),
|
|
194
|
+
].compact.join(c(" / ", :black))
|
|
195
|
+
|
|
196
|
+
location_detail unless decolorize(location_detail).empty?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def onlyexcept_data! data
|
|
200
|
+
if @opts[:only] && matchers = create_matchers(@opts[:only])
|
|
201
|
+
deep_onlyexcept_data(data, matchers, keep: true)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
if @opts[:except] && matchers = create_matchers(@opts[:except])
|
|
205
|
+
deep_onlyexcept_data(data, matchers, keep: false)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
data
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def create_matchers expressions
|
|
212
|
+
return if expressions.empty?
|
|
213
|
+
|
|
214
|
+
expressions.map do |exp|
|
|
215
|
+
/\A#{exp.gsub(/\*\*|\*/, { "**" => ".*", "*" => "[^.]*" })}\z/i
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def deep_onlyexcept_data data, matchers, keep: true, keystack: []
|
|
220
|
+
case data
|
|
221
|
+
when Hash
|
|
222
|
+
data.delete_if {|k, v| _deep_onlyexcept_kv_match?(k, v, matchers, keep: keep, keystack: keystack) }
|
|
223
|
+
when Array
|
|
224
|
+
data.delete_if.with_index {|v, i| _deep_onlyexcept_kv_match?(i, v, matchers, keep: keep, keystack: keystack) }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def _deep_onlyexcept_kv_match? key, value, matchers, keep: true, keystack: []
|
|
229
|
+
fullkey = (keystack + [key]).join(".")
|
|
230
|
+
|
|
231
|
+
unless matched = matchers.any?{ fullkey.match?(_1) }
|
|
232
|
+
deep_onlyexcept_data(value, matchers, keep: keep, keystack: keystack + [key])
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if keep
|
|
236
|
+
value.respond_to?(:each) ? !matched && value.empty? : !matched
|
|
237
|
+
else
|
|
238
|
+
matched || (value.respond_to?(:each) && value.empty?)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def dispatch action: nil
|
|
243
|
+
parse_options!
|
|
244
|
+
action ||= @opts[:action]
|
|
245
|
+
action_method = :"dispatch_#{action}"
|
|
246
|
+
raise ActionNotFound, "unknown action: #{action} (does not respond to ##{action_method})" unless respond_to?(action_method)
|
|
247
|
+
|
|
248
|
+
puts "[iparty-debug] dispatching #{action_method}" if @opts[:debug]
|
|
249
|
+
send(action_method)
|
|
250
|
+
rescue CLI::Application::Error => ex
|
|
251
|
+
appinfo_formatters(pad: 0) if ex.is_a?(CLI::Application::UnknownFormatter)
|
|
252
|
+
@opts[:debug] ? raise(ex) : abort(c(ex.message, :red))
|
|
253
|
+
rescue Interrupt, SystemExit => ex
|
|
254
|
+
raise(ex) if @opts[:debug]
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module IParty
|
|
4
|
+
module CLI
|
|
5
|
+
module Colorize
|
|
6
|
+
class UnknownColorError < ArgumentError; end
|
|
7
|
+
|
|
8
|
+
COLORMAP = {
|
|
9
|
+
black: 30,
|
|
10
|
+
gray: 30,
|
|
11
|
+
red: 31,
|
|
12
|
+
green: 32,
|
|
13
|
+
yellow: 33,
|
|
14
|
+
blue: 34,
|
|
15
|
+
magenta: 35,
|
|
16
|
+
cyan: 36,
|
|
17
|
+
white: 37,
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def colorize str, color = :yellow
|
|
21
|
+
ccode = COLORMAP[color.to_sym] || raise(UnknownColorError, "unknown color `#{color}'")
|
|
22
|
+
@opts[:colorize] ? "\e[#{ccode}m#{str}\e[0m" : str.to_s
|
|
23
|
+
end
|
|
24
|
+
alias_method :c, :colorize
|
|
25
|
+
|
|
26
|
+
def decolorize str
|
|
27
|
+
str.to_s.gsub(/\e\[.*?(\d)+m/, "")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def with_color *args
|
|
31
|
+
color_was = @opts[:colorize]
|
|
32
|
+
@opts[:colorize] = args.fetch(0, true)
|
|
33
|
+
yield
|
|
34
|
+
ensure
|
|
35
|
+
@opts[:colorize] = color_was
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "colorize"
|
|
4
|
+
|
|
5
|
+
module IParty
|
|
6
|
+
module CLI
|
|
7
|
+
class Formatter
|
|
8
|
+
VOID_OUTPUT = false
|
|
9
|
+
|
|
10
|
+
include Colorize
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_writer :id
|
|
14
|
+
|
|
15
|
+
def id
|
|
16
|
+
@id || name.split("::").last
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def descendants of: self
|
|
20
|
+
of.subclasses.flat_map{ [_1] + descendants(of: _1) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def find_by_id input, of: self
|
|
24
|
+
descendants.detect do |fmt|
|
|
25
|
+
fmt.id === input # rubocop:disable Style/CaseEquality -- deliberate to support regex/proc also
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(app, **opts)
|
|
31
|
+
@app = app
|
|
32
|
+
@opts = { colorize: true }.merge(opts)
|
|
33
|
+
|
|
34
|
+
setup if respond_to?(:setup)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def colorize?
|
|
38
|
+
@opts[:colorize]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def format_all ips, base_index: 0, **kw, &to_data
|
|
42
|
+
ips.map.with_index {|ip, index| format(ip, index: base_index + index, **kw, &to_data) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format ip, index: 0, **kw, &to_data
|
|
46
|
+
to_data.call(ip)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class NoOutput < Formatter
|
|
50
|
+
self.id = "off"
|
|
51
|
+
|
|
52
|
+
def format ip, **kw, &to_data
|
|
53
|
+
super # run to_data logic
|
|
54
|
+
VOID_OUTPUT
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class ConsolePretty < Formatter
|
|
59
|
+
self.id = "pretty"
|
|
60
|
+
|
|
61
|
+
def setup
|
|
62
|
+
@opts[:align] = @app.opts[:summarize]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def format_all ips, base_index: 0, **kw, &to_data
|
|
66
|
+
ips.map.with_index do |ip, index|
|
|
67
|
+
[].tap do |r|
|
|
68
|
+
r << nil unless (base_index + index).zero?
|
|
69
|
+
next unless out = format(ip, index: base_index + index, **kw, &to_data)
|
|
70
|
+
|
|
71
|
+
indent_header = decolorize(out[0...(out.index(":") || 0)]).length - 1 if @opts[:align]
|
|
72
|
+
r << c("#{"".rjust(indent_header || 0, "=")}=> #{ip}", :red)
|
|
73
|
+
|
|
74
|
+
r << out
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def format ip, index: 0, **kw, &to_data
|
|
80
|
+
out = to_indented_strings(to_data.call(ip), **kw).join("\n")
|
|
81
|
+
out = VOID_OUTPUT if out.empty?
|
|
82
|
+
out
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def to_indented_strings value, key: nil, indent: -1, maxkeylength: 0, buf: []
|
|
86
|
+
indent_spaces = "".rjust(2 * indent, " ")
|
|
87
|
+
|
|
88
|
+
if value.is_a?(Hash)
|
|
89
|
+
buf << "#{indent_spaces}#{c(key.is_a?(Numeric) ? "[#{key}]" : "#{key}:")}" if key
|
|
90
|
+
maxkeylength = value.keys.map{ _1.to_s.length }.max if @opts[:align]
|
|
91
|
+
value.each do |k, v|
|
|
92
|
+
to_indented_strings(v, buf: buf, maxkeylength: maxkeylength, key: k, indent: indent + 1)
|
|
93
|
+
end
|
|
94
|
+
elsif value.is_a?(Array)
|
|
95
|
+
buf << "#{indent_spaces}#{c(key.is_a?(Numeric) ? "[#{key}]" : "#{key}:")}" if key
|
|
96
|
+
value.each_with_index do |v, i|
|
|
97
|
+
to_indented_strings(v, buf: buf, maxkeylength: maxkeylength, key: i, indent: indent + 1)
|
|
98
|
+
end
|
|
99
|
+
elsif key
|
|
100
|
+
buf << "#{indent_spaces}#{c(key.to_s.rjust(maxkeylength))}: #{c(value, :blue)}"
|
|
101
|
+
else
|
|
102
|
+
buf << "#{indent_spaces}#{c(value, :blue)}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
buf
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class JsonFormatter < Formatter
|
|
110
|
+
self.id = "json"
|
|
111
|
+
|
|
112
|
+
def setup
|
|
113
|
+
@opts[:colorize] = false
|
|
114
|
+
|
|
115
|
+
require "json"
|
|
116
|
+
rescue LoadError
|
|
117
|
+
warn c("The iparty JSON output formatter requires the json gem to be installed.", :red)
|
|
118
|
+
warn c("Resolution:", :yellow)
|
|
119
|
+
warn c(" gem install json", :blue)
|
|
120
|
+
exit 1
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def format_all ips, **kw, &to_data
|
|
124
|
+
data = ips.to_h {|ip| [ip, to_data.call(ip)] }
|
|
125
|
+
[JSON.pretty_generate(data)]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def format ip, **kw, &to_data
|
|
129
|
+
JSON.pretty_generate to_data.call(ip)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class StringFormatter < Formatter
|
|
134
|
+
self.id = /%{/
|
|
135
|
+
|
|
136
|
+
def format_all ips, base_index: 0, **kw, &to_data
|
|
137
|
+
ips.map.with_index {|ip, index| format(ip, index: base_index + index, **kw, &to_data) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def format ip, index: 0, **kw, &to_data
|
|
141
|
+
data = to_data.call(ip)
|
|
142
|
+
locals = { 1 => ip }
|
|
143
|
+
fwd_hash = Hash.new do |_, key|
|
|
144
|
+
dig_loose_value(data, key, locals) || (@app.opts[:debug] ? nil : "")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
out = @opts[:argument] % fwd_hash
|
|
148
|
+
out.empty? ? VOID_OUTPUT : out
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def dig_loose_value data, key, locals = {}
|
|
152
|
+
key_part, def_val = key.to_s.split("|", 2)
|
|
153
|
+
chunks = key_part.split(".").map{ _1.match?(/\A-?\d+\z/) ? _1.to_i : _1.to_sym }
|
|
154
|
+
|
|
155
|
+
return locals.fetch(chunks[0]) if chunks.length == 1 && locals.key?(chunks[0])
|
|
156
|
+
|
|
157
|
+
dig_loose_value_in(data, chunks) || def_val
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def dig_loose_value_in ptr, chunks
|
|
161
|
+
return ptr if chunks.empty?
|
|
162
|
+
return ptr unless ptr.is_a?(Hash) || ptr.is_a?(Array)
|
|
163
|
+
|
|
164
|
+
dig_loose_value_in(ptr[chunks[0]], chunks[1..])
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|