modulation 0.10 → 0.11

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59463e46fe92ef35c671e713faccfef2cc66434cc50d82c0098e9b585f9716e6
4
- data.tar.gz: 35f0cdf9e58f5d4482008d768ce9b70ab04209bc311d132bdca5aadcb7afabc1
3
+ metadata.gz: c196472c3122b723a0d76849c604cb6b8ce13cc2613cb418bf8ecbf9c794bbe8
4
+ data.tar.gz: cd027cc00a894ed92c43db09570ee301b82efb002d410d6daf9a584aca8c8357
5
5
  SHA512:
6
- metadata.gz: 4f5e19c1025c591fe4db182222092341018eed467b0fa8cf1ecdf79d51da554ab934b105505c62c0e57c18a69cfa566a92df1d34db6f7bf76eac4e647e4d5e95
7
- data.tar.gz: d3bd42db5a9d6bf57121b433e78a1abe3121f77f25dea0c517226ff95e3c14e1b83b667877bf150df172966676578ea956bb05577c02b3b6597003f8ff42c328
6
+ metadata.gz: 9c4d82cb9ae8ea84364dffbdd56603bf7db864d4254a587545436c983e157d315068cde11e51fbfec84cb8db08382769506e8f5273f70a64f4440d56150b18f4
7
+ data.tar.gz: bf00f6e03d79ce2a2c77322aee2f433780989030cceffc031421dcec419c1f9e7bf4a9c8cf178864942e12ceb713760e4b5634a430b5b21e5840e342608d151a
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Modulation - better dependency management for Ruby
2
2
 
