hutils 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 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: