hutils 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 430d7ed6697d57adb45026682ae10d17bddd9e58
4
+ data.tar.gz: 229970d4107ed3f39c4b837aafc60d834eb46905
5
+ SHA512:
6
+ metadata.gz: 43ea0dc9490a8f4fa51a719640e9d17afac0ca2d344aada8f9453e992ed23957b21e1ada30b415a2b7c7b7eed9c136fae9933079624998e87bfc16c86d9608db
7
+ data.tar.gz: 87aa2315323f14e683a96a9366c95189b2975530f3ab3ae9c7a465b3028b405b70727eed656deb508e9489abd44b519d69c93c4b88f9225eceb0b69b51106895
data/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # hutils
2
+
3
+ A small collection of utilies for [logfmt](http://brandur.org/logfmt) processing.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ gem install hutils
9
+ ```
10
+
11
+ ## Testing
12
+
13
+ ```
14
+ bundle install
15
+ bundle exec rake
16
+ ```
data/bin/lcut ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+
5
+ require_relative "../lib/hutils"
6
+
7
+ allow_empty = false
8
+ delimiter = "\t"
9
+ file = nil
10
+ verbose = false
11
+
12
+ opts = OptionParser.new do |opts|
13
+ opts.banner = "Usage: lsel [options] <fields>"
14
+ opts.on("-d", "--delimiter [DELIMITER]", "Delimiter separating output") { |d|
15
+ delimiter = d
16
+ }
17
+ opts.on("-e", "--allow-empty", "Output empty lines") { |e| allow_empty = e }
18
+ opts.on("-f", "--file [FILE]", "File to read") { |f| file = f }
19
+ opts.on("-h", "--help", "Show this help string") { |h|
20
+ if h
21
+ puts(opts.help)
22
+ exit(0)
23
+ end
24
+ }
25
+ opts.on("-v", "--verbose", "Verbose mode") { |v| verbose = v }
26
+ end
27
+ opts.parse!
28
+
29
+ if !file && $stdin.tty? || !ARGV.first
30
+ abort(opts.help)
31
+ end
32
+
33
+ file = file ? File.open(file) : $stdin
34
+ file.each_line do |line|
35
+ messages = Hutils::Parser.new(line).parse
36
+ messages.each do |message|
37
+ values = ARGV.map { |f| message[f] ? message[f] : nil }
38
+ if allow_empty || !values.all? { |v| v.nil? }
39
+ puts values.join(delimiter)
40
+ end
41
+ end
42
+ end
43
+ file.close
data/bin/ltap ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+
5
+ require_relative "../lib/hutils"
6
+ require_relative "../lib/hutils/ltap"
7
+
8
+ conf = Hutils::Ltap::Conf.new
9
+ conf.load
10
+
11
+ opts = OptionParser.new do |opts|
12
+ opts.banner = "Usage: ltap [options] <query>"
13
+ opts.on("-h", "--help", "Show this help string") { |h|
14
+ if h
15
+ puts(opts.help)
16
+ exit(0)
17
+ end
18
+ }
19
+ opts.on("-k", "--key [KEY]", "Service API key") { |t| conf.timeout = t }
20
+ opts.on("-p", "--profile [PROFILE]", "Conf profile") { |p| conf.profile = p }
21
+ opts.on("-t", "--timeout [TIMEOUT]", "Job timeout") { |t| conf.timeout = t }
22
+ opts.on("-y", "--type [TYPE]", "Service type") { |t| conf.timeout = t }
23
+ opts.on("-u", "--url [URL]", "Service API URL") { |u| conf.url = u }
24
+ opts.on("-v", "--verbose", "Verbose mode") { |v| conf.verbose = v }
25
+ end
26
+ opts.parse!
27
+
28
+ # load a profile from ~/.ltap if one was specified
29
+ if conf.profile
30
+ conf.load_section(conf.profile)
31
+ end
32
+
33
+ unless conf.type
34
+ abort("Must set a service type; use ~/.ltap or --type")
35
+ end
36
+
37
+ unless ARGV.first
38
+ abort(opts.help)
39
+ end
40
+
41
+ drainer = case conf.type
42
+ when "papertrail"
43
+ Hutils::Ltap::PaperTrailDrainer
44
+ when "splunk"
45
+ Hutils::Ltap::SplunkDrainer
46
+ else
47
+ abort("Unknown type: #{conf.type}")
48
+ end
49
+
50
+ drainer = drainer.new(
51
+ key: conf.key,
52
+ query: ARGV.first,
53
+ timeout: conf.timeout,
54
+ url: conf.url,
55
+ verbose: conf.verbose
56
+ )
57
+
58
+ thread = Thread.start do
59
+ $stdout.puts drainer.run
60
+ end
61
+
62
+ # cancel a running job on a SIGINT because Splunk search slots are apparently a
63
+ # very valuable limited resource
64
+ trap('SIGINT', 'SIGTERM') do
65
+ drainer.cancel_job
66
+ thread.terminate
67
+ abort("Caught deadly signal")
68
+ end
69
+
70
+ thread.join
data/bin/lviz ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "inifile"
4
+ require "optparse"
5
+ require_relative "../lib/hutils"
6
+
7
+ colors = $stdout.tty?
8
+ compact = false
9
+ highlights = %w(endpoint path route_signature user user_id)
10
+ ignore = %w(at)
11
+ interactive = false
12
+
13
+ ini = IniFile.load(ENV["HOME"] + "/.lviz")
14
+ if g = ini && ini["global"]
15
+ colors = g["colors"] if g["colors"]
16
+ compact = g["compact"] if g["compact"]
17
+ highlights = g["highlights"].split(",") if g["highlights"]
18
+ ignore = g["ignore"].split(",") if g["ignore"]
19
+ end
20
+
21
+ opts = OptionParser.new do |opts|
22
+ opts.banner = "Usage: lviz [options] <file> ..."
23
+ opts.on("--compact", "Compact display") { |c| compact = c }
24
+ opts.on("-h", "--help", "Show this help string") { |h|
25
+ if h
26
+ puts(opts.help)
27
+ exit(0)
28
+ end
29
+ }
30
+ opts.on("--highlights", "Keys to highlight (comma separated)") { |h|
31
+ highlights = h.split(",") rescue []
32
+ }
33
+ opts.on("--ignore", "Keys to ignore (comma separated)") { |i|
34
+ ignore = i.split(",") rescue []
35
+ }
36
+ opts.on("-i", "--interactive", "Interactive mode") { |i| interactive = i }
37
+ opts.on("--no-color", "Disable colors") { |c| colors = c }
38
+ end
39
+ opts.parse!
40
+
41
+ # ARGF will read from stdin if available, and otherwise fall back to files
42
+ # named as parameters (similar to the behavior of `cat`)
43
+ lines = Hutils::Parser.new(ARGF.read).parse
44
+ Hutils::Stripper.new(lines, ignore).run
45
+ root = Hutils::TreeBuilder.new(lines).build
46
+
47
+ if interactive
48
+ require_relative "../lib/hutils/curses_visualizer"
49
+ require_relative "../lib/hutils/node_navigator"
50
+
51
+ unless $stdin.tty?
52
+ abort("Can't start in interactive mode if STDIN isn't a TTY")
53
+ end
54
+
55
+ Hutils::CursesVisualizer.new(
56
+ colors: colors,
57
+ compact: compact,
58
+ highlights: highlights,
59
+ root: root
60
+ ).run
61
+ else
62
+ require_relative "../lib/hutils/text_visualizer"
63
+
64
+ Hutils::TextVisualizer.new(
65
+ colors: colors,
66
+ compact: compact,
67
+ highlights: highlights,
68
+ out: $stdout,
69
+ root: root
70
+ ).display
71
+ end
@@ -0,0 +1,207 @@
1
+ require "curses"
2
+
3
+ module Hutils
4
+ class CursesVisualizer
5
+ def initialize(colors:, compact:, highlights:, root:)
6
+ @colors = colors
7
+ @highlights = highlights
8
+ @line_buffer = []
9
+ @root = root
10
+ end
11
+
12
+ def run
13
+ Curses.cbreak # no need for a newline to get type chars
14
+ Curses.curs_set(0) # invisible
15
+ Curses.noecho # don't echo character on a getch
16
+
17
+ Curses.start_color
18
+ Curses.use_default_colors
19
+
20
+ Curses.init_pair(COLOR_KEY, -1, Curses::COLOR_BLUE)
21
+ Curses.init_pair(COLOR_HIGHLIGHT, Curses::COLOR_BLACK, Curses::COLOR_YELLOW)
22
+
23
+ Curses.init_screen
24
+
25
+ trap("SIGINT", "SIGTERM") do
26
+ Curses.close_screen
27
+ # @todo: curses shutdown?
28
+ $stdout.puts "Caught deadly signal"
29
+ $stdout.flush
30
+ exit(0)
31
+ end
32
+
33
+ # set root node as "expanded"
34
+ @root.tags[:expanded] = true
35
+
36
+ @need_repaint = true
37
+ @selected_line = 0
38
+ build_line_buffer
39
+
40
+ loop do
41
+ if @need_repaint
42
+ build_line_buffer
43
+ paint
44
+ @need_repaint = false
45
+ end
46
+ handle_key
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ COLOR_KEY = 1
53
+ COLOR_HIGHLIGHT = 2
54
+
55
+ def build_line_buffer
56
+ @line_buffer = []
57
+
58
+ traverse_tree(@root) do |node|
59
+ @line_buffer << node
60
+ end
61
+
62
+ # remove the root node; it's not actually displayed
63
+ @line_buffer.shift
64
+
65
+ if @selected_line >= @line_buffer.count
66
+ @selected_line = @line_buffer.count - 1
67
+ end
68
+ end
69
+
70
+ def traverse_tree(node, &block)
71
+ yield node
72
+ if node.tags[:expanded]
73
+ node.slots.each { |child| traverse_tree(child, &block) }
74
+ end
75
+ end
76
+
77
+ def collapse_all
78
+ collapse_node(@root)
79
+ end
80
+
81
+ def collapse_node(node)
82
+ node.tags[:expanded] = false if node != @root
83
+ node.slots.each { |child| collapse_node(child) }
84
+ end
85
+
86
+ def expand_all(node = @root)
87
+ node.tags[:expanded] = true if node != @root
88
+ node.slots.each { |child| expand_all(child) }
89
+ end
90
+
91
+ def toggle_node
92
+ if node = @line_buffer[@selected_line]
93
+ node.tags[:expanded] = !node.tags[:expanded]
94
+ end
95
+ end
96
+
97
+ def expanded_str(node)
98
+ if node.slots.count > 0
99
+ node.tags[:expanded] ? "[-]" : "[+]"
100
+ else
101
+ " "
102
+ end
103
+ end
104
+
105
+ def handle_key
106
+ case Curses.getch
107
+ when Curses::KEY_RESIZE then need_repaint
108
+ when ' ' then toggle_node && need_repaint
109
+ when ?c then collapse_all && need_repaint
110
+ when ?e then expand_all && need_repaint
111
+ when ?j then move_next
112
+ when ?k then move_prev
113
+ when ?o then toggle_node && need_repaint
114
+ when ?q then exit(0)
115
+ end
116
+ end
117
+
118
+ def move_next
119
+ old_selected_line = @selected_line
120
+
121
+ @selected_line += 1
122
+ if @selected_line == @line_buffer.count
123
+ @selected_line = @line_buffer.count - 1
124
+ end
125
+
126
+ paint_line(old_selected_line)
127
+ paint_line(@selected_line)
128
+ end
129
+
130
+ def move_prev
131
+ old_selected_line = @selected_line
132
+
133
+ @selected_line -= 1
134
+ if @selected_line < 0
135
+ @selected_line = 0
136
+ end
137
+
138
+ paint_line(old_selected_line)
139
+ paint_line(@selected_line)
140
+ end
141
+
142
+ def need_repaint
143
+ @need_repaint = true
144
+ end
145
+
146
+ def paint
147
+ Curses.clear
148
+
149
+ @line_buffer.each_with_index do |node, line|
150
+ break if line > Curses.lines
151
+ paint_node(node, line)
152
+ end
153
+
154
+ Curses.refresh
155
+ end
156
+
157
+ def paint_line(line)
158
+ paint_node(@line_buffer[line], line)
159
+ end
160
+
161
+ def safe_addstr(str, num_cols_written)
162
+ if num_cols_written + str.length > Curses.cols
163
+ left = Curses.cols - num_cols_written
164
+ str = str.dup[0, left]
165
+ end
166
+ Curses.addstr(str)
167
+ num_cols_written + str.length
168
+ end
169
+
170
+ # `line` should already be incremented to the correct position before
171
+ # entering this method
172
+ def paint_node(node, line)
173
+ n = 0
174
+ Curses.setpos(line, 0)
175
+
176
+ if @selected_line == line
177
+ Curses.attron(Curses::A_UNDERLINE)
178
+ end
179
+
180
+ n = safe_addstr("\t" * (node.depth - 1), n)
181
+ n = safe_addstr(expanded_str(node) + " ", n)
182
+ node.common.to_a.sort_by { |k, v| k }.each do |k, v|
183
+ if v == true
184
+ color(COLOR_KEY) { n = safe_addstr("#{k}", n) }
185
+ else
186
+ if @highlights.include?(k)
187
+ color(COLOR_HIGHLIGHT) { n = safe_addstr("#{k}=#{v}", n) }
188
+ else
189
+ color(COLOR_KEY) { n = safe_addstr("#{k}", n) }
190
+ n = safe_addstr("=#{v}", n)
191
+ end
192
+ end
193
+ n = safe_addstr(" ", n)
194
+ end
195
+
196
+ Curses.attroff(Curses::A_UNDERLINE)
197
+ end
198
+
199
+ def color(key, &block)
200
+ if @colors
201
+ Curses.attron(Curses::color_pair(key) | Curses::A_NORMAL) { yield }
202
+ else
203
+ yield
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,41 @@
1
+ require "inifile"
2
+
3
+ module Hutils::Ltap
4
+ class Conf
5
+ attr_accessor :key
6
+ attr_accessor :profile
7
+ attr_accessor :timeout
8
+ attr_accessor :type
9
+ attr_accessor :url
10
+ attr_accessor :verbose
11
+
12
+ def initialize
13
+ @ini = IniFile.load(ENV["HOME"] + "/.ltap")
14
+ self.timeout = 60
15
+ self.verbose = false
16
+ end
17
+
18
+ def load
19
+ load_section("global")
20
+ end
21
+
22
+ def load_section(name)
23
+ if section = @ini && @ini[name]
24
+ load_value(section, :key)
25
+ load_value(section, :profile)
26
+ load_value(section, :timeout)
27
+ load_value(section, :type)
28
+ load_value(section, :url)
29
+ load_value(section, :verbose)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def load_value(section, name)
36
+ if value = section[name.to_s]
37
+ send("#{name}=", value)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,75 @@
1
+ require "json"
2
+
3
+ module Hutils::Ltap
4
+ class PaperTrailDrainer
5
+ PAPER_TRAIL_URL = "https://papertrailapp.com"
6
+
7
+ def initialize(key:, timeout:, query:, url:, verbose:)
8
+ @api = Excon.new(PAPER_TRAIL_URL,
9
+ headers: {
10
+ "X-Papertrail-Token" => key
11
+ })
12
+ @query = query
13
+ @timeout = timeout
14
+ @verbose = verbose
15
+ end
16
+
17
+ def run
18
+ messages = []
19
+ min_id = nil
20
+ start = Time.now
21
+
22
+ loop do
23
+ new_messages, reached_beginning, min_id = fetch_page(min_id)
24
+ messages += new_messages
25
+
26
+ # break if PaperTrail has indicated that we've reached the beginning of
27
+ # our results, or if we've approximately hit our timeout
28
+ if reached_beginning || (Time.now - start).to_i > @timeout
29
+ break
30
+ end
31
+ end
32
+
33
+ messages
34
+ rescue RateLimited
35
+ $stderr.puts "Papertrail rate limit reached"
36
+ messages
37
+ end
38
+
39
+ def cancel_job
40
+ debug("cancelled [noop]")
41
+ end
42
+
43
+ private
44
+
45
+ class RateLimited < StandardError
46
+ end
47
+
48
+ def debug(str)
49
+ if @verbose
50
+ puts str
51
+ end
52
+ end
53
+
54
+ def fetch_page(max_id)
55
+ resp = @api.get(
56
+ path: "/api/v1/events/search.json",
57
+ expects: [200, 429],
58
+ query: {
59
+ max_id: max_id,
60
+ q: @query
61
+ }.reject { |k, v| v == nil })
62
+
63
+ if resp.status == 429
64
+ raise RateLimited
65
+ end
66
+
67
+ data = JSON.parse(resp.body)
68
+ debug("backend_timeout: #{data["backend_timeout"] || false} " +
69
+ "min_id: #{data["min_id"]} " +
70
+ "reached_beginning: #{data["reached_beginning"] || false}")
71
+ messages = data["events"].map { |e| e["message"].strip }
72
+ [messages, data["reached_beginning"], data["min_id"]]
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,123 @@
1
+ require "csv"
2
+ require "excon"
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Hutils::Ltap
7
+ class SplunkDrainer
8
+ def initialize(key:, timeout:, query:, url:, verbose:)
9
+ @timeout = timeout
10
+ @query = query
11
+ @verbose = verbose
12
+
13
+ @user = URI.parse(url).user
14
+ @api = Excon.new(url)
15
+ end
16
+
17
+ def run
18
+ create_job(@query)
19
+ start = Time.now
20
+
21
+ loop do
22
+ sleep(2)
23
+ break if job_finished?
24
+
25
+ # finalize the job if we've broken our timeout point
26
+ if (Time.now - start).to_i > @timeout
27
+ finalize_job
28
+ break
29
+ end
30
+ end
31
+
32
+ get_job_results
33
+ end
34
+
35
+ def cancel_job
36
+ return unless @job_id
37
+ @api.post(
38
+ path: "/servicesNS/#{@user}/search/search/jobs/#{@job_id}/control",
39
+ expects: 200,
40
+ body: URI.encode_www_form({
41
+ action: "cancel"
42
+ })
43
+ )
44
+ debug("cancelled")
45
+ end
46
+
47
+ private
48
+
49
+ def create_job(query)
50
+ resp = @api.post(
51
+ path: "/servicesNS/#{@user}/search/search/jobs",
52
+ expects: 201,
53
+ body: URI.encode_www_form({
54
+ output_mode: "json",
55
+ search: "search #{query}"
56
+ })
57
+ )
58
+ @job_id = JSON.parse(resp.body)["sid"]
59
+ debug "job: #{@job_id}"
60
+ end
61
+
62
+ def debug(str)
63
+ if @verbose
64
+ puts str
65
+ end
66
+ end
67
+
68
+ def finalize_job
69
+ @api.post(
70
+ path: "/servicesNS/#{@user}/search/search/jobs/#{@job_id}/control",
71
+ expects: 200,
72
+ body: URI.encode_www_form({
73
+ action: "finalize"
74
+ })
75
+ )
76
+ debug("finalized")
77
+ end
78
+
79
+ def get_job_results
80
+ # get results as CSV because the JSON version just mixes everything together
81
+ # into a giant difficult-to-use blob
82
+ resp = @api.get(
83
+ path: "/servicesNS/#{@user}/search/search/jobs/#{@job_id}/results",
84
+ # 204 if no results available
85
+ expects: [200, 204],
86
+ body: URI.encode_www_form({
87
+ action: "finalize",
88
+ output_mode: "csv"
89
+ })
90
+ )
91
+
92
+ return [] if resp.status == 204
93
+
94
+ rows = CSV.parse(resp.body)
95
+ return [] if rows.count < 1
96
+ field = rows[0].index("_raw") || raise("no _raw field detected in Splunk response")
97
+
98
+ # skip the first line as its used for CSV headers
99
+ rows[1..-1].
100
+ map { |l| l[field] }.
101
+ # 2014-08-15T19:01:15.476590+00:00 54.197.117.24 local0.notice
102
+ # api-web-1[23399]: - api.108080@heroku.com ...
103
+ map { |l| l.gsub(/^.*: - /, "") }.
104
+ map { |l| l.strip }.
105
+ # results come in from newest to oldest; flip that
106
+ reverse
107
+ end
108
+
109
+ def job_finished?
110
+ resp = @api.get(
111
+ path: "/servicesNS/#{@user}/search/search/jobs/#{@job_id}",
112
+ expects: 200,
113
+ body: URI.encode_www_form({
114
+ output_mode: "json"
115
+ })
116
+ )
117
+ # Splunk may not be winning any awards for cleanest API anytime soon
118
+ state = JSON.parse(resp.body)["entry"][0]["content"]["dispatchState"]
119
+ debug("state: #{state}")
120
+ state == "DONE"
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,3 @@
1
+ require_relative "ltap/conf"
2
+ require_relative "ltap/paper_trail_drainer"
3
+ require_relative "ltap/splunk_drainer"
@@ -0,0 +1,52 @@
1
+ module Hutils
2
+ class NodeNavigator
3
+ def next_node(node, ignore_expanded: false)
4
+ # if expanded and we have children, move onto the first
5
+ if !ignore_expanded && node.tags[:expanded] && node.slots.count > 0
6
+ return node.slots[0]
7
+ end
8
+
9
+ index = node.parent.slots.index(node)
10
+
11
+ # Otherwise, if the node is the last in its slot, then move to the
12
+ # parent's next slot. If the parent is root, then we can go no further.
13
+ if index == node.parent.slots.count - 1
14
+ if node.parent.parent != nil
15
+ new_node = next_node(node.parent, ignore_expanded: true)
16
+ # if the sub-iteration couldn't find a next node, stay where we are
17
+ new_node != node.parent ? new_node : node
18
+ else
19
+ node
20
+ end
21
+ # otherwise, just move to the next slot
22
+ else
23
+ node.parent.slots[index + 1]
24
+ end
25
+ end
26
+
27
+ def prev_node(node)
28
+ index = node.parent.slots.index(node)
29
+
30
+ if index == 0
31
+ if node.parent.parent != nil
32
+ node.parent
33
+ else
34
+ # don't ever move up to root
35
+ return node
36
+ end
37
+ # otherwise, move to the previous node in the list
38
+ else
39
+ new_node = node.parent.slots[index - 1]
40
+
41
+ # But wait! We don't just want to move to the previous node directly, we
42
+ # actually want to move to the last child of its deepest expanded
43
+ # subnode.
44
+ while new_node.tags[:expanded] && new_node.slots.count > 0
45
+ new_node = new_node.slots.last
46
+ end
47
+
48
+ new_node
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,14 @@
1
+ module Hutils
2
+ class Stripper
3
+ def initialize(messages, ignore)
4
+ @messages = messages
5
+ @ignore = ignore
6
+ end
7
+
8
+ def run
9
+ @messages.each do |message|
10
+ message.reject! { |k, _| @ignore.include?(k) }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,61 @@
1
+ require "term/ansicolor"
2
+
3
+ module Hutils
4
+ class TextVisualizer
5
+ include Term::ANSIColor
6
+
7
+ def initialize(colors:, compact:, highlights:, root:, out:)
8
+ @colors = colors
9
+ @compact = compact
10
+ @highlights = highlights
11
+ @out = out
12
+ @root = root
13
+ end
14
+
15
+ def display
16
+ display_node(@root)
17
+ end
18
+
19
+ private
20
+
21
+ def colorize(method, str)
22
+ if @colors
23
+ send(method, str)
24
+ else
25
+ str
26
+ end
27
+ end
28
+
29
+ def display_node(node)
30
+ if !node.common.empty?
31
+ # the "- 1" is because the root node is empty
32
+ indent = "\t" * (node.depth - 1)
33
+ node.common.to_a.sort_by { |k, v| k }.map { |k, v|
34
+ pair_to_string(k, v)
35
+ }.each_with_index { |display, i|
36
+ if @compact
37
+ @out.print(indent) if i == 0
38
+ @out.print("#{display} ")
39
+ else
40
+ marker = i == 0 ? "+ " : " "
41
+ @out.puts "#{indent}#{marker}#{display}"
42
+ end
43
+ }
44
+ @out.puts ""
45
+ end
46
+ node.slots.each { |slot| display_node(slot) }
47
+ end
48
+
49
+ def pair_to_string(k, v)
50
+ if v == true
51
+ colorize(:green, k)
52
+ else
53
+ if @highlights.include?(k)
54
+ colorize(:on_yellow, colorize(:black, "#{k}: #{v}"))
55
+ else
56
+ "#{colorize(:green, k)}: #{v}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
data/lib/hutils.rb ADDED
@@ -0,0 +1,177 @@
1
+ require_relative "hutils/stripper"
2
+
3
+ module Hutils
4
+ class Node
5
+ def initialize(parent, common)
6
+ @common = common
7
+ @parent = parent
8
+ @slots = []
9
+ @tags = {}
10
+ end
11
+
12
+ # The set of common attributes that are shared by all slots in this branch of
13
+ # the tree.
14
+ attr_accessor :common
15
+
16
+ # Parent node.
17
+ attr_accessor :parent
18
+
19
+ # Ordered child nodes of this node.
20
+ attr_accessor :slots
21
+
22
+ # Arbitrary tags that applications can associate on a node.
23
+ attr_accessor :tags
24
+
25
+ # The set of common attributes that are shared by all slots in this branch of
26
+ # the tree, but accounting for all parent nodes as well.
27
+ def common_complete
28
+ @common.merge(@parent ? @parent.common_complete : {})
29
+ end
30
+
31
+ def depth
32
+ @parent ? @parent.depth + 1 : 0
33
+ end
34
+
35
+ def print
36
+ indent = " " * depth
37
+ $stdout.puts "#{indent}[ #{depth} ]#{@common}"
38
+ slots.each { |node| node.print }
39
+ end
40
+
41
+ def replace_slot(old, new)
42
+ @slots.each_with_index do |node, i|
43
+ if old == node
44
+ @slots[i] = new
45
+ return
46
+ end
47
+ end
48
+ abort("bad replace")
49
+ end
50
+
51
+ def root
52
+ @parent ? @parent.root : self
53
+ end
54
+
55
+ def root?
56
+ @parent == nil
57
+ end
58
+ end
59
+
60
+ class Parser
61
+ def initialize(str)
62
+ @str = str
63
+ end
64
+
65
+ def parse
66
+ lines = @str.split("\n").map { |line| normalize(line) }
67
+ lines.map! do |line|
68
+ pairs = line.scan(/(?:['"](?:\\.|[^'"])*['"]|[^'" ])+/).map do |pair|
69
+ key, value = pair.split("=")
70
+ [key, value].each do |str|
71
+ str.gsub!(/^['"]?(.*?)['"]?$/, '\1') if str
72
+ end
73
+ [key, value || true]
74
+ end
75
+ Hash[pairs]
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def normalize(line)
82
+ line = line.strip
83
+ line.gsub(/^[T0-9\-:+.]+( [a-z]+\[[a-z0-9\-_.]+\])?: /, '')
84
+ end
85
+ end
86
+
87
+ class TreeBuilder
88
+ def initialize(lines)
89
+ @lines = lines
90
+ end
91
+
92
+ def build
93
+ root = Node.new(nil, {})
94
+ node = root
95
+ @lines.each do |pairs|
96
+ node = build_node(node, pairs)
97
+ end
98
+ root
99
+ end
100
+
101
+ private
102
+
103
+ def diff(hash1, hash2)
104
+ same = hash1.dup.delete_if { |k, v| hash2[k] != v }
105
+
106
+ extra1 = hash1.dup
107
+ same.each { |k, _| extra1.delete(k) }
108
+
109
+ extra2 = hash2.dup
110
+ same.each { |k, _| extra2.delete(k) }
111
+
112
+ [same, extra1, extra2]
113
+ end
114
+
115
+ def debug(title, data, node)
116
+ return unless ENV["DEBUG"] == "true"
117
+ puts "---"
118
+ puts title
119
+ data.each { |k, v| puts "#{k}: #{v}" }
120
+ node.root.print if node
121
+ puts ""
122
+ end
123
+
124
+ def build_node(node, pairs)
125
+ complete = node.common_complete
126
+ same, complete_extra, pairs_extra = diff(complete, pairs)
127
+
128
+ if complete == same
129
+ if pairs_extra.empty?
130
+ # we've hit a rare case of a line which is a duplicate of its immediate
131
+ # successor: do nothing
132
+ else
133
+ # all common is shared but we have extra: add a new child node
134
+ new = Node.new(node, pairs_extra)
135
+ node.slots << new
136
+ node = new
137
+ debug("simple leaf addition", { pairs_extra: pairs_extra }, node)
138
+ end
139
+ else
140
+ # First of all, determine whether our node has any data in common with
141
+ # the current node.
142
+ local_same, _, _ = diff(node.common, pairs)
143
+
144
+ # Then figure out that given a split, what the old node's new set would
145
+ # look like in the context of a shared common parent.
146
+ _, other_extra, _ = diff(node.common, local_same)
147
+
148
+ # And finally, determine whether the parent hierarchy contains any data
149
+ # that is incompatible with our new node because it's not shared.
150
+ _, illegal_extra, _ = diff(complete_extra, other_extra)
151
+
152
+ if !local_same.empty? && illegal_extra.empty?
153
+ # create a replacement node and swap it into place
154
+ new_parent = Node.new(node.parent, local_same)
155
+ node.common = other_extra
156
+ node.parent = new_parent
157
+ new_parent.slots << node
158
+ new_parent.parent.replace_slot(node, new_parent)
159
+
160
+ new = Node.new(new_parent, pairs_extra)
161
+ new_parent.slots << new
162
+ node = new
163
+ debug("split", { pairs_extra: pairs_extra }, node)
164
+ else
165
+ debug("before tree ascention", { pairs: pairs }, nil)
166
+ # Nothing is shared, ascend the tree until something is. Eventually
167
+ # something will be because the root node contains an empty hash of common
168
+ # attributes.
169
+ node = build_node(node.parent, pairs)
170
+ debug("after tree ascention", { pairs: pairs }, nil)
171
+ end
172
+ end
173
+
174
+ node
175
+ end
176
+ end
177
+ end
metadata ADDED
@@ -0,0 +1,120 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hutils
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Brandur
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-08-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: excon
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.39'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 0.39.5
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '0.39'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.39.5
33
+ - !ruby/object:Gem::Dependency
34
+ name: inifile
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 3.0.0
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '3.0'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 3.0.0
53
+ - !ruby/object:Gem::Dependency
54
+ name: term-ansicolor
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '1.3'
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 1.3.0
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '1.3'
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 1.3.0
73
+ description:
74
+ email: brandur@mutelight.org
75
+ executables:
76
+ - lcut
77
+ - ltap
78
+ - lviz
79
+ extensions: []
80
+ extra_rdoc_files: []
81
+ files:
82
+ - "./lib/hutils.rb"
83
+ - "./lib/hutils/curses_visualizer.rb"
84
+ - "./lib/hutils/ltap.rb"
85
+ - "./lib/hutils/ltap/conf.rb"
86
+ - "./lib/hutils/ltap/paper_trail_drainer.rb"
87
+ - "./lib/hutils/ltap/splunk_drainer.rb"
88
+ - "./lib/hutils/node_navigator.rb"
89
+ - "./lib/hutils/stripper.rb"
90
+ - "./lib/hutils/text_visualizer.rb"
91
+ - README.md
92
+ - bin/lcut
93
+ - bin/ltap
94
+ - bin/lviz
95
+ homepage: https://github.com/brandur/hutils
96
+ licenses:
97
+ - MIT
98
+ metadata: {}
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubyforge_project:
115
+ rubygems_version: 2.2.2
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: A collection of command line utilities for working with logfmt.
119
+ test_files: []
120
+ has_rdoc: