standard-procedure-plumbing 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ # Pipes, pipelines, valves and rubber ducks
2
+ module Plumbing
3
+ Config = Data.define :mode, :valve_proxy_classes, :timeout do
4
+ def valve_proxy_class_for target_class
5
+ valve_proxy_classes[target_class]
6
+ end
7
+
8
+ def register_valve_proxy_class_for target_class, proxy_class
9
+ valve_proxy_classes[target_class] = proxy_class
10
+ end
11
+ end
12
+ private_constant :Config
13
+
14
+ # Access the current configuration
15
+ # @return [Config]
16
+ def self.config
17
+ configs.last
18
+ end
19
+
20
+ # Configure the plumbing
21
+ # @param params [Hash] the configuration options
22
+ # @option mode [Symbol] the mode to use (:inline is the default, :async uses fibers)
23
+ # @option timeout [Integer] the timeout (in seconds) to use (30s is the default)
24
+ # @yield optional block - after the block has completed its execution, the configuration is restored to its previous state (useful for test suites)
25
+ def self.configure(**params, &block)
26
+ new_config = Config.new(**config.to_h.merge(params).merge(valve_proxy_classes: {}))
27
+ if block.nil?
28
+ set_configuration_to new_config
29
+ else
30
+ set_configuration_and_yield new_config, &block
31
+ end
32
+ end
33
+
34
+ def self.set_configuration_to config
35
+ configs << config
36
+ end
37
+ private_class_method :set_configuration_to
38
+
39
+ def self.set_configuration_and_yield(new_config, &block)
40
+ set_configuration_to new_config
41
+ yield
42
+ ensure
43
+ configs.pop
44
+ end
45
+ private_class_method :set_configuration_and_yield
46
+
47
+ def self.configs
48
+ @configs ||= [Config.new(mode: :inline, timeout: 30, valve_proxy_classes: {})]
49
+ end
50
+ private_class_method :configs
51
+ end
@@ -0,0 +1,15 @@
1
+ module Plumbing
2
+ # A pipe that can be subclassed to filter events from a source pipe
3
+ class CustomFilter < Pipe
4
+ # Chain this pipe to the source pipe
5
+ # @param source [Plumbing::Observable] the source from which to receive and filter events
6
+ def initialize source:
7
+ super()
8
+ source.as(Observable).add_observer { |event| received event }
9
+ end
10
+
11
+ protected
12
+
13
+ def received(event) = raise NoMethodError.new("Subclass should define #received")
14
+ end
15
+ end
@@ -10,10 +10,4 @@ module Plumbing
10
10
 
11
11
  # Error raised because an invalid [Event] object was pushed into the pipe
12
12
  class InvalidEvent < Error; end
13
-
14
- # Error raised because an invalid observer was registered
15
- class InvalidObserver < Error; end
16
-
17
- # Error raised because a Pipe was connected to a non-Pipe
18
- class InvalidSource < Plumbing::Error; end
19
13
  end
@@ -1,5 +1,4 @@
1
1
  module Plumbing
2
2
  # An immutable data structure representing an Event
3
- Event = Data.define :type, :data do
4
- end
3
+ Event = Data.define :type, :data
5
4
  end
@@ -1,21 +1,20 @@
1
+ require_relative "custom_filter"
1
2
  module Plumbing
2
3
  # A pipe that filters events from a source pipe
3
- class Filter < Pipe
4
+ class Filter < CustomFilter
4
5
  # Chain this pipe to the source pipe
5
- # @param source [Plumbing::Pipe]
6
+ # @param source [Plumbing::Observable] the source from which to receive and filter events
6
7
  # @param &accepts [Block] a block that returns a boolean value - true to accept the event, false to reject it
7
- def initialize source:, dispatcher: nil, &accepts
8
- super(dispatcher: dispatcher)
9
- raise InvalidSource.new "#{source} must be a Plumbing::Pipe descendant" unless source.is_a? Plumbing::Pipe
10
- @accepts = accepts
11
- source.add_observer do |event|
12
- filter_and_republish event
13
- end
8
+ # @yield [Plumbing::Event] event the event that is currently being processed
9
+ # @yieldreturn [Boolean] true to accept the event, false to reject it
10
+ def initialize source:, &accepts
11
+ super(source: source)
12
+ @accepts = accepts.as(Callable)
14
13
  end
