job_grapher 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cd41d58ef6c11f7e0b6c1989b75434a0e9bc410010350608619e413c4aa3999c
4
+ data.tar.gz: 8e7456d73ccf71b53b8861d4ea6e2d9e40614c42db13b59c0476448be1c0b14a
5
+ SHA512:
6
+ metadata.gz: 7277bedcf1fdc757958dbe4eb311e2c5a95f81b45960dbbe50be52262381ad2a6e51385df445f5c7f2332a3dfa39b57bc77c2b5cb216b3b2dca04f93a30fe377
7
+ data.tar.gz: 399aae0f3e25ce96fed12fa6d1ca2b75aaa9fde7dd9c8dfed9ad9105326141560837fd26783a895fe5e3559c8948808aacf05648f4e9637723b79fe0a6a721c6
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in job_grapher.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
data/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ Copyright 2022 Jeremy Friesen
2
+ Additional copyright may be held by others, as reflected in the commit history.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # JobGrapher
2
+
3
+ While looking through multiple gems and applications, I needed a little tool to help me.
4
+
5
+ This gem outputs a [PlantUML](https://plantuml.com) diagram of class/location's that perform a job. The parser and logic were built while exploring [Samvera](https://samvera.org)'s ActiveJob implementations.
6
+
7
+ This gem requires [Ripgrep](https://github.com/BurntSushi/ripgrep).
8
+
9
+ ## Installation
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ $ bundle add job_grapher
14
+
15
+ If bundler is not being used to manage dependencies, install the gem by executing:
16
+
17
+ $ gem install job_grapher
18
+
19
+ ## Usage
20
+
21
+ This gem came about as a thought experiment. Here's the command line:
22
+
23
+ ```shell
24
+ job_grapher ~/path/to/repo ~/path/to/other-repo
25
+ ```
26
+
27
+ ### From Ruby
28
+
29
+ At present, the command-line only allows you to specify directories. However, you can call the underlying Ruby class and provide additional parameters.
30
+
31
+ ```ruby
32
+ require "job_grapher"
33
+
34
+ JobGrapher.plantuml_for(
35
+ dirs: [
36
+ "~/git/hyrax",
37
+ "~/git/bulkrax",
38
+ "~/git/hyku",
39
+ "~/git/newspaper_works/"
40
+ ],
41
+ filter: ->(job) do
42
+ job.include?("Permission") || job.include?("Ingest")
43
+ end
44
+ )
45
+ ```
46
+
47
+ ## Contributing
48
+
49
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jeremyf/job_grapher.
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
data/exe/job_grapher ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby -U
2
+
3
+ require_relative File.expand_path("../lib/job_grapher", __dir__)
4
+
5
+ if ARGV.size == 0
6
+ $STDERR.puts "You must provide at least one directory"
7
+ exit!(1)
8
+ end
9
+ JobGrapher.plantuml_for(dirs: ARGV)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/job_grapher/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "job_grapher"
7
+ spec.version = JobGrapher::VERSION
8
+ spec.authors = ["Jeremy Friesen"]
9
+ spec.email = ["jeremy.n.friesen@gmail.com"]
10
+
11
+ spec.summary = "A naive graph generator for ActiveJob's declared and called across projects."
12
+ spec.description = "A naive graph generator for ActiveJob's declared and called across projects."
13
+ spec.homepage = "https://github.com/jeremyf/job_grapher"
14
+ spec.required_ruby_version = ">= 2.6.0"
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = spec.homepage
18
+ spec.metadata["changelog_uri"] = spec.homepage
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
25
+ end
26
+ end
27
+ spec.bindir = "exe"
28
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobGrapher
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,228 @@
1
+ require "set"
2
+
3
+ # @see .plant_uml
4
+ module JobGrapher
5
+ DEFAULT_FILTER = ->(job) { true }
6
+ DEFAULT_PATH_FORMATTER = -> (path) { path.sub(ENV['HOME'], "~") }
7
+ # @api public
8
+ #
9
+ # Generate a PlantUML diagram
10
+ #
11
+ # @param dirs [Array<String>] the directories to check for jobs and
12
+ # performance.
13
+ # @param path_formatter [String] for file names, remove this from
14
+ # the beginning of that path.
15
+ # @param filter [#call] the filter to call which when true means to
16
+ # include the given job's class name in the generated graph.
17
+ # When false, skip this job.
18
+ # @param buffer [#puts] the buffer to which we'll "#puts" the graph.
19
+ #
20
+ # @see https://plantuml.com
21
+ def self.plantuml_for(dirs:, path_formatter: DEFAULT_PATH_FORMATTER, filter: DEFAULT_FILTER, buffer: STDOUT)
22
+ graph = Graph.new(filter: filter)
23
+ Array(dirs).each do |dir|
24
+ PerformInformation.each_from(dir) do |info|
25
+ graph.add_perform_info(info)
26
+ end
27
+
28
+ JobDeclaration.each_from(dir) do |declaration|
29
+ graph.add_declaration(declaration)
30
+ end
31
+ end
32
+ graph.to_plantuml(path_formatter: path_formatter, buffer: buffer)
33
+ true
34
+ end
35
+
36
+ class Graph
37
+ def initialize(filter:)
38
+ @performances = []
39
+ @declarations = []
40
+ @filter = filter
41
+ end
42
+
43
+ def add_declaration(declaration)
44
+ @declarations << declaration
45
+ end
46
+
47
+ def add_perform_info(info)
48
+ @performances << info
49
+ end
50
+
51
+ def to_plantuml(buffer:, path_formatter:)
52
+ buffer.puts "@startuml"
53
+ resolver = Resolver.new(
54
+ declarations: @declarations,
55
+ performances: @performances,
56
+ filter: @filter,
57
+ path_formatter: path_formatter
58
+ )
59
+
60
+ resolver.edges.each do |edge|
61
+ buffer.puts "(#{edge.from}) --> (#{edge.to})"
62
+ end
63
+ buffer.puts "@enduml"
64
+ end
65
+ end
66
+
67
+ class Resolver
68
+ def initialize(declarations:, performances:, filter:, path_formatter:)
69
+ @declarations = declarations
70
+ @performances = performances
71
+ @filter = filter
72
+ @path_formatter = path_formatter
73
+ compile!
74
+ end
75
+
76
+ attr_reader :edges, :filter, :path_formatter
77
+
78
+ Edge = Struct.new(:from, :to, keyword_init: true)
79
+
80
+ # For each performance's job_class_name_candidates find the corresponding constant.
81
+ def compile!
82
+ @edges = Set.new
83
+ jobs = @declarations.map(&:job_class_name)
84
+ @performances.each do |perf|
85
+ from = path_formatter.call(perf.invoking_constant)
86
+ to = perf.job_class_name_candidates.find do |cand|
87
+ jobs.include?(cand)
88
+ end
89
+ next unless filter.call(to)
90
+ @edges << Edge.new(from: from, to: to)
91
+ end
92
+ end
93
+ end
94
+
95
+ # This module extracts the qualified constant name for a declared
96
+ # class/module.
97
+ module QualifiedConstantNameExtractor
98
+ NAMESPACE_REGEXP = %r{^(?<padding> *)(class|module) +(?<namespace>[\w:]+)[ \n]}
99
+ # @param path [String]
100
+ # @param line_number [Integer, #to_i]
101
+ # @return [String]
102
+ def self.call(path:, line_number:)
103
+ declarations = module_delcarations_for(path: path, line_number: line_number.to_i)
104
+ determine_namespace_from(declarations: declarations)
105
+ end
106
+
107
+ def self.module_delcarations_for(path:, line_number:)
108
+ declarations = []
109
+
110
+ # Should we read this in reverse order?
111
+ File.readlines(path).each_with_index do |line, index|
112
+ break if index + 1 > line_number
113
+ match = NAMESPACE_REGEXP.match(line)
114
+ next unless match
115
+ declarations << match
116
+ end
117
+ declarations
118
+ end
119
+ private_class_method :module_delcarations_for
120
+
121
+ def self.determine_namespace_from(declarations:)
122
+ # Based on convention, this would be a 40 deep module; gods have
123
+ # mercy.
124
+ current_padding_size = 80
125
+ qualified_module_name = []
126
+ # This will be easier to do if we reverse things; Because we can
127
+ # handle files that have multiple module declarations.
128
+ declarations.reverse.each do |dec|
129
+ if dec[:padding].length < current_padding_size
130
+ current_padding_size = dec[:padding].length
131
+ qualified_module_name.unshift(dec[:namespace])
132
+ end
133
+ end
134
+ qualified_module_name.join("::")
135
+ end
136
+ private_class_method :determine_namespace_from
137
+ end
138
+
139
+ class PerformInformation
140
+ def self.each_from(dir)
141
+ command = %(rg "^ *[^#]*Job\\.perform" #{dir} -g '!spec/' -n)
142
+ `#{command}`.split("\n").each do |line|
143
+ parser = Parser.new(line)
144
+ info = new(
145
+ path: parser.path,
146
+ invoking_constant: parser.invoking_constant,
147
+ job_class_name_candidates: parser.job_class_name_candidates
148
+ )
149
+ yield(info)
150
+ end
151
+ end
152
+
153
+ def initialize(path:, invoking_constant:, job_class_name_candidates:)
154
+ @path = path
155
+ @invoking_constant = invoking_constant
156
+ @job_class_name_candidates = job_class_name_candidates
157
+ end
158
+
159
+ attr_reader :path, :invoking_constant, :job_class_name_candidates
160
+
161
+ class Parser
162
+ LINE_REGEXP = %r{(?<path>[^:]*):(?<line_number>[^:]*):(?<content>.*)}
163
+ JOB_REGEXP = %r{(?<job>[\w:]+Job)\.perform}
164
+ def initialize(grep_result_line)
165
+ @grep_result_line = grep_result_line
166
+ match = LINE_REGEXP.match(grep_result_line)
167
+ @path = match[:path].strip
168
+ @line_number = match[:line_number].to_i
169
+ @content = match[:content]
170
+ @invoking_constant = QualifiedConstantNameExtractor.call(path: @path, line_number: @line_number)
171
+
172
+ @job = JOB_REGEXP.match(@content)[:job]
173
+
174
+ if @invoking_constant.length == 0
175
+ @job_class_name_candidates = [@job]
176
+ @invoking_constant = @path
177
+ else
178
+ @job_class_name_candidates = determine_job_candidates_from(job: @job, namespace: @invoking_constant)
179
+ end
180
+ end
181
+
182
+ attr_reader :path
183
+ attr_reader :invoking_constant
184
+ attr_reader :job_class_name_candidates
185
+
186
+ private
187
+ def determine_job_candidates_from(job:, namespace:)
188
+ candidates = [job]
189
+ slugs = namespace.split("::")
190
+ slugs.each_with_index do |mod, i|
191
+ candidates << slugs[0..i].join("::") + "::" + job
192
+ end
193
+ candidates
194
+ end
195
+ end
196
+
197
+ end
198
+
199
+ # This class is responsible for parsing where we found a job and building the possible name space of
200
+ # the job.
201
+ class JobDeclaration
202
+ def self.each_from(dir)
203
+ command = %(rg "^ *class ([\\w:]+)Job <" #{dir} -g '!spec/' -n)
204
+ `#{command}`.split("\n").each do |line|
205
+ yield(new(line))
206
+ end
207
+ end
208
+
209
+ include Comparable
210
+ def <=>(other)
211
+ job_class_name <=> other.job_class_name
212
+ end
213
+
214
+ LINE_REGEXP = %r{(?<path>[^:]*):(?<line_number>[^:]*):(?<content>.*)}
215
+ JOB_REGEXP = %r{(?<job>[\w:]+Job) \<}
216
+ def initialize(grep_result_line)
217
+ @grep_result_line = grep_result_line
218
+ match = LINE_REGEXP.match(grep_result_line)
219
+ @path = match[:path].strip
220
+ @line_number = match[:line_number].to_i
221
+ @content = match[:content]
222
+ @job = JOB_REGEXP.match(@content)[:job]
223
+ @job_class_name = QualifiedConstantNameExtractor.call(path: @path, line_number: @line_number)
224
+ end
225
+
226
+ attr_reader :job_class_name
227
+ end
228
+ end
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: job_grapher
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeremy Friesen
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-10-22 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A naive graph generator for ActiveJob's declared and called across projects.
14
+ email:
15
+ - jeremy.n.friesen@gmail.com
16
+ executables:
17
+ - job_grapher
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - Gemfile
22
+ - LICENSE
23
+ - README.md
24
+ - Rakefile
25
+ - exe/job_grapher
26
+ - job_grapher.gemspec
27
+ - lib/job_grapher.rb
28
+ - lib/job_grapher/version.rb
29
+ homepage: https://github.com/jeremyf/job_grapher
30
+ licenses: []
31
+ metadata:
32
+ homepage_uri: https://github.com/jeremyf/job_grapher
33
+ source_code_uri: https://github.com/jeremyf/job_grapher
34
+ changelog_uri: https://github.com/jeremyf/job_grapher
35
+ post_install_message:
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 2.6.0
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.3.7
51
+ signing_key:
52
+ specification_version: 4
53
+ summary: A naive graph generator for ActiveJob's declared and called across projects.
54
+ test_files: []