gem_lookup 1.0.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.
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