15
14
 
16
- private
15
+ protected
17
16
 
18
- def filter_and_republish event
17
+ def received(event)
19
18
  return nil unless @accepts.call event
20
19
  dispatch event
21
20
  end
@@ -2,17 +2,16 @@ module Plumbing
2
2
  # A pipe that filters events from a source pipe
3
3
  class Junction < Pipe
4
4
  # Chain multiple sources to this pipe
5
- # @param [Array<Plumbing::Pipe>]
6
- def initialize *sources, dispatcher: nil
7
- super(dispatcher: dispatcher)
5
+ # @param sources [Array<Plumbing::Observable>] the sources which will be joined and relayed
6
+ def initialize *sources
7
+ super()
8
8
  @sources = sources.collect { |source| add(source) }
9
9
  end
10
10
 
11
11
  private
12
12
 
13
13
  def add source
14
- raise InvalidSource.new "#{source} must be a Plumbing::Pipe descendant" unless source.is_a? Plumbing::Pipe
15
- source.add_observer do |event|
14
+ source.as(Observable).add_observer do |event|
16
15
  dispatch event
17
16
  end
18
17
  source
data/lib/plumbing/pipe.rb CHANGED
@@ -1,12 +1,10 @@
1
1
  module Plumbing
2
2
  # A basic pipe
3
3
  class Pipe
4
- require_relative "event_dispatcher"
4
+ include Plumbing::Valve
5
5
 
6
- # Subclasses should call `super()` to ensure the pipe is initialised corrected
7
- def initialize dispatcher: nil
8
- @dispatcher = dispatcher || EventDispatcher.new
9
- end
6
+ command :notify, :<<, :remove_observer, :shutdown
7
+ query :add_observer, :is_observer?
10
8
 
11
9
  # Push an event into the pipe
12
10
  # @param event [Plumbing::Event] the event to push into the pipe
@@ -25,40 +23,33 @@ module Plumbing
25
23
  end
26
24
 
27
25
  # Add an observer to this pipe
28
- # @param callable [Proc] (optional)
29
- # @param &block [Block] (optional)
30
- # @return an object representing this observer (dependent upon the implementation of the pipe itself)
31
26
  # Either a `callable` or a `block` must be supplied. If the latter, it is converted to a [Proc]
32
- def add_observer(observer = nil, &)
33
- @dispatcher.add_observer(observer, &)
27
+ # @param callable [#call] (optional)
28
+ # @param &block [Block] (optional)
29
+ # @return [#call]
30
+ def add_observer(observer = nil, &block)
31
+ observer ||= block.to_proc
32
+ observers << observer.as(Callable).target
33
+ observer
34
34
  end
35
35
 
36
36
  # Remove an observer from this pipe
37
- # @param observer
38
- # This removes the given observer from this pipe. The observer should have previously been returned by #add_observer and is implementation-specific
37
+ # @param observer [#call] remove the observer from this pipe (where the observer was previously added by #add_observer)
39
38
  def remove_observer observer
40
- @dispatcher.remove_observer observer
39
+ observers.delete observer
41
40
  end
42
41
 
43
42
  # Test whether the given observer is observing this pipe
44
- # @param observer
45
- # @return [boolean]
43
+ # @param [#call] observer
44
+ # @return [Boolean]
46
45
  def is_observer? observer
47
- @dispatcher.is_observer? observer
46
+ observers.include? observer
48
47
  end
49
48
 
50
49
  # Close this pipe and perform any cleanup.
51
50
  # Subclasses should override this to perform their own shutdown routines and call `super` to ensure everything is tidied up
52
51
  def shutdown
