dry-cli-ui 0.6.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +246 -18
- data/examples/Gemfile.lock +20 -7
- data/lib/dry/cli/ui/configuration.rb +27 -2
- data/lib/dry/cli/ui/console.rb +31 -4
- data/lib/dry/cli/ui/flags.rb +143 -0
- data/lib/dry/cli/ui/invocation.rb +100 -0
- data/lib/dry/cli/ui/logging.rb +192 -0
- data/lib/dry/cli/ui/report.rb +16 -0
- data/lib/dry/cli/ui/reporting.rb +134 -0
- data/lib/dry/cli/ui/terminal.rb +19 -2
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/legend.rb +48 -0
- data/lib/dry/cli/ui/widgets/multi.rb +20 -3
- data/lib/dry/cli/ui/widgets/multi_progress.rb +36 -28
- data/lib/dry/cli/ui/widgets/outcome.rb +4 -2
- data/lib/dry/cli/ui/widgets/progress.rb +291 -43
- data/lib/dry/cli/ui/widgets/prompt.rb +6 -2
- data/lib/dry/cli/ui/widgets.rb +1 -0
- data/lib/dry/cli/ui.rb +76 -10
- data/sig/dry/cli/ui.rbs +91 -2
- metadata +49 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
# The reserved flags: options that mean the same thing in every CLI built on this gem.
|
|
9
|
+
#
|
|
10
|
+
# | Flag | Long | Meaning |
|
|
11
|
+
# | ---- | --------------------- | ---------------------------------------------------------- |
|
|
12
|
+
# | `-n` | `--dry-run` | change nothing, print what would happen |
|
|
13
|
+
# | `-y` | `--yes` | skip every interactive prompt |
|
|
14
|
+
# | `-o` | `--output [FILE]` | write the command's report to FILE, in colour |
|
|
15
|
+
# | `-l` | `--log [FILE]` | log to FILE through SemanticLogger |
|
|
16
|
+
# | `-L` | `--log-level LEVEL` | debug, info, warn, error or fatal (info by default) |
|
|
17
|
+
# | | `--log-format FORMAT` | standard (the default), json, or another SemanticLogger formatter |
|
|
18
|
+
#
|
|
19
|
+
# Extend a command class with it and name the flags it takes. `:log` brings `-L` and
|
|
20
|
+
# `--log-format` with it. The options reach `call` as `dry_run:`, `yes:`, `output:`, `log:`,
|
|
21
|
+
# `log_level:` and `log_format:`; hand them to {Console#with_flags}, or to {Console#output},
|
|
22
|
+
# {Console#logging} and {Console#confirm} one at a time.
|
|
23
|
+
#
|
|
24
|
+
# dry-cli gives every string option a required value, so on its own it rejects a bare `-o`.
|
|
25
|
+
# Loading this module prepends {Hook} onto `Dry::CLI`, which rewrites a bare `-o`,
|
|
26
|
+
# `--output`, `-l` or `--log` into an empty value (see {.arguments}) for the commands that
|
|
27
|
+
# extend this module, and nothing else. An empty value means "the default file".
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# class Export < Dry::CLI::Command
|
|
31
|
+
# include Dry::CLI::UI
|
|
32
|
+
# extend Dry::CLI::UI::Flags
|
|
33
|
+
#
|
|
34
|
+
# flags :dry_run, :yes, :output, :log
|
|
35
|
+
# end
|
|
36
|
+
module Flags
|
|
37
|
+
# Every option each reserved flag declares, as arguments to dry-cli's `option`.
|
|
38
|
+
OPTIONS = {
|
|
39
|
+
dry_run: {
|
|
40
|
+
dry_run: { type: :flag, default: false, aliases: ["-n"], desc: "Change nothing; print what would happen" }
|
|
41
|
+
},
|
|
42
|
+
yes: {
|
|
43
|
+
yes: { type: :flag, default: false, aliases: ["-y"], desc: "Answer yes to every prompt" }
|
|
44
|
+
},
|
|
45
|
+
output: {
|
|
46
|
+
output: { aliases: ["-o"], desc: "Write the report to FILE: log/ by default, - for STDOUT" }
|
|
47
|
+
},
|
|
48
|
+
log: {
|
|
49
|
+
log: { aliases: ["-l"], desc: "Log to FILE: log/ by default, - for STDOUT" },
|
|
50
|
+
log_level: { aliases: ["-L"], default: "info", values: %w[debug info warn error fatal],
|
|
51
|
+
desc: "Log level" },
|
|
52
|
+
log_format: { default: "standard", desc: "Log format: standard, json, or another SemanticLogger formatter" }
|
|
53
|
+
}
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# The reserved flags that take an optional file.
|
|
57
|
+
OPTIONAL_VALUES = %i[output log].freeze
|
|
58
|
+
|
|
59
|
+
# The switches {.arguments} rewrites unless told otherwise.
|
|
60
|
+
SWITCHES = %w[-o --output -l --log].freeze
|
|
61
|
+
|
|
62
|
+
# Gives a bare switch an empty value, so that dry-cli, which requires one, accepts it.
|
|
63
|
+
#
|
|
64
|
+
# A switch is bare when it is last, or when the next argument starts with `-` and is not
|
|
65
|
+
# exactly `-`. `-o=x`, `--output=x`, `-ox`, `-o x` and `-o -` are left alone, as is
|
|
66
|
+
# everything after `--`.
|
|
67
|
+
#
|
|
68
|
+
# @example
|
|
69
|
+
# Dry::CLI::UI::Flags.arguments(%w[export -o --verbose]) # => ["export", "-o", "", "--verbose"]
|
|
70
|
+
#
|
|
71
|
+
# @param argv [Array<String>] the command line
|
|
72
|
+
# @param switches [Array<String>] the switches that take an optional value
|
|
73
|
+
# @return [Array<String>] a new array; argv is not changed
|
|
74
|
+
def self.arguments(argv, switches: SWITCHES)
|
|
75
|
+
ended = false
|
|
76
|
+
argv.each_with_index.flat_map do |token, index|
|
|
77
|
+
ended ||= token == "--"
|
|
78
|
+
bare = !ended && switches.include?(token) && bare_before?(argv[index + 1])
|
|
79
|
+
bare ? [token, ""] : [token]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Whether a switch followed by this argument has no value.
|
|
84
|
+
#
|
|
85
|
+
# @param following [String, nil] the next argument, nil at the end of the line
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def self.bare_before?(following)
|
|
88
|
+
following.nil? || (following.start_with?("-") && following != "-")
|
|
89
|
+
end
|
|
90
|
+
private_class_method :bare_before?
|
|
91
|
+
|
|
92
|
+
# The switches of a command's options that take an optional file, as typed on a command
|
|
93
|
+
# line: `--output` and each of its aliases, and the same for `--log`.
|
|
94
|
+
#
|
|
95
|
+
# @param command [Class] a command class extended with this module
|
|
96
|
+
# @return [Array<String>]
|
|
97
|
+
def self.switches(command)
|
|
98
|
+
command.options.select { OPTIONAL_VALUES.include?(it.name.to_sym) }.flat_map do |option|
|
|
99
|
+
names = [option.name.to_s.tr("_", "-"), *option.aliases.map { it.delete_prefix("-").delete_prefix("-") }]
|
|
100
|
+
names.map { it.size == 1 ? "-#{it}" : "--#{it}" }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Declares reserved flags as options of this command.
|
|
105
|
+
#
|
|
106
|
+
# @param names [Array<Symbol>] any of `:dry_run`, `:yes`, `:output` and `:log`
|
|
107
|
+
# @return [Array<Symbol>] the names
|
|
108
|
+
# @raise [ArgumentError] when a name is not a reserved flag; nothing is declared
|
|
109
|
+
def flags(*names)
|
|
110
|
+
unknown = names - OPTIONS.keys
|
|
111
|
+
raise ArgumentError, "unknown flag #{unknown.map(&:inspect).join(', ')}, expected any of #{OPTIONS.keys}" if unknown.any?
|
|
112
|
+
|
|
113
|
+
names.each { |name| OPTIONS.fetch(name).each { |option_name, settings| option(option_name, settings) } }
|
|
114
|
+
names
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Prepended onto `Dry::CLI` when {Flags} loads. It rewrites the arguments of a command that
|
|
118
|
+
# extends {Flags} with {Flags.arguments}, and records on a command that includes {UI} the
|
|
119
|
+
# names it was called by, which name its default report and log files.
|
|
120
|
+
#
|
|
121
|
+
# @api private
|
|
122
|
+
module Hook
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# @param command [Class, Dry::CLI::Command] the command dry-cli resolved
|
|
126
|
+
# @param arguments [Array<String>] the arguments after the command's names
|
|
127
|
+
# @param names [Array<String>] the names the command was called by
|
|
128
|
+
# @return [Array(Dry::CLI::Command, Hash)] the command to call and its arguments
|
|
129
|
+
def parse(command, arguments, names)
|
|
130
|
+
klass = command.is_a?(Class) ? command : command.class
|
|
131
|
+
arguments = Flags.arguments(arguments, switches: Flags.switches(klass)) if klass.is_a?(Flags)
|
|
132
|
+
result = super
|
|
133
|
+
instance, = result
|
|
134
|
+
instance.instance_variable_set(:@ui_command_names, names) if instance.is_a?(UI)
|
|
135
|
+
result
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
CLI.prepend(Hook)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
# How a command was run, as far as naming its report and log files goes: the program, the
|
|
9
|
+
# command's names, when the process started, and where.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] executable
|
|
12
|
+
# @return [String] the program's basename, e.g. `law-cli`
|
|
13
|
+
# @!attribute [r] action
|
|
14
|
+
# @return [String, nil] the command's names joined with `-`, e.g. `generate-text`; nil for a
|
|
15
|
+
# CLI that is a single command
|
|
16
|
+
# @!attribute [r] name
|
|
17
|
+
# @return [String] what the command's logger is called, usually its class name
|
|
18
|
+
# @!attribute [r] started_at
|
|
19
|
+
# @return [Time] when the process started; stamps the report's file name
|
|
20
|
+
# @!attribute [r] directory
|
|
21
|
+
# @return [String] the directory the command runs in
|
|
22
|
+
Invocation = ::Data.define(:executable, :action, :name, :started_at, :directory) do
|
|
23
|
+
# Describes a command: by the names dry-cli resolved it by, when {Flags::Hook} recorded
|
|
24
|
+
# them, and otherwise by its class name.
|
|
25
|
+
#
|
|
26
|
+
# @param command [Object, nil] the command, or nil for none
|
|
27
|
+
# @param program [String] the program's path
|
|
28
|
+
# @param started_at [Time]
|
|
29
|
+
# @param directory [String]
|
|
30
|
+
# @return [Invocation]
|
|
31
|
+
def self.for(command = nil, program: $PROGRAM_NAME, started_at: UI.started_at, directory: Dir.pwd)
|
|
32
|
+
executable = File.basename(program)
|
|
33
|
+
class_name = command.class.name unless command.nil?
|
|
34
|
+
new(executable: executable, action: action(command, class_name), name: class_name || executable,
|
|
35
|
+
started_at: started_at, directory: directory)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The names the command was called by, else the last part of its class name, dasherized.
|
|
39
|
+
#
|
|
40
|
+
# @param command [Object, nil]
|
|
41
|
+
# @param class_name [String, nil]
|
|
42
|
+
# @return [String, nil]
|
|
43
|
+
def self.action(command, class_name)
|
|
44
|
+
names = command.instance_variable_get(:@ui_command_names)
|
|
45
|
+
return names.join("-") if names
|
|
46
|
+
return if class_name.nil?
|
|
47
|
+
|
|
48
|
+
class_name.split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
|
|
49
|
+
end
|
|
50
|
+
private_class_method :action
|
|
51
|
+
|
|
52
|
+
# The nearest directory up from `directory` that holds `.git`, else `directory` itself.
|
|
53
|
+
#
|
|
54
|
+
# @param directory [String]
|
|
55
|
+
# @return [String]
|
|
56
|
+
def self.root(directory)
|
|
57
|
+
start = Pathname(directory).expand_path
|
|
58
|
+
(start.ascend.find { it.join(".git").exist? } || start).to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The program and the action, joined with `-`: `law-cli-generate-text`.
|
|
62
|
+
#
|
|
63
|
+
# @return [String]
|
|
64
|
+
def basename
|
|
65
|
+
[executable, action].reject { it.nil? || it.empty? }.join("-")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Where `-o` writes. Creates the file's directory when it is missing.
|
|
69
|
+
#
|
|
70
|
+
# @param value [String, nil] what `-o` was given: nil without `-o`, `-` for STDOUT, an
|
|
71
|
+
# empty string for the default name, or a path
|
|
72
|
+
# @return [String, nil] the path, or nil for the command's own output
|
|
73
|
+
def report_path(value)
|
|
74
|
+
stamp = started_at.strftime("%Y-%m-%d.%H%M%S")
|
|
75
|
+
file(value, "#{basename}.#{stamp}.log") unless value.nil? || value == "-"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Where `-l` logs. Creates the file's directory when it is missing.
|
|
79
|
+
#
|
|
80
|
+
# @param value [String, nil] what `-l` was given: nil without `-l`, `-` for STDOUT, an
|
|
81
|
+
# empty string for the default name, or a path
|
|
82
|
+
# @return [String, nil] the path, `-` for STDOUT, or nil for no log
|
|
83
|
+
def log_path(value)
|
|
84
|
+
value.nil? || value == "-" ? value : file(value, "#{basename}.log")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# @param value [String] a path, or empty for the default
|
|
90
|
+
# @param default [String] the default file name, under `log/` at the repository root
|
|
91
|
+
# @return [String]
|
|
92
|
+
def file(value, default)
|
|
93
|
+
path = value.empty? ? File.join(Invocation.root(directory), "log", default) : File.expand_path(value, directory)
|
|
94
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
95
|
+
path
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "semantic_logger"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
# SemanticLogger set up from the reserved `-l`, `-L` and `--log-format` flags, for as long as
|
|
9
|
+
# a block runs. See {Console#logging}.
|
|
10
|
+
#
|
|
11
|
+
# At level `debug` it also records, for every exception raised while the block runs, the
|
|
12
|
+
# local variables of each frame on the stack where it was raised. {Console#log_exception}
|
|
13
|
+
# logs them with the exception. Recording them costs time, which is why it happens at
|
|
14
|
+
# `debug` only.
|
|
15
|
+
class Logging
|
|
16
|
+
# The levels `-L` takes.
|
|
17
|
+
LEVELS = %w[debug info warn error fatal].freeze
|
|
18
|
+
|
|
19
|
+
# Formats named differently here than in SemanticLogger.
|
|
20
|
+
FORMATS = { "standard" => :default }.freeze
|
|
21
|
+
|
|
22
|
+
# The most characters of a local variable's `inspect` kept.
|
|
23
|
+
INSPECT_LIMIT = 200
|
|
24
|
+
|
|
25
|
+
# The most frames whose local variables are kept for one exception.
|
|
26
|
+
FRAME_LIMIT = 25
|
|
27
|
+
|
|
28
|
+
# Frames in this gem's own files, which are left out of what is recorded.
|
|
29
|
+
OWN_FILES = "#{File.expand_path(__dir__)}/".freeze
|
|
30
|
+
|
|
31
|
+
# Local variables recorded for each exception, keyed by the exception.
|
|
32
|
+
LOCALS = ::ObjectSpace::WeakMap.new
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Checks a level name.
|
|
36
|
+
#
|
|
37
|
+
# @param name [String, Symbol]
|
|
38
|
+
# @return [Symbol]
|
|
39
|
+
# @raise [ArgumentError] when it is not one of {LEVELS}
|
|
40
|
+
def level(name)
|
|
41
|
+
return name.to_sym if LEVELS.include?(name.to_s)
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "unknown log level #{name.to_s.inspect}, expected one of #{LEVELS.join(', ')}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Builds a SemanticLogger formatter from its name: `standard` for SemanticLogger's default,
|
|
47
|
+
# one line per entry, or the name of any formatter SemanticLogger has, such as `json`,
|
|
48
|
+
# `color` or `logfmt`.
|
|
49
|
+
#
|
|
50
|
+
# @param name [String, Symbol]
|
|
51
|
+
# @return [#call] the formatter
|
|
52
|
+
# @raise [ArgumentError] when SemanticLogger has no formatter of that name
|
|
53
|
+
def formatter(name)
|
|
54
|
+
SemanticLogger::Formatters.factory(FORMATS.fetch(name.to_s) { name.to_s.to_sym })
|
|
55
|
+
rescue ArgumentError
|
|
56
|
+
raise ArgumentError, "unknown log format #{name.to_s.inspect}, expected one of #{formats.join(', ')}", cause: nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Array<String>] the names {.formatter} takes
|
|
60
|
+
def formats
|
|
61
|
+
names = (SemanticLogger::Formatters.constants - [:Base]).map do |constant|
|
|
62
|
+
constant.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
63
|
+
end
|
|
64
|
+
(FORMATS.keys + names - FORMATS.values.map(&:to_s)).sort
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A SemanticLogger logger.
|
|
68
|
+
#
|
|
69
|
+
# @param name [String]
|
|
70
|
+
# @return [SemanticLogger::Logger]
|
|
71
|
+
def logger(name)
|
|
72
|
+
SemanticLogger[name]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The local variables recorded for an exception raised while logging at `debug`.
|
|
76
|
+
#
|
|
77
|
+
# @param exception [Exception]
|
|
78
|
+
# @return [Array<Hash{Symbol => Object}>, nil] one entry per frame, innermost first, each
|
|
79
|
+
# with its `frame` and `locals`; nil when nothing was recorded
|
|
80
|
+
def locals(exception)
|
|
81
|
+
LOCALS[exception]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Records the local variables of the frames on the stack, for an exception being raised.
|
|
85
|
+
# Keeps what was recorded where the exception was first raised.
|
|
86
|
+
#
|
|
87
|
+
# @param exception [Exception]
|
|
88
|
+
# @return [void]
|
|
89
|
+
def record(exception)
|
|
90
|
+
return if LOCALS.key?(exception) || Thread.current[:dry_cli_ui_recording]
|
|
91
|
+
|
|
92
|
+
Thread.current[:dry_cli_ui_recording] = true
|
|
93
|
+
begin
|
|
94
|
+
LOCALS[exception] = frames(binding.callers)
|
|
95
|
+
ensure
|
|
96
|
+
Thread.current[:dry_cli_ui_recording] = nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# @param bindings [Array<Binding>] the stack, innermost first
|
|
103
|
+
# @return [Array<Hash{Symbol => Object}>]
|
|
104
|
+
def frames(bindings)
|
|
105
|
+
bindings.reject { it.source_location.first.start_with?(OWN_FILES) }.filter_map do |frame|
|
|
106
|
+
names = frame.local_variables
|
|
107
|
+
next if names.empty?
|
|
108
|
+
|
|
109
|
+
{ frame: frame.source_location.join(":"), locals: names.to_h { [it, describe(frame.local_variable_get(it))] } }
|
|
110
|
+
end.first(FRAME_LIMIT)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param value [Object]
|
|
114
|
+
# @return [String] its `inspect`, cut to {INSPECT_LIMIT} characters
|
|
115
|
+
def describe(value)
|
|
116
|
+
text = value.inspect
|
|
117
|
+
text.length > INSPECT_LIMIT ? "#{text[0, INSPECT_LIMIT]}..." : text
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
"#<#{e.class} from inspect>"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param target [String, nil] a file, `-` for `io`, or nil for no log
|
|
124
|
+
# @param io [IO] where `-` logs
|
|
125
|
+
# @param level [String, Symbol] one of {LEVELS}
|
|
126
|
+
# @param format [String, Symbol] see {.formatter}
|
|
127
|
+
# @raise [ArgumentError] for an unknown level or format
|
|
128
|
+
def initialize(target, io:, level:, format:)
|
|
129
|
+
@target = target
|
|
130
|
+
@io = io
|
|
131
|
+
@level = Logging.level(level)
|
|
132
|
+
@formatter = Logging.formatter(format)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Adds the appender, sets the level, and runs the block. Afterwards flushes and removes the
|
|
136
|
+
# appender and puts the level back.
|
|
137
|
+
#
|
|
138
|
+
# @yield
|
|
139
|
+
# @return [Object] whatever the block returns
|
|
140
|
+
def run(&)
|
|
141
|
+
return yield if target.nil?
|
|
142
|
+
|
|
143
|
+
start
|
|
144
|
+
yield
|
|
145
|
+
ensure
|
|
146
|
+
stop
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# @return [String, nil]
|
|
152
|
+
attr_reader :target
|
|
153
|
+
|
|
154
|
+
# @return [IO]
|
|
155
|
+
attr_reader :io
|
|
156
|
+
|
|
157
|
+
# @return [Symbol]
|
|
158
|
+
attr_reader :level
|
|
159
|
+
|
|
160
|
+
# @return [#call]
|
|
161
|
+
attr_reader :formatter
|
|
162
|
+
|
|
163
|
+
# @return [void]
|
|
164
|
+
def start
|
|
165
|
+
destination = target == "-" ? { io: io } : { file_name: target }
|
|
166
|
+
@appender = SemanticLogger.add_appender(**destination, formatter: formatter)
|
|
167
|
+
@previous_level = SemanticLogger.default_level
|
|
168
|
+
SemanticLogger.default_level = level
|
|
169
|
+
trace if level == :debug
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @return [void]
|
|
173
|
+
def trace
|
|
174
|
+
require "binding_of_caller"
|
|
175
|
+
@trace = TracePoint.new(:raise) { |point| Logging.record(point.raised_exception) }
|
|
176
|
+
@trace.enable
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @return [void]
|
|
180
|
+
def stop
|
|
181
|
+
return unless @appender
|
|
182
|
+
|
|
183
|
+
@trace&.disable
|
|
184
|
+
SemanticLogger.flush
|
|
185
|
+
SemanticLogger.remove_appender(@appender)
|
|
186
|
+
SemanticLogger.default_level = @previous_level
|
|
187
|
+
@appender = nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
# A file a command writes its report to, through `-o`. It behaves as the file it wraps, and
|
|
9
|
+
# answers `color?` with true, so a {Terminal} on it writes colour although it is no TTY.
|
|
10
|
+
class Report < ::SimpleDelegator
|
|
11
|
+
# @return [Boolean] always true: a report is written in colour
|
|
12
|
+
def color? = true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
# The part of {Console} behind the reserved `-o`, `-l`, `-L` and `--log-format` flags (see
|
|
7
|
+
# {Flags}): where a command's report goes, and how it logs.
|
|
8
|
+
module Reporting
|
|
9
|
+
# Sends what the block writes to `out` to the file `-o` named. Tables, boxes and statuses at
|
|
10
|
+
# `info` and `success` land there, in colour; spinners, bars, prompts and warnings stay on
|
|
11
|
+
# `err`. The block is given the IO the report goes to, for writing to it directly.
|
|
12
|
+
#
|
|
13
|
+
# The file's last line says when it was closed, and a line on `err` says where it is.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# ui.output(output) do |io|
|
|
17
|
+
# ui.table(rows, header: %w[Rule Count])
|
|
18
|
+
# io.puts "#{rows.size} rules"
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @param value [String, nil] what `-o` was given: nil without `-o` and `-` for STDOUT, both
|
|
22
|
+
# meaning the command's own `out`; an empty string for
|
|
23
|
+
# `log/<executable>-<action>.<YYYY-MM-DD>.<HHMMSS>.log` at the repository root, stamped
|
|
24
|
+
# with the time the process started; or a path
|
|
25
|
+
# @yieldparam io [IO] where the report goes
|
|
26
|
+
# @return [Object] whatever the block returns
|
|
27
|
+
# @raise [ArgumentError] without a block
|
|
28
|
+
def output(value = nil, &)
|
|
29
|
+
raise ArgumentError, "output needs a block" unless block_given?
|
|
30
|
+
|
|
31
|
+
path = invocation.report_path(value)
|
|
32
|
+
return yield(out.io) if path.nil?
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
File.open(path, "w") { |file| write_report(Report.new(file), &) }
|
|
36
|
+
ensure
|
|
37
|
+
notice = "Report written to #{Pathname(path).relative_path_from(invocation.directory)}"
|
|
38
|
+
err.puts(Widgets::Status.line(err, Theme.level(:info), notice))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Logs through SemanticLogger to the file `-l` named while the block runs, then flushes and
|
|
43
|
+
# removes the appender. At level `debug` it records the local variables of every frame an
|
|
44
|
+
# exception is raised through, for {#log_exception}.
|
|
45
|
+
#
|
|
46
|
+
# @example
|
|
47
|
+
# ui.logging(log, level: log_level, format: log_format) do
|
|
48
|
+
# ui.logger.info("Importing", count: rules.size)
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# @param value [String, nil] what `-l` was given: nil for no log; `-` for the command's own
|
|
52
|
+
# `out`; an empty string for `log/<executable>-<action>.log` at the repository root; or a
|
|
53
|
+
# path
|
|
54
|
+
# @param level [String, Symbol] `debug`, `info`, `warn`, `error` or `fatal`
|
|
55
|
+
# @param format [String, Symbol] `standard`, `json`, or another SemanticLogger formatter
|
|
56
|
+
# @return [Object] whatever the block returns
|
|
57
|
+
# @raise [ArgumentError] without a block, or for an unknown level or format
|
|
58
|
+
def logging(value, level: "info", format: "standard", &)
|
|
59
|
+
raise ArgumentError, "logging needs a block" unless block_given?
|
|
60
|
+
|
|
61
|
+
Logging.new(invocation.log_path(value), io: out.io, level: level, format: format).run(&)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Applies the reserved flags a command was called with: {#logging} around {#output}. Takes
|
|
65
|
+
# the whole options hash `call` receives and ignores what it does not use.
|
|
66
|
+
#
|
|
67
|
+
# @example
|
|
68
|
+
# def call(**options)
|
|
69
|
+
# ui.with_flags(**options) { |io| ui.table(rows) }
|
|
70
|
+
# end
|
|
71
|
+
#
|
|
72
|
+
# @param output [String, nil] see {#output}
|
|
73
|
+
# @param log [String, nil] see {#logging}
|
|
74
|
+
# @param log_level [String, Symbol]
|
|
75
|
+
# @param log_format [String, Symbol]
|
|
76
|
+
# @yieldparam io [IO] where the report goes
|
|
77
|
+
# @return [Object] whatever the block returns
|
|
78
|
+
# @raise [ArgumentError] without a block, or for an unknown level or format
|
|
79
|
+
def with_flags(output: nil, log: nil, log_level: "info", log_format: "standard", **, &)
|
|
80
|
+
raise ArgumentError, "with_flags needs a block" unless block_given?
|
|
81
|
+
|
|
82
|
+
logging(log, level: log_level, format: log_format) { self.output(output, &) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The command's SemanticLogger logger, named after the command's class. It writes nothing
|
|
86
|
+
# until {#logging} adds an appender.
|
|
87
|
+
#
|
|
88
|
+
# @return [SemanticLogger::Logger]
|
|
89
|
+
def logger
|
|
90
|
+
@logger ||= Logging.logger(invocation.name)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Logs an exception with its backtrace and, when it was raised while logging at `debug`,
|
|
94
|
+
# the local variables of each frame it was raised through, as the payload's `locals`.
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# rescue => e
|
|
98
|
+
# ui.log_exception(e, "Import failed")
|
|
99
|
+
#
|
|
100
|
+
# @param exception [Exception]
|
|
101
|
+
# @param message [String, nil] the exception's message by default
|
|
102
|
+
# @param level [String, Symbol]
|
|
103
|
+
# @return [nil]
|
|
104
|
+
# @raise [ArgumentError] for an unknown level
|
|
105
|
+
def log_exception(exception, message = nil, level: :error)
|
|
106
|
+
locals = Logging.locals(exception)
|
|
107
|
+
logger.public_send(Logging.level(level), message || exception.message, locals && { locals: locals }, exception)
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# @return [Invocation] how the command was run; describes no command when none was given
|
|
114
|
+
def invocation
|
|
115
|
+
@invocation ||= Invocation.for
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Points `out` at a report while the block runs, then ends the report with the time.
|
|
119
|
+
#
|
|
120
|
+
# @param report [Report]
|
|
121
|
+
# @yieldparam report [Report]
|
|
122
|
+
# @return [Object] whatever the block returns
|
|
123
|
+
def write_report(report)
|
|
124
|
+
previous = @out
|
|
125
|
+
@out = previous.to(report)
|
|
126
|
+
yield report
|
|
127
|
+
ensure
|
|
128
|
+
@out = previous
|
|
129
|
+
report.puts("Closed at #{Time.now.strftime('%Y-%m-%d %H:%M:%S %z')}")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
data/lib/dry/cli/ui/terminal.rb
CHANGED
|
@@ -89,13 +89,30 @@ module Dry
|
|
|
89
89
|
tty? && env["TERM"] != "dumb"
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
# Whether to emit ANSI colour codes
|
|
92
|
+
# Whether to emit ANSI colour codes: on an animated terminal, or on a stream that asks for
|
|
93
|
+
# colour by answering `color?` with true, such as a {Report}; never under `NO_COLOR`.
|
|
93
94
|
#
|
|
94
95
|
# @return [Boolean]
|
|
95
96
|
def color?
|
|
96
97
|
return color unless color.nil?
|
|
97
98
|
|
|
98
|
-
animated? && env["NO_COLOR"].to_s.empty?
|
|
99
|
+
(animated? || (io.respond_to?(:color?) && io.color?)) && env["NO_COLOR"].to_s.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A terminal like this one, writing to another stream, and never animated: what a
|
|
103
|
+
# command's report goes to.
|
|
104
|
+
#
|
|
105
|
+
# @param io [IO]
|
|
106
|
+
# @return [Terminal]
|
|
107
|
+
def to(io)
|
|
108
|
+
Terminal.new(io, env: env, color: color, animate: false, width: @width)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Leaves out the environment, which can hold secrets, from what a debug log records.
|
|
112
|
+
#
|
|
113
|
+
# @return [String]
|
|
114
|
+
def inspect
|
|
115
|
+
"#<#{self.class} io=#{io.inspect}>"
|
|
99
116
|
end
|
|
100
117
|
|
|
101
118
|
# @return [Integer] columns available
|
data/lib/dry/cli/ui/version.rb
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
module Widgets
|
|
7
|
+
# The line that says what each colour of a progress bar means:
|
|
8
|
+
#
|
|
9
|
+
# Color Mapping: [ red: errors and invalid files | yellow: relevant but auxiliary | green: forms ]
|
|
10
|
+
#
|
|
11
|
+
# With colour, each label is drawn on its colour's background instead
|
|
12
|
+
# of naming it. The colours are the configured ones:
|
|
13
|
+
# {Configuration#bar_failed_color}, {Configuration#bar_aux_color} and
|
|
14
|
+
# {Configuration#bar_color}.
|
|
15
|
+
module Legend
|
|
16
|
+
# Each label's keyword, and the setting its colour comes from, in
|
|
17
|
+
# the order a bar draws them.
|
|
18
|
+
SETTINGS = { failed: :bar_failed_color, aux: :bar_aux_color, ok: :bar_color }.freeze
|
|
19
|
+
|
|
20
|
+
# @param terminal [Terminal]
|
|
21
|
+
# @param config [Configuration]
|
|
22
|
+
# @param labels [Hash{Symbol => #to_s, nil}] keyed as {SETTINGS};
|
|
23
|
+
# a nil label is left out
|
|
24
|
+
# @return [String] the line, without a newline
|
|
25
|
+
# @raise [ArgumentError] when every label is nil
|
|
26
|
+
def self.line(terminal, config, labels)
|
|
27
|
+
entries = SETTINGS.filter_map { |key, setting| [labels[key].to_s, config.public_send(setting)] unless labels[key].nil? }
|
|
28
|
+
raise ArgumentError, "legend needs at least one of #{SETTINGS.keys.map { "#{it}:" }.join(', ')}" if entries.empty?
|
|
29
|
+
|
|
30
|
+
shown = entries.map { |label, style| terminal.color? ? swatch(terminal.pastel, label, style) : "#{style || 'plain'}: #{label}" }
|
|
31
|
+
"Color Mapping: [ #{shown.join(' | ')} ]"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param pastel [Pastel::Delegator]
|
|
35
|
+
# @param label [String]
|
|
36
|
+
# @param style [Symbol, nil]
|
|
37
|
+
# @return [String] the label, padded, in black on the style's
|
|
38
|
+
# background; on the style itself when it has no background form
|
|
39
|
+
def self.swatch(pastel, label, style)
|
|
40
|
+
background = :"on_#{style}"
|
|
41
|
+
styles = Configuration::STYLES.include?(background) ? [:black, background] : [style].compact
|
|
42
|
+
pastel.decorate(" #{label} ", *styles)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|