Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract bootstrap/framework code from dev #5

Merged
merged 1 commit into from
Jan 23, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
PATH
remote: .
specs:
dev-kit (0.1.0)
dev-ui (>= 0.1.0)
cli-kit (2.0.0)
cli-ui (>= 1.0.0)

GEM
remote: https://rubygems.org/
Expand All @@ -11,7 +11,7 @@ GEM
ast (2.3.0)
builder (3.2.3)
byebug (9.0.6)
dev-ui (0.1.0)
cli-ui (1.0.0)
metaclass (0.0.4)
method_source (0.8.2)
minitest (5.10.2)
Expand Down Expand Up @@ -45,7 +45,7 @@ PLATFORMS
DEPENDENCIES
bundler (~> 1.15)
byebug
dev-kit!
cli-kit!
method_source
minitest (>= 5.0.0)
minitest-reporters
Expand Down
53 changes: 52 additions & 1 deletion lib/cli/kit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,57 @@

module CLI
module Kit
autoload :System, 'cli/kit/system'
class << self
attr_accessor :tool_name
end

autoload :BaseCommand, 'cli/kit/base_command'
autoload :CommandRegistry, 'cli/kit/command_registry'
autoload :Config, 'cli/kit/config'
autoload :EntryPoint, 'cli/kit/entry_point'
autoload :Ini, 'cli/kit/ini'
autoload :Levenshtein, 'cli/kit/levenshtein'
autoload :ReportErrors, 'cli/kit/report_errors'
autoload :System, 'cli/kit/system'

EXIT_FAILURE_BUT_NOT_BUG = 30
EXIT_BUG = 1
EXIT_SUCCESS = 0

# Abort, Bug, AbortSilent, and BugSilent are four ways of immediately bailing
# on command-line execution when an unrecoverable error occurs.
#
# Note that these don't inherit from StandardError, and so are not caught by
# a bare `rescue => e`.
#
# * Abort prints its message in red and exits 1;
# * Bug additionally submits the exception to Bugsnag;
# * AbortSilent and BugSilent do the same as above, but do not print
# messages before exiting.
#
# Treat these like panic() in Go:
# * Don't rescue them. Use a different Exception class if you plan to recover;
# * Provide a useful message, since it will be presented in brief to the
# user, and will be useful for debugging.
# * Avoid using it if it does actually make sense to recover from an error.
#
# Additionally:
# * Do not subclass these.
# * Only use AbortSilent or BugSilent if you prefer to print a more
# contextualized error than Abort or Bug would present to the user.
# * In general, don't attach a message to AbortSilent or BugSilent.
# * Never raise GenericAbort directly.
# * Think carefully about whether Abort or Bug is more appropriate. Is this
# a bug in the tool? Or is it just user error, transient network
# failure, etc.?
# * One case where it's ok to rescue these outside of EntryPoint (or tests):
# 1. rescue Abort or Bug
# 2. Print a contextualized error message
# 3. Re-raise AbortSilent or BugSilent respectively.
GenericAbort = Class.new(Exception)
Abort = Class.new(GenericAbort)
Bug = Class.new(GenericAbort)
BugSilent = Class.new(GenericAbort)
AbortSilent = Class.new(GenericAbort)
end
end
43 changes: 43 additions & 0 deletions lib/cli/kit/base_command.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require 'cli/kit'

module CLI
module Kit
class BaseCommand
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is Dev::Command, more or less. I was able to extract a reasonable amount of it to here and subclass this over there.

def self.defined?
true
end

def self.statsd_increment(metric, **kwargs)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll probably migrate Statsd over here too, but for now, these two are interface methods.

nil
end

def self.statsd_time(metric, **kwargs)
yield
end

def self.call(args, command_name)
cmd = new
stats_tags = ["task:#{cmd.class}"]
stats_tags << "subcommand:#{args.first}" if args && args.first && cmd.has_subcommands?
begin
statsd_increment("cli.command.invoked", tags: stats_tags)
statsd_time("cli.command.time", tags: stats_tags) do
cmd.call(args, command_name)
end
statsd_increment("cli.command.success", tags: stats_tags)
rescue => e
statsd_increment("cli.command.exception", tags: stats_tags + ["exception:#{e.class}"])
raise e
end
end

def call(args, command_name)
raise NotImplementedError
end

def has_subcommands?
false
end
end
end
end
79 changes: 79 additions & 0 deletions lib/cli/kit/command_registry.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
require 'cli/kit'

module CLI
module Kit
module CommandRegistry
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some modest gymnastics here to manage variables on this command vs. access the module it's extended in.

attr_accessor :commands, :aliases
class << self
attr_accessor :registry_target
end

