standard-procedure-plumbing 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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