console 1.19.0 → 1.29.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/bake/console.rb +3 -3
  4. data/lib/console/adapter.rb +2 -1
  5. data/lib/console/capture.rb +46 -17
  6. data/lib/console/compatible/logger.rb +4 -3
  7. data/lib/console/event/failure.rb +43 -39
  8. data/lib/console/event/generic.rb +9 -6
  9. data/lib/console/event/spawn.rb +35 -27
  10. data/lib/console/event.rb +3 -4
  11. data/lib/console/filter.rb +24 -10
  12. data/lib/console/format/safe.rb +136 -0
  13. data/lib/console/format.rb +14 -0
  14. data/lib/console/interface.rb +53 -0
  15. data/lib/console/logger.rb +17 -16
  16. data/lib/console/output/default.rb +8 -5
  17. data/lib/console/output/failure.rb +32 -0
  18. data/lib/console/output/null.rb +5 -1
  19. data/lib/console/output/sensitive.rb +12 -10
  20. data/lib/console/{serialized/logger.rb → output/serialized.rb} +14 -49
  21. data/lib/console/output/split.rb +3 -3
  22. data/lib/console/{terminal/logger.rb → output/terminal.rb} +98 -54
  23. data/lib/console/output/wrapper.rb +28 -0
  24. data/lib/console/output.rb +7 -8
  25. data/lib/console/progress.rb +20 -9
  26. data/lib/console/resolver.rb +5 -5
  27. data/lib/console/terminal/formatter/failure.rb +57 -0
  28. data/lib/console/terminal/formatter/progress.rb +58 -0
  29. data/lib/console/terminal/formatter/spawn.rb +42 -0
  30. data/lib/console/terminal/text.rb +6 -2
  31. data/lib/console/terminal/xterm.rb +10 -3
  32. data/lib/console/terminal.rb +19 -2
  33. data/lib/console/version.rb +2 -2
  34. data/lib/console/warn.rb +33 -0
  35. data/lib/console.rb +4 -22
  36. data/license.md +5 -1
  37. data/readme.md +39 -2
  38. data/releases.md +25 -0
  39. data.tar.gz.sig +0 -0
  40. metadata +31 -92
  41. metadata.gz.sig +0 -0
  42. data/lib/console/buffer.rb +0 -25
  43. data/lib/console/event/progress.rb +0 -60
  44. data/lib/console/output/json.rb +0 -16
  45. data/lib/console/output/text.rb +0 -16
  46. data/lib/console/output/xterm.rb +0 -16
  47. data/lib/console/split.rb +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e68f107945b1831b98497a3a7d6a251b030788dfb0f85e212af7da334816f791
4
- data.tar.gz: 0460616df8b87571a01eba8d41cdfa2c30ae2efb4c7847c322273cc1de8d74fd
3
+ metadata.gz: 1fa166f5900ef3a9c90b337ce6dd57faa99527fd9746a47aa61bb7f7535e606d
4
+ data.tar.gz: c6d7d06fdd475006e000c3da6d65fdcd3a5c58e10c90afa8b380f795d45a16d1
5
5
  SHA512:
6
- metadata.gz: 9d7f4c0af841a44c643e24dec336bb1a71642fe292fe204382282e6ef1766a6fe6ec4390486c6e9b3bc6d841b5d603739711ed14848fc4024f6d9c2b169e2e09
7
- data.tar.gz: 65399cb6f4c81c5bbe67a746797fa37a96d8a01f1b540abdb01cccad73ea174439a2c27131db84fa2ee80b6860a69150ed77a19e9d3a298cc22dc1e6ed4a97bf
6
+ metadata.gz: 620b134d260d72d3b69a57e455584d556d611aa8a441fbd7624722b1cfaf3541015335717b82da8ea2aa0682f94e2898c9f28c4162fa5b5842297035f81bd5c0
7
+ data.tar.gz: be9318b5f775a9bd33f5c8951ffae4b921dbe0618f209080645a3eb12dcd096ef092d94f1239564e03497961ebbfbbb926d43294b8b524a94486e6b62a835b1a
checksums.yaml.gz.sig CHANGED
Binary file
data/bake/console.rb CHANGED
@@ -1,18 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2020-2022, by Samuel Williams.
4
+ # Copyright, 2020-2024, by Samuel Williams.
5
5
 
