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
+18
View File
@@ -0,0 +1,18 @@
# frozen_string_literal: true
# Released under the MIT License.
# Copyright, 2020-2024, by Samuel Williams.
# Increase the verbosity of the logger to info.
def info
require_relative "../lib/console"
Console.logger.info!
end
# Increase the verbosity of the logger to debug.
def debug
require_relative "../lib/console"
Console.logger.debug!
end
+14
View File
@@ -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
+31
View File
@@ -0,0 +1,31 @@
# MIT License
Copyright, 2019-2025, by Samuel Williams.
Copyright, 2019-2021, by Bryan Powell.
Copyright, 2019, by Cyril Roelandt.
Copyright, 2020, by Olle Jonsson.
Copyright, 2020, by Michael Adams.
Copyright, 2021, by Cédric Boutillier.
Copyright, 2021, by Robert Schulze.
Copyright, 2022, by Anton Sozontov.
Copyright, 2022, by William T. Nelson.
Copyright, 2023, by Felix Yan.
Copyright, 2024, by Patrik Wenger.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+80
View File
@@ -0,0 +1,80 @@
# Console
Provides beautiful console logging for Ruby applications. Implements fast, buffered log output.
[![Development Status](https://github.com/socketry/console/workflows/Test/badge.svg)](https://github.com/socketry/console/actions?workflow=Test)
## Motivation
When Ruby decided to reverse the order of exception backtraces, I finally gave up using the built in logging and decided restore sanity to the output of my programs once and for all\!
## Features
- Thread safe global logger with per-fiber context.
- Carry along context with nested loggers.
- Enable/disable log levels per-class.
- Detailed logging of exceptions.
- Beautiful logging to the terminal or structured logging using JSON.
## Usage
Please see the [project documentation](https://socketry.github.io/console/) for more details.
- [Getting Started](https://socketry.github.io/console/guides/getting-started/index) - This guide explains how to use `console` for logging.
- [Command Line](https://socketry.github.io/console/guides/command-line/index) - This guide explains how the `console` gem can be controlled using environment variables.
- [Integration](https://socketry.github.io/console/guides/integration/index) - This guide explains how to integrate the `console` output into different systems.
- [Events](https://socketry.github.io/console/guides/events/index) - This guide explains how to log structured events with a well-defined schema.
## Releases
Please see the [project releases](https://socketry.github.io/console/releases/index) for all releases.
### v1.29.3
- Serialized output now uses `IO#write` with a single string to reduce the chance of interleaved output.
### v1.29.2
- Always return `nil` from `Console::Filter` logging methods.
### v1.29.1
- Fix logging `exception:` keyword argument when the value was not an exception.
### v1.29.0
- Don't make `Kernel#warn` redirection to `Console.warn` the default behavior, you must `require 'console/warn'` to enable it.
- Remove deprecated `Console::Logger#failure`.
- [Consistent Handling of Exceptions](https://socketry.github.io/console/releases/index#consistent-handling-of-exceptions)
### v1.28.0
- Add support for `Kernel#warn` redirection to `Console.warn`.
## Contributing
We welcome contributions to this project.
1. Fork it.
2. Create your feature branch (`git checkout -b my-new-feature`).
3. Commit your changes (`git commit -am 'Add some feature'`).
4. Push to the branch (`git push origin my-new-feature`).
5. Create new Pull Request.
### Developer Certificate of Origin
In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
### Community Guidelines
This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
## See Also
- [console-adapter-rails](https://github.com/socketry/console-adapter-rails)
- [console-adapter-sidekiq](https://github.com/socketry/console-adapter-sidekiq)
- [console-output-datadog](https://github.com/socketry/console-output-datadog)
- [sus-fixtures-console](https://github.com/sus-rb/sus-fixtures-console)
+37
View File
@@ -0,0 +1,37 @@
# Releases
## v1.29.3
- Serialized output now uses `IO#write` with a single string to reduce the chance of interleaved output.
## v1.29.2
- Always return `nil` from `Console::Filter` logging methods.
## v1.29.1
- Fix logging `exception:` keyword argument when the value was not an exception.
## v1.29.0
- Don't make `Kernel#warn` redirection to `Console.warn` the default behavior, you must `require 'console/warn'` to enable it.
- Remove deprecated `Console::Logger#failure`.
### Consistent Handling of Exceptions
`Console.call` and all wrapper methods will now consistently handle exceptions that are the last positional argument or keyword argument. This means that the following code will work as expected:
``` ruby
begin
rescue => error
# Last positional argument:
Console.warn(self, "There may be an issue", error)
# Keyword argument (preferable):
Console.error(self, "There is an issue", exception: error)
end
```
## v1.28.0
- Add support for `Kernel#warn` redirection to `Console.warn`.