This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2019, by Bryan Powell.
|
||||
# Copyright, 2020, by Michael Adams.
|
||||
# Copyright, 2021, by Cédric Boutillier.
|
||||
|
||||
require_relative "console/version"
|
||||
require_relative "console/interface"
|
||||
|
||||
module Console
|
||||
Console.extend(Interface)
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2023-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
# This namespace is reserved for logging adapters provided by other gems.
|
||||
module Adapter
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require_relative "filter"
|
||||
require_relative "output/failure"
|
||||
|
||||
module Console
|
||||
# A general sink which captures all events into a buffer.
|
||||
class Capture
|
||||
def initialize
|
||||
@records = []
|
||||
@verbose = false
|
||||
end
|
||||
|
||||
attr :records
|
||||
|
||||
# @deprecated Use {#records} instead of {#buffer}.
|
||||
alias buffer records
|
||||
|
||||
alias to_a records
|
||||
|
||||
attr :verbose
|
||||
|
||||
def include?(pattern)
|
||||
@records.any? do |record|
|
||||
record[:subject].to_s&.match?(pattern) or record[:message].to_s&.match?(pattern)
|
||||
end
|
||||
end
|
||||
|
||||
def each(&block)
|
||||
@records.each(&block)
|
||||
end
|
||||
|
||||
include Enumerable
|
||||
|
||||
def first
|
||||
@records.first
|
||||
end
|
||||
|
||||
def last
|
||||
@records.last
|
||||
end
|
||||
|
||||
def clear
|
||||
@records.clear
|
||||
end
|
||||
|
||||
def empty?
|
||||
@records.empty?
|
||||
end
|
||||
|
||||
def verbose!(value = true)
|
||||
@verbose = value
|
||||
end
|
||||
|
||||
def verbose?
|
||||
@verbose
|
||||
end
|
||||
|
||||
def call(subject = nil, *arguments, severity: UNKNOWN, event: nil, **options, &block)
|
||||
record = {
|
||||
time: ::Time.now.iso8601,
|
||||
severity: severity,
|
||||
**options,
|
||||
}
|
||||
|
||||
if subject
|
||||
record[:subject] = subject
|
||||
end
|
||||
|
||||
if event
|
||||
record[:event] = event.to_hash
|
||||
end
|
||||
|
||||
if arguments.any?
|
||||
record[:arguments] = arguments
|
||||
end
|
||||
|
||||
if annotation = Fiber.current.annotation
|
||||
record[:annotation] = annotation
|
||||
end
|
||||
|
||||
if block_given?
|
||||
if block.arity.zero?
|
||||
record[:message] = yield
|
||||
else
|
||||
buffer = StringIO.new
|
||||
yield buffer
|
||||
record[:message] = buffer.string
|
||||
end
|
||||
else
|
||||
record[:message] = arguments.join(" ")
|
||||
end
|
||||
|
||||
@records << record
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2022, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Clock
|
||||
def self.formatted_duration(duration)
|
||||
if duration < 60.0
|
||||
return "#{duration.round(2)}s"
|
||||
end
|
||||
|
||||
duration /= 60.0
|
||||
|
||||
if duration < 60.0
|
||||
return "#{duration.floor}m"
|
||||
end
|
||||
|
||||
duration /= 60.0
|
||||
|
||||
if duration < 24.0
|
||||
return "#{duration.floor}h"
|
||||
end
|
||||
|
||||
duration /= 24.0
|
||||
|
||||
return "#{duration.floor}d"
|
||||
end
|
||||
|
||||
# Get the current elapsed monotonic time.
|
||||
def self.now
|
||||
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2022-2024, by Samuel Williams.
|
||||
|
||||
require "logger"
|
||||
|
||||
module Console
|
||||
module Compatible
|
||||
# A compatible interface for {::Logger} which can be used with {Console}.
|
||||
class Logger < ::Logger
|
||||
class LogDevice
|
||||
def initialize(subject, output)
|
||||
@subject = subject
|
||||
@output = output
|
||||
end
|
||||
|
||||
def write(message)
|
||||
@output.call(@subject, message)
|
||||
end
|
||||
|
||||
def call(*arguments, **options)
|
||||
@output.call(*arguments, **options)
|
||||
end
|
||||
|
||||
def reopen
|
||||
end
|
||||
|
||||
def close
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(subject, output = Console)
|
||||
super(nil)
|
||||
|
||||
@progname = subject
|
||||
@logdev = LogDevice.new(subject, output)
|
||||
end
|
||||
|
||||
def add(severity, message = nil, progname = nil)
|
||||
severity ||= UNKNOWN
|
||||
|
||||
if @logdev.nil? or severity < level
|
||||
return true
|
||||
end
|
||||
|
||||
if progname.nil?
|
||||
progname = @progname
|
||||
end
|
||||
|
||||
if message.nil?
|
||||
if block_given?
|
||||
message = yield
|
||||
else
|
||||
message = progname
|
||||
progname = @progname
|
||||
end
|
||||
end
|
||||
|
||||
@logdev.call(
|
||||
progname, message,
|
||||
severity: format_severity(severity)
|
||||
)
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
def format_severity(value)
|
||||
super.downcase.to_sym
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require_relative "event/spawn"
|
||||
require_relative "event/failure"
|
||||
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2021, by Robert Schulze.
|
||||
# Copyright, 2024, by Patrik Wenger.
|
||||
|
||||
require_relative "generic"
|
||||
|
||||
module Console
|
||||
module Event
|
||||
# Represents a failure event.
|
||||
#
|
||||
# ```ruby
|
||||
# Console::Event::Failure.for(exception).emit(self)
|
||||
# ```
|
||||
class Failure < Generic
|
||||
def self.default_root
|
||||
Dir.getwd
|
||||
rescue # e.g. Errno::EMFILE
|
||||
nil
|
||||
end
|
||||
|
||||
def self.for(exception)
|
||||
self.new(exception, self.default_root)
|
||||
end
|
||||
|
||||
def self.log(subject, exception, **options)
|
||||
Console.error(subject, **self.for(exception).to_hash, **options)
|
||||
end
|
||||
|
||||
attr_reader :exception
|
||||
|
||||
def initialize(exception, root = Dir.getwd)
|
||||
@exception = exception
|
||||
@root = root
|
||||
end
|
||||
|
||||
def to_hash
|
||||
Hash.new.tap do |hash|
|
||||
hash[:type] = :failure
|
||||
hash[:root] = @root if @root
|
||||
extract(@exception, hash)
|
||||
end
|
||||
end
|
||||
|
||||
def emit(*arguments, **options)
|
||||
options[:severity] ||= :error
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract(exception, hash)
|
||||
hash[:class] = exception.class.name
|
||||
|
||||
if exception.respond_to?(:detailed_message)
|
||||
message = exception.detailed_message
|
||||
|
||||
# We want to remove the trailling exception class as we format it differently:
|
||||
message.sub!(/\s*\(.*?\)$/, "")
|
||||
|
||||
hash[:message] = message
|
||||
else
|
||||
hash[:message] = exception.message
|
||||
end
|
||||
|
||||
hash[:backtrace] = exception.backtrace
|
||||
|
||||
if cause = exception.cause
|
||||
hash[:cause] = Hash.new.tap do |cause_hash|
|
||||
extract(cause, cause_hash)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Event
|
||||
class Generic
|
||||
def as_json(...)
|
||||
to_hash
|
||||
end
|
||||
|
||||
def to_json(...)
|
||||
JSON.generate(as_json, ...)
|
||||
end
|
||||
|
||||
def to_s
|
||||
to_json
|
||||
end
|
||||
|
||||
def emit(*arguments, **options)
|
||||
Console.call(*arguments, event: self, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require_relative "generic"
|
||||
require_relative "../clock"
|
||||
|
||||
module Console
|
||||
module Event
|
||||
# Represents a spawn event.
|
||||
#
|
||||
# ```ruby
|
||||
# Console.info(self, **Console::Event::Spawn.for("ls", "-l"))
|
||||
#
|
||||
# event = Console::Event::Spawn.for("ls", "-l")
|
||||
# event.status = Process.wait
|
||||
# ```
|
||||
class Spawn < Generic
|
||||
def self.for(*arguments, **options)
|
||||
# Extract out the command environment:
|
||||
if arguments.first.is_a?(Hash)
|
||||
environment = arguments.shift
|
||||
self.new(environment, arguments, options)
|
||||
else
|
||||
self.new(nil, arguments, options)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(environment, arguments, options)
|
||||
@environment = environment
|
||||
@arguments = arguments
|
||||
@options = options
|
||||
|
||||
@start_time = Clock.now
|
||||
|
||||
@end_time = nil
|
||||
@status = nil
|
||||
end
|
||||
|
||||
def duration
|
||||
if @end_time
|
||||
@end_time - @start_time
|
||||
end
|
||||
end
|
||||
|
||||
def to_hash
|
||||
Hash.new.tap do |hash|
|
||||
hash[:type] = :spawn
|
||||
hash[:environment] = @environment if @environment&.any?
|
||||
hash[:arguments] = @arguments if @arguments&.any?
|
||||
hash[:options] = @options if @options&.any?
|
||||
|
||||
hash[:status] = @status.to_i if @status
|
||||
|
||||
if duration = self.duration
|
||||
hash[:duration] = duration
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def emit(*arguments, **options)
|
||||
options[:severity] ||= :info
|
||||
super
|
||||
end
|
||||
|
||||
def status=(status)
|
||||
@end_time = Time.now
|
||||
@status = status
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,159 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2019, by Bryan Powell.
|
||||
# Copyright, 2020, by Michael Adams.
|
||||
# Copyright, 2021, by Robert Schulze.
|
||||
|
||||
module Console
|
||||
UNKNOWN = :unknown
|
||||
|
||||
class Filter
|
||||
if Object.const_defined?(:Ractor) and RUBY_VERSION >= "3.1"
|
||||
def self.define_immutable_method(name, &block)
|
||||
block = Ractor.make_shareable(block)
|
||||
self.define_method(name, &block)
|
||||
end
|
||||
else
|
||||
def self.define_immutable_method(name, &block)
|
||||
define_method(name, &block)
|
||||
end
|
||||
end
|
||||
|
||||
def self.[] **levels
|
||||
klass = Class.new(self)
|
||||
minimum_level, maximum_level = levels.values.minmax
|
||||
|
||||
klass.instance_exec do
|
||||
const_set(:LEVELS, levels.freeze)
|
||||
const_set(:MINIMUM_LEVEL, minimum_level)
|
||||
const_set(:MAXIMUM_LEVEL, maximum_level)
|
||||
|
||||
levels.each do |name, level|
|
||||
const_set(name.to_s.upcase, level)
|
||||
|
||||
define_immutable_method(name) do |subject = nil, *arguments, **options, &block|
|
||||
if self.enabled?(subject, level)
|
||||
@output.call(subject, *arguments, severity: name, **@options, **options, &block)
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
define_immutable_method("#{name}!") do
|
||||
@level = level
|
||||
end
|
||||
|
||||
define_immutable_method("#{name}?") do
|
||||
@level <= level
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return klass
|
||||
end
|
||||
|
||||
def initialize(output, verbose: true, level: self.class::DEFAULT_LEVEL, **options)
|
||||
@output = output
|
||||
@verbose = verbose
|
||||
@level = level
|
||||
|
||||
@subjects = {}
|
||||
|
||||
@options = options
|
||||
end
|
||||
|
||||
def with(level: @level, verbose: @verbose, **options)
|
||||
dup.tap do |logger|
|
||||
logger.level = level
|
||||
logger.verbose! if verbose
|
||||
logger.options = @options.merge(options)
|
||||
end
|
||||
end
|
||||
|
||||
attr_accessor :output
|
||||
attr :verbose
|
||||
attr :level
|
||||
|
||||
attr :subjects
|
||||
|
||||
attr_accessor :options
|
||||
|
||||
def level= level
|
||||
if level.is_a? Symbol
|
||||
@level = self.class::LEVELS[level]
|
||||
else
|
||||
@level = level
|
||||
end
|
||||
end
|
||||
|
||||
def verbose!(value = true)
|
||||
@verbose = value
|
||||
@output.verbose!(value)
|
||||
end
|
||||
|
||||
def off!
|
||||
@level = self.class::MAXIMUM_LEVEL + 1
|
||||
end
|
||||
|
||||
def all!
|
||||
@level = self.class::MINIMUM_LEVEL - 1
|
||||
end
|
||||
|
||||
def filter(subject, level)
|
||||
unless subject.is_a?(Module)
|
||||
raise ArgumentError, "Expected a class, got #{subject.inspect}"
|
||||
end
|
||||
|
||||
@subjects[subject] = level
|
||||
end
|
||||
|
||||
# You can enable and disable logging for classes. This function checks if logging for a given subject is enabled.
|
||||
# @param subject [Object] the subject to check.
|
||||
def enabled?(subject, level = self.class::MINIMUM_LEVEL)
|
||||
subject = subject.class unless subject.is_a?(Module)
|
||||
|
||||
if specific_level = @subjects[subject]
|
||||
return level >= specific_level
|
||||
end
|
||||
|
||||
if level >= @level
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
# Enable specific log level for the given class.
|
||||
# @parameter name [Module] The class to enable.
|
||||
def enable(subject, level = self.class::MINIMUM_LEVEL)
|
||||
# Set the filter level of logging for a given subject which passes all log messages:
|
||||
filter(subject, level)
|
||||
end
|
||||
|
||||
def disable(subject)
|
||||
# Set the filter level of the logging for a given subject which filters all log messages:
|
||||
filter(subject, self.class::MAXIMUM_LEVEL + 1)
|
||||
end
|
||||
|
||||
# Clear any specific filters for the given class.
|
||||
# @parameter name [Module] The class to disable.
|
||||
def clear(subject)
|
||||
unless subject.is_a?(Module)
|
||||
raise ArgumentError, "Expected a class, got #{subject.inspect}"
|
||||
end
|
||||
|
||||
@subjects.delete(subject)
|
||||
end
|
||||
|
||||
def call(subject, *arguments, **options, &block)
|
||||
severity = options[:severity] || UNKNOWN
|
||||
level = self.class::LEVELS[severity]
|
||||
|
||||
if self.enabled?(subject, level)
|
||||
@output.call(subject, *arguments, **options, &block)
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2023-2024, by Samuel Williams.
|
||||
|
||||
require_relative "format/safe"
|
||||
|
||||
module Console
|
||||
module Format
|
||||
def self.default
|
||||
Safe.new(format: ::JSON)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,136 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2023-2024, by Samuel Williams.
|
||||
|
||||
require "json"
|
||||
|
||||
module Console
|
||||
module Format
|
||||
# This class is used to safely dump objects.
|
||||
# It will attempt to dump the object using the given format, but if it fails, it will generate a safe version of the object.
|
||||
class Safe
|
||||
def initialize(format: ::JSON, limit: 8, encoding: ::Encoding::UTF_8)
|
||||
@format = format
|
||||
@limit = limit
|
||||
@encoding = encoding
|
||||
end
|
||||
|
||||
def dump(object)
|
||||
@format.dump(object, @limit)
|
||||
rescue SystemStackError, StandardError => error
|
||||
@format.dump(safe_dump(object, error))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_backtrace(error)
|
||||
frames = error.backtrace
|
||||
filtered = {}
|
||||
filtered_count = nil
|
||||
skipped = nil
|
||||
|
||||
frames = frames.filter_map do |frame|
|
||||
if filtered[frame]
|
||||
if filtered_count == nil
|
||||
filtered_count = 1
|
||||
skipped = frame.dup
|
||||
else
|
||||
filtered_count += 1
|
||||
nil
|
||||
end
|
||||
else
|
||||
if skipped
|
||||
if filtered_count > 1
|
||||
skipped.replace("[... #{filtered_count} frames skipped ...]")
|
||||
end
|
||||
|
||||
filtered_count = nil
|
||||
skipped = nil
|
||||
end
|
||||
|
||||
filtered[frame] = true
|
||||
frame
|
||||
end
|
||||
end
|
||||
|
||||
if skipped && filtered_count > 1
|
||||
skipped.replace("[... #{filtered_count} frames skipped ...]")
|
||||
end
|
||||
|
||||
return frames
|
||||
end
|
||||
|
||||
def safe_dump(object, error)
|
||||
object = safe_dump_recurse(object)
|
||||
|
||||
object[:truncated] = true
|
||||
object[:error] = {
|
||||
class: safe_dump_recurse(error.class.name),
|
||||
message: safe_dump_recurse(error.message),
|
||||
backtrace: safe_dump_recurse(filter_backtrace(error)),
|
||||
}
|
||||
|
||||
return object
|
||||
end
|
||||
|
||||
def replacement_for(object)
|
||||
case object
|
||||
when Array
|
||||
"[...]"
|
||||
when Hash
|
||||
"{...}"
|
||||
else
|
||||
"..."
|
||||
end
|
||||
end
|
||||
|
||||
def default_objects
|
||||
Hash.new.compare_by_identity
|
||||
end
|
||||
|
||||
# This will recursively generate a safe version of the object.
|
||||
# Nested hashes and arrays will be transformed recursively.
|
||||
# Strings will be encoded with the given encoding.
|
||||
# Primitive values will be returned as-is.
|
||||
# Other values will be converted using `as_json` if available, otherwise `to_s`.
|
||||
def safe_dump_recurse(object, limit = @limit, objects = default_objects)
|
||||
if limit <= 0 || objects[object]
|
||||
return replacement_for(object)
|
||||
end
|
||||
|
||||
case object
|
||||
when Hash
|
||||
objects[object] = true
|
||||
|
||||
object.to_h do |key, value|
|
||||
[
|
||||
String(key).encode(@encoding, invalid: :replace, undef: :replace),
|
||||
safe_dump_recurse(value, limit - 1, objects)
|
||||
]
|
||||
end
|
||||
when Array
|
||||
objects[object] = true
|
||||
|
||||
object.map do |value|
|
||||
safe_dump_recurse(value, limit - 1, objects)
|
||||
end
|
||||
when String
|
||||
object.encode(@encoding, invalid: :replace, undef: :replace)
|
||||
when Numeric, TrueClass, FalseClass, NilClass
|
||||
object
|
||||
else
|
||||
objects[object] = true
|
||||
|
||||
# We could do something like this but the chance `as_json` will blow up.
|
||||
# We'd need to be extremely careful about it.
|
||||
# if object.respond_to?(:as_json)
|
||||
# safe_dump_recurse(object.as_json, limit - 1, objects)
|
||||
# else
|
||||
|
||||
safe_dump_recurse(object.to_s, limit - 1, objects)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2024-2025, by Samuel Williams.
|
||||
|
||||
require_relative "logger"
|
||||
|
||||
module Console
|
||||
# The public logger interface.
|
||||
module Interface
|
||||
# Get the current logger instance.
|
||||
def logger
|
||||
Logger.instance
|
||||
end
|
||||
|
||||
# Set the current logger instance.
|
||||
#
|
||||
# The current logger instance is assigned per-fiber.
|
||||
def logger= instance
|
||||
Logger.instance= instance
|
||||
end
|
||||
|
||||
# Emit a debug log message.
|
||||
def debug(...)
|
||||
Logger.instance.debug(...)
|
||||
end
|
||||
|
||||
# Emit an informational log message.
|
||||
def info(...)
|
||||
Logger.instance.info(...)
|
||||
end
|
||||
|
||||
# Emit a warning log message.
|
||||
def warn(...)
|
||||
Logger.instance.warn(...)
|
||||
end
|
||||
|
||||
# Emit an error log message.
|
||||
def error(...)
|
||||
Logger.instance.error(...)
|
||||
end
|
||||
|
||||
# Emit a fatal log message.
|
||||
def fatal(...)
|
||||
Logger.instance.fatal(...)
|
||||
end
|
||||
|
||||
# Emit a log message with arbitrary arguments and options.
|
||||
def call(...)
|
||||
Logger.instance.call(...)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2021, by Bryan Powell.
|
||||
# Copyright, 2021, by Robert Schulze.
|
||||
|
||||
require_relative "output"
|
||||
require_relative "output/failure"
|
||||
|
||||
require_relative "filter"
|
||||
require_relative "event"
|
||||
require_relative "resolver"
|
||||
require_relative "progress"
|
||||
|
||||
require "fiber/local"
|
||||
|
||||
module Console
|
||||
class Logger < Filter[debug: 0, info: 1, warn: 2, error: 3, fatal: 4]
|
||||
extend Fiber::Local
|
||||
|
||||
# Set the default log level based on `$DEBUG` and `$VERBOSE`.
|
||||
# You can also specify CONSOLE_LEVEL=debug or CONSOLE_LEVEL=info in environment.
|
||||
# https://mislav.net/2011/06/ruby-verbose-mode/ has more details about how it all fits together.
|
||||
def self.default_log_level(env = ENV)
|
||||
if level = env["CONSOLE_LEVEL"]
|
||||
LEVELS[level.to_sym] || level.to_i
|
||||
elsif $DEBUG
|
||||
DEBUG
|
||||
elsif $VERBOSE.nil?
|
||||
WARN
|
||||
else
|
||||
INFO
|
||||
end
|
||||
end
|
||||
|
||||
# Controls verbose output using `$VERBOSE`.
|
||||
def self.verbose?(env = ENV)
|
||||
!$VERBOSE.nil? || env["CONSOLE_VERBOSE"]
|
||||
end
|
||||
|
||||
def self.default_logger(output = $stderr, env = ENV, **options)
|
||||
if options[:verbose].nil?
|
||||
options[:verbose] = self.verbose?(env)
|
||||
end
|
||||
|
||||
if options[:level].nil?
|
||||
options[:level] = self.default_log_level(env)
|
||||
end
|
||||
|
||||
output = Output.new(output, env, **options)
|
||||
|
||||
logger = self.new(output, **options)
|
||||
|
||||
Resolver.default_resolver(logger)
|
||||
|
||||
return logger
|
||||
end
|
||||
|
||||
def self.local
|
||||
self.default_logger
|
||||
end
|
||||
|
||||
DEFAULT_LEVEL = 1
|
||||
|
||||
def initialize(output, **options)
|
||||
# This is the expected default behaviour, but it may be nice to have a way to override it.
|
||||
output = Output::Failure.new(output, **options)
|
||||
|
||||
super(output, **options)
|
||||
end
|
||||
|
||||
def progress(subject, total, **options)
|
||||
options[:severity] ||= :info
|
||||
|
||||
Progress.new(subject, total, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
require_relative "output/default"
|
||||
require_relative "output/serialized"
|
||||
require_relative "output/terminal"
|
||||
require_relative "output/null"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
def self.new(output = nil, env = ENV, **options)
|
||||
if names = env["CONSOLE_OUTPUT"]
|
||||
names = names.split(",").reverse
|
||||
|
||||
names.inject(output) do |output, name|
|
||||
Output.const_get(name).new(output, **options)
|
||||
end
|
||||
else
|
||||
return Output::Default.new(output, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
require_relative "terminal"
|
||||
require_relative "serialized"
|
||||
require_relative "failure"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
module Default
|
||||
def self.new(output, **options)
|
||||
output ||= $stderr
|
||||
|
||||
if output.tty?
|
||||
output = Terminal.new(output, **options)
|
||||
else
|
||||
output = Serialized.new(output, **options)
|
||||
end
|
||||
|
||||
return output
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2024-2025, by Samuel Williams.
|
||||
|
||||
require_relative "wrapper"
|
||||
require_relative "../event/failure"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
# A wrapper for outputting failure messages, which can include exceptions.
|
||||
class Failure < Wrapper
|
||||
def initialize(output, **options)
|
||||
super(output, **options)
|
||||
end
|
||||
|
||||
# The exception must be either the last argument or passed as an option.
|
||||
def call(subject = nil, *arguments, exception: nil, **options, &block)
|
||||
if exception.nil?
|
||||
last = arguments.last
|
||||
if last.is_a?(Exception)
|
||||
options[:event] = Event::Failure.for(last)
|
||||
end
|
||||
elsif exception.is_a?(Exception)
|
||||
options[:event] = Event::Failure.for(exception)
|
||||
else
|
||||
# We don't know what this is, so we just pass it through:
|
||||
options[:exception] = exception
|
||||
end
|
||||
|
||||
super(subject, *arguments, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2023-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Null
|
||||
def initialize(...)
|
||||
end
|
||||
|
||||
def last_output
|
||||
self
|
||||
end
|
||||
|
||||
def call(...)
|
||||
# Do nothing.
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
require_relative "wrapper"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Sensitive < Wrapper
|
||||
REDACT = /
|
||||
phone
|
||||
| email
|
||||
| full_?name
|
||||
| first_?name
|
||||
| last_?name
|
||||
|
||||
| device_name
|
||||
| user_agent
|
||||
|
||||
| zip
|
||||
| address
|
||||
| location
|
||||
| latitude
|
||||
| longitude
|
||||
|
||||
| ip
|
||||
| gps
|
||||
|
||||
| sex
|
||||
| gender
|
||||
|
||||
| token
|
||||
| password
|
||||
/xi
|
||||
|
||||
def initialize(output, redact: REDACT, **options)
|
||||
super(output, **options)
|
||||
|
||||
@redact = redact
|
||||
end
|
||||
|
||||
def redact?(text)
|
||||
text.match?(@redact)
|
||||
end
|
||||
|
||||
def redact_hash(arguments, filter)
|
||||
arguments.transform_values do |value|
|
||||
redact(value, filter)
|
||||
end
|
||||
end
|
||||
|
||||
def redact_array(array, filter)
|
||||
array.map do |value|
|
||||
redact(value, filter)
|
||||
end
|
||||
end
|
||||
|
||||
def redact(argument, filter)
|
||||
case argument
|
||||
when String
|
||||
if filter
|
||||
filter.call(argument)
|
||||
elsif redact?(argument)
|
||||
"[REDACTED]"
|
||||
else
|
||||
argument
|
||||
end
|
||||
when Array
|
||||
redact_array(argument, filter)
|
||||
when Hash
|
||||
redact_hash(argument, filter)
|
||||
else
|
||||
redact(argument.to_s, filter)
|
||||
end
|
||||
end
|
||||
|
||||
class Filter
|
||||
def initialize(substitutions)
|
||||
@substitutions = substitutions
|
||||
@pattern = Regexp.union(substitutions.keys)
|
||||
end
|
||||
|
||||
def call(text)
|
||||
text.gsub(@pattern, @substitutions)
|
||||
end
|
||||
end
|
||||
|
||||
def call(subject = nil, *arguments, sensitive: true, **options, &block)
|
||||
if sensitive
|
||||
if sensitive.respond_to?(:call)
|
||||
filter = sensitive
|
||||
elsif sensitive.is_a?(Hash)
|
||||
filter = Filter.new(sensitive)
|
||||
end
|
||||
|
||||
subject = redact(subject, filter)
|
||||
arguments = redact_array(arguments, filter)
|
||||
end
|
||||
|
||||
super(subject, *arguments, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2025, by Samuel Williams.
|
||||
|
||||
require_relative "../format"
|
||||
require "time"
|
||||
require "fiber/annotation"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Serialized
|
||||
def initialize(io, format: Format.default, **options)
|
||||
@io = io
|
||||
@format = format
|
||||
end
|
||||
|
||||
# This a final output that then writes to an IO object.
|
||||
def last_output
|
||||
self
|
||||
end
|
||||
|
||||
attr :io
|
||||
attr :format
|
||||
|
||||
def dump(record)
|
||||
@format.dump(record)
|
||||
end
|
||||
|
||||
def call(subject = nil, *arguments, severity: UNKNOWN, **options, &block)
|
||||
record = {
|
||||
time: Time.now.iso8601,
|
||||
severity: severity,
|
||||
oid: subject.object_id,
|
||||
pid: Process.pid,
|
||||
}
|
||||
|
||||
# We want to log just a brief subject:
|
||||
if subject.is_a?(String)
|
||||
record[:subject] = subject
|
||||
elsif subject.is_a?(Module)
|
||||
record[:subject] = subject.name
|
||||
else
|
||||
record[:subject] = subject.class.name
|
||||
end
|
||||
|
||||
if annotation = Fiber.current.annotation
|
||||
record[:annotation] = annotation
|
||||
end
|
||||
|
||||
message = arguments
|
||||
|
||||
if block_given?
|
||||
if block.arity.zero?
|
||||
message << yield
|
||||
else
|
||||
buffer = StringIO.new
|
||||
yield buffer
|
||||
message << buffer.string
|
||||
end
|
||||
end
|
||||
|
||||
if message.size == 1
|
||||
record[:message] = message.first
|
||||
elsif message.any?
|
||||
record[:message] = message
|
||||
end
|
||||
|
||||
record.update(options)
|
||||
|
||||
@io.write(self.dump(record) << "\n")
|
||||
end
|
||||
end
|
||||
|
||||
JSON = Serialized
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2022-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Split
|
||||
def self.[](*outputs)
|
||||
self.new(outputs)
|
||||
end
|
||||
|
||||
def initialize(outputs)
|
||||
@outputs = outputs
|
||||
end
|
||||
|
||||
def verbose!(value = true)
|
||||
@outputs.each{|output| output.verbose!(value)}
|
||||
end
|
||||
|
||||
def call(...)
|
||||
@outputs.each do |output|
|
||||
output.call(...)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,257 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2021, by Robert Schulze.
|
||||
|
||||
require_relative "../clock"
|
||||
require_relative "../terminal"
|
||||
|
||||
require "json"
|
||||
require "fiber"
|
||||
require "fiber/annotation"
|
||||
require "stringio"
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Terminal
|
||||
class Buffer < StringIO
|
||||
def initialize(prefix = nil)
|
||||
@prefix = prefix
|
||||
|
||||
super()
|
||||
end
|
||||
|
||||
attr :prefix
|
||||
|
||||
def puts(*args, prefix: @prefix)
|
||||
args.each do |arg|
|
||||
self.write(prefix) if prefix
|
||||
super(arg)
|
||||
end
|
||||
end
|
||||
|
||||
alias << puts
|
||||
end
|
||||
|
||||
# This, and all related methods, is considered private.
|
||||
CONSOLE_START_AT = "CONSOLE_START_AT"
|
||||
|
||||
# Exports CONSOLE_START which can be used to synchronize the start times of all child processes when they log using delta time.
|
||||
def self.start_at!(environment = ENV)
|
||||
if time_string = environment[CONSOLE_START_AT]
|
||||
start_at = Time.parse(time_string) rescue nil
|
||||
end
|
||||
|
||||
unless start_at
|
||||
start_at = Time.now
|
||||
environment[CONSOLE_START_AT] = start_at.to_s
|
||||
end
|
||||
|
||||
return start_at
|
||||
end
|
||||
|
||||
def initialize(output, verbose: nil, start_at: Terminal.start_at!, format: nil, **options)
|
||||
@io = output
|
||||
@start_at = start_at
|
||||
|
||||
@terminal = format.nil? ? Console::Terminal.for(@io) : format.new(@io)
|
||||
|
||||
if verbose.nil?
|
||||
@verbose = !@terminal.colors?
|
||||
else
|
||||
@verbose = verbose
|
||||
end
|
||||
|
||||
@terminal[:logger_suffix] ||= @terminal.style(:white, nil, :faint)
|
||||
@terminal[:subject] ||= @terminal.style(nil, nil, :bold)
|
||||
@terminal[:debug] = @terminal.style(:cyan)
|
||||
@terminal[:info] = @terminal.style(:green)
|
||||
@terminal[:warn] = @terminal.style(:yellow)
|
||||
@terminal[:error] = @terminal.style(:red)
|
||||
@terminal[:fatal] = @terminal[:error]
|
||||
|
||||
@terminal[:annotation] = @terminal.reset
|
||||
@terminal[:value] = @terminal.style(:blue)
|
||||
|
||||
@formatters = {}
|
||||
self.register_formatters
|
||||
end
|
||||
|
||||
# This a final output that then writes to an IO object.
|
||||
def last_output
|
||||
self
|
||||
end
|
||||
|
||||
attr :io
|
||||
|
||||
attr_accessor :verbose
|
||||
|
||||
attr :start
|
||||
attr :terminal
|
||||
|
||||
def verbose!(value = true)
|
||||
@verbose = value
|
||||
end
|
||||
|
||||
def register_formatters(namespace = Console::Terminal::Formatter)
|
||||
namespace.constants.each do |name|
|
||||
formatter = namespace.const_get(name)
|
||||
@formatters[formatter::KEY] = formatter.new(@terminal)
|
||||
end
|
||||
end
|
||||
|
||||
UNKNOWN = :unknown
|
||||
|
||||
def call(subject = nil, *arguments, name: nil, severity: UNKNOWN, event: nil, **options, &block)
|
||||
width = @terminal.width
|
||||
|
||||
prefix = build_prefix(name || severity.to_s)
|
||||
indent = " " * prefix.size
|
||||
|
||||
buffer = Buffer.new("#{indent}| ")
|
||||
indent_size = buffer.prefix.size
|
||||
|
||||
format_subject(severity, prefix, subject, buffer)
|
||||
|
||||
arguments.each do |argument|
|
||||
format_argument(argument, buffer)
|
||||
end
|
||||
|
||||
if block_given?
|
||||
if block.arity.zero?
|
||||
format_argument(yield, buffer)
|
||||
else
|
||||
yield(buffer, @terminal)
|
||||
end
|
||||
end
|
||||
|
||||
if event
|
||||
format_event(event, buffer, width - indent_size)
|
||||
end
|
||||
|
||||
if options&.any?
|
||||
format_options(options, buffer)
|
||||
end
|
||||
|
||||
@io.write buffer.string
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def format_event(event, buffer, width)
|
||||
event = event.to_hash
|
||||
type = event[:type]
|
||||
|
||||
if formatter = @formatters[type]
|
||||
formatter.format(event, buffer, verbose: @verbose, width: width)
|
||||
else
|
||||
format_value(::JSON.pretty_generate(event), buffer)
|
||||
end
|
||||
end
|
||||
|
||||
def format_options(options, output)
|
||||
format_value(::JSON.pretty_generate(options), output)
|
||||
end
|
||||
|
||||
def format_argument(argument, output)
|
||||
argument.to_s.each_line do |line|
|
||||
output.puts line
|
||||
end
|
||||
end
|
||||
|
||||
def format_subject(severity, prefix, subject, buffer)
|
||||
if subject.is_a?(String)
|
||||
format_string_subject(severity, prefix, subject, buffer)
|
||||
elsif subject.is_a?(Module)
|
||||
format_string_subject(severity, prefix, subject.to_s, buffer)
|
||||
else
|
||||
format_object_subject(severity, prefix, subject, buffer)
|
||||
end
|
||||
end
|
||||
|
||||
def default_suffix(object = nil)
|
||||
buffer = +""
|
||||
|
||||
if @verbose
|
||||
if annotation = Fiber.current.annotation
|
||||
# While typically annotations should be strings, that is not always the case.
|
||||
annotation = annotation.to_s
|
||||
|
||||
# If the annotation is empty, we don't want to print it, as it will look like a formatting bug.
|
||||
if annotation.size > 0
|
||||
buffer << ": #{@terminal[:annotation]}#{annotation}#{@terminal.reset}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
buffer << " #{@terminal[:logger_suffix]}"
|
||||
|
||||
if object
|
||||
buffer << "[oid=0x#{object.object_id.to_s(16)}] "
|
||||
end
|
||||
|
||||
buffer << "[ec=0x#{Fiber.current.object_id.to_s(16)}] [pid=#{Process.pid}] [#{::Time.now}]#{@terminal.reset}"
|
||||
|
||||
return buffer
|
||||
end
|
||||
|
||||
def format_object_subject(severity, prefix, subject, output)
|
||||
prefix_style = @terminal[severity]
|
||||
|
||||
if @verbose
|
||||
suffix = default_suffix(subject)
|
||||
end
|
||||
|
||||
prefix = "#{prefix_style}#{prefix}:#{@terminal.reset} "
|
||||
|
||||
output.puts "#{@terminal[:subject]}#{subject.class}#{@terminal.reset}#{suffix}", prefix: prefix
|
||||
end
|
||||
|
||||
def format_string_subject(severity, prefix, subject, output)
|
||||
prefix_style = @terminal[severity]
|
||||
|
||||
if @verbose
|
||||
suffix = default_suffix
|
||||
end
|
||||
|
||||
prefix = "#{prefix_style}#{prefix}:#{@terminal.reset} "
|
||||
|
||||
output.puts "#{@terminal[:subject]}#{subject}#{@terminal.reset}#{suffix}", prefix: prefix
|
||||
end
|
||||
|
||||
def format_value(value, output)
|
||||
string = value.to_s
|
||||
|
||||
string.each_line do |line|
|
||||
line.chomp!
|
||||
output.puts "#{@terminal[:value]}#{line}#{@terminal.reset}"
|
||||
end
|
||||
end
|
||||
|
||||
def time_offset_prefix
|
||||
Clock.formatted_duration(Time.now - @start_at).rjust(6)
|
||||
end
|
||||
|
||||
def build_prefix(name)
|
||||
if @verbose
|
||||
"#{time_offset_prefix} #{name.rjust(8)}"
|
||||
else
|
||||
time_offset_prefix
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module Text
|
||||
def self.new(output, **options)
|
||||
Terminal.new(output, format: Console::Terminal::Text, **options)
|
||||
end
|
||||
end
|
||||
|
||||
module XTerm
|
||||
def self.new(output, **options)
|
||||
Terminal.new(output, format: Console::Terminal::XTerm, **options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2021-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Output
|
||||
class Wrapper
|
||||
def initialize(delegate, **options)
|
||||
@delegate = delegate
|
||||
end
|
||||
|
||||
attr :delegate
|
||||
|
||||
def last_output
|
||||
@delegate.last_output
|
||||
end
|
||||
|
||||
def verbose!(value = true)
|
||||
@delegate.verbose!(value)
|
||||
end
|
||||
|
||||
def call(...)
|
||||
@delegate.call(...)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2020-2024, by Samuel Williams.
|
||||
# Copyright, 2022, by Anton Sozontov.
|
||||
|
||||
require_relative "clock"
|
||||
|
||||
module Console
|
||||
class Progress
|
||||
def self.now
|
||||
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
|
||||
def initialize(subject, total = 0, minimum_output_duration: 0.1, **options)
|
||||
@subject = subject
|
||||
@options = options
|
||||
|
||||
@start_time = Progress.now
|
||||
|
||||
@last_output_time = nil
|
||||
@minimum_output_duration = minimum_output_duration
|
||||
|
||||
@current = 0
|
||||
@total = total
|
||||
end
|
||||
|
||||
attr :subject
|
||||
attr :current
|
||||
attr :total
|
||||
|
||||
def duration
|
||||
Progress.now - @start_time
|
||||
end
|
||||
|
||||
def ratio
|
||||
Rational(@current.to_f, @total.to_f)
|
||||
end
|
||||
|
||||
def remaining
|
||||
@total - @current
|
||||
end
|
||||
|
||||
def average_duration
|
||||
if @current > 0
|
||||
duration / @current
|
||||
end
|
||||
end
|
||||
|
||||
def estimated_remaining_time
|
||||
if average_duration = self.average_duration
|
||||
average_duration * remaining
|
||||
end
|
||||
end
|
||||
|
||||
def to_hash
|
||||
Hash.new.tap do |hash|
|
||||
hash[:type] = :progress
|
||||
hash[:current] = @current
|
||||
hash[:total] = @total
|
||||
|
||||
hash[:duration] = self.duration
|
||||
hash[:estimated_remaining_time] = self.estimated_remaining_time
|
||||
end
|
||||
end
|
||||
|
||||
def increment(amount = 1)
|
||||
@current += amount
|
||||
|
||||
if output?
|
||||
Console.call(@subject, self.to_s, event: self.to_hash, **@options)
|
||||
@last_output_time = Progress.now
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
def resize(total)
|
||||
@total = total
|
||||
|
||||
Console.call(@subject, self.to_s, event: self.to_hash, **@options)
|
||||
@last_output_time = Progress.now
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
def mark(*arguments, **options)
|
||||
Console.call(@subject, *arguments, **options, **@options)
|
||||
end
|
||||
|
||||
def to_s
|
||||
if estimated_remaining_time = self.estimated_remaining_time
|
||||
"#{@current}/#{@total} completed in #{Clock.formatted_duration(self.duration)}, #{Clock.formatted_duration(estimated_remaining_time)} remaining."
|
||||
else
|
||||
"#{@current}/#{@total} completed, waiting for estimate..."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def duration_since_last_output
|
||||
if @last_output_time
|
||||
Progress.now - @last_output_time
|
||||
end
|
||||
end
|
||||
|
||||
def output?
|
||||
if remaining.zero?
|
||||
return true
|
||||
elsif duration = duration_since_last_output
|
||||
return duration > @minimum_output_duration
|
||||
else
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
# Copyright, 2021, by Robert Schulze.
|
||||
|
||||
require_relative "filter"
|
||||
|
||||
module Console
|
||||
class Resolver
|
||||
# You can change the log level for different classes using CONSOLE_$LEVEL env vars.
|
||||
#
|
||||
# e.g. `CONSOLE_WARN=Acorn,Banana CONSOLE_DEBUG=Cat` will set the log level for the classes Acorn and Banana to `warn` and Cat to `debug`. This overrides the default log level.
|
||||
#
|
||||
# You can enable all log levels for a given class by using `CONSOLE_ON=MyClass`. Similarly you can disable all logging using `CONSOLE_OFF=MyClass`.
|
||||
#
|
||||
# @parameter logger [Logger] A logger instance to set the logging levels on.
|
||||
# @parameter env [Hash] The environment to read levels from.
|
||||
#
|
||||
# @returns [Nil] If there were no custom logging levels specified in the environment.
|
||||
# @returns [Resolver] If there were custom logging levels, then the created resolver is returned.
|
||||
def self.default_resolver(logger, env = ENV)
|
||||
# Find all CONSOLE_$LEVEL variables from environment:
|
||||
levels = logger.class::LEVELS
|
||||
.map{|label, level| [level, env["CONSOLE_#{label.upcase}"]&.split(",")]}
|
||||
.to_h
|
||||
.compact
|
||||
|
||||
off_klasses = env["CONSOLE_OFF"]&.split(",")
|
||||
on_klasses = env["CONSOLE_ON"]&.split(",")
|
||||
|
||||
resolver = nil
|
||||
|
||||
# If we have any levels, then create a class resolver, and each time a class is resolved, set the log level for that class to the specified level:
|
||||
if on_klasses&.any?
|
||||
resolver ||= Resolver.new
|
||||
|
||||
resolver.bind(on_klasses) do |klass|
|
||||
logger.enable(klass, logger.class::MINIMUM_LEVEL - 1)
|
||||
end
|
||||
end
|
||||
|
||||
if off_klasses&.any?
|
||||
resolver ||= Resolver.new
|
||||
|
||||
resolver.bind(off_klasses) do |klass|
|
||||
logger.disable(klass)
|
||||
end
|
||||
end
|
||||
|
||||
levels.each do |level, names|
|
||||
resolver ||= Resolver.new
|
||||
|
||||
resolver.bind(names) do |klass|
|
||||
logger.enable(klass, level)
|
||||
end
|
||||
end
|
||||
|
||||
return resolver
|
||||
end
|
||||
|
||||
def initialize
|
||||
@names = {}
|
||||
|
||||
@trace_point = TracePoint.new(:class, &self.method(:resolve))
|
||||
end
|
||||
|
||||
def bind(names, &block)
|
||||
names.each do |name|
|
||||
if klass = Object.const_get(name) rescue nil
|
||||
yield klass
|
||||
else
|
||||
@names[name] = block
|
||||
end
|
||||
end
|
||||
|
||||
if @names.any?
|
||||
@trace_point.enable
|
||||
else
|
||||
@trace_point.disable
|
||||
end
|
||||
end
|
||||
|
||||
def waiting?
|
||||
@trace_point.enabled?
|
||||
end
|
||||
|
||||
def resolve(trace_point)
|
||||
if block = @names.delete(trace_point.self.to_s)
|
||||
block.call(trace_point.self)
|
||||
end
|
||||
|
||||
if @names.empty?
|
||||
@trace_point.disable
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require_relative "terminal/text"
|
||||
require_relative "terminal/xterm"
|
||||
|
||||
require_relative "terminal/formatter/progress"
|
||||
require_relative "terminal/formatter/failure"
|
||||
require_relative "terminal/formatter/spawn"
|
||||
|
||||
module Console
|
||||
module Terminal
|
||||
def self.for(io)
|
||||
if io.tty?
|
||||
XTerm.new(io)
|
||||
else
|
||||
Text.new(io)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Terminal
|
||||
module Formatter
|
||||
class Failure
|
||||
KEY = :failure
|
||||
|
||||
def initialize(terminal)
|
||||
@terminal = terminal
|
||||
|
||||
@terminal[:exception_title] ||= @terminal.style(:red, nil, :bold)
|
||||
@terminal[:exception_detail] ||= @terminal.style(:yellow)
|
||||
@terminal[:exception_backtrace] ||= @terminal.style(:red)
|
||||
@terminal[:exception_backtrace_other] ||= @terminal.style(:red, nil, :faint)
|
||||
@terminal[:exception_message] ||= @terminal.style(:default)
|
||||
end
|
||||
|
||||
def format(event, output, prefix: nil, verbose: false, width: 80)
|
||||
title = event[:class]
|
||||
message = event[:message]
|
||||
backtrace = event[:backtrace]
|
||||
root = event[:root]
|
||||
|
||||
lines = message.lines.map(&:chomp)
|
||||
|
||||
output.puts " #{prefix}#{@terminal[:exception_title]}#{title}#{@terminal.reset}: #{lines.shift}"
|
||||
|
||||
lines.each do |line|
|
||||
output.puts " #{@terminal[:exception_detail]}#{line}#{@terminal.reset}"
|
||||
end
|
||||
|
||||
root_pattern = /^#{root}\// if root
|
||||
|
||||
backtrace&.each_with_index do |line, index|
|
||||
path, offset, message = line.split(":", 3)
|
||||
style = :exception_backtrace
|
||||
|
||||
# Make the path a bit more readable:
|
||||
if root_pattern and path.sub!(root_pattern, "").nil?
|
||||
style = :exception_backtrace_other
|
||||
end
|
||||
|
||||
output.puts " #{index == 0 ? "→" : " "} #{@terminal[style]}#{path}:#{offset}#{@terminal[:exception_message]} #{message}#{@terminal.reset}"
|
||||
end
|
||||
|
||||
if cause = event[:cause]
|
||||
format(cause, output, prefix: "Caused by ", verbose: verbose, width: width)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2020-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Terminal
|
||||
module Formatter
|
||||
class Progress
|
||||
KEY = :progress
|
||||
|
||||
BLOCK = [
|
||||
" ",
|
||||
"▏",
|
||||
"▎",
|
||||
"▍",
|
||||
"▌",
|
||||
"▋",
|
||||
"▊",
|
||||
"▉",
|
||||
"█",
|
||||
]
|
||||
|
||||
def initialize(terminal)
|
||||
@terminal = terminal
|
||||
@terminal[:progress_bar] ||= terminal.style(:blue, :white)
|
||||
end
|
||||
|
||||
def format(event, output, verbose: false, width: 80)
|
||||
current = event[:current].to_f
|
||||
total = event[:total].to_f
|
||||
value = current / total
|
||||
|
||||
# Clamp value to 1.0 to avoid rendering issues:
|
||||
if value > 1.0
|
||||
value = 1.0
|
||||
end
|
||||
|
||||
output.puts "#{@terminal[:progress_bar]}#{self.bar(value, width-10)}#{@terminal.reset} #{sprintf('%6.2f', value * 100)}%"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bar(value, width)
|
||||
blocks = width * value
|
||||
full_blocks = blocks.floor
|
||||
partial_block = ((blocks - full_blocks) * BLOCK.size).floor
|
||||
|
||||
if partial_block.zero?
|
||||
BLOCK.last * full_blocks
|
||||
else
|
||||
"#{BLOCK.last * full_blocks}#{BLOCK[partial_block]}"
|
||||
end.ljust(width)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
module Terminal
|
||||
module Formatter
|
||||
# Format spawn events.
|
||||
class Spawn
|
||||
KEY = :spawn
|
||||
|
||||
def initialize(terminal)
|
||||
@terminal = terminal
|
||||
@terminal[:spawn_command] ||= @terminal.style(:blue, nil, :bold)
|
||||
end
|
||||
|
||||
def format(event, output, verbose: false, width: 80)
|
||||
environment, arguments, options = event.values_at(:environment, :arguments, :options)
|
||||
|
||||
arguments = arguments.flatten.collect(&:to_s)
|
||||
|
||||
output.puts "#{@terminal[:spawn_command]}#{arguments.join(' ')}#{@terminal.reset}#{chdir_string(options)}"
|
||||
|
||||
if verbose and environment
|
||||
environment.each do |key, value|
|
||||
output.puts "export #{key}=#{value}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def chdir_string(options)
|
||||
if options and chdir = options[:chdir]
|
||||
" in #{chdir}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require "io/console"
|
||||
|
||||
module Console
|
||||
# Styled terminal output.
|
||||
module Terminal
|
||||
class Text
|
||||
def initialize(output)
|
||||
@output = output
|
||||
@styles = {reset: self.reset}
|
||||
end
|
||||
|
||||
def [] key
|
||||
@styles[key]
|
||||
end
|
||||
|
||||
def []= key, value
|
||||
@styles[key] = value
|
||||
end
|
||||
|
||||
def colors?
|
||||
false
|
||||
end
|
||||
|
||||
def width
|
||||
80
|
||||
end
|
||||
|
||||
def style(foreground, background = nil, *attributes)
|
||||
end
|
||||
|
||||
def reset
|
||||
end
|
||||
|
||||
def write(*arguments, style: nil)
|
||||
if style and prefix = self[style]
|
||||
@output.write(prefix)
|
||||
@output.write(*arguments)
|
||||
@output.write(self.reset)
|
||||
else
|
||||
@output.write(*arguments)
|
||||
end
|
||||
end
|
||||
|
||||
def puts(*arguments, style: nil)
|
||||
if style and prefix = self[style]
|
||||
@output.write(prefix)
|
||||
@output.puts(*arguments)
|
||||
@output.write(self.reset)
|
||||
else
|
||||
@output.puts(*arguments)
|
||||
end
|
||||
end
|
||||
|
||||
# Print out the given arguments.
|
||||
# When the argument is a symbol, look up the style and inject it into the output stream.
|
||||
# When the argument is a proc/lambda, call it with self as the argument.
|
||||
# When the argument is anything else, write it directly to the output.
|
||||
def print(*arguments)
|
||||
arguments.each do |argument|
|
||||
case argument
|
||||
when Symbol
|
||||
@output.write(self[argument])
|
||||
when Proc
|
||||
argument.call(self)
|
||||
else
|
||||
@output.write(argument)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Print out the arguments as per {#print}, followed by the reset sequence and a newline.
|
||||
def print_line(*arguments)
|
||||
print(*arguments)
|
||||
@output.puts(self.reset)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
require "io/console"
|
||||
|
||||
require_relative "text"
|
||||
|
||||
module Console
|
||||
# Styled terminal output.
|
||||
module Terminal
|
||||
class XTerm < Text
|
||||
COLORS = {
|
||||
black: 0,
|
||||
red: 1,
|
||||
green: 2,
|
||||
yellow: 3,
|
||||
blue: 4,
|
||||
magenta: 5,
|
||||
cyan: 6,
|
||||
white: 7,
|
||||
default: 9,
|
||||
}.freeze
|
||||
|
||||
ATTRIBUTES = {
|
||||
normal: 0,
|
||||
bold: 1,
|
||||
bright: 1,
|
||||
faint: 2,
|
||||
italic: 3,
|
||||
underline: 4,
|
||||
blink: 5,
|
||||
reverse: 7,
|
||||
hidden: 8,
|
||||
}.freeze
|
||||
|
||||
def colors?
|
||||
true
|
||||
end
|
||||
|
||||
def size
|
||||
@output.winsize
|
||||
rescue Errno::ENOTTY
|
||||
# Fake it...
|
||||
[24, 80]
|
||||
end
|
||||
|
||||
def width
|
||||
size.last
|
||||
end
|
||||
|
||||
def style(foreground, background = nil, *attributes)
|
||||
tokens = []
|
||||
|
||||
if foreground
|
||||
tokens << 30 + COLORS.fetch(foreground)
|
||||
end
|
||||
|
||||
if background
|
||||
tokens << 40 + COLORS.fetch(background)
|
||||
end
|
||||
|
||||
attributes.each do |attribute|
|
||||
tokens << ATTRIBUTES.fetch(attribute){attribute.to_i}
|
||||
end
|
||||
|
||||
return "\e[#{tokens.join(';')}m"
|
||||
end
|
||||
|
||||
def reset
|
||||
"\e[0m"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2019-2024, by Samuel Williams.
|
||||
|
||||
module Console
|
||||
VERSION = "1.29.3"
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Released under the MIT License.
|
||||
# Copyright, 2024, by Samuel Williams.
|
||||
|
||||
require_relative "logger"
|
||||
|
||||
module Console
|
||||
# Whether the current fiber is emitting a warning.
|
||||
Fiber.attr_accessor :console_warn
|
||||
|
||||
# Redirect warnings to Console.warn.
|
||||
module Warn
|
||||
# Redirect warnings to {Console.warn}.
|
||||
def warn(message, **options)
|
||||
fiber = Fiber.current
|
||||
|
||||
# We do this to be extra pendantic about avoiding infinite recursion.
|
||||
return super if fiber.console_warn
|
||||
|
||||
begin
|
||||
fiber.console_warn = true
|
||||
message.chomp!
|
||||
|
||||
Console::Logger.instance.warn(message, **options)
|
||||
ensure
|
||||
fiber.console_warn = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
::Warning.extend(Warn)
|
||||
end
|
||||
Reference in New Issue
Block a user