53
- # clean up and release any observers, just in case
54
- @dispatcher.shutdown
55
- end
56
-
57
- # Start this pipe
58
- # Subclasses may override this method to add any implementation specific details.
59
- # By default any supplied parameters are called to the subclass' `initialize` method
60
- def self.start(*, **, &)
61
- new(*, **, &)
52
+ observers.clear
62
53
  end
63
54
 
64
55
  protected
@@ -68,7 +59,16 @@ module Plumbing
68
59
  # Enumerates all observers and `calls` them with this event
69
60
  # Discards any errors raised by the observer so that all observers will be successfully notified
70
61
  def dispatch event
71
- @dispatcher.dispatch event
62
+ observers.each do |observer|
63
+ observer.call event
64
+ rescue => ex
65
+ puts ex
66
+ ex
67
+ end
68
+ end
69
+
70
+ def observers
71
+ @observers ||= []
72
72
  end
73
73
  end
74
74
  end
@@ -1,14 +1,24 @@
1
1
  module Plumbing
2
2
  class Pipeline
3
+ # Validate input and output data with pre and post conditions or [Dry::Validation::Contract]s
3
4
  module Contracts
5
+ # @param name [Symbol] the name of the precondition
6
+ # @param &validator [Block] a block that returns a boolean value - true to accept the input, false to reject it
7
+ # @yield [Object] input the input data to be validated
8
+ # @yieldreturn [Boolean] true to accept the input, false to reject it
4
9
  def pre_condition name, &validator
5
10
  pre_conditions[name.to_sym] = validator
6
11
  end
7
12
 
13
+ # @param [String] contract_class the class name of the [Dry::Validation::Contract] that will be used to validate the input data
8
14
  def validate_with contract_class
9
15
  @validation_contract = contract_class
10
16
  end
11
17
 
18
+ # @param name [Symbol] the name of the postcondition
19
+ # @param &validator [Block] a block that returns a boolean value - true to accept the input, false to reject it
20
+ # @yield [Object] output the output data to be validated
21
+ # @yieldreturn [Boolean] true to accept the output, false to reject it
12
22
  def post_condition name, &validator
13
23
  post_conditions[name.to_sym] = validator
14
24
  end
@@ -25,19 +35,19 @@ module Plumbing
25
35
 
26
36
  def validate_contract_for input
27
37
  return true if @validation_contract.nil?
28
- result = const_get(@validation_contract).new.call(input)
38
+ result = const_get(@validation_contract).new.as(Callable).call(input)
29
39
  raise PreConditionError, result.errors.to_h.to_yaml unless result.success?
30
40
  input
31
41
  end
32
42
 
33
43
  def validate_preconditions_for input
34
- failed_preconditions = pre_conditions.select { |name, validator| !validator.call(input) }
44
+ failed_preconditions = pre_conditions.select { |name, validator| !validator.as(Callable).call(input) }
35
45
  raise PreConditionError, failed_preconditions.keys.join(", ") if failed_preconditions.any?
36
46
  input
37
47
  end
38
48
 
39
49
  def validate_postconditions_for output
40
- failed_postconditions = post_conditions.select { |name, validator| !validator.call(output) }
50
+ failed_postconditions = post_conditions.select { |name, validator| !validator.as(Callable).call(output) }
41
51
  raise PostConditionError, failed_postconditions.keys.join(", ") if failed_postconditions.any?
42
52
  output
43
53
  end
@@ -1,10 +1,26 @@
1
1
  module Plumbing
2
2
  class Pipeline
3
+ # Defining the operations that will be performed on the input data
3
4
  module Operations
5
+ # Add an operation to the pipeline
6
+ # Operations are processed in order, unless interrupted by an exception
7
+ # The output from the previous operation is fed in as the input to the this operation
8
+ # and the output from this operation is fed in as the input to the next operation
9
+ #
10
+ # @param method [Symbol] the method to be called on the input data
11
+ # @param using [String, Class] the optional class name or class that will be used to perform the operation
12
+ # @param &implementation [Block] the optional block that will be used to perform the operation (instead of calling a method)
13
+ # @yield [Object] input the input data to be processed
14
+ # @yieldreturn [Object] the output data
4
15
  def perform method, using: nil, &implementation
