chewy 0.4.1 → 0.5.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.
- checksums.yaml +4 -4
- data/.travis.yml +3 -3
- data/CHANGELOG.md +43 -1
- data/Gemfile +3 -0
- data/README.md +49 -11
- data/chewy.gemspec +1 -2
- data/gemfiles/Gemfile.rails-3.2 +1 -0
- data/gemfiles/Gemfile.rails-4.0 +1 -0
- data/lib/chewy.rb +8 -2
- data/lib/chewy/backports/deep_dup.rb +46 -0
- data/lib/chewy/backports/duplicable.rb +90 -0
- data/lib/chewy/config.rb +33 -6
- data/lib/chewy/errors.rb +1 -1
- data/lib/chewy/fields/base.rb +19 -7
- data/lib/chewy/fields/root.rb +13 -0
- data/lib/chewy/index/actions.rb +14 -6
- data/lib/chewy/index/search.rb +3 -2
- data/lib/chewy/query.rb +131 -17
- data/lib/chewy/query/compose.rb +27 -17
- data/lib/chewy/query/criteria.rb +34 -22
- data/lib/chewy/query/loading.rb +94 -10
- data/lib/chewy/query/nodes/exists.rb +1 -1
- data/lib/chewy/query/nodes/has_relation.rb +1 -1
- data/lib/chewy/query/nodes/missing.rb +1 -1
- data/lib/chewy/query/pagination.rb +8 -38
- data/lib/chewy/query/pagination/kaminari.rb +37 -0
- data/lib/chewy/runtime.rb +9 -0
- data/lib/chewy/runtime/version.rb +25 -0
- data/lib/chewy/type/adapter/active_record.rb +21 -7
- data/lib/chewy/type/adapter/base.rb +1 -1
- data/lib/chewy/type/adapter/object.rb +9 -6
- data/lib/chewy/type/import.rb +7 -4
- data/lib/chewy/type/mapping.rb +9 -9
- data/lib/chewy/type/wrapper.rb +1 -1
- data/lib/chewy/version.rb +1 -1
- data/lib/tasks/chewy.rake +40 -21
- data/spec/chewy/config_spec.rb +1 -1
- data/spec/chewy/fields/base_spec.rb +273 -8
- data/spec/chewy/index/actions_spec.rb +1 -2
- data/spec/chewy/index/aliases_spec.rb +0 -1
- data/spec/chewy/index/search_spec.rb +0 -8
- data/spec/chewy/index/settings_spec.rb +0 -2
- data/spec/chewy/index_spec.rb +0 -2
- data/spec/chewy/query/criteria_spec.rb +85 -18
- data/spec/chewy/query/loading_spec.rb +26 -9
- data/spec/chewy/query/nodes/and_spec.rb +2 -2
- data/spec/chewy/query/nodes/exists_spec.rb +6 -6
- data/spec/chewy/query/nodes/missing_spec.rb +4 -4
- data/spec/chewy/query/nodes/or_spec.rb +2 -2
- data/spec/chewy/query/pagination/kaminari_spec.rb +55 -0
- data/spec/chewy/query/pagination_spec.rb +15 -22
- data/spec/chewy/query_spec.rb +121 -52
- data/spec/chewy/rspec/update_index_spec.rb +0 -1
- data/spec/chewy/runtime/version_spec.rb +48 -0
- data/spec/chewy/runtime_spec.rb +9 -0
- data/spec/chewy/type/adapter/active_record_spec.rb +52 -0
- data/spec/chewy/type/adapter/object_spec.rb +33 -0
- data/spec/chewy/type/import_spec.rb +1 -3
- data/spec/chewy/type/mapping_spec.rb +4 -6
- data/spec/chewy/type/observe_spec.rb +0 -2
- data/spec/chewy/type/wrapper_spec.rb +0 -2
- data/spec/chewy/type_spec.rb +26 -5
- data/spec/chewy_spec.rb +0 -2
- data/spec/spec_helper.rb +2 -2
- metadata +15 -21
- data/lib/chewy/fields/default.rb +0 -10
- data/spec/chewy/fields/default_spec.rb +0 -6
data/lib/chewy/query/loading.rb
CHANGED
@@ -3,24 +3,108 @@ module Chewy
|
|
3
3
|
module Loading
|
4
4
|
extend ActiveSupport::Concern
|
5
5
|
|
6
|
+
# Lazily loads actual ORM/ODM objects for search result.
|
7
|
+
# Returns scope marked to return loaded objects array instead of
|
8
|
+
# chewy wrappers. In case when object can not be loaded because it
|
9
|
+
# was deleted or don't satisfy given scope or options - the
|
10
|
+
# result collection will contain nil value in the place of this
|
11
|
+
# object. Use `compact` method to avoid this if necessary.
|
12
|
+
#
|
13
|
+
# UsersIndex.query(...).load #=> [#<User id: 42...>, ...]
|
14
|
+
# UsersIndex.query(...).load.filter(...) #=> [#<User id: 42...>, ...]
|
15
|
+
#
|
16
|
+
# Possible options:
|
17
|
+
#
|
18
|
+
# <tt>:scope</tt> - used to give a scope for _every_ loaded type.
|
19
|
+
#
|
20
|
+
# PlacesIndex.query(...).load(scope: ->{ includes(:testimonials) })
|
21
|
+
#
|
22
|
+
# If places here contain cities and countries then preload will be
|
23
|
+
# done like this:
|
24
|
+
#
|
25
|
+
# City.where(id: [...]).includes(:testimonials)
|
26
|
+
# Country.where(id: [...]).includes(:testimonials)
|
27
|
+
#
|
28
|
+
# It is also possible to pass own scope for every loaded type:
|
29
|
+
#
|
30
|
+
# PlacesIndex.query(...).load(
|
31
|
+
# city: { scope: ->{ includes(:testimonials, :country) }}
|
32
|
+
# country: { scope: ->{ includes(:testimonials, :cities) }}
|
33
|
+
# )
|
34
|
+
#
|
35
|
+
# And loading will be performed as:
|
36
|
+
#
|
37
|
+
# City.where(id: [...]).includes(:testimonials, :country)
|
38
|
+
# Country.where(id: [...]).includes(:testimonials, :cities)
|
39
|
+
#
|
40
|
+
# In case of ActiveRecord objects loading the same result
|
41
|
+
# will be reached using ActiveRecord scopes instead of
|
42
|
+
# lambdas. But it works only with per-type scopes,
|
43
|
+
# and doesn't work with the common scope.
|
44
|
+
#
|
45
|
+
# PlacesIndex.query(...).load(
|
46
|
+
# city: { scope: City.includes(:testimonials, :country) }
|
47
|
+
# country: { scope: Country.includes(:testimonials, :cities) }
|
48
|
+
# )
|
49
|
+
#
|
50
|
+
# <tt>:only</tt> - loads objects for the specified types
|
51
|
+
#
|
52
|
+
# PlacesIndex.query(...).load(only: :city)
|
53
|
+
# PlacesIndex.query(...).load(only: [:city])
|
54
|
+
# PlacesIndex.query(...).load(only: [:city, :country])
|
55
|
+
#
|
56
|
+
# <tt>:except</tt> - doesn't load listed types
|
57
|
+
#
|
58
|
+
# PlacesIndex.query(...).load(except: :city)
|
59
|
+
# PlacesIndex.query(...).load(except: [:city])
|
60
|
+
# PlacesIndex.query(...).load(except: [:city, :country])
|
61
|
+
#
|
6
62
|
def load(options = {})
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
63
|
+
chain { criteria.update_options preload: options, loaded_objects: true }
|
64
|
+
end
|
65
|
+
|
66
|
+
# This methods is just convenient way to preload some ORM/ODM
|
67
|
+
# objects and continue to work with Chewy wrappers. Returns
|
68
|
+
# Chewy query scope. Note that `load` method performs ES request
|
69
|
+
# so preload method should also be the last in scope methods chain.
|
70
|
+
# Takes the same options as the `load` method
|
71
|
+
#
|
72
|
+
# PlacesIndex.query(...).preload(only: :city)
|
73
|
+
#
|
74
|
+
# Loaded objects are also attached to corresponding Chewy
|
75
|
+
# type wrapper objects and available with `_object` accessor.
|
76
|
+
#
|
77
|
+
# scope = PlacesIndex.query(...)
|
78
|
+
# preload_scope = scope.preload
|
79
|
+
# preload_scope.first #=> PlacesIndex::City wrapper instance
|
80
|
+
# preload_scope.first._object #=> City model instance
|
81
|
+
# scope.load == preload_scope.map(&:_object) #=> true
|
82
|
+
#
|
83
|
+
def preload(options = {})
|
84
|
+
chain { criteria.update_options preload: options, loaded_objects: false }
|
13
85
|
end
|
14
86
|
|
15
87
|
private
|
16
88
|
|
17
|
-
def _load_objects
|
89
|
+
def _load_objects!
|
90
|
+
options = criteria.options[:preload]
|
91
|
+
only = Array.wrap(options[:only]).map(&:to_s)
|
92
|
+
except = Array.wrap(options[:except]).map(&:to_s)
|
93
|
+
|
18
94
|
loaded_objects = Hash[_results.group_by(&:class).map do |type, objects|
|
95
|
+
next if except.include?(type.type_name)
|
96
|
+
next if only.any? && !only.include?(type.type_name)
|
97
|
+
|
19
98
|
loaded = type.adapter.load(objects, options.merge(_type: type))
|
20
|
-
[type, loaded.index_by.with_index
|
21
|
-
|
99
|
+
[type, loaded.index_by.with_index do |loaded, i|
|
100
|
+
objects[i]._object = loaded
|
101
|
+
objects[i]
|
102
|
+
end]
|
103
|
+
end.compact]
|
22
104
|
|
23
|
-
_results.map
|
105
|
+
_results.map do |result|
|
106
|
+
loaded_objects[result.class][result] if loaded_objects[result.class]
|
107
|
+
end
|
24
108
|
end
|
25
109
|
end
|
26
110
|
end
|
@@ -1,45 +1,15 @@
|
|
1
1
|
module Chewy
|
2
2
|
class Query
|
3
3
|
module Pagination
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
module Kaminari
|
11
|
-
extend ActiveSupport::Concern
|
12
|
-
|
13
|
-
included do
|
14
|
-
include ::Kaminari::PageScopeMethods
|
15
|
-
|
16
|
-
delegate :default_per_page, :max_per_page, :max_pages, to: :_kaminari_config
|
17
|
-
|
18
|
-
class_eval <<-METHOD, __FILE__, __LINE__ + 1
|
19
|
-
def #{::Kaminari.config.page_method_name}(num = 1)
|
20
|
-
limit(limit_value).offset(limit_value * ([num.to_i, 1].max - 1))
|
21
|
-
end
|
22
|
-
METHOD
|
23
|
-
end
|
24
|
-
|
25
|
-
def total_count
|
26
|
-
_response['hits']['total']
|
27
|
-
end
|
28
|
-
|
29
|
-
def limit_value
|
30
|
-
(criteria.options[:size].presence || default_per_page).to_i
|
31
|
-
end
|
32
|
-
|
33
|
-
def offset_value
|
34
|
-
criteria.options[:from].to_i
|
35
|
-
end
|
36
|
-
|
37
|
-
private
|
38
|
-
|
39
|
-
def _kaminari_config
|
40
|
-
::Kaminari.config
|
41
|
-
end
|
4
|
+
# Returns request total found documents count
|
5
|
+
#
|
6
|
+
# PlacesIndex.query(...).filter(...).total_count
|
7
|
+
#
|
8
|
+
def total_count
|
9
|
+
_response['hits'].try(:[], 'total') || 0
|
42
10
|
end
|
43
11
|
end
|
44
12
|
end
|
45
13
|
end
|
14
|
+
|
15
|
+
require 'chewy/query/pagination/kaminari' if defined?(::Kaminari)
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Chewy
|
2
|
+
class Query
|
3
|
+
module Pagination
|
4
|
+
module Kaminari
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
include ::Kaminari::PageScopeMethods
|
9
|
+
|
10
|
+
delegate :default_per_page, :max_per_page, :max_pages, to: :_kaminari_config
|
11
|
+
|
12
|
+
class_eval <<-METHOD, __FILE__, __LINE__ + 1
|
13
|
+
def #{::Kaminari.config.page_method_name}(num = 1)
|
14
|
+
limit(limit_value).offset(limit_value * ([num.to_i, 1].max - 1))
|
15
|
+
end
|
16
|
+
METHOD
|
17
|
+
end
|
18
|
+
|
19
|
+
def limit_value
|
20
|
+
(criteria.request_options[:size].presence || default_per_page).to_i
|
21
|
+
end
|
22
|
+
|
23
|
+
def offset_value
|
24
|
+
criteria.request_options[:from].to_i
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def _kaminari_config
|
30
|
+
::Kaminari.config
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
Chewy::Query::Pagination.send :include, Chewy::Query::Pagination::Kaminari
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Chewy
|
2
|
+
module Runtime
|
3
|
+
class Version
|
4
|
+
include Comparable
|
5
|
+
attr_reader :major, :minor, :patch
|
6
|
+
|
7
|
+
def initialize version
|
8
|
+
@major, @minor, @patch = *(version.to_s.split('.', 3) + [0]*3).first(3).map(&:to_i)
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_s
|
12
|
+
[major, minor, patch].join('.')
|
13
|
+
end
|
14
|
+
|
15
|
+
def <=> other
|
16
|
+
other = self.class.new(other) unless other.is_a?(self.class)
|
17
|
+
[
|
18
|
+
major <=> other.major,
|
19
|
+
minor <=> other.minor,
|
20
|
+
patch <=> other.patch
|
21
|
+
].detect { |c| c != 0 } || 0
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -16,11 +16,7 @@ module Chewy
|
|
16
16
|
end
|
17
17
|
|
18
18
|
def name
|
19
|
-
@name ||= options[:name].present? ? options[:name].to_s.camelize : model.model_name.to_s
|
20
|
-
end
|
21
|
-
|
22
|
-
def type_name
|
23
|
-
@type_name ||= (options[:name].presence || model.model_name).to_s.underscore
|
19
|
+
@name ||= (options[:name].present? ? options[:name].to_s.camelize : model.model_name.to_s).demodulize
|
24
20
|
end
|
25
21
|
|
26
22
|
# Import method fo ActiveRecord takes import data and import options
|
@@ -44,8 +40,24 @@ module Chewy
|
|
44
40
|
# users = User.all
|
45
41
|
# users.each { |user| user.destroy if user.incative? }
|
46
42
|
# UsersIndex::User.import users # inactive users will be deleted from index
|
43
|
+
# # or
|
47
44
|
# UsersIndex::User.import users.map(&:id) # deleted user ids will be deleted from index
|
48
45
|
#
|
46
|
+
# Also there is custom API method `delete_from_index?`. It it returns `true`
|
47
|
+
# object will be deleted from index. Note that if this method is defined and
|
48
|
+
# return `false` Chewy will still check `destroyed?` method. This is useful
|
49
|
+
# for paranoid objects sdeleting implementation.
|
50
|
+
#
|
51
|
+
# class User
|
52
|
+
# alias_method :delete_from_index?, :deleted_at?
|
53
|
+
# end
|
54
|
+
#
|
55
|
+
# users = User.all
|
56
|
+
# users.each { |user| user.deleted_at = Time.now }
|
57
|
+
# UsersIndex::User.import users # paranoid deleted users will be deleted from index
|
58
|
+
# # or
|
59
|
+
# UsersIndex::User.import users.map(&:id) # user ids will be deleted from index
|
60
|
+
#
|
49
61
|
def import *args, &block
|
50
62
|
import_options = args.extract_options!
|
51
63
|
import_options[:batch_size] ||= BATCH_SIZE
|
@@ -97,7 +109,7 @@ module Chewy
|
|
97
109
|
indexed = true
|
98
110
|
merged_scope(scoped_model(ids)).find_in_batches(import_options.slice(:batch_size)) do |objects|
|
99
111
|
ids -= objects.map(&:id)
|
100
|
-
indexed &= block.call
|
112
|
+
indexed &= block.call(grouped_objects(objects))
|
101
113
|
end
|
102
114
|
|
103
115
|
deleted = ids.in_groups_of(import_options[:batch_size], false).map do |group|
|
@@ -109,7 +121,9 @@ module Chewy
|
|
109
121
|
|
110
122
|
def grouped_objects(objects)
|
111
123
|
objects.group_by do |object|
|
112
|
-
object.
|
124
|
+
delete = object.delete_from_index? if object.respond_to?(:delete_from_index?)
|
125
|
+
delete ||= object.destroyed?
|
126
|
+
delete ? :delete : :index
|
113
127
|
end
|
114
128
|
end
|
115
129
|
|
@@ -10,11 +10,7 @@ module Chewy
|
|
10
10
|
end
|
11
11
|
|
12
12
|
def name
|
13
|
-
@name ||= (options[:name] || target).to_s.camelize
|
14
|
-
end
|
15
|
-
|
16
|
-
def type_name
|
17
|
-
@type_name ||= (options[:name] || target).to_s.underscore
|
13
|
+
@name ||= (options[:name] || target).to_s.camelize.demodulize
|
18
14
|
end
|
19
15
|
|
20
16
|
# Imports passed data with options
|
@@ -27,6 +23,11 @@ module Chewy
|
|
27
23
|
#
|
28
24
|
# <tt>:batch_size</tt> - import batch size, 1000 objects by default
|
29
25
|
#
|
26
|
+
# If methods `delete_from_index?` or `destroyed?` are defined for object
|
27
|
+
# and any return true then object will be deleted from index. But to be
|
28
|
+
# destroyed objects need to respond to `id` method as well, so ElasticSearch
|
29
|
+
# could know which one to delete.
|
30
|
+
#
|
30
31
|
def import *args, &block
|
31
32
|
import_options = args.extract_options!
|
32
33
|
batch_size = import_options.delete(:batch_size) || BATCH_SIZE
|
@@ -35,7 +36,9 @@ module Chewy
|
|
35
36
|
objects.in_groups_of(batch_size, false).map do |group|
|
36
37
|
action_groups = group.group_by do |object|
|
37
38
|
raise "Object is not a `#{target}`" if class_target? && !object.is_a?(target)
|
38
|
-
object.
|
39
|
+
delete = object.delete_from_index? if object.respond_to?(:delete_from_index?)
|
40
|
+
delete ||= object.destroyed? if object.respond_to?(:destroyed?)
|
41
|
+
delete ? :delete : :index
|
39
42
|
end
|
40
43
|
block.call action_groups
|
41
44
|
end.all?
|
data/lib/chewy/type/import.rb
CHANGED
@@ -15,6 +15,8 @@ module Chewy
|
|
15
15
|
# UsersIndex::User.import suffix: Time.now.to_i # imports data to index with specified suffix if such is exists
|
16
16
|
# UsersIndex::User.import batch_size: 300 # import batch size
|
17
17
|
#
|
18
|
+
# See adapters documentation for more details.
|
19
|
+
#
|
18
20
|
def import *args
|
19
21
|
import_options = args.extract_options!
|
20
22
|
bulk_options = import_options.reject { |k, v| ![:refresh, :suffix].include?(k) }.reverse_merge!(refresh: true)
|
@@ -34,7 +36,7 @@ module Chewy
|
|
34
36
|
end
|
35
37
|
|
36
38
|
# Perform import operation for specified documents.
|
37
|
-
# Raises Chewy::
|
39
|
+
# Raises Chewy::ImportFailed exception in case of import errors.
|
38
40
|
#
|
39
41
|
# UsersIndex::User.import! # imports default data set
|
40
42
|
# UsersIndex::User.import! User.active # imports active users
|
@@ -44,6 +46,8 @@ module Chewy
|
|
44
46
|
# UsersIndex::User.import! suffix: Time.now.to_i # imports data to index with specified suffix if such is exists
|
45
47
|
# UsersIndex::User.import! batch_size: 300 # import batch size
|
46
48
|
#
|
49
|
+
# See adapters documentation for more details.
|
50
|
+
#
|
47
51
|
def import! *args
|
48
52
|
errors = nil
|
49
53
|
subscriber = ActiveSupport::Notifications.subscribe('import_objects.chewy') do |*args|
|
@@ -51,7 +55,7 @@ module Chewy
|
|
51
55
|
end
|
52
56
|
import *args
|
53
57
|
ActiveSupport::Notifications.unsubscribe(subscriber)
|
54
|
-
raise Chewy::
|
58
|
+
raise Chewy::ImportFailed.new(self, errors) if errors.present?
|
55
59
|
true
|
56
60
|
end
|
57
61
|
|
@@ -60,9 +64,8 @@ module Chewy
|
|
60
64
|
def bulk options = {}
|
61
65
|
suffix = options.delete(:suffix)
|
62
66
|
|
63
|
-
Chewy.wait_for_status
|
64
|
-
|
65
67
|
result = client.bulk options.merge(index: index.build_index_name(suffix: suffix), type: type_name)
|
68
|
+
Chewy.wait_for_status
|
66
69
|
|
67
70
|
extract_errors result
|
68
71
|
end
|
data/lib/chewy/type/mapping.rb
CHANGED
@@ -82,14 +82,14 @@ module Chewy
|
|
82
82
|
# will be an array of hashes, if `user.quiz` is not a collection association
|
83
83
|
# then just values hash will be put in the index.
|
84
84
|
#
|
85
|
-
# field :quiz
|
85
|
+
# field :quiz do
|
86
86
|
# field :question, :answer
|
87
87
|
# field :score, type: 'integer'
|
88
88
|
# end
|
89
89
|
#
|
90
90
|
# Nested fields are composed from nested objects:
|
91
91
|
#
|
92
|
-
# field :name,
|
92
|
+
# field :name, value: -> { name_translations } do
|
93
93
|
# field :ru, value: ->(name) { name['ru'] }
|
94
94
|
# field :en, value: ->(name) { name['en'] }
|
95
95
|
# end
|
@@ -99,12 +99,12 @@ module Chewy
|
|
99
99
|
#
|
100
100
|
# field :name, type: 'object', value: -> { name_translations }
|
101
101
|
#
|
102
|
-
# The special case is
|
103
|
-
#
|
102
|
+
# The special case is multi_field. If type options and block are
|
103
|
+
# both present field is treated as a multi-field. In that case field
|
104
|
+
# composition changes satisfy elasticsearch rules:
|
104
105
|
#
|
105
|
-
# field :full_name, type: '
|
106
|
-
# field :
|
107
|
-
# field :sorted, index: 'analyzed', analyzer: 'sorted'
|
106
|
+
# field :full_name, type: 'string', analyzer: 'name', value: ->{ full_name.try(:strip) } do
|
107
|
+
# field :sorted, analyzer: 'sorted'
|
108
108
|
# end
|
109
109
|
#
|
110
110
|
def field *args, &block
|
@@ -114,7 +114,7 @@ module Chewy
|
|
114
114
|
if args.size > 1
|
115
115
|
args.map { |name| field(name, options) }
|
116
116
|
else
|
117
|
-
expand_nested(Chewy::Fields::
|
117
|
+
expand_nested(Chewy::Fields::Base.new(args.first, options), &block)
|
118
118
|
end
|
119
119
|
end
|
120
120
|
|
@@ -135,7 +135,7 @@ module Chewy
|
|
135
135
|
# template 'title.*', mapping_hash # dot in template causes "path_match" using
|
136
136
|
# template /tit.+/, mapping_hash # using "match_pattern": "regexp"
|
137
137
|
# template /title\..+/, mapping_hash # "\." - escaped dot causes "path_match" using
|
138
|
-
# template /tit.+/, 'string' mapping_hash # "match_mapping_type" as the optionsl second argument
|
138
|
+
# template /tit.+/, 'string', mapping_hash # "match_mapping_type" as the optionsl second argument
|
139
139
|
# template template42: {match: 'hello*', mapping: {type: 'object'}} # or even pass a template as is
|
140
140
|
#
|
141
141
|
def template *args
|