gem_lookup 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ ENV['APP_ENV'] = 'development'
5
+ require_relative '../booster_pack'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ Pry.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/booster_pack.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'init/environment'
4
+ require_relative 'init/bundler'
5
+ require_relative 'init/zeitwerk'
data/exe/gems ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ ENV['APP_ENV'] = 'production'
6
+ require_relative '../booster_pack'
7
+
8
+ GemLookup::RubyGems.new(ARGV).find_all
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/gem_lookup/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'gem_lookup'
7
+ spec.version = GemLookup::VERSION
8
+ spec.authors = ['Josh Mills']
9
+ spec.email = ['josh@trueheart78.com']
10
+
11
+ spec.summary = 'Retrieves gem-related information from https://rubygems.org'
12
+ spec.description = <<~DESC
13
+ Simple but effective command line interface that looks up gems using RubyGems.org's public API
14
+ and displays results in an emoji-filled fashion.
15
+ DESC
16
+ spec.homepage = 'https://github.com/trueheart78/gem_lookup'
17
+ spec.license = 'MIT'
18
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.4.0')
19
+
20
+ spec.metadata = {
21
+ 'bug_tracker_uri' => 'https://github.com/trueheart78/gem_lookup/issues',
22
+ 'changelog_uri' => 'https://github.com/trueheart78/gem_lookup/blob/main/CHANGELOG.md',
23
+ 'documentation_uri' => spec.homepage,
24
+ 'homepage_uri' => spec.homepage,
25
+ 'source_code_uri' => "https://github.com/trueheart78/gem_lookup/tree/v#{GemLookup::VERSION}"
26
+ }
27
+
28
+ # Specify which files should be added to the gem when it is released.
29
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
30
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
31
+ `git ls-files -z`.split("\x0").reject {|f| f.match(%r{\A(?:test|spec|features)/}) }
32
+ end
33
+ spec.bindir = 'exe'
34
+ spec.executables = spec.files.grep(%r{\Aexe/}) {|f| File.basename(f) }
35
+
36
+ # These gems need to be explicitly required to be utilized.
37
+ spec.add_runtime_dependency 'colorize', '~> 0.8.1'
38
+ spec.add_runtime_dependency 'typhoeus', '~> 1.4'
39
+ spec.add_runtime_dependency 'zeitwerk', '~> 2.4.2'
40
+
41
+ # Development and test dependencies are defined in the Gemfile due to ENV['APP_ENV'] usage.
42
+ end
data/init/bundler.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+
6
+ Bundler.require(:default, ENV['APP_ENV'].to_sym)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV['APP_ENV'] = 'development' unless ENV['APP_ENV']
data/init/zeitwerk.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+
5
+ loader = Zeitwerk::Loader.new
6
+
7
+ root_dir = File.expand_path(File.dirname(File.dirname(__FILE__)))
8
+
9
+ loader.push_dir(File.join(root_dir, 'lib'))
10
+
11
+ loader.push_dir(File.join(root_dir, 'spec'))
12
+ loader.ignore(File.join(root_dir, '**', '*_spec.rb'))
13
+ loader.setup
data/lib/gem_lookup.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../booster_pack'
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemLookup
4
+ module Errors
5
+ class InvalidDisplayMode < StandardError; end
6
+
7
+ class UndefinedInterfaceMethod < StandardError; end
8
+
9
+ class UnsupportedFlags < StandardError; end
10
+
11
+ class UnsupportedFlag < StandardError; end
12
+ end
13
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'colorize'
4
+
5
+ module GemLookup
6
+ class Flags
7
+ class << self
8
+ # Returns the supported flags.
9
+ # @return [Hash] the supported flags.
10
+ def supported
11
+ {
12
+ help: { matches: %w[-h --help], desc: 'Display the help screen.' },
13
+ version: { matches: %w[-v --version], desc: 'Display version information.' },
14
+ json: { matches: %w[-j --json], desc: 'Bulk the output results as raw JSON.' },
15
+ wordy: { matches: %w[-w --wordy], desc: 'Stream the output using only words.' }
16
+ }
17
+ end
18
+
19
+ # Checks to see if any flags passed in match those defined by the type.
20
+ # @param type [Symbol] the type of flag (`:help`, `:version`, etc).
21
+ # @param flags [Array] an array that may or may not contain a supported flag.
22
+ # @return [Boolean] whether the flags passed in contain a flag supported by type.
23
+ def supported?(type, flags: [])
24
+ return false if type.nil?
25
+ return false if flags.empty?
26
+
27
+ type = type.to_sym
28
+ return false unless supported.key? type
29
+
30
+ supported[type][:matches].each do |flag|
31
+ return true if flags.include? flag
32
+ end
33
+
34
+ false
35
+ end
36
+
37
+ # Outputs the unsupported flags and exits with a code of 1.
38
+ # @param flags [Array] the list of unsupported flags.
39
+ def unsupported(flags:)
40
+ flags = flags.compact.reject(&:empty?)
41
+ return false unless flags.any?
42
+
43
+ raise Errors::UnsupportedFlag, flags.first if flags.size == 1
44
+
45
+ raise Errors::UnsupportedFlags, flags.join(', ')
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module GemLookup
6
+ class Gems
7
+ extend Forwardable
8
+
9
+ def_delegators :@serializer, :display, :batch_iterator, :gem_count, :querying, :streaming?
10
+
11
+ def initialize(gem_list, serializer:)
12
+ @gem_list = gem_list
13
+ @serializer = serializer
14
+ @batches = []
15
+ @json = { gems: [] }
16
+ end
17
+
18
+ def process
19
+ batch_gems
20
+ process_batches
21
+ end
22
+
23
+ private
24
+
25
+ def process_batches
26
+ display_gem_count
27
+
28
+ @batches.each_with_index {|batch, index| process_batch batch: batch, index: index }
29
+
30
+ display(json: @json) unless streaming?
31
+ end
32
+
33
+ def process_batch(batch:, index:)
34
+ display_batch_details(index + 1, @batches.size, batch)
35
+
36
+ make_requests batch: batch
37
+ display_results
38
+
39
+ sleep RateLimit.interval if batch_mode?
40
+ end
41
+
42
+ def make_requests(batch:)
43
+ @json = Requests.new(batch: batch, json: @json).process
44
+ end
45
+
46
+ def batch_gems
47
+ gems = @gem_list.dup
48
+
49
+ @batches.push gems.shift(RateLimit.number) while gems.any?
50
+ end
51
+
52
+ def batch_mode?
53
+ @batches.size > 1
54
+ end
55
+
56
+ def display_gem_count
57
+ return unless streaming?
58
+
59
+ gem_count(num: @gem_list.size) if @gem_list.size > 1
60
+ end
61
+
62
+ def display_batch_details(num, total, batch)
63
+ return unless streaming?
64
+
65
+ batch_iterator(num: num, total: total) if batch_mode?
66
+ querying(batch: batch)
67
+ end
68
+
69
+ def display_results
70
+ return unless streaming?
71
+
72
+ display(json: @json[:gems].shift) while @json[:gems].any?
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemLookup
4
+ class Help
5
+ # @return [Numeric] the spacing for output options
6
+ OUTPUT_OPTION_SPACING = 21
7
+
8
+ class << self
9
+ # Outputs the generated content.
10
+ # @param exit_code [Numeric, nil] the exit code (if any) to exit with.
11
+ def display(exit_code: nil)
12
+ puts documentation
13
+
14
+ exit exit_code unless exit_code.nil?
15
+ end
16
+
17
+ # Outputs the gem name and current gem version.
18
+ # @param exit_code [Numeric, nil] the exit code (if any) to exit with.
19
+ def version(exit_code: nil)
20
+ puts "#{NAME} #{VERSION}"
21
+
22
+ exit exit_code unless exit_code.nil?
23
+ end
24
+
25
+ # Generates the help documentation.
26
+ # @return [String] the help documentation.
27
+ def documentation
28
+ <<~HELP
29
+
30
+ Usage: gems [OPTIONS] GEMS
31
+
32
+ Retrieves gem-related information from https://rubygems.org
33
+
34
+ Example: gems -j rails rspec
35
+
36
+ This application's purpose is to make working with with RubyGems.org easier. 💖
37
+ It uses the RubyGems public API to perform lookups, and parses the JSON response
38
+ body to provide details about the most recent version, as well as links to
39
+ the home page, source code, changelog, and mailing list.
40
+
41
+ Feel free to pass in as many gems that you like, as it makes requests in
42
+ parallel. There is a rate limit, #{RateLimit.number}/sec. If it detects the amount of gems it
43
+ has been passed is more than the rate limit, the application will run in Batch
44
+ mode, and introduce a one second delay between batch lookups.
45
+
46
+ #{options}
47
+
48
+ Rate limit documentation: #{RateLimit.documentation_url}
49
+ HELP
50
+ end
51
+
52
+ private
53
+
54
+ # Generates an Output Options string that includes the supported flag details.
55
+ # @return [String] the supported output options.
56
+ def options
57
+ <<~OPTIONS.chomp
58
+ Output Options:
59
+ #{flag_output}
60
+ OPTIONS
61
+ end
62
+
63
+ # rubocop:disable Metrics/AbcSize
64
+ # Generates a formatted string that displays the supported flag details.
65
+ # @return [String] the supported flags and their details.
66
+ def flag_output
67
+ [].tap do |output|
68
+ Flags.supported.keys.sort.each do |key|
69
+ matches = Flags.supported[key][:matches].join ' '
70
+ spaces = ' ' * (OUTPUT_OPTION_SPACING - matches.length)
71
+ output.push " #{matches}#{spaces}#{Flags.supported[key][:desc]}"
72
+ end
73
+ end.join "\n"
74
+ end
75
+ # rubocop:enable Metrics/AbcSize
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemLookup
4
+ module RateLimit
5
+ # @return [Numeric] the max requests that can be made per interval.
6
+ MAX_REQUESTS_PER_INTERVAL = 10
7
+
8
+ # @return [Numeric] the frequency interval, in seconds.
9
+ FREQUENCY_INTERVAL_IN_SECONDS = 1
10
+
11
+ # @return [String] the url that documents the rate limit
12
+ RATE_LIMIT_DOCUMENTATION_URL = 'https://guides.rubygems.org/rubygems-org-rate-limits/'
13
+
14
+ class << self
15
+ # Calls MAX_REQUESTS_PER_SECOND.
16
+ # @return [Numeric] the max requests that can be made per interval.
17
+ def number
18
+ MAX_REQUESTS_PER_INTERVAL
19
+ end
20
+
21
+ # Calls FREQUENCY_INTERVAL_IN_SECONDS
22
+ # @return [Numeric] the frequency interval, in seconds.
23
+ def interval
24
+ FREQUENCY_INTERVAL_IN_SECONDS
25
+ end
26
+
27
+ # Calls RATE_LIMIT_DOCUMENTATION_URL.
28
+ # @return [String] the url that documents the rate limit.
29
+ def documentation_url
30
+ RATE_LIMIT_DOCUMENTATION_URL
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'typhoeus'
4
+ require 'json'
5
+
6
+ module GemLookup
7
+ class Requests
8
+ # @return [Numeric] the seconds to wait before a response is considered timed out.
9
+ TIMEOUT_THRESHOLD = 10
10
+
11
+ def initialize(batch:, json: nil)
12
+ @batch = batch
13
+ @json = json || { gems: [] }
14
+ end
15
+
16
+ def process
17
+ Typhoeus::Hydra.hydra.tap do |hydra|
18
+ populate_requests hydra: hydra, batch: @batch
19
+ end.run
20
+
21
+ @json
22
+ end
23
+
24
+ private
25
+
26
+ def populate_requests(hydra:, batch:)
27
+ batch.each do |gem_name|
28
+ hydra.queue build_request gem_name: gem_name
29
+ end
30
+ end
31
+
32
+ # rubocop:disable Layout/LineLength
33
+ def build_request(gem_name:)
34
+ url = api_url gem_name: gem_name
35
+ Typhoeus::Request.new(url, accept_encoding: 'gzip', timeout: TIMEOUT_THRESHOLD).tap do |request|
36
+ request.on_complete do |response|
37
+ if response.success?
38
+ handle_successful_response json: JSON.parse(response.body, symbolize_names: true)
39
+ elsif response.timed_out?
40
+ handle_timed_out_response gem_name: gem_name
41
+ else
42
+ handle_failed_response gem_name: gem_name
43
+ end
44
+ end
45
+ end
46
+ end
47
+ # rubocop:enable Layout/LineLength
48
+
49
+ def handle_successful_response(json:)
50
+ json[:exists] = true
51
+ json[:timeout] = false
52
+ @json[:gems].push json
53
+ end
54
+
55
+ def handle_timed_out_response(gem_name:)
56
+ json = { name: gem_name, exists: false, timeout: true }
57
+ @json[:gems].push json
58
+ end
59
+
60
+ def handle_failed_response(gem_name:)
61
+ json = { name: gem_name, exists: false, timeout: false }
62
+ @json[:gems].push json
63
+ end
64
+
65
+ def api_url(gem_name:)
66
+ "https://rubygems.org/api/v1/gems/#{gem_name}.json"
67
+ end
68
+ end
69
+ end