Add bin and edit workflow
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 9s

This commit is contained in:
2026-09-16 13:11:16 -06:00
parent c8ac4fcae5
commit 4cee170d66
17576 changed files with 895740 additions and 2 deletions
@@ -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