5
16
  using.nil? ? perform_internal(method, &implementation) : perform_external(method, using)
6
17
  end
7
18
 
19
+ # Add an operation which does not alter the input data to the pipeline
20
+ # The output from the previous operation is fed in as the input to the this operation
21
+ # but the output from this operation is discarded and the previous input is fed in to the next operation
22
+ #
23
+ # @param method [Symbol] the method to be called on the input data
8
24
  def execute method
9
25
  implementation ||= ->(input, instance) do
10
26
  instance.send(method, input)
@@ -13,12 +29,13 @@ module Plumbing
13
29
  operations << implementation
14
30
  end
15
31
 
32
+ # Internal use only
16
33
  def _call input, instance
17
34
  validate_contract_for input
18
35
  validate_preconditions_for input
19
36
  result = input
20
37
  operations.each do |operation|
21
- result = operation.call(result, instance)
38
+ result = operation.as(Callable).call(result, instance)
22
39
  end
23
40
  validate_postconditions_for result
24
41
  result
@@ -37,7 +54,7 @@ module Plumbing
37
54
 
38
55
  def perform_external method, class_or_class_name
39
56
  external_class = class_or_class_name.is_a?(String) ? const_get(class_or_class_name) : class_or_class_name
40
- implementation = ->(input, instance) { external_class.new.call(input) }
57
+ implementation = ->(input, instance) { external_class.new.as(Callable).call(input) }
41
58
  operations << implementation
42
59
  end
43
60
  end
@@ -7,6 +7,9 @@ module Plumbing
7
7
  extend Plumbing::Pipeline::Contracts
8
8
  extend Plumbing::Pipeline::Operations
9
9
 
10
+ # Start the pipeline operation with the given input
11
+ # @param input [Object] the input data to be processed
12
+ # @return [Object] the output data
10
13
  def call input
11
14
  self.class._call input, self
12
15
  end
