clone_kit 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.
- checksums.yaml +7 -0
- data/.gitignore +2 -0
- data/.rspec +2 -0
- data/.rubocop.kapost.yml +99 -0
- data/.rubocop.yml +31 -0
- data/.ruby-version +1 -0
- data/Gemfile +6 -0
- data/README.md +79 -0
- data/Rakefile +8 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/clone_kit.gemspec +37 -0
- data/lib/clone_kit.rb +46 -0
- data/lib/clone_kit/cloners/mongoid_merging_ruleset_cloner.rb +106 -0
- data/lib/clone_kit/cloners/mongoid_ruleset_cloner.rb +135 -0
- data/lib/clone_kit/cloners/no_op.rb +11 -0
- data/lib/clone_kit/decorators/embedded_cloner_decorator.rb +31 -0
- data/lib/clone_kit/emitters/empty.rb +15 -0
- data/lib/clone_kit/event_outlet.rb +27 -0
- data/lib/clone_kit/graph.rb +44 -0
- data/lib/clone_kit/merge_attributes_tool.rb +99 -0
- data/lib/clone_kit/operation.rb +79 -0
- data/lib/clone_kit/rule.rb +30 -0
- data/lib/clone_kit/rules/allow_only_mongoid_fields.rb +45 -0
- data/lib/clone_kit/rules/except.rb +21 -0
- data/lib/clone_kit/rules/remap.rb +44 -0
- data/lib/clone_kit/rules/safe_remap.rb +26 -0
- data/lib/clone_kit/shared_id_map.rb +55 -0
- data/lib/clone_kit/specification.rb +52 -0
- data/lib/clone_kit/strategies/synchronous.rb +33 -0
- data/lib/clone_kit/version.rb +5 -0
- metadata +227 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CloneKit
|
4
|
+
class Rule
|
5
|
+
def current_operation=(operation)
|
6
|
+
@shared_id_map = nil
|
7
|
+
@current_operation = operation
|
8
|
+
end
|
9
|
+
|
10
|
+
protected
|
11
|
+
|
12
|
+
attr_reader :current_operation
|
13
|
+
|
14
|
+
def operation_arguments
|
15
|
+
current_operation.arguments
|
16
|
+
end
|
17
|
+
|
18
|
+
def shared_id_map
|
19
|
+
@shared_id_map ||= CloneKit::SharedIdMap.new(current_operation.id)
|
20
|
+
end
|
21
|
+
|
22
|
+
def warn_event(message)
|
23
|
+
current_operation.warn(message)
|
24
|
+
end
|
25
|
+
|
26
|
+
def error_event(message)
|
27
|
+
current_operation.error(message)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CloneKit
|
4
|
+
module Rules
|
5
|
+
# The purpose of this rule is to only include attributes that are presently defined on the model
|
6
|
+
# (and its embedded models)
|
7
|
+
class AllowOnlyMongoidFields < CloneKit::Rule
|
8
|
+
def initialize(model_klass)
|
9
|
+
self.model_klass = model_klass
|
10
|
+
end
|
11
|
+
|
12
|
+
def fix(_old_id, attributes)
|
13
|
+
slice_allowed!(polymorphic_class(model_klass.to_s, attributes), attributes)
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
attr_accessor :model_klass
|
19
|
+
|
20
|
+
def slice_allowed!(klass, attributes)
|
21
|
+
return if attributes.nil?
|
22
|
+
|
23
|
+
attributes.slice!(*(klass.attribute_names + klass.embedded_relations.keys))
|
24
|
+
|
25
|
+
klass.embedded_relations.each do |name, metadata|
|
26
|
+
if metadata.macro == :embeds_many
|
27
|
+
Array.wrap(attributes[name]).each do |item|
|
28
|
+
slice_allowed!(polymorphic_class(metadata.class_name, item), item)
|
29
|
+
end
|
30
|
+
elsif !attributes[name].nil?
|
31
|
+
slice_allowed!(polymorphic_class(metadata.class_name, attributes[name]), attributes[name])
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def polymorphic_class(class_name, item)
|
37
|
+
if item.key?("_type")
|
38
|
+
item["_type"]
|
39
|
+
else
|
40
|
+
class_name
|
41
|
+
end.constantize
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CloneKit
|
4
|
+
module Rules
|
5
|
+
class Except < CloneKit::Rule
|
6
|
+
def initialize(*attributes)
|
7
|
+
self.except_attributes = attributes
|
8
|
+
end
|
9
|
+
|
10
|
+
def fix(_old_id, attributes)
|
11
|
+
except_attributes.each do |key|
|
12
|
+
attributes.delete(key)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
attr_accessor :except_attributes
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CloneKit
|
4
|
+
module Rules
|
5
|
+
class Remap < CloneKit::Rule
|
6
|
+
def initialize(model_name, remap_hash = {})
|
7
|
+
self.remap_hash = remap_hash
|
8
|
+
self.model_name = model_name
|
9
|
+
end
|
10
|
+
|
11
|
+
def fix(_old_id, attributes)
|
12
|
+
remap_hash.each do |klass, remap_attributes|
|
13
|
+
Array.wrap(remap_attributes).each do |att|
|
14
|
+
next unless try?(attributes, att)
|
15
|
+
|
16
|
+
attributes[att] = if attributes[att].is_a?(Array)
|
17
|
+
attributes[att].map { |id| remap(klass, id) unless id.blank? }.compact
|
18
|
+
else
|
19
|
+
remap(klass, attributes[att])
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
protected
|
26
|
+
|
27
|
+
def remap(klass, old_id)
|
28
|
+
shared_id_map.lookup(klass, old_id)
|
29
|
+
rescue ArgumentError
|
30
|
+
error_event("#{model_name} missing remapped id for #{klass} #{old_id}")
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
|
34
|
+
def try?(attributes, key)
|
35
|
+
attributes.key?(key) && !attributes[key].blank?
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
attr_accessor :remap_hash,
|
41
|
+
:model_name
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "clone_kit/rules/remap"
|
4
|
+
|
5
|
+
module CloneKit
|
6
|
+
module Rules
|
7
|
+
class SafeRemap < Remap
|
8
|
+
def initialize(model_name, remap_hash = {}, safe_value = nil)
|
9
|
+
self.safe_value = safe_value
|
10
|
+
super(model_name, remap_hash)
|
11
|
+
end
|
12
|
+
|
13
|
+
protected
|
14
|
+
|
15
|
+
def remap(klass, old_id)
|
16
|
+
result = shared_id_map.lookup_safe(klass, old_id, safe_value)
|
17
|
+
warn_event("#{model_name} missing remapped id for #{klass}/#{old_id}") if result == safe_value
|
18
|
+
result
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
attr_accessor :safe_value
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "redis"
|
4
|
+
|
5
|
+
module CloneKit
|
6
|
+
class SharedIdMap
|
7
|
+
attr_reader :namespace
|
8
|
+
|
9
|
+
def initialize(namespace, redis: Redis.new)
|
10
|
+
self.namespace = namespace
|
11
|
+
self.redis = redis
|
12
|
+
end
|
13
|
+
|
14
|
+
def lookup(klass, original_id)
|
15
|
+
BSON::ObjectId.from_string(redis.hget(hash_key(klass), original_id.to_s))
|
16
|
+
rescue BSON::ObjectId::Invalid
|
17
|
+
raise ArgumentError, "No mapping found for #{klass}. This usually indicates a dependency has not be specified"
|
18
|
+
end
|
19
|
+
|
20
|
+
def lookup_safe(klass, original_id, default = nil)
|
21
|
+
val = redis.hget(hash_key(klass), original_id.to_s)
|
22
|
+
if val.blank?
|
23
|
+
default
|
24
|
+
else
|
25
|
+
BSON::ObjectId.from_string(val)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def insert(klass, original_id, new_id)
|
30
|
+
redis.hset(hash_key(klass), original_id.to_s, new_id.to_s)
|
31
|
+
end
|
32
|
+
|
33
|
+
def insert_many(klass, hash)
|
34
|
+
redis.pipelined do
|
35
|
+
hash.each do |k, v|
|
36
|
+
insert(klass, k, v)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def mapping(klass)
|
42
|
+
Hash[redis.hgetall(hash_key(klass)).map { |k, v| [k, v] }]
|
43
|
+
end
|
44
|
+
|
45
|
+
def hash_key(klass)
|
46
|
+
klass = klass.name if klass.is_a?(Class)
|
47
|
+
"clone_kit_id_map/#{namespace}/#{klass}"
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_writer :namespace
|
53
|
+
attr_accessor :redis
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "clone_kit/emitters/empty"
|
4
|
+
require "clone_kit/cloners/no_op"
|
5
|
+
|
6
|
+
module CloneKit
|
7
|
+
class SpecificationError < StandardError; end
|
8
|
+
|
9
|
+
class Specification
|
10
|
+
attr_accessor :model,
|
11
|
+
:emitter,
|
12
|
+
:cloner,
|
13
|
+
:dependencies,
|
14
|
+
:after_operation_block
|
15
|
+
|
16
|
+
EMPTY_EMITTER = Emitters::Empty.new
|
17
|
+
NO_OP_CLONER = Cloners::NoOp.new
|
18
|
+
|
19
|
+
def initialize(model, &block)
|
20
|
+
self.model = model
|
21
|
+
self.emitter = EMPTY_EMITTER
|
22
|
+
self.cloner = NO_OP_CLONER
|
23
|
+
self.dependencies = []
|
24
|
+
self.after_operation_block = -> (_op) {}
|
25
|
+
|
26
|
+
validate!
|
27
|
+
|
28
|
+
model.instance_exec(self, &block)
|
29
|
+
|
30
|
+
CloneKit.add_specification(self)
|
31
|
+
end
|
32
|
+
|
33
|
+
def after_operation(&block)
|
34
|
+
self.after_operation_block = block
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def validate!
|
40
|
+
fail SpecificationError, "Model type not supported" unless mongoid_document?
|
41
|
+
fail SpecificationError, "Cannot clone embedded documents" if mongoid_embedded_document?
|
42
|
+
end
|
43
|
+
|
44
|
+
def mongoid_document?
|
45
|
+
defined?(Mongoid) && model < Mongoid::Document
|
46
|
+
end
|
47
|
+
|
48
|
+
def mongoid_embedded_document?
|
49
|
+
mongoid_document? && model.embedded?
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module CloneKit
|
4
|
+
module Strategies
|
5
|
+
class Synchronous
|
6
|
+
def initialize(operation)
|
7
|
+
self.operation = operation
|
8
|
+
end
|
9
|
+
|
10
|
+
def all_batches_complete
|
11
|
+
# NOP
|
12
|
+
end
|
13
|
+
|
14
|
+
def clone_next_batch(model_specs, complete_handler)
|
15
|
+
model_specs.each do |spec|
|
16
|
+
spec.cloner.clone_ids(spec.emitter.scope(operation.arguments).pluck(:id), operation)
|
17
|
+
end
|
18
|
+
|
19
|
+
complete_handler.new.complete(
|
20
|
+
true,
|
21
|
+
"operation" => {
|
22
|
+
already_cloned: operation.already_cloned + model_specs.map(&:model).map(&:to_s),
|
23
|
+
id: operation.id,
|
24
|
+
arguments: operation.arguments,
|
25
|
+
strategy: CloneKit::Strategies::Synchronous
|
26
|
+
}
|
27
|
+
)
|
28
|
+
end
|
29
|
+
|
30
|
+
attr_accessor :operation
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
metadata
ADDED
@@ -0,0 +1,227 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: clone_kit
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.3.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Brandon Croft
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-03-24 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: redis
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activesupport
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 3.0.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 3.0.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bundler
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.13'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.13'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: mongoid
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 4.0.2
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 4.0.2
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: database_cleaner
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - '='
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 1.5.3
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - '='
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 1.5.3
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '11.0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '11.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rspec
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '3.4'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '3.4'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rspec-collection_matchers
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: pry-byebug
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: pry
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0'
|
153
|
+
- !ruby/object:Gem::Dependency
|
154
|
+
name: fakeredis
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - ">="
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '0'
|
160
|
+
type: :development
|
161
|
+
prerelease: false
|
162
|
+
version_requirements: !ruby/object:Gem::Requirement
|
163
|
+
requirements:
|
164
|
+
- - ">="
|
165
|
+
- !ruby/object:Gem::Version
|
166
|
+
version: '0'
|
167
|
+
description: Supports rules-based cloning, Mongoid, and distributed operations
|
168
|
+
email:
|
169
|
+
- brandon@kapost.com
|
170
|
+
executables: []
|
171
|
+
extensions: []
|
172
|
+
extra_rdoc_files: []
|
173
|
+
files:
|
174
|
+
- ".gitignore"
|
175
|
+
- ".rspec"
|
176
|
+
- ".rubocop.kapost.yml"
|
177
|
+
- ".rubocop.yml"
|
178
|
+
- ".ruby-version"
|
179
|
+
- Gemfile
|
180
|
+
- README.md
|
181
|
+
- Rakefile
|
182
|
+
- bin/console
|
183
|
+
- bin/setup
|
184
|
+
- clone_kit.gemspec
|
185
|
+
- lib/clone_kit.rb
|
186
|
+
- lib/clone_kit/cloners/mongoid_merging_ruleset_cloner.rb
|
187
|
+
- lib/clone_kit/cloners/mongoid_ruleset_cloner.rb
|
188
|
+
- lib/clone_kit/cloners/no_op.rb
|
189
|
+
- lib/clone_kit/decorators/embedded_cloner_decorator.rb
|
190
|
+
- lib/clone_kit/emitters/empty.rb
|
191
|
+
- lib/clone_kit/event_outlet.rb
|
192
|
+
- lib/clone_kit/graph.rb
|
193
|
+
- lib/clone_kit/merge_attributes_tool.rb
|
194
|
+
- lib/clone_kit/operation.rb
|
195
|
+
- lib/clone_kit/rule.rb
|
196
|
+
- lib/clone_kit/rules/allow_only_mongoid_fields.rb
|
197
|
+
- lib/clone_kit/rules/except.rb
|
198
|
+
- lib/clone_kit/rules/remap.rb
|
199
|
+
- lib/clone_kit/rules/safe_remap.rb
|
200
|
+
- lib/clone_kit/shared_id_map.rb
|
201
|
+
- lib/clone_kit/specification.rb
|
202
|
+
- lib/clone_kit/strategies/synchronous.rb
|
203
|
+
- lib/clone_kit/version.rb
|
204
|
+
homepage: https://github.com/kapost/clone_kit
|
205
|
+
licenses: []
|
206
|
+
metadata: {}
|
207
|
+
post_install_message:
|
208
|
+
rdoc_options: []
|
209
|
+
require_paths:
|
210
|
+
- lib
|
211
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
212
|
+
requirements:
|
213
|
+
- - ">="
|
214
|
+
- !ruby/object:Gem::Version
|
215
|
+
version: '0'
|
216
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
217
|
+
requirements:
|
218
|
+
- - ">="
|
219
|
+
- !ruby/object:Gem::Version
|
220
|
+
version: '0'
|
221
|
+
requirements: []
|
222
|
+
rubyforge_project:
|
223
|
+
rubygems_version: 2.5.1
|
224
|
+
signing_key:
|
225
|
+
specification_version: 4
|
226
|
+
summary: A toolkit to assist in complex cloning operations
|
227
|
+
test_files: []
|