3
3
  [INSTALL](#installing-modulation) |
4
- [GUIDE](#developing-with-modulation) |
4
+ [GUIDE](#organizing-your-code-with-modulation) |
5
5
  [EXAMPLES](https://github.com/ciconia/modulation/tree/master/examples) |
6
6
  [DOCS](https://www.rubydoc.info/gems/modulation/)
7
7
 
@@ -18,12 +18,18 @@ code in a functional style, with a minimum of boilerplate code.
18
18
 
19
19
  ## Features
20
20
 
21
- - Complete isolation of each module for better control of dependencies.
22
- - Explicit exporting of methods, classes, modules and other constants.
23
- - Default exports for modules exporting a single class or value.
24
- - Nested namespaces with explicit exports.
25
- - Modules can be reloaded at runtime without breaking dependencies.
26
- - Can be used to write gems.
21
+ - Provides complete isolation of each module: constant declarations in one file
22
+ don't leak into another.
23
+ - Supports circular dependencies.
24
+ - Enforces explicit exporting and importing of methods, classes, modules and
25
+ constants.
26
+ - Allows [default exports](#default-exports) for modules exporting a single
27
+ class or value.
28
+ - Can [reload](#reloading-modules) modules at runtime without breaking your
29
+ code in wierd ways.
30
+ - Supports [nested namespaces](#using-nested-namespaces) with explicit exports.
31
+ - Allows [mocking of dependencies](#mocking-dependencies) for testing purposes.
32
+ - Can be used to [write gems](#writing-gems-using-modulation).
27
33
 
28
34
  ## Rationale
29
35
 
@@ -80,14 +86,16 @@ $ gem install modulation
80
86
 
81
87
  ## Organizing your code with Modulation
82
88
 
83
- Modulation enhances the idea of a Ruby module as a ["collection of methods and constants"](https://ruby-doc.org/core-2.5.1/Module.html).
89
+ Modulation builds on the idea of a Ruby module as a
90
+ ["collection of methods and constants"](https://ruby-doc.org/core-2.5.1/Module.html).
84
91
  Using modulation, any Ruby source file can be a module. Modules usually export
85
92
  method and constant declarations (usually an API for a specific, well-defined
86
93
  functionality) to be shared with other modules. Modules can also import
87
94
  declarations from other modules.
88
95
 
89
- Each module is loaded and evaluated in the context of a newly-created `Module`,
90
- then transformed into a class and handed off to the importing module.
96
+ Each module is evaluated in the context of a newly-created `Module` instance,
97
+ with some additional methods that make it possible to identify the module's
98
+ source location and reload it.
91
99
 
92
100
  ### Exporting declarations
93
101
 
@@ -189,11 +197,12 @@ export_default(
189
197
 
190
198
  *app.rb*
191
199
  ```ruby
200
+ require 'modulation'
192
201
  config = import('./config')
193
202
  db.connect(config[:host], config[:port])
194
203
  ```
195
204
 
196
- ### Further organising module functionality into nested namespaces
205
+ ### Using nested namespaces
197
206
 
198
207
  Code inside modules can be further organised by separating it into nested
199
208
  namespaces. The `export` method can be used to turn a normal nested module
@@ -243,6 +252,7 @@ end
243
252
  Sequences.fib(5)
244
253
 
245
254
  # extend integers
255
+ require 'modulation'
246
256
  class Integer
247
257
  include_from('./seq.rb')
248
258
 
@@ -293,11 +303,43 @@ end
293
303
  what = ::MEANING_OF_LIFE
294
304
  ```
295
305
 
306
+ ### Mocking dependencies
307
+
308
+ Modules loaded by Modulation can be easily mocked when running tests or specs,
309
+ using `Modulation.mock`:
310
+
311
+ ```ruby
312
+ require 'minitest/autorun'
313
+ require 'modulation'
314
+
315
+ module MockStorage
316
+ extend self
317
+
318
+ def get_user(user_id)
319
+ {
320
+ user_id: user_id,
321
+ name: 'John Doe',
322
+ email: 'johndoe@gmail.com'
323
+ }
324
+ end
325
+ end
326
+
327
+ class UserControllerTest < Minitest::Test
328
+ def test_user_storage
329
+ Modulation.mock('../lib/storage', MockStorage) do
330
+ controller = UserController.
331
+ assert_equal
332
+ end
333
+ end
334
+ end
335
+ ```
336
+
296
337
  ### Reloading modules
297
338
 
298
339
  Modules can be easily reloaded in order to implement hot code reloading:
299
340
 
300
341
  ```ruby
342
+ require 'modulation'
301
343
  SQL = import('./sql')
302
344
  ...
303
345
  SQL.__reload!
@@ -321,6 +363,7 @@ exported value with a `#__reload!` method. The value will need to be
321
363
  reassigned:
322
364
 
323
365
  ```ruby
366
+ require 'modulation'
324
367
  settings = import('settings')
325
368
  ...
326
369
  settings = settings.__reload!
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modulation
4
+ # Implements creation of module instances
5
+ module Builder
6
+ extend self
7
+
8
+ # Loads a module from file or block, wrapping it in a module facade
9
+ # @param info [Hash] module info
10
+ # @param block [Proc] module block
11
+ # @return [Class] module facade
12
+ def make(info, &block)
13
+ default = nil
14
+ mod = create(info) { |default_info| default = default_info }
15
+ Modulation.loaded_modules[info[:location]] = mod
16
+ load_module_code(mod, info, &block)
17
+ if default
18
+ set_module_default_value(default[:value], info, mod, default[:caller])
19
+ else
20
+ set_exported_symbols(mod, mod.__exported_symbols, true)
21
+ mod
22
+ end
23
+ end
24
+
25
+ # Initializes a new module ready to evaluate a file module
26
+ # @note The given block is used to pass the value given to `export_default`
27
+ # @param info [Hash] module info
28
+ # @return [Module] new module
29
+ def create(info, &export_default_block)
30
+ Module.new.tap do |mod|
31
+ mod.extend(mod)
32
+ mod.extend(ModuleMixin)
33
+ mod.__module_info = info
34
+ mod.__export_default_block = export_default_block
35
+ mod.const_set(:MODULE, mod)
36
+ end
37
+ end
38
+
39
+ # Loads a source file or a block into the given module
40
+ # @param mod [Module] module
41
+ # @param info [Hash] module info
42
+ # @return [void]
43
+ def load_module_code(mod, info, &block)
44
+ old_top_level_module = Modulation.top_level_module
45
+ Modulation.top_level_module = mod
46
+ if block
47
+ mod.module_eval(&block)
48
+ else
49
+ path = info[:location]
50
+ mod.module_eval(IO.read(path), path)
51
+ end
52
+ ensure
53
+ Modulation.top_level_module = old_top_level_module
54
+ end
55
+
56
+ # Marks all non-exported methods as private
57
+ # @param mod [Module] module with exported symbols
58
+ # @param symbols [Array] array of exported symbols
59
+ # @param perform_deferred_exports [Boolean] perform deferred export flag
60
+ # @return [void]
61
+ def set_exported_symbols(mod, symbols, perform_deferred_exports = false)
62
+ mod.__perform_deferred_namespace_exports if perform_deferred_exports
63
+
64
+ mod.instance_methods.each do |sym|
65
+ next if symbols.include?(sym)
66
+ mod.send(:private, sym)
67
+ end
68
+ mod.constants.each do |sym|
69
+ next if sym == :MODULE || symbols.include?(sym)
70
+ mod.send(:private_constant, sym)
71
+ end
72
+ end
73
+
74
+ # Returns exported value for a default export
75
+ # If the given value is a symbol, returns the value of the corresponding
76
+ # constant.
77
+ # @param value [any] export_default value
78
+ # @param mod [Module] module
79
+ # @return [any] exported value
80
+ def transform_export_default_value(value, mod)
81
+ value.is_a?(Symbol) ? mod.const_get(value) : value
82
+ rescue NameError
83
+ value
84
+ end
85
+
86
+ # Loads code for a module being reloaded, turning warnings off in order to
87
+ # not generate warnings upon re-assignment of constants
88
+ def reload_module_code(mod)
89
+ orig_verbose = $VERBOSE
90
+ $VERBOSE = nil
91
+ load_module_code(mod, mod.__module_info)
92
+ ensure
93
+ $VERBOSE = orig_verbose
94
+ end
95
+
96
+ # Removes methods and constants from module
97
+ # @param mod [Module] module
98
+ # @return [void]
99
+ def cleanup_module(mod)
100
+ mod.constants(false).each { |c| mod.send(:remove_const, c) }
101
+ mod.methods(false).each { |sym| mod.send(:undef_method, sym) }
102
+
103
+ private_methods = mod.private_methods(false) -
104
+ Module.private_instance_methods(false)
105
+ private_methods.each { |sym| mod.send(:undef_method, sym) }
106
+
107
+ mod.__exported_symbols.clear
108
+ end
109
+
110
+ # Error message to be displayed when trying to set a singleton value as
111
+ # default export
112
+ DEFAULT_VALUE_ERROR_MSG =
113
+ 'Default export cannot be boolean, numeric, or symbol'
114
+
115
+ # Sets the default value for a module using export_default
116
+ # @param value [any] default value
117
+ # @param info [Hash] module info
118
+ # @param mod [Module] module
119
+ # @return [any] default value
120
+ def set_module_default_value(value, info, mod, caller)
121
+ value = transform_export_default_value(value, mod)
122
+ case value
123
+ when nil, true, false, Numeric, Symbol
124
+ raise(TypeError, DEFAULT_VALUE_ERROR_MSG, caller)
125
+ end
126
+ set_reload_info(value, mod.__module_info)
127
+ Modulation.loaded_modules[info[:location]] = value
128
+ end
129
+
130
+ # Adds methods for module_info and reloading to a value exported as default
131
+ # @param value [any] export_default value
132
+ # @param info [Hash] module info
133
+ # @return [void]
134
+ def set_reload_info(value, info)
135
+ value.define_singleton_method(:__module_info) { info }
136
+ value.define_singleton_method(:__reload!) do
137
+ Modulation::Builder.make(info)
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Implements main Modulation functionality
4
+ module Modulation
5
+ require_relative './paths'
6
+ require_relative './builder'
7
+ require_relative './module_mixin'
8
+
9
+ extend self
10
+
11
+ # @return [Hash] hash of loaded modules, mapping absolute paths to modules
12
+ attr_reader :loaded_modules
13
+
14
+ # @return [Module] currently loaded top-level module
15
+ attr_accessor :top_level_module
16
+
17
+ # Resets the loaded modules hash
18
+ def reset!
19
+ @loaded_modules = {}
20
+ end
21
+
22
+ # Show full backtrace for errors occuring while loading a module. Normally
23
+ # Modulation will remove stack frames occurring inside the modulation.rb code
24
+ # in order to make backtraces more readable when debugging.
25
+ def full_backtrace!
26
+ @full_backtrace = true
27
+ end
28
+
29
+ # Imports a module from a file
30
+ # If the module is already loaded, returns the loaded module.
31
+ # @param path [String] unqualified file name
32
+ # @param caller_location [String] caller location
33
+ # @return [Module] loaded module object
34
+ def import(path, caller_location = caller(1..1).first)
35
+ path = Paths.absolute_path(path, caller_location)
36
+ @loaded_modules[path] || create_module_from_file(path)
37
+ end
38
+
39
+ # Creates a new module from a source file
40
+ # @param path [String] source file name
41
+ # @return [Module] module
42
+ def create_module_from_file(path)
43
+ Builder.make(location: path)
44
+ rescue StandardError => e
45
+ @full_backtrace ? raise : raise_with_clean_backtrace(e)
46
+ end
47
+
48
+ # (Re-)raises an error, filtering its backtrace to remove stack frames
49
+ # occuring in Modulation code
50
+ # @param error [Error] raised error
51
+ # @return [void]
52
+ def raise_with_clean_backtrace(error)
53
+ backtrace = error.backtrace.reject { |l| l.include?(__FILE__) }
54
+ raise(error, error.message, backtrace)
55
+ end
56
+
57
+ # Reloads the given module from its source file
58
+ # @param mod [Module, String] module to reload
59
+ # @return [Module] module
60
+ def reload(mod)
61
+ if mod.is_a?(String)
62
+ path = mod
63
+ mod = @loaded_modules[File.expand_path(mod)]
64
+ raise "No module loaded from #{path}" unless mod
65
+ end
66
+
67
+ Builder.cleanup_module(mod)
68
+ Builder.reload_module_code(mod)
69
+
70
+ mod.tap { Builder.set_exported_symbols(mod, mod.__exported_symbols, true) }
71
+ end
72
+
73
+ # Maps the given path to the given mock module, restoring the previously
74
+ # loaded module (if any) after calling the given block
75
+ # @param path [String] module path
76
+ # @param mod [Module] module
77
+ # @param caller_location [String] caller location
78
+ # @return [void]
79
+ def mock(path, mod, caller_location = caller(1..1).first)
80
+ path = Paths.absolute_path(path, caller_location)
81
+ old_module = @loaded_modules[path]
82
+ @loaded_modules[path] = mod
83
+ yield if block_given?
84
+ ensure
85
+ @loaded_modules[path] = old_module if block_given?
86
+ end
87
+ end
88
+
89
+ Modulation.reset!
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Kernel extensions
4
+ module Kernel
5
+ # Returns an encapsulated imported module.
6
+ # @param path [String] module file name
7
+ # @param caller_location [String] caller location
8
+ # @return [Class] module facade
9
+ def import(path, caller_location = caller(1..1).first)
10
+ Modulation.import(path, caller_location)
11
+ end
12
+ end
13
+
14
+ # Module extensions
15
+ class Module
16
+ # Exports symbols from a namespace module declared inside an importable
17
+ # module. Exporting the actual symbols is deferred until the entire code
18
+ # has been loaded
19
+ # @param symbols [Array] array of symbols
20
+ # @return [void]
21
+ def export(*symbols)
22
+ unless Modulation.top_level_module
23
+ raise NameError, "Can't export symbols outside of an imported module"
24
+ end
25
+
26
+ extend self
27
+ Modulation.top_level_module.__defer_namespace_export(self, symbols)
28
+ end
29
+
30
+ # Extends the receiver with exported methods from the given file name
31
+ # @param path [String] module filename
32
+ # @return [void]
33
+ def extend_from(path)
34
+ mod = import(path, caller(1..1).first)
35
+ mod.instance_methods(false).each do |sym|
36
+ self.class.send(:define_method, sym, mod.method(sym).to_proc)
37
+ end
38
+ end
39
+
40
+ # Includes exported methods from the given file name in the receiver
41
+ # The module's methods will be available as instance methods
42
+ # @param path [String] module filename
43
+ # @return [void]
44
+ def include_from(path)
45
+ mod = import(path, caller(1..1).first)
46
+ mod.instance_methods(false).each do |sym|
47
+ send(:define_method, sym, mod.method(sym).to_proc)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modulation
4
+ # Extension methods for loaded modules
5
+ module ModuleMixin
6
+ # read and write module information
7
+ attr_accessor :__module_info
8
+
9
+ # Adds given symbols to the exported_symbols array
10
+ # @param symbols [Array] array of symbols
11
+ # @return [void]
12
+ def export(*symbols)
13
+ symbols = symbols.first if symbols.first.is_a?(Array)
14
+ __exported_symbols.concat(symbols)
15
+ end
16
+
17
+ # Sets a module's value, so when imported it will represent the given value,
18
+ # instead of a module facade
19
+ # @param value [Symbol, any] symbol or value
20
+ # @return [void]
21
+ def export_default(value)
22
+ @__export_default_block&.call(value: value, caller: caller)
23
+ end
24
+
25
+ # Returns a text representation of the module for inspection
26
+ # @return [String] module string representation
27
+ def inspect
28
+ module_name = name || 'Module'
29
+ if __module_info[:location]
30
+ "#{module_name}:#{__module_info[:location]}"
31
+ else
32
+ module_name
33
+ end
34
+ end
35
+
36
+ # Sets export_default block, used for setting the returned module object to
37
+ # a class or constant
38
+ # @param block [Proc] default export block
39
+ # @return [void]
40
+ def __export_default_block=(block)
41
+ @__export_default_block = block
42
+ end
43
+
44
+ # Reload module
45
+ # @return [Module] module
46
+ def __reload!
47
+ Modulation.reload(self)
48
+ end
49
+
50
+ # Defers exporting of symbols for a namespace (nested module), to be
51
+ # performed after the entire module has been loaded
52
+ # @param namespace [Module] namespace module
53
+ # @param symbols [Array] array of symbols
54
+ # @return [void]
55
+ def __defer_namespace_export(namespace, symbols)
56
+ @__namespace_exports ||= Hash.new { |h, k| h[k] = [] }
57
+ @__namespace_exports[namespace].concat(symbols)
58
+ end
59
+
60
+ # Performs exporting of symbols for all namespaces defined in the module,
61
+ # marking unexported methods and constants as private
62
+ # @return [void]
63
+ def __perform_deferred_namespace_exports
64
+ return unless @__namespace_exports
65
+
66
+ @__namespace_exports.each do |m, symbols|
67
+ Builder.set_exported_symbols(m, symbols)
68
+ end
69
+ end
70
+
71
+ # Returns exported_symbols array
72
+ # @return [Array] array of exported symbols
73
+ def __exported_symbols
74
+ @__exported_symbols ||= []
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Modulation
4
+ # Implements methods for expanding relative or incomplete module file names
5
+ module Paths
6
+ extend self
7
+
8
+ # Regexp for extracting filename from caller reference
9
+ CALLER_FILE_REGEXP = /^([^\:]+)\:/
10
+
11
+ # Resolves the absolute path to the provided reference. If the file is not
12
+ # found, will try to resolve to a gem
13
+ # @param path [String] unqualified file name
14
+ # @param caller_location [String] caller location
15
+ # @return [String] absolute file name
16
+ def absolute_path(path, caller_location = caller(1..1).first)
17
+ orig_path = path
18
+ caller_file = caller_location[CALLER_FILE_REGEXP, 1]
19
+ raise 'Could not expand path' unless caller_file
20
+
21
+ path = File.expand_path(path, File.dirname(caller_file))
22
+ check_path(path) || lookup_gem(orig_path) ||
23
+ (raise "Module not found: #{path}")
24
+ end
25
+
26
+ # Checks that the given path references an existing file, adding the .rb
27
+ # extension if needed
28
+ # @param path [String] absolute file path (with/without .rb extension)
29
+ # @return [String, nil] path of file or nil if not found
30
+ def check_path(path)
31
+ if File.file?("#{path}.rb")
32
+ path + '.rb'
33
+ elsif File.file?(path)
34
+ path
35
+ end
36
+ end
37
+
38
+ # Resolves the provided file name into a gem. If no gem is found, returns
39
+ # nil
40
+ # @param name [String] gem name
41
+ # @return [String] absolute path to gem main source file
42
+ def lookup_gem(name)
43
+ spec = Gem::Specification.find_by_name(name)
44
+ unless spec.dependencies.map(&:name).include?('modulation')
45
+ raise NameError, 'Cannot import gem not based on modulation'
46
+ end
47
+ path = File.join(spec.full_require_paths, "#{name}.rb")
48
+ File.file?(path) ? path : nil
49
+ rescue Gem::MissingSpecError
50
+ nil
51
+ end
52
+ end
53
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: modulation
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.10'
4
+ version: '0.11'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-08-19 00:00:00.000000000 Z
11
+ date: 2018-08-20 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: "Modulation provides an better way to organize Ruby code. Modulation
14
14
  lets you \nexplicitly import and export declarations in order to better control
@@ -22,7 +22,12 @@ extra_rdoc_files:
22
22
  files:
23
23
  - README.md
24
24
  - lib/modulation.rb
25
+ - lib/modulation/builder.rb
26
+ - lib/modulation/core.rb
27
+ - lib/modulation/ext.rb
25
28
  - lib/modulation/gem.rb
29
+ - lib/modulation/module_mixin.rb
30
+ - lib/modulation/paths.rb
26
31
  homepage: http://github.com/ciconia/modulation
27
32
  licenses:
28
33
  - MIT