@@ -0,0 +1,11 @@
1
+ module Plumbing
2
+ class RubberDuck
3
+ ::Object.class_eval do
4
+ # Cast the object to a duck-type
5
+ # @return [Plumbing::RubberDuck::Proxy] the duck-type proxy
6
+ def as duck_type
7
+ duck_type.proxy_for self
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,19 @@
1
+ module Plumbing
2
+ class RubberDuck
3
+ # Proxy object that forwards the duck-typed methods to the target object
4
+ class Proxy
5
+ attr_reader :target
6
+
7
+ def initialize target, duck_type
8
+ @target = target
9
+ @duck_type = duck_type
10
+ end
11
+
12
+ # Convert the proxy to the given duck-type, ensuring that existing proxies are not duplicated
13
+ # @return [Plumbing::RubberDuck::Proxy] the proxy for the given duck-type
14
+ def as duck_type
15
+ (duck_type == @duck_type) ? self : duck_type.proxy_for(target)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,59 @@
1
+ module Plumbing
2
+ # A type-checker for duck-types
3
+ class RubberDuck
4
+ require_relative "rubber_duck/object"
5
+ require_relative "rubber_duck/proxy"
6
+
7
+ def initialize *methods
8
+ @methods = methods.map(&:to_sym)
9
+ @proxy_classes = {}
10
+ end
11
+
12
+ # Verify that the given object responds to the required methods
13
+ # @param object [Object] the object to verify
14
+ # @return [Object] the object if it passes the verification
15
+ # @raise [TypeError] if the object does not respond to the required methods
16
+ def verify object
17
+ missing_methods = @methods.reject { |method| object.respond_to? method }
18
+ raise TypeError, "Expected object to respond to #{missing_methods.join(", ")}" unless missing_methods.empty?
19
+ object
20
+ end
21
+
22
+ # Test if the given object is a proxy
23
+ # @param object [Object] the object to test
24
+ # @return [Boolean] true if the object is a proxy, false otherwise
25
+ def proxy_for object
26
+ is_a_proxy?(object) || build_proxy_for(object)
27
+ end
28
+
29
+ # Define a new rubber duck type
30
+ # @param *methods [Array<Symbol>] the methods that the duck-type should respond to
31
+ def self.define *methods
32
+ new(*methods)
33
+ end
34
+
35
+ private
36
+
37
+ def is_a_proxy? object
38
+ @proxy_classes.value?(object.class) ? object : nil
39
+ end
40
+
41
+ def build_proxy_for object
42
+ proxy_class_for(object).new(verify(object), self)
43
+ end
44
+
45
+ def proxy_class_for object
46
+ @proxy_classes[object.class] ||= define_proxy_class_for(object.class)
47
+ end
48
+
49
+ def define_proxy_class_for klass
50
+ Class.new(Plumbing::RubberDuck::Proxy).tap do |proxy_class|
51
+ @methods.each do |method|
52
+ proxy_class.define_method method do |*args, &block|
53
+ @target.send method, *args, &block
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,6 @@
1
+ module Plumbing
2
+ Callable = RubberDuck.define :call
3
+ Observable = RubberDuck.define :add_observer, :remove_observer, :is_observer?
4
+ DispatchesEvents = RubberDuck.define :add_observer, :remove_observer, :is_observer?, :shutdown, :dispatch
5
+ Collection = RubberDuck.define :each, :<<, :delete, :include?
6
+ end
@@ -0,0 +1,42 @@
1
+ require "async"
2
+ require "async/semaphore"
3
+ require "timeout"
4
+
5
+ module Plumbing
6
+ module Valve
7
+ class Async
8
+ attr_reader :target
9
+
10
+ def initialize target
11
+ @target = target
12
+ @queue = []
13
+ @semaphore = ::Async::Semaphore.new(1)
14
+ end
15
+
16
+ # Ask the target to answer the given message
17
+ def ask(message, *args, **params, &block)
18
+ task = @semaphore.async do
19
+ @target.send message, *args, **params, &block
20
+ end
21
+ Timeout.timeout(timeout) do
22
+ task.wait
23
+ end
24
+ end
25
+
26
+ # Tell the target to execute the given message
27
+ def tell(message, *args, **params, &block)
28
+ @semaphore.async do |task|
29
+ @target.send message, *args, **params, &block
30
+ rescue
31
+ nil
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def timeout
38
+ Plumbing.config.timeout
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ module Plumbing
2
+ module Valve
3
+ class Inline
4
+ def initialize target
5
+ @target = target
6
+ end
7
+
8
+ # Ask the target to answer the given message
9
+ def ask(message, ...)
10
+ @target.send(message, ...)
11
+ end
12
+
13
+ # Tell the target to execute the given message
14
+ def tell(message, ...)
15
+ @target.send(message, ...)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ module Plumbing
2
+ module Valve
3
+ Message = Struct.new :message, :args, :params, :block, :result, :status
4
+ end
5
+ end
@@ -0,0 +1,71 @@
1
+ require_relative "valve/inline"
2
+
3
+ module Plumbing
4
+ module Valve
5
+ def self.included base
6
+ base.extend ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ # Create a new valve instance and build a proxy for it using the current mode
11
+ # @return [Plumbing::Valve::Base] the proxy for the valve instance
12
+ def start(*, **, &)
13
+ build_proxy_for(new(*, **, &))
14
+ end
15
+
16
+ # Define the queries that this valve can answer
17
+ # @param names [Array<Symbol>] the names of the queries
18
+ def query(*names) = queries.concat(names.map(&:to_sym))
19
+
20
+ # List the queries that this valve can answer
21
+ def queries = @queries ||= []
22
+
23
+ # Define the commands that this valve can execute
24
+ # @param names [Array<Symbol>] the names of the commands
25
+ def command(*names) = commands.concat(names.map(&:to_sym))
26
+
27
+ # List the commands that this valve can execute
28
+ def commands = @commands ||= []
29
+
30
+ def inherited subclass
31
+ subclass.commands.concat commands
32
+ subclass.queries.concat queries
33
+ end
34
+
35
+ private
36
+
37
+ def build_proxy_for(target)
38
+ proxy_class_for(target.class).new(target)
39
+ end
40
+
41
+ def proxy_class_for target_class
42
+ Plumbing.config.valve_proxy_class_for(target_class) || register_valve_proxy_class_for(target_class)
43
+ end
44
+
45
+ def proxy_base_class = const_get "Plumbing::Valve::#{Plumbing.config.mode.to_s.capitalize}"
46
+
47
+ def register_valve_proxy_class_for target_class
48
+ Plumbing.config.register_valve_proxy_class_for(target_class, build_proxy_class)
49
+ end
50
+
51
+ def build_proxy_class
52
+ Class.new(proxy_base_class).tap do |proxy_class|
53
+ queries.each do |query|
54
+ proxy_class.define_method query do |*args, **params, &block|
55
+ ask(query, *args, **params, &block)
56
+ end
57
+ end
58
+
59
+ commands.each do |command|
60
+ proxy_class.define_method command do |*args, **params, &block|
61
+ tell(command, *args, **params, &block)
62
+ nil
63
+ rescue
64
+ nil
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Plumbing
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/plumbing.rb CHANGED
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "plumbing/version"
4
-
5
- require_relative "plumbing/error"
6
- require_relative "plumbing/event"
7
- require_relative "plumbing/pipe"
8
- require_relative "plumbing/filter"
9
- require_relative "plumbing/junction"
10
- require_relative "plumbing/pipeline"
11
-
12
3
  module Plumbing