def resolve_contextual_command
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The concept of "Project-local" and "Type" commands was generalized a bit into "Contextual" commands. The interface to add contextual commands to a command registry is:

  • resolve_contextual_command
  • contextual_aliases
  • contextual_command_class

This is a pretty niche concern and I don't really expect to build too many tools other than dev that use this feature.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is only included for stuff from a dev.yml?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well it's "included" everywhere depending on what you mean, but, well: https://github.com/Shopify/dev/blob/4eea78118cc0f6fe4180f37aef97ab676101915c/lib/dev/commands.rb#L7-L19

nil
end

def contextual_aliases
{}
end

def contextual_command_class(_name)
raise NotImplementedError
end

def self.extended(base)
raise "multiple registries unsupported" if self.registry_target
self.registry_target = base
base.commands = {}
base.aliases = {}
end

def register(const, name, path)
autoload(const, path)
commands[name] = const
end

def lookup_command(name)
return default_command if name.to_s == ""
resolve_command(name)
end

def register_alias(from, to)
aliases[from] = to unless aliases[from]
end

def resolve_command(name)
resolve_global_command(name) || \
resolve_contextual_command(name) || \
[nil, resolve_alias(name)]
end

def resolve_alias(name)
aliases[name] || contextual_aliases.fetch(name, name)
end

def resolve_global_command(name)
name = aliases.fetch(name, name)
command_class = const_get(commands.fetch(name, ""))
return nil unless command_class.defined?
[command_class, name]
rescue NameError
nil
end

def resolve_contextual_command(name)
name = resolve_alias(name)
found = contextual_command_names.include?(name)
return nil unless found
[contextual_command_class(name), name]
end

def command_names
contextual_command_names + commands.keys
end

def exist?(name)
!resolve_command(name).first.nil?
end
end
end
end
102 changes: 102 additions & 0 deletions lib/cli/kit/config.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
require 'cli/kit'
require 'fileutils'

module CLI
module Kit
class Config
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick follow up here is to make CLI::Kit::Config subclass our IniParser (or use it)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup. I have bad timing but there wasn't much transformation here so it's an easy follow-up

XDG_CONFIG_HOME = 'XDG_CONFIG_HOME'

# Returns the config corresponding to `name` from the config file
# `false` is returned if it doesn't exist
#
# #### Parameters
# `section` : the section of the config value you are looking for
# `name` : the name of the config value you are looking for
#
# #### Returns
# `value` : the value of the config variable (false if none)
#
# #### Example Usage
# `config.get('name.of.config')`
#
def get(section, name = nil)
section, name = section.split('.', 2) if name.nil?
# TODO: Remove this and all global configs
return get("global", section) if name.nil?
all_configs.dig("[#{section}]", name) || false
end

# Sets the config value in the config file
#
# #### Parameters
# `section` : the section of the config you are setting
# `name` : the name of the config you are setting
# `value` : the value of the config you are setting
#
# #### Example Usage
# `config.set('section', 'name.of.config', 'value')`
#
def set(section, name = nil, value)
section, name = section.split('.', 2) if name.nil?
# TODO: Remove this and all global configs
return set("global", section, value) if name.nil?
all_configs["[#{section}]"] ||= {}
all_configs["[#{section}]"][name] = value.nil? ? nil : value.to_s
write_config
end

def get_section(section)
(all_configs["[#{section}]"] || {}).dup
end

# Returns a path from config in expanded form
# e.g. shopify corresponds to ~/src/shopify, but is expanded to /Users/name/src/shopify
#
# #### Example Usage
# `config.get_path('srcpath', 'shopify')`
#
# #### Returns
# `path` : the expanded path to the corrsponding value
#
def get_path(section, name = nil)
v = get(section, name)
false == v ? v : File.expand_path(v)
end

def to_s
ini.to_s
end

# The path on disk at which the configuration is stored:
# `$XDG_CONFIG_HOME/<toolname>/config`
# if ENV['XDG_CONFIG_HOME'] is not set, we default to ~/.config, e.g.:
# ~/.config/tool/config
#
def file
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is different. I've overridden it back to the current behaviour in dev, but this is what we probably should have done initially. It's a bit tricky to migrate now, but I might shave that yak eventually.

config_home = ENV.fetch(XDG_CONFIG_HOME, '~/.config')
File.expand_path(File.join(CLI::Kit.tool_name, 'config'), config_home)
end

private

def all_configs
ini.ini
end

def ini
@ini ||= CLI::Kit::Ini
.new(file, default_section: "[global]", convert_types: false)
.tap(&:parse)
end

def write_config
all_configs.each do |section, sub_config|
all_configs[section] = sub_config.reject { |_, value| value.nil? }
all_configs.delete(section) if all_configs[section].empty?
end
FileUtils.mkdir_p(File.dirname(file))
File.write(file, to_s)
end
end
end
end
Loading