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 +7 -0
- data/Gemfile +8 -0
- data/LICENSE +14 -0
- data/README.md +49 -0
- data/Rakefile +4 -0
- data/exe/job_grapher +9 -0
- data/job_grapher.gemspec +30 -0
- data/lib/job_grapher/version.rb +5 -0
- data/lib/job_grapher.rb +228 -0
- metadata +54 -0
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
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
data/exe/job_grapher
ADDED
data/job_grapher.gemspec
ADDED
@@ -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
|
data/lib/job_grapher.rb
ADDED
@@ -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: []
|