dependency_manager 0.0.1

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.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "dependency_manager"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,44 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "dependency_manager/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "dependency_manager"
8
+ spec.version = DependencyManager::VERSION
9
+ spec.authors = ["Brandon Weaver"]
10
+ spec.email = ["keystonelemur@gmail.com"]
11
+
12
+ spec.summary = %q{Dependency Management for applications with several dependencies}
13
+ spec.description =
14
+ %q{Manages and loads large collections of dependencies and wraps them into a service container for later consumption}
15
+ spec.homepage = "https://www.github/com/baweaver/dependency_manager"
16
+ spec.license = "MIT"
17
+
18
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
19
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
20
+ if spec.respond_to?(:metadata)
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = "https://www.github/com/baweaver/dependency_manager"
23
+ spec.metadata["changelog_uri"] = "https://www.github/com/baweaver/dependency_manager/CHANGELOG.md"
24
+ else
25
+ raise "RubyGems 2.0 or newer is required to protect against " \
26
+ "public gem pushes."
27
+ end
28
+
29
+ # Specify which files should be added to the gem when it is released.
30
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
31
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
32
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
33
+ end
34
+ spec.bindir = "exe"
35
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
36
+ spec.require_paths = ["lib"]
37
+
38
+ spec.add_development_dependency "bundler", "~> 2.0"
39
+ spec.add_development_dependency "rake", "~> 10.0"
40
+ spec.add_development_dependency "rspec", "~> 3.0"
41
+ spec.add_development_dependency "guard-rspec", "~> 4.7"
42
+
43
+ spec.add_runtime_dependency "dry-schema", "~> 1.5"
44
+ end
@@ -0,0 +1,9 @@
1
+ require "dependency_manager/version"
2
+
3
+ require "dependency_manager/container"
4
+ require "dependency_manager/dependency_tree"
5
+ require "dependency_manager/factory"
6
+ require "dependency_manager/resolver"
7
+
8
+ module DependencyManager
9
+ end
@@ -0,0 +1,74 @@
1
+ require 'dry/schema'
2
+
3
+ module DependencyManager
4
+ # Class-level methods for validation of configurations
5
+ module ConfigSchemaMacros
6
+ # Hook for binding class-level methods to the child class
7
+ #
8
+ # @param klass [Class]
9
+ # Factory to bind to
10
+ #
11
+ # @return [void]
12
+ def self.included(klass)
13
+ klass.extend(ClassMethods)
14
+ end
15
+
16
+ # Runs validation against configuration without throwing errors
17
+ #
18
+ # @param target: configuration [Hash[Symbol, Any]]
19
+ # Configuration to validate, defaulting to `configuration`
20
+ #
21
+ # @return [Dry::Validation::Result]
22
+ def validate(target: configuration)
23
+ self.class.validate(**target)
24
+ end
25
+
26
+ # Immediate return validation that will raise an exception if the contract
27
+ # is not fulfilled
28
+ #
29
+ # @param target: configuration [Hash[Symbol, Any]]
30
+ # Configuration to validate, defaulting to `configuration`
31
+ #
32
+ # @raises [ArgumentError]
33
+ # Failure
34
+ #
35
+ # @return [TrueClass]
36
+ # Success
37
+ def validate!(target: configuration)
38
+ validation_result = validate(target: target)
39
+
40
+ return true if validation_result.success?
41
+
42
+ errors = validation_result
43
+ .errors
44
+ .map { |e| "#{e.path} #{e.text}" }
45
+ .join(', ')
46
+
47
+ raise ArgumentError, "Configuration is invalid: #{errors}"
48
+ end
49
+
50
+ module ClassMethods
51
+ # Class-level macro for validations
52
+ #
53
+ # @see https://dry-rb.org/gems/dry-validation
54
+ #
55
+ # @param &dry_schema [Proc]
56
+ # Dry Schema to validate with
57
+ #
58
+ # @return [Dry::Schema]
59
+ def validate_with(&dry_schema)
60
+ @dry_schema = Dry::Schema.Params(&dry_schema)
61
+ end
62
+
63
+ # Runs validator
64
+ #
65
+ # @param **configuration [Hash[Symbol, Any]]
66
+ # Hash to validate with schema
67
+ #
68
+ # @return [Dry::Validation::Result]
69
+ def validate(**configuration)
70
+ @dry_schema.call(configuration)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,119 @@
1
+ require 'set'
2
+
3
+ module DependencyManager
4
+ class Container
5
+ # A container should only be built once
6
+ class BuildOnceError < ArgumentError; end
7
+
8
+ # You can't add new dependencies after a build
9
+ class AddedFactoryAfterBuildError < ArgumentError; end
10
+
11
+ attr_reader :dependencies
12
+
13
+ # Creates a new Dependency Container
14
+ #
15
+ # @param app_context: [Any]
16
+ # Contextual information for the currently running application
17
+ #
18
+ # @param configuration: [Hash[Symbol, Any]]
19
+ # Hash of configuration values, typically loaded from a YAML or JSON file
20
+ #
21
+ # @param factories: Factory.factories [Array[Factory]]
22
+ # All factories to build dependency chain from. This will default to the
23
+ # `Factory.factories` method, which will grab all children of the base
24
+ # `Factory` class.
25
+ #
26
+ # @return [Container]
27
+ def initialize(app_context:, configuration:, factories: Factory.factories)
28
+ @app_context = app_context
29
+ @configuration = configuration
30
+ @factories = factories.is_a?(Set) ? factories : Set[*factories]
31
+ @built = false
32
+ end
33
+
34
+ # Register a factory explicitly
35
+ #
36
+ # @param factory [type] [description]
37
+ #
38
+ # @return [type] [description]
39
+ def register(factory)
40
+ raise AddedFactoryAfterBuildError, "Cannot add Factories after Container has been built" if @built
41
+
42
+ @factories.add factory
43
+ end
44
+
45
+ # Builds all the dependencies from factories
46
+ #
47
+ # @return [Hash[Symbol, Any]]
48
+ # Built resources
49
+ def build
50
+ raise BuildOnceError, "Cannot build more than once" if @built
51
+
52
+ @dependency_tree = dependency_tree
53
+ @ordered_factory_dependencies = dependency_tree.tsort
54
+
55
+ @dependencies = {}
56
+
57
+ # Take the ordered factories
58
+ @ordered_factory_dependencies.each do |factory_name|
59
+ # Get their associated class
60
+ factory = DependencyManager::Factory.get(factory_name)
61
+
62
+ # Figure out which dependencies we need, which are optional, and which
63
+ # will break the factory build coming up
64
+ resolved_dependencies = Resolver.new(
65
+ factory: factory,
66
+ loaded_dependencies: dependencies
67
+ ).resolve
68
+
69
+ # Create an instance of the factory including its resolved dependencies.
70
+ factory_instance = factory.new(
71
+ app_context: @app_context,
72
+ factory_config: get_config(factory),
73
+ **resolved_dependencies
74
+ )
75
+
76
+ # ...and build the dependency based on the provided configuration options.
77
+ @dependencies[factory.dependency_name] = factory_instance.build
78
+ end
79
+
80
+ @built = true
81
+
82
+ @dependencies
83
+ end
84
+
85
+ # Fetch a dependency by name
86
+ #
87
+ # @param dependency [Symbol]
88
+ #
89
+ # @return [Any]
90
+ def fetch(dependency)
91
+ @dependencies.fetch(dependency)
92
+ end
93
+
94
+ # Listing of all dependencies
95
+ #
96
+ # @return [Hash[Symbol, Any]]
97
+ def to_h
98
+ @dependencies
99
+ end
100
+
101
+ def dependency_tree
102
+ DependencyTree.new(dependency_hash)
103
+ end
104
+
105
+ private def dependency_hash
106
+ @factories.map { |k| [k.name, k.factory_dependencies] }.to_h
107
+ end
108
+
109
+ # Gets the dependencies configuration from the master configuration.
110
+ #
111
+ # @param klass [Class]
112
+ # Class to get configuration for
113
+ #
114
+ # @return [Hash[Symbol, Any]]
115
+ private def get_config(klass)
116
+ @configuration.fetch(klass.dependency_name, {})
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,29 @@
1
+ require 'tsort'
2
+ require 'delegate'
3
+
4
+ module DependencyManager
5
+ # Dependency tree implementation using TSort to resolve the order in which
6
+ # factories should be run.
7
+ class DependencyTree < Delegator
8
+ include TSort
9
+
10
+ attr_reader :resources
11
+
12
+ # Allow access to the underlying hash
13
+ alias_method :__getobj__, :resources
14
+
15
+ def initialize(resources)
16
+ @resources = resources
17
+ end
18
+
19
+ # TSort interface method
20
+ def tsort_each_node(&block)
21
+ @resources.each_key(&block)
22
+ end
23
+
24
+ # TSort interface method
25
+ def tsort_each_child(node, &block)
26
+ @resources.fetch(node).each(&block)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,257 @@
1
+ require 'dependency_manager/config_schema_macros'
2
+ require 'set'
3
+
4
+ module DependencyManager
5
+ # Base for all other factories, providing interface hints and generic
6
+ # functionality
7
+ #
8
+ # ### Initialize for Dependency Specifications
9
+ #
10
+ # Every keyword argument used in the `initialize` function for a Factory
11
+ # is used to resolve the dependencies of the class with the exception of
12
+ # `CONTEXT_DEPENDENCIES`.
13
+ #
14
+ # `:keyreq` represents a required argument, while `:key` represents an
15
+ # optional one:
16
+ #
17
+ # ```ruby
18
+ # def initialize(logger:, optional_dependency: nil, **dependencies)
19
+ # super(**dependencies)
20
+ #
21
+ # @logger = logger
22
+ # @optional_dependency = optional_dependency
23
+ # end
24
+ # ```
25
+ #
26
+ # This value could be `nil` or any other sane default value for the
27
+ # dependency specified.
28
+ #
29
+ # The `Factory` implements several helper methods on its singleton class
30
+ # like `dependencies`, `optional_dependencies`, and `factory_dependencies` to
31
+ # help with constructing dependency chains.
32
+ class Factory
33
+ # Methods for validating configurations
34
+ include ConfigSchemaMacros
35
+
36
+ # Dependencies that are always present and injected at a top level
37
+ # rather than by other factories
38
+ CONTEXT_DEPENDENCIES = %i(app_context factory_config)
39
+
40
+ # Keyword param types
41
+ KEYWORD_ARGS = %i(keyreq key)
42
+ OPTIONAL_ARG = :key
43
+
44
+ class << self
45
+ # Captures classes inheriting from Factory for later use
46
+ #
47
+ # @param subclass [Class]
48
+ # The subclass
49
+ #
50
+ # @return [void]
51
+ def inherited(subclass)
52
+ @factories ||= Set.new
53
+ @factories.add subclass
54
+ end
55
+
56
+ # Get all available factory names except the Base factory
57
+ #
58
+ # @return [Array[Symbol]]
59
+ def factories
60
+ @factories || Set.new
61
+ end
62
+
63
+ # Get a factory by its underscored name
64
+ #
65
+ # @param factory_name [Symbol]
66
+ #
67
+ # @return [Symbol] Constant name
68
+ def get(factory_name)
69
+ const_name = constantize(factory_name)
70
+
71
+ unless const_defined?(const_name)
72
+ raise ArgumentError, "Tried to get non-existant Factory. Did you remember to define it?: #{const_name}"
73
+ end
74
+
75
+ const_get const_name
76
+ end
77
+
78
+ # Utility to constantize an underscored string or symbol
79
+ #
80
+ # @param s [String, Symbol]
81
+ #
82
+ # @return [Symbol]
83
+ def constantize(s)
84
+ s.to_s.split('_').map(&:capitalize).join.to_sym
85
+ end
86
+
87
+ def const_name
88
+ to_s.split('::').last
89
+ end
90
+
91
+ # Name of the factory
92
+ #
93
+ # @return [String]
94
+ def name
95
+ underscore const_name
96
+ end
97
+
98
+ # Name of the expected dependency to be generated
99
+ #
100
+ # @return [Symbol]
101
+ def dependency_name
102
+ name.to_s.sub(/_factory$/, '').to_sym
103
+ end
104
+
105
+ def parameters
106
+ instance_method(:initialize).parameters
107
+ end
108
+
109
+ # Dependencies of the class under the factory that it needs to initialize.
110
+ #
111
+ # @return [Array[Symbol]]
112
+ def dependencies
113
+ dependencies = parameters
114
+ .select { |type, _name| KEYWORD_ARGS.include?(type) }
115
+ .map(&:last)
116
+
117
+ dependencies - CONTEXT_DEPENDENCIES
118
+ end
119
+
120
+ # Dependencies of the factory itself to make sure factories load in the
121
+ # correct order.
122
+ #
123
+ # @return [Array[Symbol]]
124
+ def factory_dependencies
125
+ dependencies.map { |d| "#{d}_factory".to_sym }
126
+ end
127
+
128
+ # Dependencies required to build the factory
129
+ #
130
+ # @return [Array[Symbol]]
131
+ def required_dependencies
132
+ dependencies - optional_dependencies
133
+ end
134
+
135
+ # Optional arguments that are not strictly required, but used
136
+ # for additional functionality.
137
+ #
138
+ # @return [Array[Symbol]]
139
+ def optional_dependencies
140
+ optionals = parameters
141
+ .select { |type, _name| type == OPTIONAL_ARG }
142
+ .map(&:last)
143
+
144
+ optionals - CONTEXT_DEPENDENCIES
145
+ end
146
+
147
+ # Underscores a constant name
148
+ #
149
+ # @param const_name [Symbol]
150
+ #
151
+ # @return [Symbol]
152
+ def underscore(const_name)
153
+ const_name.gsub(/([^\^])([A-Z])/,'\1_\2').downcase.to_sym
154
+ end
155
+ end
156
+
157
+ # Creates a new Factory.
158
+ #
159
+ # @param app_context: nil [AppContext]
160
+ # Application context information. Defaulted to `nil` in case users
161
+ # do not need this information.
162
+ #
163
+ # @param factory_config: [Hash[Symbol, Any]]
164
+ # Configuration specific to the factory
165
+ #
166
+ # @return [Factory]
167
+ def initialize(app_context: nil, factory_config:)
168
+ @app_context = app_context
169
+ @factory_config = factory_config
170
+ end
171
+
172
+ # Used to build the dependency
173
+ #
174
+ # @raise [NotImplementedError]
175
+ def build
176
+ raise NotImplementedError
177
+ end
178
+
179
+ # Used to generate configuration for the dependency,
180
+ # not always necessary for shorter builds.
181
+ #
182
+ # As a default will be an alias for `@factory_config`,
183
+ # and is used as a hook point for any validation.
184
+ #
185
+ # @raise [Hash[Symbol, Any]]
186
+ def configuration
187
+ @configuration ||= deep_merge(default_configuration, @factory_config)
188
+ end
189
+
190
+ # Default configuration of the Factory
191
+ #
192
+ # @return [Hash[Symbol, Any]]
193
+ def default_configuration
194
+ {}
195
+ end
196
+
197
+ # Used to load and require any associated external dependencies.
198
+ #
199
+ # @raise [NotImplementedError]
200
+ def load_requirements
201
+ raise NotImplementedError
202
+ end
203
+
204
+ # Whether or not the dependency should be enabled. It is suggested to
205
+ # use this as a guard when building dependencies:
206
+ #
207
+ # ```ruby
208
+ # def build
209
+ # return unless enabled?
210
+ #
211
+ # # ...
212
+ # end
213
+ # ```
214
+ #
215
+ # @return [FalseClass] Disabled by default
216
+ def enabled?
217
+ false
218
+ end
219
+
220
+ # Deeply merges two Hashes
221
+ #
222
+ # @param a [Hash]
223
+ # Original Hash
224
+ #
225
+ # @param b [Hash]
226
+ # Hash to merge
227
+ #
228
+ # @param &fn [Proc[Any, Any, Any]]
229
+ # Merging function
230
+ #
231
+ # @return [Hash]
232
+ protected def deep_merge(a, b, &fn)
233
+ deep_merge!(a.dup, b.dup, &fn)
234
+ end
235
+
236
+ # Destructive merge of two hashes
237
+ #
238
+ # @param a [Hash]
239
+ # Original Hash
240
+ #
241
+ # @param b [Hash]
242
+ # Hash to merge
243
+ #
244
+ # @param &fn [Proc[Any, Any, Any]]
245
+ # Merging function
246
+ #
247
+ # @return [Hash]
248
+ protected def deep_merge!(a, b, &fn)
249
+ a.merge!(b) do |key, left, right|
250
+ next deep_merge(left, right, &fn) if left.is_a?(Hash) && right.is_a?(Hash)
251
+ next fn.call(left, right, &fn) if block_given?
252
+
253
+ right
254
+ end
255
+ end
256
+ end
257
+ end