6
6
  # Increase the verbosity of the logger to info.
7
7
  def info
8
- require_relative '../lib/console'
8
+ require_relative "../lib/console"
9
9
 
10
10
  Console.logger.info!
11
11
  end
12
12
 
13
13
  # Increase the verbosity of the logger to debug.
14
14
  def debug
15
- require_relative '../lib/console'
15
+ require_relative "../lib/console"
16
16
 
17
17
  Console.logger.debug!
18
18
  end
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2023, by Samuel Williams.
4
+ # Copyright, 2023-2024, by Samuel Williams.
5
5
 
6
6
  module Console
7
+ # This namespace is reserved for logging adapters provided by other gems.
7
8
  module Adapter
8
9
  end
9
10
  end
@@ -1,35 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2024, by Samuel Williams.
5
5
 
6
- require_relative 'filter'
6
+ require_relative "filter"
7
+ require_relative "output/failure"
7
8
 
8
9
  module Console
9
10
  # A general sink which captures all events into a buffer.
10
11
  class Capture
11
12
  def initialize
12
- @buffer = []
13
+ @records = []
13
14
  @verbose = false
14
15
  end
15
16
 
16
- attr :buffer
17
+ attr :records
18
+
19
+ # @deprecated Use {#records} instead of {#buffer}.
20
+ alias buffer records
21
+
22
+ alias to_a records
23
+
17
24
  attr :verbose
18
25
 
19
- def last
20
- @buffer.last
26
+ def include?(pattern)
27
+ @records.any? do |record|
28
+ record[:subject].to_s&.match?(pattern) or record[:message].to_s&.match?(pattern)
29
+ end
21
30
  end
22
31
 
23
- def include?(pattern)
24
- JSON.dump(@buffer).include?(pattern)
32
+ def each(&block)
33
+ @records.each(&block)
34
+ end
35
+
36
+ include Enumerable
37
+
38
+ def first
39
+ @records.first
40
+ end
41
+
42
+ def last
43
+ @records.last
25
44
  end
26
45
 
27
46
  def clear
28
- @buffer.clear
47
+ @records.clear
29
48
  end
30
49
 
31
50
  def empty?
32
- @buffer.empty?
51
+ @records.empty?
33
52
  end
34
53
 
35
54
  def verbose!(value = true)
@@ -40,32 +59,42 @@ module Console
40
59
  @verbose
41
60
  end
42
61
 