4
+ require_relative "plumbing/config"
5
+ require_relative "plumbing/valve"
6
+ require_relative "plumbing/rubber_duck"
7
+ require_relative "plumbing/types"
8
+ require_relative "plumbing/error"
9
+ require_relative "plumbing/event"
10
+ require_relative "plumbing/pipe"
11
+ require_relative "plumbing/filter"
12
+ require_relative "plumbing/junction"
13
+ require_relative "plumbing/pipeline"
14
+ require_relative "plumbing/version"
13
15
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard-procedure-plumbing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rahoul Baruah
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-25 00:00:00.000000000 Z
11
+ date: 2024-08-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A composable event pipeline and sequential pipelines of operations
14
14
  email:
@@ -31,17 +31,27 @@ files:
31
31
  - checksums/standard-procedure-plumbing-0.1.2.gem.sha512
32
32
  - checksums/standard-procedure-plumbing-0.2.0.gem.sha512
33
33
  - checksums/standard-procedure-plumbing-0.2.1.gem.sha512
34
+ - checksums/standard-procedure-plumbing-0.2.2.gem.sha512
35
+ - checksums/standard-procedure-plumbing-0.3.0.gem.sha512
34
36
  - lib/plumbing.rb
37
+ - lib/plumbing/config.rb
38
+ - lib/plumbing/custom_filter.rb
35
39
  - lib/plumbing/error.rb
36
40
  - lib/plumbing/event.rb
37
- - lib/plumbing/event_dispatcher.rb
38
- - lib/plumbing/event_dispatcher/fiber.rb
39
41
  - lib/plumbing/filter.rb
40
42
  - lib/plumbing/junction.rb
41
43
  - lib/plumbing/pipe.rb
42
44
  - lib/plumbing/pipeline.rb
43
45
  - lib/plumbing/pipeline/contracts.rb
44
46
  - lib/plumbing/pipeline/operations.rb
47
+ - lib/plumbing/rubber_duck.rb
48
+ - lib/plumbing/rubber_duck/object.rb
49
+ - lib/plumbing/rubber_duck/proxy.rb
50
+ - lib/plumbing/types.rb
51
+ - lib/plumbing/valve.rb
52
+ - lib/plumbing/valve/async.rb
53
+ - lib/plumbing/valve/inline.rb
54
+ - lib/plumbing/valve/message.rb
45
55
  - lib/plumbing/version.rb
46
56
  - sig/plumbing.rbs
47
57
  homepage: https://github.com/standard-procedure/plumbing