use Thor for CLI

This commit is contained in:
Ben Balter 2018-01-25 15:29:15 -05:00
parent 1553efdd95
commit f93bdaabe3
No known key found for this signature in database
GPG Key ID: DBB67C246AD356C4
22 changed files with 325 additions and 158 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ Gemfile.lock
/tmp
/spec/examples.txt
/coverage
.env
vendor/html5shiv
vendor/jquery

View File

@ -38,3 +38,16 @@ Layout/IndentHeredoc:
Style/StructInheritance:
Enabled: false
Metrics/LineLength:
Exclude:
- bin/*
- lib/licensee/commands/*
Metrics/PerceivedComplexity:
Exclude:
- lib/licensee/commands/*
Metrics/CyclomaticComplexity:
Exclude:
- lib/licensee/commands/*

View File

@ -1,64 +1,37 @@
#!/usr/bin/env ruby
require 'dotenv/load'
require 'thor'
require 'json'
require_relative '../lib/licensee'
path = ARGV[0] || Dir.pwd
class LicenseeCLI < Thor
package_name 'Licensee'
class_option :remote, type: :boolean, desc: 'Assume PATH is a GitHub owner/repo path'
default_task :detect
# Given a string or object, prepares it for output and human consumption
def humanize(value, type = nil)
case type
when :license
value.name
when :matcher
value.class
when :confidence
Licensee::ContentHelper.format_percent(value)
when :method
value.to_s.tr('_', ' ').capitalize
else
value
private
def path
@path ||= if !options[:remote] || args.first =~ %r{^https://}
args.first || Dir.pwd
else
"https://github.com/#{args.first}"
end
end
def project
@project ||= Licensee.project(path,
detect_packages: options[:packages], detect_readme: options[:readme])
end
def remote?
path =~ %r{^https://}
end
end
# Methods to call when displaying information about ProjectFiles
MATCHED_FILE_METHODS = %i[
content_hash attribution confidence matcher license
].freeze
commands_dir = File.expand_path 'lib/licensee/commands/'
Dir["#{commands_dir}/*.rb"].each { |c| require(c) }
project = Licensee.project(path, detect_packages: true, detect_readme: true)
if project.license
puts "License: #{project.license.name} (#{project.license.spdx_id})"
elsif project.licenses
puts "Licenses: #{project.licenses.map(&:name)}"
else
puts 'License: Not detected'
end
puts "Matched files: #{project.matched_files.map(&:filename)}"
project.matched_files.each do |matched_file|
puts "#{matched_file.filename}:"
MATCHED_FILE_METHODS.each do |method|
next unless matched_file.respond_to? method
value = matched_file.public_send method
next if value.nil?
puts " #{humanize(method, :method)}: #{humanize(value, method)}"
end
next unless matched_file.is_a? Licensee::ProjectFiles::LicenseFile
next unless matched_file.confidence != 100
matcher = Licensee::Matchers::Dice.new(matched_file)
licenses = matcher.licenses_by_similiarity
next if licenses.empty?
puts ' Closest licenses:'
licenses[0...3].each do |license, similarity|
spdx_id = license.meta['spdx-id']
percent = Licensee::ContentHelper.format_percent(similarity)
puts " * #{spdx_id} similarity: #{percent}"
end
end
exit !project.licenses.empty?
LicenseeCLI.start(ARGV)

View File

@ -6,6 +6,7 @@ require 'yaml'
module Licensee
autoload :ContentHelper, 'licensee/content_helper'
autoload :HashHelper, 'licensee/hash_helper'
autoload :License, 'licensee/license'
autoload :LicenseField, 'licensee/license_field'
autoload :LicenseMeta, 'licensee/license_meta'

View File

@ -0,0 +1,99 @@
class LicenseeCLI < Thor
# Methods to call when displaying information about ProjectFiles
MATCHED_FILE_METHODS = %i[
content_hash attribution confidence matcher license
].freeze
desc 'detect [PATH]', 'Detect the license of the given project'
option :json, type: :boolean, desc: 'Return output as JSON'
option :packages, type: :boolean, default: true, desc: 'Detect licenses in package manager files'
option :readme, type: :boolean, default: true, desc: 'Detect licenses in README files'
option :confidence, type: :numeric, default: Licensee.confidence_threshold, desc: 'Confidence threshold'
option :license, type: :string, desc: 'The SPDX ID or key of the license to compare (implies --diff)'
option :diff, type: :boolean, desc: 'Compare the license to the closest match'
def detect(_path = nil)
Licensee.confidence_threshold = options[:confidence]
if options[:json]
say project.to_h.to_json
exit !project.licenses.empty?
end
rows = []
rows << if project.license
['License:', project.license.name]
elsif project.licenses
['Licenses:', project.licenses.map(&:name)]
else
['License:', set_color('None', :red)]
end
rows << ['Matched files:', project.matched_files.map(&:filename).join(', ')]
print_table rows
project.matched_files.each do |matched_file|
rows = []
say "#{matched_file.filename}:"
MATCHED_FILE_METHODS.each do |method|
next unless matched_file.respond_to? method
value = matched_file.public_send method
next if value.nil?
rows << [humanize(method, :method), humanize(value, method)]
end
print_table rows, indent: 2
next unless matched_file.is_a? Licensee::ProjectFiles::LicenseFile
next if matched_file.confidence == 100
licenses = licenses_by_similiarity(matched_file)
next if licenses.empty?
say ' Closest licenses:'
rows = licenses[0...3].map do |license, similarity|
spdx_id = license.meta['spdx-id']
percent = Licensee::ContentHelper.format_percent(similarity)
["#{spdx_id} similarity:", percent]
end
print_table rows, indent: 4
end
if project.license_file && (options[:license] || options[:diff])
license = options[:license] || closest_license_key(project.license_file)
invoke(:diff, nil,
license: license, license_to_diff: project.license_file
) if license
end
exit !project.licenses.empty?
end
private
# Given a string or object, prepares it for output and human consumption
def humanize(value, type = nil)
case type
when :license
value.name
when :matcher
value.class
when :confidence
Licensee::ContentHelper.format_percent(value)
when :method
value.to_s.tr('_', ' ').capitalize + ':'
else
value
end
end
def licenses_by_similiarity(matched_file)
matcher = Licensee::Matchers::Dice.new(matched_file)
potential_licenses = Licensee.licenses(hidden: true).select(&:wordset)
matcher.instance_variable_set('@potential_licenses', potential_licenses)
matcher.licenses_by_similiarity
end
def closest_license_key(matched_file)
licenses = licenses_by_similiarity(matched_file)
licenses.first.first.key unless licenses.empty?
end
end

View File

@ -0,0 +1,62 @@
require 'tmpdir'
class LicenseeCLI < Thor
desc 'diff [PATH]', 'Compare the given license text to a known license'
option :license, type: :string, desc: 'The SPDX ID or key of the license to compare'
def diff(_path = nil)
say "Comparing to #{expected_license.name}:"
rows = []
left = expected_license.content_normalized(wrap: 80)
right = license_to_diff.content_normalized(wrap: 80)
similarity = expected_license.similarity(license_to_diff)
similarity = Licensee::ContentHelper.format_percent(similarity)
rows << ['Input Length:', license_to_diff.length]
rows << ['License length:', expected_license.length]
rows << ['Similarity:', similarity]
print_table rows
if left == right
say 'Exact match!', :green
exit
end
Dir.mktmpdir do |dir|
path = File.expand_path 'LICENSE', dir
Dir.chdir(dir) do
`git init`
File.write(path, left)
`git add LICENSE`
`git commit -m 'left'`
File.write(path, right)
say `git diff --word-diff`
end
end
end
private
def license_to_diff
return options[:license_to_diff] if options[:license_to_diff]
return project.license_file if remote?
@license_via_stdin ||= begin
if STDIN.tty?
error 'You must pipe license contents to the command via STDIN'
exit 1
end
Licensee::ProjectFiles::LicenseFile.new(STDIN.read, 'LICENSE')
end
end
def expected_license
@expected_license ||= Licensee::License.find options[:license]
return @expected_license if @expected_license
error "#{options[:license]} is not a valid license"
error "Valid licenses: #{Licensee::License.all(hidden: true).map(&:key).join(', ')}"
exit 1
end
end

View File

@ -0,0 +1,14 @@
class LicenseeCLI < Thor
desc 'license-path [PATH]', "Returns the path to the given project's license file"
def license_path(_path)
if project.license_file
if remote?
say project.license_file.path
else
say File.expand_path project.license_file.path
end
else
exit 1
end
end
end

View File

@ -0,0 +1,6 @@
class LicenseeCLI < Thor
desc 'version', 'Return the Licensee version'
def version
say Licensee::VERSION
end
end

View File

@ -0,0 +1,20 @@
module Licensee
module HashHelper
def to_h
hash = {}
self.class::HASH_METHODS.each do |method|
key = method.to_s.delete('?').to_sym
value = public_send(method)
hash[key] = if value.is_a?(Array)
value.map { |v| v.respond_to?(:to_h) ? v.to_h : v }
elsif value.respond_to?(:to_h) && !value.nil?
value.to_h
else
value
end
end
hash
end
end
end

View File

@ -94,7 +94,12 @@ module Licensee
SOURCE_PREFIX = %r{https?://(?:www\.)?}i
SOURCE_SUFFIX = %r{(?:\.html?|\.txt|\/)}i
HASH_METHODS = %i[
key spdx_id meta url rules fields other? gpl? lgpl? cc?
].freeze
include Licensee::ContentHelper
include Licensee::HashHelper
extend Forwardable
def_delegators :meta, *LicenseMeta.helper_methods

View File

@ -12,6 +12,9 @@ module Licensee
PREDICATE_FIELDS = %i[featured hidden].freeze
include Licensee::HashHelper
HASH_METHODS = members - %i[conditions permissions limitations spdx_id]
class << self
# Create a new LicenseMeta from YAML
#

View File

@ -1,6 +1,9 @@
module Licensee
# Exposes #conditions, #permissions, and #limitation arrays of LicenseRules
class LicenseRules < Struct.new(:conditions, :permissions, :limitations)
include Licensee::HashHelper
HASH_METHODS = Rule.groups
class << self
def from_license(license)
from_meta(license.meta)
@ -9,7 +12,7 @@ module Licensee
def from_meta(meta)
rules = {}
Rule.groups.each do |group|
rules[group] = meta[group].map do |tag|
rules[group] = (meta[group] || []).map do |tag|
Rule.find_by_tag_and_group(tag, group)
end
end

View File

@ -1,12 +1,6 @@
module Licensee
module Matchers
class Exact
attr_reader :file
def initialize(file)
@file = file
end
class Exact < Licensee::Matchers::Matcher
def match
return @match if defined? @match
@match = Licensee.licenses(hidden: true).find do |license|

View File

@ -3,10 +3,17 @@ module Licensee
class Matcher
attr_reader :file
include Licensee::HashHelper
HASH_METHODS = %i[name confidence].freeze
def initialize(file)
@file = file
end
def name
@name ||= self.class.to_s.split('::').last.to_sym
end
def match
raise 'Not implemented'
end

View File

@ -35,6 +35,14 @@ module Licensee
FILENAMES_SCORES[filename] || 0.0
end
def content_hash
nil
end
def content_normalized
nil
end
private
def extension

View File

@ -10,6 +10,11 @@ module Licensee
attr_reader :content
include Licensee::HashHelper
HASH_METHODS = %i[
filename content content_hash content_normalized matcher matched_license
].freeze
ENCODING = Encoding::UTF_8
ENCODING_OPTIONS = {
invalid: :replace,
@ -61,6 +66,10 @@ module Licensee
alias match license
alias path filename
def matched_license
license.key if license
end
# Is this file a COPYRIGHT file with only a copyright statement?
# If so, it can be excluded from determining if a project has >1 license
def copyright?

View File

@ -10,6 +10,9 @@ module Licensee
alias detect_readme? detect_readme
alias detect_packages? detect_packages
include Licensee::HashHelper
HASH_METHODS = %i[licenses matched_files].freeze
def initialize(detect_packages: false, detect_readme: false)
@detect_packages = detect_packages
@detect_readme = detect_readme

View File

@ -2,6 +2,9 @@ module Licensee
class Rule
attr_reader :tag, :label, :description, :group
include Licensee::HashHelper
HASH_METHODS = %i[tag label description].freeze
def initialize(tag: nil, label: nil, description: nil, group: nil)
@tag = tag
@label = label

View File

@ -20,6 +20,8 @@ Gem::Specification.new do |gem|
gem.add_dependency('octokit', '~> 4.8.0')
gem.add_dependency('rugged', '~> 0.24')
gem.add_dependency('thor', '~> 0.19')
gem.add_dependency('dotenv', '~> 2.0')
gem.add_development_dependency('coveralls', '~> 0.8')
gem.add_development_dependency('mustache', '>= 0.9', '< 2.0')

View File

@ -1,50 +1,3 @@
#!/usr/bin/env ruby
# Diffs STDIN against a known license
# Example usage: cat LICENSE.md | be script/diff mit
require 'licensee'
require 'tmpdir'
if STDIN.tty? || ARGV[0].to_s.empty?
puts 'USAGE: [LICENSE_CONTENT] | script/diff [LICENSE_ID]'
exit 1
end
text = STDIN.read
license_file = Licensee::ProjectFiles::LicenseFile.new(text, 'LICENSE')
license = Licensee::License.find(ARGV[0])
if license.nil?
puts "#{ARGV[0]} is not a valid license"
licenses = Licensee::License.all(hidden: true).map(&:key).join(', ')
puts "Valid licenses: #{licenses}"
exit 1
end
puts "Comparing to #{license.name}:"
left = license.content_normalized(wrap: 80)
right = license_file.content_normalized(wrap: 80)
puts " Input length: #{license_file.length}"
puts " License length: #{license.length}"
similarity = license.similarity(license_file)
similarity = Licensee::ContentHelper.format_percent(similarity)
puts " Similarity: #{similarity}"
if left == right
puts ' Exact match!'
exit
end
dir = Dir.mktmpdir
path = File.expand_path 'LICENSE', dir
Dir.chdir(dir) do
`git init`
File.write(path, left)
`git add LICENSE`
`git commit -m 'left'`
File.write(path, right)
puts `git diff --word-diff`
end

View File

@ -1,64 +1,51 @@
RSpec.describe 'command line invocation' do
let(:command) { ['ruby', 'bin/licensee'] }
let(:command) { ['bundle', 'exec', 'bin/licensee'] }
let(:output) do
Dir.chdir project_root do
puts [command, arguments].flatten.inspect
Open3.capture3(*[command, arguments].flatten)
end
end
let(:parsed_output) { YAML.safe_load(stdout) }
let(:stdout) { output[0] }
let(:stderr) { output[1] }
let(:status) { output[2] }
let(:hash) { '46cdc03462b9af57968df67b450cc4372ac41f53' }
let(:expected) {
{
"License" => "MIT License",
"Matched files" => "LICENSE.md, licensee.gemspec",
"LICENSE.md" => {
"Content hash" => hash,
"Attribution" => "Copyright (c) 2014-2017 Ben Balter",
"Confidence" => "100.00%",
"Matcher" => "Licensee::Matchers::Exact",
"License" => "MIT License"
},
"licensee.gemspec" => {
"Confidence" => "90.00%",
"Matcher" => "Licensee::Matchers::Gemspec",
"License" => "MIT License"
}
}
}
context 'without any arguments' do
let(:arguments) { [] }
{
"No arguments" => [],
"Project root" => [project_root],
"License path" => [File.expand_path('LICENSE.md', project_root)]
}.each do |name, args|
context "When given #{name}" do
let(:arguments) { args }
it 'Returns a zero exit code' do
expect(status.exitstatus).to eql(0)
end
it 'Returns a zero exit code' do
expect(status.exitstatus).to eql(0)
end
it "detects the folder's license" do
expect(stdout).to match('License: MIT License')
end
it 'outputs the hash' do
expect(stdout).to match(hash)
end
it 'outputs the attribution' do
expect(stdout).to match('2014-2017 Ben Balter')
end
it 'outputs the confidence' do
expect(stdout).to match('Confidence: 100.00%')
expect(stdout).to match('Confidence: 90.00%')
end
it 'outputs the method' do
expect(stdout).to match('Matcher: Licensee::Matchers::Exact')
expect(stdout).to match('Matcher: Licensee::Matchers::Gemspec')
end
it 'outputs the matched files' do
matched_files = 'Matched files: ["LICENSE.md", "licensee.gemspec"]'
expect(stdout).to include(matched_files)
end
end
context 'when given a folder path' do
let(:arguments) { [project_root] }
it "detects the folder's license" do
expect(stdout).to match('License: MIT License')
end
end
context 'when given a license path' do
let(:license_path) { File.expand_path 'LICENSE.md', project_root }
let(:arguments) { [license_path] }
it "detects the file's license" do
expect(stdout).to match('License: MIT License')
it "returns the exected values" do
puts stdout
expect(parsed_output).to eql(expected), stdout + stderr
end
end
end
end

View File

@ -5,6 +5,7 @@ require 'licensee'
require 'open3'
require 'tmpdir'
require 'mustache'
require 'yaml'
require 'webmock/rspec'
WebMock.disable_net_connect!