43
- def call(subject = nil, *arguments, severity: UNKNOWN, **options, &block)
44
- message = {
62
+ def call(subject = nil, *arguments, severity: UNKNOWN, event: nil, **options, &block)
63
+ record = {
45
64
  time: ::Time.now.iso8601,
46
65
  severity: severity,
47
66
  **options,
48
67
  }
49
68
 
50
69
  if subject
51
- message[:subject] = subject
70
+ record[:subject] = subject
71
+ end
72
+
73
+ if event
74
+ record[:event] = event.to_hash
52
75
  end
53
76
 
54
77
  if arguments.any?
55
- message[:arguments] = arguments
78
+ record[:arguments] = arguments
79
+ end
80
+
81
+ if annotation = Fiber.current.annotation
82
+ record[:annotation] = annotation
56
83
  end
57
84
 
58
85
  if block_given?
59
86
  if block.arity.zero?
60
- message[:message] = yield
87
+ record[:message] = yield
61
88
  else
62
89
  buffer = StringIO.new
63
90
  yield buffer
64
- message[:message] = buffer.string
91
+ record[:message] = buffer.string
65
92
  end
93
+ else
94
+ record[:message] = arguments.join(" ")
66
95
  end
67
96
 
68
- @buffer << message
97
+ @records << record
69
98
  end
70
99
  end
71
100
  end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2022, by Samuel Williams.
4
+ # Copyright, 2022-2024, by Samuel Williams.
5
5
 
6
- require 'logger'
6
+ require "logger"
7
7
 
8
8
  module Console
9
9
  module Compatible
10
+ # A compatible interface for {::Logger} which can be used with {Console}.
10
11
  class Logger < ::Logger
11
12
  class LogDevice
12
13
  def initialize(subject, output)
@@ -29,7 +30,7 @@ module Console
29
30
  end
30
31
  end
31
32
 
32
- def initialize(subject, output)
33
+ def initialize(subject, output = Console)
33
34
  super(nil)
34
35
 
35
36
  @progname = subject
@@ -1,73 +1,77 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2024, by Samuel Williams.
5
5
  # Copyright, 2021, by Robert Schulze.
6
+ # Copyright, 2024, by Patrik Wenger.
6
7
 
7
- require_relative 'generic'
8
+ require_relative "generic"
8
9
 
9
10
  module Console
10
11
  module Event
12
+ # Represents a failure event.
13
+ #
14
+ # ```ruby
15
+ # Console::Event::Failure.for(exception).emit(self)
16
+ # ```
11
17
  class Failure < Generic
12
- def self.current_working_directory
18
+ def self.default_root
13
19
  Dir.getwd
14
20
  rescue # e.g. Errno::EMFILE
15
21
  nil
16
22
  end
17
23
 
18
24
  def self.for(exception)
19
- self.new(exception, self.current_working_directory)
25
+ self.new(exception, self.default_root)
20
26
  end
21
27
 
22
- def initialize(exception, root = nil)
23
- @exception = exception
24
- @root = root
28
+ def self.log(subject, exception, **options)
29
+ Console.error(subject, **self.for(exception).to_hash, **options)
25
30
  end
26
31
 
27
- attr :exception
28
- attr :root
32
+ attr_reader :exception
29
33
 
30
- def self.register(terminal)
31
- terminal[:exception_title] ||= terminal.style(:red, nil, :bold)
32
- terminal[:exception_detail] ||= terminal.style(:yellow)
33
- terminal[:exception_backtrace] ||= terminal.style(:red)
34
- terminal[:exception_backtrace_other] ||= terminal.style(:red, nil, :faint)
35
- terminal[:exception_message] ||= terminal.style(:default)
34
+ def initialize(exception, root = Dir.getwd)
35
+ @exception = exception
36
+ @root = root
36
37
  end
37
38
 
38
- def to_h
39
- {exception: @exception, root: @root}
39
+ def to_hash
40
+ Hash.new.tap do |hash|
41
+ hash[:type] = :failure
42
+ hash[:root] = @root if @root
43
+ extract(@exception, hash)
44
+ end
40
45
  end
41
46
 
42
- def format(output, terminal, verbose)
43
- format_exception(@exception, nil, output, terminal, verbose)
47
+ def emit(*arguments, **options)
48
+ options[:severity] ||= :error
49
+
50
+ super
44
51
  end
45
52
 
46
- def format_exception(exception, prefix, output, terminal, verbose)
47
- lines = exception.message.lines.map(&:chomp)
48
-
49
- output.puts " #{prefix}#{terminal[:exception_title]}#{exception.class}#{terminal.reset}: #{lines.shift}"
50
-
51
- lines.each do |line|
52
- output.puts " #{terminal[:exception_detail]}#{line}#{terminal.reset}"
53
- end
54
-
55
- root_pattern = /^#{@root}\// if @root
53
+ private
54
+
55
+ def extract(exception, hash)
56
+ hash[:class] = exception.class.name
56
57
 
57
- exception.backtrace&.each_with_index do |line, index|
58
- path, offset, message = line.split(":")
59
- style = :exception_backtrace
58
+ if exception.respond_to?(:detailed_message)
59
+ message = exception.detailed_message
60
60
 
61
- # Make the path a bit more readable
62
- if root_pattern and path.sub!(root_pattern, "").nil?
63
- style = :exception_backtrace_other
64
- end
61
+ # We want to remove the trailling exception class as we format it differently:
62
+ message.sub!(/\s*\(.*?\)$/, "")
65
63
 
66
- output.puts " #{index == 0 ? "→" : " "} #{terminal[style]}#{path}:#{offset}#{terminal[:exception_message]} #{message}#{terminal.reset}"
64
+ hash[:message] = message
65
+ else
66
+ hash[:message] = exception.message
67
67
  end
68
68
 
69
- if exception.cause
70
- format_exception(exception.cause, "Caused by ", output, terminal, verbose)
69
+ hash[:backtrace] = exception.backtrace
70
+
71
+ if cause = exception.cause
72
+ hash[:cause] = Hash.new.tap do |cause_hash|
73
+ extract(cause, cause_hash)
74
+ end
71
75
  end
72
76
  end
73
77
  end
@@ -1,22 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2024, by Samuel Williams.
5
5
 
6
6
  module Console
7
7
  module Event
8
8
  class Generic
9
- def self.register(terminal)
9
+ def as_json(...)
10
+ to_hash
10
11
  end
11
12
 
12
- def to_h
13
+ def to_json(...)
14
+ JSON.generate(as_json, ...)
13
15
  end
14
16
 
15
- def to_json(*arguments)
16
- JSON.generate([self.class, to_h], *arguments)
17
+ def to_s
18
+ to_json
17
19
  end
18
20
 
19
- def format(buffer, terminal)
21
+ def emit(*arguments, **options)
22
+ Console.call(*arguments, event: self, **options)
20
23
  end
21
24
  end
22
25
  end
@@ -1,12 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2024, by Samuel Williams.
5
5
 
6
- require_relative 'generic'
6
+ require_relative "generic"
7
+ require_relative "../clock"
7
8
 
8
9
  module Console
9
10
  module Event
11
+ # Represents a spawn event.
12
+ #
13
+ # ```ruby
14
+ # Console.info(self, **Console::Event::Spawn.for("ls", "-l"))
15
+ #
16
+ # event = Console::Event::Spawn.for("ls", "-l")
17
+ # event.status = Process.wait
18
+ # ```
10
19
  class Spawn < Generic
11
20
  def self.for(*arguments, **options)
12
21
  # Extract out the command environment:
@@ -22,44 +31,43 @@ module Console
22
31
  @environment = environment
23
32
  @arguments = arguments
24
33
  @options = options
34
+
35
+ @start_time = Clock.now
36
+
37
+ @end_time = nil
38
+ @status = nil
25
39
  end
26
40
 
27
- attr :environment
28
- attr :arguments
29
- attr :options
30
-
31
- def chdir_string(options)
32
- if options and chdir = options[:chdir]
33
- " in #{chdir}"
41
+ def duration
42
+ if @end_time
43
+ @end_time - @start_time
34
44
  end
35
45
  end
36
46
 
37
- def self.register(terminal)
38
- terminal[:shell_command] ||= terminal.style(:blue, nil, :bold)
39
- end
40
-
41
- def to_h
47
+ def to_hash
42
48
  Hash.new.tap do |hash|
49
+ hash[:type] = :spawn
43
50
  hash[:environment] = @environment if @environment&.any?
44
51
  hash[:arguments] = @arguments if @arguments&.any?
45
52
  hash[:options] = @options if @options&.any?
53
+
54
+ hash[:status] = @status.to_i if @status
55
+
56
+ if duration = self.duration
57
+ hash[:duration] = duration
58
+ end
46
59
  end
47
60
  end
48
61
 
49
- def format(output, terminal, verbose)
50
- arguments = @arguments.flatten.collect(&:to_s)
51
-
52
- output.puts " #{terminal[:shell_command]}#{arguments.join(' ')}#{terminal.reset}#{chdir_string(options)}"
53
-
54
- if verbose and @environment
55
- @environment.each do |key, value|
56
- output.puts " export #{key}=#{value}"
57
- end
58
- end
62
+ def emit(*arguments, **options)
63
+ options[:severity] ||= :info
64
+ super
65
+ end
66
+
67
+ def status=(status)
68
+ @end_time = Time.now
69
+ @status = status
59
70
  end
60
71
  end
61
72
  end
62
-
63
- # Deprecated.
64
- Shell = Event::Spawn
65
73
  end
data/lib/console/event.rb CHANGED
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2024, by Samuel Williams.
5
5
 
6
- require_relative 'event/spawn'
7
- require_relative 'event/failure'
8
- require_relative 'event/progress'
6
+ require_relative "event/spawn"
7
+ require_relative "event/failure"
@@ -1,17 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2022, by Samuel Williams.
4
+ # Copyright, 2019-2024, by Samuel Williams.
5
5
  # Copyright, 2019, by Bryan Powell.
6
6
  # Copyright, 2020, by Michael Adams.
7
7
  # Copyright, 2021, by Robert Schulze.
8
8
 
9
- require_relative 'buffer'
10
-
11
9
  module Console
12
- UNKNOWN = 'unknown'
10
+ UNKNOWN = :unknown
13
11
 
14
12
  class Filter
13
+ if Object.const_defined?(:Ractor) and RUBY_VERSION >= "3.1"
14
+ def self.define_immutable_method(name, &block)
15
+ block = Ractor.make_shareable(block)
16
+ self.define_method(name, &block)
17
+ end
18
+ else
19
+ def self.define_immutable_method(name, &block)
20
+ define_method(name, &block)
21
+ end
22
+ end
23
+
15
24
  def self.[] **levels
16
25
  klass = Class.new(self)
17
26
  minimum_level, maximum_level = levels.values.minmax
@@ -24,17 +33,17 @@ module Console
24
33
  levels.each do |name, level|
25
34
  const_set(name.to_s.upcase, level)
26
35
 
27
- define_method(name) do |subject = nil, *arguments, **options, &block|
36
+ define_immutable_method(name) do |subject = nil, *arguments, **options, &block|
28
37
  if self.enabled?(subject, level)
29
- self.call(subject, *arguments, severity: name, **options, **@options, &block)
38
+ @output.call(subject, *arguments, severity: name, **@options, **options, &block)
30
39
  end
31
40
  end
32
41
 
33
- define_method("#{name}!") do
42
+ define_immutable_method("#{name}!") do
34
43
  @level = level
35
44
  end
36
45
 
37
- define_method("#{name}?") do
46
+ define_immutable_method("#{name}?") do
38
47
  @level <= level
39
48
  end
40
49
  end
@@ -134,8 +143,13 @@ module Console
134
143
  @subjects.delete(subject)
135
144
  end
136
145
 
137
- def call(*arguments, **options, &block)
138
- @output.call(*arguments, **options, &block)
146
+ def call(subject, *arguments, **options, &block)
147
+ severity = options[:severity] || UNKNOWN
148
+ level = self.class::LEVELS[severity]
149
+
150
+ if self.enabled?(subject, level)
151
+ @output.call(subject, *arguments, **options, &block)
152
+ end
139
153
  end
140
154
  end
141
155
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2023-2024, by Samuel Williams.
5
+
6
+ require "json"
7
+
8
+ module Console
9
+ module Format
10
+ # This class is used to safely dump objects.
11
+ # It will attempt to dump the object using the given format, but if it fails, it will generate a safe version of the object.
12
+ class Safe
13
+ def initialize(format: ::JSON, limit: 8, encoding: ::Encoding::UTF_8)
14
+ @format = format
15
+ @limit = limit
16
+ @encoding = encoding
17
+ end
18
+
19
+ def dump(object)
20
+ @format.dump(object, @limit)
21
+ rescue SystemStackError, StandardError => error
22
+ @format.dump(safe_dump(object, error))
23
+ end
24
+
25
+ private
26
+
27
+ def filter_backtrace(error)
28
+ frames = error.backtrace
29
+ filtered = {}
30
+ filtered_count = nil
31
+ skipped = nil
32
+
33
+ frames = frames.filter_map do |frame|
34
+ if filtered[frame]
35
+ if filtered_count == nil
36
+ filtered_count = 1
37
+ skipped = frame.dup
38
+ else
39
+ filtered_count += 1
40
+ nil
41
+ end
42
+ else
43
+ if skipped
44
+ if filtered_count > 1
45
+ skipped.replace("[... #{filtered_count} frames skipped ...]")
46
+ end
47
+
48
+ filtered_count = nil
49
+ skipped = nil
50
+ end
51
+
52
+ filtered[frame] = true
53
+ frame
54
+ end
55
+ end
56
+
57
+ if skipped && filtered_count > 1
58
+ skipped.replace("[... #{filtered_count} frames skipped ...]")
59
+ end
60
+
61
+ return frames
62
+ end
63
+
64
+ def safe_dump(object, error)
65
+ object = safe_dump_recurse(object)
66
+
67
+ object[:truncated] = true
68
+ object[:error] = {
69
+ class: safe_dump_recurse(error.class.name),
70
+ message: safe_dump_recurse(error.message),
71
+ backtrace: safe_dump_recurse(filter_backtrace(error)),
72
+ }
73
+
74
+ return object
75
+ end
76
+
77
+ def replacement_for(object)
78
+ case object
79
+ when Array
80
+ "[...]"
81
+ when Hash
82
+ "{...}"
83
+ else
84
+ "..."
85
+ end
86
+ end
87
+
88
+ def default_objects
89
+ Hash.new.compare_by_identity
90
+ end
91
+
92
+ # This will recursively generate a safe version of the object.
93
+ # Nested hashes and arrays will be transformed recursively.
94
+ # Strings will be encoded with the given encoding.
95
+ # Primitive values will be returned as-is.
96
+ # Other values will be converted using `as_json` if available, otherwise `to_s`.
97
+ def safe_dump_recurse(object, limit = @limit, objects = default_objects)
98
+ if limit <= 0 || objects[object]
99
+ return replacement_for(object)
100
+ end
101
+
102
+ case object
103
+ when Hash
104
+ objects[object] = true
105
+
106
+ object.to_h do |key, value|
107
+ [
108
+ String(key).encode(@encoding, invalid: :replace, undef: :replace),
109
+ safe_dump_recurse(value, limit - 1, objects)
110
+ ]
111
+ end
112
+ when Array
113
+ objects[object] = true
114
+
115
+ object.map do |value|
116
+ safe_dump_recurse(value, limit - 1, objects)
117
+ end
118
+ when String
119
+ object.encode(@encoding, invalid: :replace, undef: :replace)
120
+ when Numeric, TrueClass, FalseClass, NilClass
121
+ object
122
+ else
123
+ objects[object] = true
124
+
125
+ # We could do something like this but the chance `as_json` will blow up.
126
+ # We'd need to be extremely careful about it.
127
+ # if object.respond_to?(:as_json)
128
+ # safe_dump_recurse(object.as_json, limit - 1, objects)
129
+ # else
130
+
131
+ safe_dump_recurse(object.to_s, limit - 1, objects)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2023-2024, by Samuel Williams.
5
+
6
+ require_relative "format/safe"
7
+
8
+ module Console
9
+ module Format
10
+ def self.default
11
+ Safe.new(format: ::JSON)
12
+ end
13
+ end
14
+ end