etna 0.1.27 → 0.1.28
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/etna.completion +137 -55
- data/lib/etna.rb +1 -0
- data/lib/etna/clients/magma/formatting.rb +2 -1
- data/lib/etna/clients/magma/formatting/models_odm_xml.rb +293 -0
- data/lib/etna/clients/magma/workflows/materialize_magma_record_files_workflow.rb +3 -2
- data/lib/etna/command.rb +10 -0
- data/lib/etna/cwl.rb +697 -0
- data/lib/etna/directed_graph.rb +21 -0
- data/lib/etna/filesystem.rb +2 -0
- data/lib/etna/generate_autocompletion_script.rb +11 -6
- data/lib/etna/route.rb +44 -4
- metadata +5 -3
data/lib/etna.rb
CHANGED
@@ -1 +1,2 @@
|
|
1
|
-
require_relative 'formatting/models_csv'
|
1
|
+
require_relative 'formatting/models_csv'
|
2
|
+
require_relative 'formatting/models_odm_xml'
|
@@ -0,0 +1,293 @@
|
|
1
|
+
require 'nokogiri'
|
2
|
+
|
3
|
+
module Etna
|
4
|
+
module Clients
|
5
|
+
class Magma
|
6
|
+
module ModelsOdmXml
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
module Etna
|
13
|
+
module Clients
|
14
|
+
class Magma
|
15
|
+
module ModelsOdmXml
|
16
|
+
module Prettify
|
17
|
+
def prettify(name)
|
18
|
+
name.split('_').map(&:capitalize).join(' ')
|
19
|
+
end
|
20
|
+
|
21
|
+
def shorten(name)
|
22
|
+
name.gsub('_', '').capitalize
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class Exporter
|
27
|
+
include Prettify
|
28
|
+
|
29
|
+
attr_reader :project_name, :models
|
30
|
+
|
31
|
+
def initialize(project_name:, models:)
|
32
|
+
@project_name = project_name
|
33
|
+
@models = models
|
34
|
+
end
|
35
|
+
|
36
|
+
def data_type_map
|
37
|
+
@data_type_map ||= begin
|
38
|
+
map = {}
|
39
|
+
map[Etna::Clients::Magma::AttributeType::STRING] = 'text'
|
40
|
+
map[Etna::Clients::Magma::AttributeType::DATE_TIME] = 'date'
|
41
|
+
map[Etna::Clients::Magma::AttributeType::BOOLEAN] = 'text'
|
42
|
+
map[Etna::Clients::Magma::AttributeType::FLOAT] = 'float'
|
43
|
+
map[Etna::Clients::Magma::AttributeType::INTEGER] = 'integer'
|
44
|
+
|
45
|
+
map
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def redcap_field_type_map
|
50
|
+
@redcap_field_type_map ||= begin
|
51
|
+
map = {}
|
52
|
+
map[Etna::Clients::Magma::AttributeType::STRING] = 'textarea'
|
53
|
+
map[Etna::Clients::Magma::AttributeType::DATE_TIME] = 'text'
|
54
|
+
map[Etna::Clients::Magma::AttributeType::BOOLEAN] = 'radio'
|
55
|
+
map[Etna::Clients::Magma::AttributeType::FLOAT] = 'text'
|
56
|
+
map[Etna::Clients::Magma::AttributeType::INTEGER] = 'text'
|
57
|
+
|
58
|
+
map
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def redcap_text_validation_map
|
63
|
+
@redcap_text_validation_map ||= begin
|
64
|
+
map = {}
|
65
|
+
map[Etna::Clients::Magma::AttributeType::DATE_TIME] = 'date_mdy'
|
66
|
+
map[Etna::Clients::Magma::AttributeType::FLOAT] = 'float'
|
67
|
+
map[Etna::Clients::Magma::AttributeType::INTEGER] = 'int'
|
68
|
+
|
69
|
+
map
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def write_models(output_io: nil, filename: nil)
|
74
|
+
@document = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
|
75
|
+
xml.ODM(odm_headers) do
|
76
|
+
xml.Study(OID: "Project.#{shorten(project_name)}") do
|
77
|
+
end
|
78
|
+
global_variables(xml)
|
79
|
+
metadata(xml)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
@document.to_xml
|
84
|
+
end
|
85
|
+
|
86
|
+
def odm_headers
|
87
|
+
{
|
88
|
+
xmlns: "http://www.cdisc.org/ns/odm/v1.3",
|
89
|
+
'xmlns:ds': "http://www.w3.org/2000/09/xmldsig#",
|
90
|
+
'xmlns:xsi': "http://www.w3.org/2001/XMLSchema-instance",
|
91
|
+
'xmlns:redcap': "https://projectredcap.org",
|
92
|
+
'xsi:schemaLocation': "http://www.cdisc.org/ns/odm/v1.3 schema/odm/ODM1-3-1.xsd",
|
93
|
+
ODMVersion: "1.3.2",
|
94
|
+
FileOID: "000-00-0000",
|
95
|
+
FileType: "Snapshot",
|
96
|
+
Description: project_name,
|
97
|
+
AsOfDateTime: DateTime.now,
|
98
|
+
CreationDateTime: DateTime.now,
|
99
|
+
SourceSystem: "Magma",
|
100
|
+
SourceSystemVersion: DateTime.now
|
101
|
+
}
|
102
|
+
end
|
103
|
+
|
104
|
+
def global_variables(xml)
|
105
|
+
# Includes general metadata about the project, as well as
|
106
|
+
# declarations of all repeating instruments,
|
107
|
+
# which seem like Timepoints to me.
|
108
|
+
# NOTE:
|
109
|
+
# <redcap:Purpose>0</redcap:Purpose>
|
110
|
+
# 0 = Practice / just for fun
|
111
|
+
# 1 = Operational Support
|
112
|
+
# 2 = Research
|
113
|
+
# 3 = Quality Improvement
|
114
|
+
# 4 = Other
|
115
|
+
|
116
|
+
xml.GlobalVariables do
|
117
|
+
xml.StudyName "#{project_name}"
|
118
|
+
xml.StudyDescription "#{project_name} - Data Library integration project"
|
119
|
+
xml.ProtocolName "#{project_name}"
|
120
|
+
xml.send('redcap:RecordAutonumberingEnabled', 1)
|
121
|
+
xml.send('redcap:CustomRecordLabel')
|
122
|
+
xml.send('redcap:SecondaryUniqueField')
|
123
|
+
xml.send('redcap:SchedulingEnabled', 0)
|
124
|
+
xml.send('redcap:SurveysEnabled', 0)
|
125
|
+
xml.send('redcap:SurveyInvitationEmailField')
|
126
|
+
xml.send('redcap:Purpose', 2) # 2 == research
|
127
|
+
xml.send('redcap:PurposeOther')
|
128
|
+
xml.send('redcap:ProjectNotes', "Used to easily ingest clinical data for #{project_name} into the Data Library.")
|
129
|
+
xml.send('redcap:MissingDataCodes')
|
130
|
+
|
131
|
+
repeating_instruments(xml)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def repeating_attribute_types
|
136
|
+
# We don't have a good indicator for what is a repeating
|
137
|
+
# attribute for REDCap...
|
138
|
+
# Will this work for models without timepoint, that go
|
139
|
+
# straight from subject -> sample?
|
140
|
+
models.model('subject').template.attributes.all.select do |attribute|
|
141
|
+
Etna::Clients::Magma::AttributeType::COLLECTION == attribute.attribute_type
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def clinical_dictionaries
|
146
|
+
# Our indicator for if something needs a REDCap form will be any
|
147
|
+
# model with a dictionary.
|
148
|
+
models.all.select do |model|
|
149
|
+
model.template.raw['dictionary']
|
150
|
+
end.map do |model|
|
151
|
+
model.template.dictionary
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def repeating_instruments(xml)
|
156
|
+
# Now we get into repeating instruments and events.
|
157
|
+
# From a Magma model perspective, this should be
|
158
|
+
# Timepoint that hangs off of
|
159
|
+
# a Subject model.
|
160
|
+
xml.send('redcap:RepeatingInstrumentsAndEvents') do
|
161
|
+
repeating_attribute_types.map do |repeating_attribute|
|
162
|
+
write_repeating_instrument_xml(xml, repeating_attribute)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def write_repeating_instrument_xml(xml, instrument)
|
168
|
+
node = xml.send('redcap:RepeatingInstrument')
|
169
|
+
node['redcap:UniqueEventName'] = 'event_1_arm_1'
|
170
|
+
node['redcap:RepeatInstrument'] = instrument.attribute_name
|
171
|
+
node['redcap:CustomLabel'] = instrument.display_name || instrument.attribute_name.capitalize
|
172
|
+
|
173
|
+
node
|
174
|
+
end
|
175
|
+
|
176
|
+
def metadata(xml)
|
177
|
+
# Includes form and field definitions
|
178
|
+
xml.MetaDataVersion(
|
179
|
+
OID: "Metadata.#{shorten(project_name)}_#{DateTime.now}",
|
180
|
+
Name: project_name,
|
181
|
+
'redcap:RecordIdField': 'record_id'
|
182
|
+
) do
|
183
|
+
clinical_dictionaries.map do |dictionary|
|
184
|
+
# Each Magma dictionary needs a FormDef, with
|
185
|
+
# ItemGroupRef children for each form page (?).
|
186
|
+
# Each ItemGroupRef requires a corresponding ItemGroupDef
|
187
|
+
# with ItemRef children for each input (?).
|
188
|
+
# Each ItemRef requires a correspdonding ItemDef
|
189
|
+
# that defines the label and type, and includes
|
190
|
+
# <Question> as a label (?).
|
191
|
+
# Option validations are present as a CodeList (with
|
192
|
+
# CodeListItem children).
|
193
|
+
write_form_def(xml, dictionary)
|
194
|
+
write_item_group_def(xml, dictionary)
|
195
|
+
write_item_def(xml, dictionary)
|
196
|
+
write_code_list(xml, dictionary)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def write_form_def(xml, dictionary)
|
202
|
+
xml.FormDef(
|
203
|
+
OID: "Form.#{dictionary.model_name}",
|
204
|
+
Name: dictionary.model_name.capitalize,
|
205
|
+
Repeating: "No",
|
206
|
+
'redcap:FormName': dictionary.model_name
|
207
|
+
) do
|
208
|
+
# Assume a single item group
|
209
|
+
xml.ItemGroupRef(
|
210
|
+
ItemGroupOID: "#{dictionary.model_name}.attributes",
|
211
|
+
Mandatory: "No"
|
212
|
+
) do
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def write_item_group_def(xml, dictionary)
|
218
|
+
xml.ItemGroupDef(
|
219
|
+
OID: "#{dictionary.model_name}.attributes",
|
220
|
+
Name: "#{dictionary.model_name.capitalize} Attributes",
|
221
|
+
Repeating: "No"
|
222
|
+
) do
|
223
|
+
dictionary.attributes.keys.map do |attribute_name|
|
224
|
+
xml.ItemRef(
|
225
|
+
ItemOID: "#{dictionary.model_name}.#{attribute_name}", # Does this need to be unique across all items?
|
226
|
+
Mandatory: "No",
|
227
|
+
'redcap:Variable': attribute_name # Does this need to be unique?
|
228
|
+
) do
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
def write_item_def(xml, dictionary)
|
235
|
+
model_attributes = models.model(dictionary.model_name).template.attributes
|
236
|
+
dictionary.attributes.keys.map do |attribute_name|
|
237
|
+
attribute = model_attributes.attribute(attribute_name)
|
238
|
+
attribute_type = attribute.attribute_type
|
239
|
+
params = {
|
240
|
+
OID: "#{dictionary.model_name}.#{attribute_name}", # Does this need to be unique across all items?
|
241
|
+
Name: attribute_name,
|
242
|
+
DataType: data_type_map[attribute_type] || 'text',
|
243
|
+
'redcap:Variable': attribute_name,
|
244
|
+
'redcap:FieldType': redcap_field_type_map[attribute_type] || 'text',
|
245
|
+
Length: '999' # How could we infer shorter values?
|
246
|
+
}
|
247
|
+
|
248
|
+
params['redcap:TextValidationType'] = redcap_text_validation_map[attribute_type] if redcap_text_validation_map[attribute_type]
|
249
|
+
params['redcap:FieldNote'] = attribute.desc if attribute.desc
|
250
|
+
xml.ItemDef(params) do
|
251
|
+
xml.Question do
|
252
|
+
xml.send('TranslatedText', attribute_name.capitalize)
|
253
|
+
end
|
254
|
+
|
255
|
+
if attribute.validation && Etna::Clients::Magma::AttributeValidationType::ARRAY == attribute.validation['type']
|
256
|
+
xml.CodeListRef(
|
257
|
+
CodeListOID: "#{dictionary.model_name}.#{attribute_name}.choices"
|
258
|
+
) do
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
def write_code_list(xml, dictionary)
|
266
|
+
model_attributes = models.model(dictionary.model_name).template.attributes
|
267
|
+
dictionary.attributes.keys.map do |attribute_name|
|
268
|
+
attribute = model_attributes.attribute(attribute_name)
|
269
|
+
if attribute.validation && Etna::Clients::Magma::AttributeValidationType::ARRAY == attribute.validation['type']
|
270
|
+
xml.CodeList(
|
271
|
+
OID: "#{dictionary.model_name}.#{attribute_name}.choices",
|
272
|
+
Name: "#{attribute_name}",
|
273
|
+
DataType: "text",
|
274
|
+
'redcap:Variable': attribute_name
|
275
|
+
) do
|
276
|
+
attribute.validation['value'].map do |option|
|
277
|
+
xml.CodeListItem(
|
278
|
+
CodedValue: option
|
279
|
+
) do
|
280
|
+
xml.Decode() do
|
281
|
+
xml.send('TranslatedText', option)
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
292
|
+
end
|
293
|
+
end
|
@@ -97,8 +97,9 @@ module Etna
|
|
97
97
|
|
98
98
|
dest_file = File.join(dest_dir, metadata_file_name(record_name: record[template.identifier], record_model_name: template.name, ext: '.json'))
|
99
99
|
filesystem.mkdir_p(File.dirname(dest_file))
|
100
|
-
|
101
|
-
|
100
|
+
json = record_to_serialize.to_json
|
101
|
+
filesystem.with_writeable(dest_file, "w", size_hint: json.bytes.length) do |io|
|
102
|
+
io.write(json)
|
102
103
|
end
|
103
104
|
end
|
104
105
|
|
data/lib/etna/command.rb
CHANGED
@@ -39,6 +39,10 @@ module Etna
|
|
39
39
|
def string_flags
|
40
40
|
@string_flags ||= []
|
41
41
|
end
|
42
|
+
|
43
|
+
def multi_flags
|
44
|
+
@multi_flags ||= []
|
45
|
+
end
|
42
46
|
end
|
43
47
|
|
44
48
|
def flag_as_parameter(flag)
|
@@ -69,6 +73,12 @@ module Etna
|
|
69
73
|
else
|
70
74
|
flags[arg_name] = args.shift
|
71
75
|
end
|
76
|
+
elsif self.class.multi_flags.include?(next_arg)
|
77
|
+
if args.empty?
|
78
|
+
raise "flag #{next_arg} requires an argument"
|
79
|
+
else
|
80
|
+
(flags[arg_name] ||= []) << args.shift
|
81
|
+
end
|
72
82
|
elsif !found_non_flag
|
73
83
|
raise "#{program_name} does not recognize flag #{next_arg}"
|
74
84
|
else
|
data/lib/etna/cwl.rb
ADDED
@@ -0,0 +1,697 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Etna
|
4
|
+
class Cwl
|
5
|
+
FIELD_LOADERS = {}
|
6
|
+
|
7
|
+
def initialize(attributes)
|
8
|
+
@attributes = attributes
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.loader
|
12
|
+
Etna::Cwl::RecordLoader.new(self)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.as_json(obj)
|
16
|
+
if obj.is_a?(Cwl)
|
17
|
+
as_json(obj.instance_variable_get(:@attributes))
|
18
|
+
elsif obj.is_a?(Hash)
|
19
|
+
{}.tap do |result|
|
20
|
+
obj.each do |k, v|
|
21
|
+
result[k] = as_json(v)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
elsif obj.is_a?(Array)
|
25
|
+
obj.map { |v| as_json(v) }
|
26
|
+
else
|
27
|
+
obj
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def as_json
|
32
|
+
self.class.as_json(@attributes)
|
33
|
+
end
|
34
|
+
|
35
|
+
class Loader
|
36
|
+
def load(val)
|
37
|
+
raise "Unimplemented"
|
38
|
+
end
|
39
|
+
|
40
|
+
def optional
|
41
|
+
OptionalLoader.new(self)
|
42
|
+
end
|
43
|
+
|
44
|
+
def map(&block)
|
45
|
+
FunctorMapLoader.new(self, &block)
|
46
|
+
end
|
47
|
+
|
48
|
+
def as_mapped_array(id_key = nil, value_key = nil)
|
49
|
+
MapLoader.new(self.as_array, id_key, value_key)
|
50
|
+
end
|
51
|
+
|
52
|
+
def or(*alternatives)
|
53
|
+
UnionLoader.new(self, *alternatives)
|
54
|
+
end
|
55
|
+
|
56
|
+
def as_array
|
57
|
+
ArrayLoader.new(self)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class AnyLoader < Loader
|
62
|
+
def load(val)
|
63
|
+
val
|
64
|
+
end
|
65
|
+
|
66
|
+
ANY = AnyLoader.new
|
67
|
+
end
|
68
|
+
|
69
|
+
class PrimitiveLoader < Loader
|
70
|
+
def initialize(name, type)
|
71
|
+
@name = name
|
72
|
+
@type = type
|
73
|
+
end
|
74
|
+
|
75
|
+
def load(val)
|
76
|
+
unless val.is_a?(@type)
|
77
|
+
raise "Unexpected val #{val.inspect} for #{@name} type"
|
78
|
+
end
|
79
|
+
|
80
|
+
val
|
81
|
+
end
|
82
|
+
|
83
|
+
def name
|
84
|
+
@name
|
85
|
+
end
|
86
|
+
|
87
|
+
def self.find_primitive_type_loader(type_name)
|
88
|
+
constants.each do |c|
|
89
|
+
c = const_get(c)
|
90
|
+
if c.is_a?(Loader)
|
91
|
+
return c if c.name == type_name
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
STRING = PrimitiveLoader.new('string', String)
|
97
|
+
INT = PrimitiveLoader.new('int', Integer)
|
98
|
+
LONG = PrimitiveLoader.new('long', Integer)
|
99
|
+
FLOAT = PrimitiveLoader.new('float', Float)
|
100
|
+
DOUBLE = PrimitiveLoader.new('double', Float)
|
101
|
+
NULL = PrimitiveLoader.new('null', NilClass)
|
102
|
+
|
103
|
+
class BooleanLoader < Loader
|
104
|
+
def name
|
105
|
+
'boolean'
|
106
|
+
end
|
107
|
+
|
108
|
+
def load(val)
|
109
|
+
raise "Invalid value #{val.inspect} for boolean" unless val.instance_of?(TrueClass) || val.instance_of?(FalseClass)
|
110
|
+
val
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
BOOLEAN = BooleanLoader.new
|
115
|
+
end
|
116
|
+
|
117
|
+
class SourceLoader < Loader
|
118
|
+
# Resolves a string of the forms "a-primary-identifier" or "step_name/output" into
|
119
|
+
# [:primary_inputs, "a-primary-identifier"] or
|
120
|
+
# ["step_name", "output"] respectively
|
121
|
+
def load(val)
|
122
|
+
parts = []
|
123
|
+
|
124
|
+
if val.is_a?(Symbol)
|
125
|
+
val = val.to_s
|
126
|
+
end
|
127
|
+
|
128
|
+
if val.is_a?(Array)
|
129
|
+
parts = PrimitiveLoader::STRING.as_array.load(val)
|
130
|
+
elsif val.is_a?(String)
|
131
|
+
parts = val.split('/', max = 2)
|
132
|
+
end
|
133
|
+
|
134
|
+
if parts.length == 1
|
135
|
+
return [:primary_inputs, parts[0]]
|
136
|
+
elsif parts.length == 2
|
137
|
+
return parts
|
138
|
+
end
|
139
|
+
|
140
|
+
raise "Unexpected value for source #{val.inspect}"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
class StrictMapLoader < Loader
|
145
|
+
def initialize(items, keys)
|
146
|
+
@items = items
|
147
|
+
@keys = keys
|
148
|
+
end
|
149
|
+
|
150
|
+
def load(val)
|
151
|
+
if val.is_a?(Hash)
|
152
|
+
val.map do |k, v|
|
153
|
+
[@keys.load(k), @items.load(v)]
|
154
|
+
end.to_h
|
155
|
+
else
|
156
|
+
raise "Unexpected val #{val.inspect} for hash"
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
class MapLoader < Loader
|
162
|
+
def initialize(items, idKey = nil, valueKey = nil)
|
163
|
+
@items = items
|
164
|
+
@idKey = idKey
|
165
|
+
@valueKey = valueKey
|
166
|
+
end
|
167
|
+
|
168
|
+
def load(val)
|
169
|
+
if val.is_a?(Hash)
|
170
|
+
val = [].tap do |result|
|
171
|
+
errors = {}
|
172
|
+
val.keys.sort.each do |k|
|
173
|
+
begin
|
174
|
+
v = val[k]
|
175
|
+
if v.is_a?(Hash)
|
176
|
+
v[@idKey] = k
|
177
|
+
else
|
178
|
+
v = {@idKey => k, @valueKey => v}
|
179
|
+
end
|
180
|
+
|
181
|
+
result << v
|
182
|
+
rescue => e
|
183
|
+
errors[k] = e.to_s
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
unless errors.empty?
|
188
|
+
raise errors.map { |k, v| "#{k}: #{v}" }.join("\n")
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
@items.load(val)
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
class ArrayLoader < Loader
|
198
|
+
def initialize(items)
|
199
|
+
@items = items
|
200
|
+
end
|
201
|
+
|
202
|
+
def load(val)
|
203
|
+
unless val.is_a?(Array)
|
204
|
+
raise "Unexpected val #{val.inspect} for array"
|
205
|
+
end
|
206
|
+
|
207
|
+
[].tap do |result|
|
208
|
+
errors = []
|
209
|
+
val.each do |item|
|
210
|
+
begin
|
211
|
+
loaded = Cwl.load_item(item, UnionLoader.new(self, @items))
|
212
|
+
if loaded.is_a?(Array)
|
213
|
+
result.push(*loaded)
|
214
|
+
else
|
215
|
+
result << loaded
|
216
|
+
end
|
217
|
+
rescue => e
|
218
|
+
errors << e.to_s
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
unless errors.empty?
|
223
|
+
raise errors.join("\n")
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
class EnumLoader < Loader
|
230
|
+
def initialize(*options)
|
231
|
+
@options = options
|
232
|
+
end
|
233
|
+
|
234
|
+
def load(val)
|
235
|
+
if @options.include?(val)
|
236
|
+
return val
|
237
|
+
end
|
238
|
+
|
239
|
+
raise "Value #{val.inspect} does not belong to one of (#{@options.join(', ')})"
|
240
|
+
end
|
241
|
+
|
242
|
+
PRIMITIVE_TYPE = EnumLoader.new("null", "boolean", "int", "long", "float", "double", "string")
|
243
|
+
NOMINAL_TYPE = EnumLoader.new("File")
|
244
|
+
end
|
245
|
+
|
246
|
+
class OptionalLoader < Loader
|
247
|
+
def initialize(inner_loader)
|
248
|
+
@inner_loader = inner_loader
|
249
|
+
end
|
250
|
+
|
251
|
+
def load(val)
|
252
|
+
if val.nil?
|
253
|
+
return nil
|
254
|
+
end
|
255
|
+
|
256
|
+
@inner_loader.load(val)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
class RecordLoader < Loader
|
261
|
+
def initialize(klass, field_loaders = nil)
|
262
|
+
@klass = klass
|
263
|
+
@field_loaders = field_loaders
|
264
|
+
end
|
265
|
+
|
266
|
+
def field_loaders
|
267
|
+
@field_loaders || @klass::FIELD_LOADERS
|
268
|
+
end
|
269
|
+
|
270
|
+
def load(val)
|
271
|
+
unless val.is_a?(Hash)
|
272
|
+
raise "Unexpected value #{val.inspect} for type #{@klass.name}"
|
273
|
+
end
|
274
|
+
|
275
|
+
errors = {}
|
276
|
+
@klass.new({}.tap do |result|
|
277
|
+
field_loaders.each do |field_sym, loader|
|
278
|
+
field_str = field_sym.to_s
|
279
|
+
begin
|
280
|
+
result[field_str] = loader.load(val[field_str])
|
281
|
+
rescue => e
|
282
|
+
errors[field_str] = e.to_s
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
unless errors.empty?
|
287
|
+
raise errors.map { |k, e| "#{k}: #{e}" }.join(',')
|
288
|
+
end
|
289
|
+
end)
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
class NeverLoader < Loader
|
294
|
+
def load(val)
|
295
|
+
raise "This feature is not supported"
|
296
|
+
end
|
297
|
+
|
298
|
+
UNSUPPORTED = NeverLoader.new.optional
|
299
|
+
end
|
300
|
+
|
301
|
+
class UnionLoader < Loader
|
302
|
+
def initialize(*alternatives)
|
303
|
+
@alternatives = alternatives
|
304
|
+
end
|
305
|
+
|
306
|
+
def load(val)
|
307
|
+
errors = []
|
308
|
+
@alternatives.each do |loader|
|
309
|
+
begin
|
310
|
+
return loader.load(val)
|
311
|
+
rescue => e
|
312
|
+
errors << e.to_s
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
raise errors.join(", ")
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
class ArrayType < Cwl
|
321
|
+
class InnerLoader < Loader
|
322
|
+
def load(val)
|
323
|
+
RecordLoader.new(ArrayType, {
|
324
|
+
type: EnumLoader.new("array"),
|
325
|
+
items: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
|
326
|
+
}).load(val)
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
def type_loader
|
331
|
+
loader = RecordType::Field.type_loader(@attributes['items'])
|
332
|
+
return nil if loader.nil?
|
333
|
+
@type_loader ||= ArrayLoader.new(loader)
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
class EnumType < Cwl
|
338
|
+
class InnerLoader < Loader
|
339
|
+
def load(val)
|
340
|
+
RecordLoader.new(EnumType, {
|
341
|
+
type: EnumLoader.new("enum"),
|
342
|
+
symbols: PrimitiveLoader::STRING.as_array,
|
343
|
+
}).load(val)
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
def type_loader
|
348
|
+
@type_loader ||= EnumLoader.new(*@attributes['symbols'])
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
class RecordType < Cwl
|
353
|
+
class RecordTypeLoader
|
354
|
+
def load(val)
|
355
|
+
RecordLoader.new(RecordType, {
|
356
|
+
type: EnumLoader.new("record"),
|
357
|
+
fields: Field::FieldLoader.new.as_mapped_array('name', 'type')
|
358
|
+
}).load(val)
|
359
|
+
end
|
360
|
+
end
|
361
|
+
|
362
|
+
class Record
|
363
|
+
def self.new(h)
|
364
|
+
h
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
def type_loader
|
369
|
+
@type_loader ||= begin
|
370
|
+
record_class = Class.new(Record)
|
371
|
+
RecordLoader.new(record_class, @attributes['fields'].map do |field|
|
372
|
+
loader = Field.type_loader(field.type)
|
373
|
+
return nil if loader.nil?
|
374
|
+
[field.name.to_sym, loader]
|
375
|
+
end.to_h)
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
class Field < Cwl
|
380
|
+
class FieldLoader < Loader
|
381
|
+
def load(val)
|
382
|
+
RecordLoader.new(Field, {
|
383
|
+
name: PrimitiveLoader::STRING,
|
384
|
+
type: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
|
385
|
+
doc: PrimitiveLoader::STRING.optional,
|
386
|
+
}).load(val)
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
def name
|
391
|
+
@attributes['name']
|
392
|
+
end
|
393
|
+
|
394
|
+
def type
|
395
|
+
@attributes['type']
|
396
|
+
end
|
397
|
+
|
398
|
+
def type_loader
|
399
|
+
self.class.type_loader(self.type)
|
400
|
+
end
|
401
|
+
|
402
|
+
def self.type_loader(type)
|
403
|
+
case type
|
404
|
+
when Array
|
405
|
+
type_loaders = type.map { |t| Field.type_loader(t) }
|
406
|
+
return nil if type_loaders.any?(&:nil?)
|
407
|
+
UnionLoader.new(*type_loaders)
|
408
|
+
when EnumType
|
409
|
+
type.type_loader
|
410
|
+
when RecordType
|
411
|
+
type.type_loader
|
412
|
+
when ArrayType
|
413
|
+
type.type_loader
|
414
|
+
when String
|
415
|
+
PrimitiveLoader.find_primitive_type_loader(type)
|
416
|
+
else
|
417
|
+
raise "Could not determine loader for type #{type.inspect}"
|
418
|
+
end
|
419
|
+
end
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
class FunctorMapLoader < Loader
|
424
|
+
def initialize(inner, &block)
|
425
|
+
@block = block
|
426
|
+
@inner = inner
|
427
|
+
end
|
428
|
+
|
429
|
+
def load(val)
|
430
|
+
@block.call(@inner.load(val))
|
431
|
+
end
|
432
|
+
end
|
433
|
+
|
434
|
+
# Prepares a unique set of structured nominal types for an inner
|
435
|
+
# loading of types
|
436
|
+
class TypedDSLLoader < Loader
|
437
|
+
def initialize(inner)
|
438
|
+
@inner = inner
|
439
|
+
end
|
440
|
+
|
441
|
+
REGEX = /^([^\[?]+)(\[\])?(\?)?$/
|
442
|
+
|
443
|
+
def resolve(val)
|
444
|
+
m = REGEX.match(val)
|
445
|
+
|
446
|
+
unless m.nil?
|
447
|
+
type = m[1]
|
448
|
+
unless m[2].nil?
|
449
|
+
type = {'type' => 'array', 'items' => type}
|
450
|
+
end
|
451
|
+
unless m[3].nil?
|
452
|
+
type = ["null", type]
|
453
|
+
end
|
454
|
+
|
455
|
+
return type
|
456
|
+
end
|
457
|
+
|
458
|
+
val
|
459
|
+
end
|
460
|
+
|
461
|
+
def load(val)
|
462
|
+
if val.is_a?(Array)
|
463
|
+
@inner.load(val.map do |item|
|
464
|
+
item.is_a?(String) ? resolve(item) : item
|
465
|
+
end)
|
466
|
+
elsif val.is_a?(String)
|
467
|
+
@inner.load(resolve(val))
|
468
|
+
else
|
469
|
+
@inner.load(val)
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
OUTER_TYPE_LOADER = TypedDSLLoader.new(
|
474
|
+
UnionLoader.new(
|
475
|
+
RecordType::RecordTypeLoader.new,
|
476
|
+
ArrayType::InnerLoader.new,
|
477
|
+
EnumType::InnerLoader.new,
|
478
|
+
EnumLoader::PRIMITIVE_TYPE,
|
479
|
+
EnumLoader::NOMINAL_TYPE,
|
480
|
+
)
|
481
|
+
)
|
482
|
+
|
483
|
+
WITH_UNIONS_TYPE_LOADER = TypedDSLLoader.new(
|
484
|
+
UnionLoader.new(
|
485
|
+
OUTER_TYPE_LOADER,
|
486
|
+
ArrayLoader.new(OUTER_TYPE_LOADER),
|
487
|
+
)
|
488
|
+
)
|
489
|
+
end
|
490
|
+
|
491
|
+
def self.load_item(val, field_type)
|
492
|
+
if val.is_a?(Hash)
|
493
|
+
if val.include?("$import")
|
494
|
+
raise "$import expressions are not yet supported"
|
495
|
+
elsif val.include?("$include")
|
496
|
+
raise "$include expressions are not yet supported"
|
497
|
+
end
|
498
|
+
end
|
499
|
+
|
500
|
+
return field_type.load(val)
|
501
|
+
end
|
502
|
+
|
503
|
+
class InputParameter < Cwl
|
504
|
+
FIELD_LOADERS = {
|
505
|
+
id: PrimitiveLoader::STRING.optional,
|
506
|
+
label: PrimitiveLoader::STRING.optional,
|
507
|
+
secondaryFiles: NeverLoader::UNSUPPORTED,
|
508
|
+
streamable: NeverLoader::UNSUPPORTED,
|
509
|
+
loadContents: NeverLoader::UNSUPPORTED,
|
510
|
+
loadListing: NeverLoader::UNSUPPORTED,
|
511
|
+
valueFrom: NeverLoader::UNSUPPORTED,
|
512
|
+
doc: PrimitiveLoader::STRING.optional,
|
513
|
+
|
514
|
+
type: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
|
515
|
+
default: AnyLoader::ANY.optional,
|
516
|
+
format: PrimitiveLoader::STRING.optional,
|
517
|
+
}
|
518
|
+
|
519
|
+
def default
|
520
|
+
default = @attributes['default']
|
521
|
+
return nil unless default
|
522
|
+
RecordType::Field.type_loader(@attributes['type'])&.load(default)
|
523
|
+
end
|
524
|
+
end
|
525
|
+
|
526
|
+
class OutputParameter < Cwl
|
527
|
+
FIELD_LOADERS = {
|
528
|
+
id: PrimitiveLoader::STRING.optional,
|
529
|
+
label: PrimitiveLoader::STRING.optional,
|
530
|
+
secondaryFiles: NeverLoader::UNSUPPORTED,
|
531
|
+
streamable: NeverLoader::UNSUPPORTED,
|
532
|
+
doc: PrimitiveLoader::STRING.optional,
|
533
|
+
|
534
|
+
outputBinding: NeverLoader::UNSUPPORTED,
|
535
|
+
type: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
|
536
|
+
format: PrimitiveLoader::STRING.optional,
|
537
|
+
}
|
538
|
+
end
|
539
|
+
|
540
|
+
class WorkflowOutputParameter < Cwl
|
541
|
+
FIELD_LOADERS = {
|
542
|
+
id: PrimitiveLoader::STRING,
|
543
|
+
label: PrimitiveLoader::STRING.optional,
|
544
|
+
secondaryFiles: NeverLoader::UNSUPPORTED,
|
545
|
+
streamable: NeverLoader::UNSUPPORTED,
|
546
|
+
linkMerge: NeverLoader::UNSUPPORTED,
|
547
|
+
pickValue: NeverLoader::UNSUPPORTED,
|
548
|
+
doc: PrimitiveLoader::STRING.optional,
|
549
|
+
|
550
|
+
outputSource: SourceLoader.new,
|
551
|
+
type: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
|
552
|
+
format: PrimitiveLoader::STRING.optional,
|
553
|
+
}
|
554
|
+
|
555
|
+
def outputSource
|
556
|
+
@attributes['outputSource']
|
557
|
+
end
|
558
|
+
|
559
|
+
def id
|
560
|
+
@attributes['id']
|
561
|
+
end
|
562
|
+
end
|
563
|
+
|
564
|
+
class WorkflowInputParameter < Cwl
|
565
|
+
FIELD_LOADERS = {
|
566
|
+
id: PrimitiveLoader::STRING,
|
567
|
+
label: PrimitiveLoader::STRING.optional,
|
568
|
+
secondaryFiles: NeverLoader::UNSUPPORTED,
|
569
|
+
streamable: NeverLoader::UNSUPPORTED,
|
570
|
+
loadContents: NeverLoader::UNSUPPORTED,
|
571
|
+
loadListing: NeverLoader::UNSUPPORTED,
|
572
|
+
doc: PrimitiveLoader::STRING.optional,
|
573
|
+
inputBinding: NeverLoader::UNSUPPORTED,
|
574
|
+
|
575
|
+
default: AnyLoader::ANY,
|
576
|
+
type: TypedDSLLoader::WITH_UNIONS_TYPE_LOADER,
|
577
|
+
format: PrimitiveLoader::STRING.optional,
|
578
|
+
}
|
579
|
+
|
580
|
+
def id
|
581
|
+
@attributes['id']
|
582
|
+
end
|
583
|
+
|
584
|
+
def default
|
585
|
+
@attributes['default']
|
586
|
+
end
|
587
|
+
end
|
588
|
+
|
589
|
+
class StepOutput < Cwl
|
590
|
+
FIELD_LOADERS = {
|
591
|
+
id: PrimitiveLoader::STRING,
|
592
|
+
}
|
593
|
+
|
594
|
+
def id
|
595
|
+
@attributes['id']
|
596
|
+
end
|
597
|
+
end
|
598
|
+
|
599
|
+
|
600
|
+
class StepInput < Cwl
|
601
|
+
FIELD_LOADERS = {
|
602
|
+
id: PrimitiveLoader::STRING.optional,
|
603
|
+
source: SourceLoader.new.optional,
|
604
|
+
label: PrimitiveLoader::STRING.optional,
|
605
|
+
linkMerge: NeverLoader::UNSUPPORTED,
|
606
|
+
pickValue: NeverLoader::UNSUPPORTED,
|
607
|
+
loadContents: NeverLoader::UNSUPPORTED,
|
608
|
+
loadListing: NeverLoader::UNSUPPORTED,
|
609
|
+
valueFrom: NeverLoader::UNSUPPORTED,
|
610
|
+
default: AnyLoader::ANY.optional,
|
611
|
+
}
|
612
|
+
|
613
|
+
def id
|
614
|
+
@attributes['id']
|
615
|
+
end
|
616
|
+
|
617
|
+
def source
|
618
|
+
@attributes['source']
|
619
|
+
end
|
620
|
+
end
|
621
|
+
|
622
|
+
class Operation < Cwl
|
623
|
+
FIELD_LOADERS = {
|
624
|
+
id: PrimitiveLoader::STRING.optional,
|
625
|
+
label: PrimitiveLoader::STRING.optional,
|
626
|
+
doc: PrimitiveLoader::STRING.optional,
|
627
|
+
requirements: NeverLoader::UNSUPPORTED,
|
628
|
+
hints: NeverLoader::UNSUPPORTED,
|
629
|
+
cwlVersion: EnumLoader.new("v1.0", "v1.1", "v1.2").optional,
|
630
|
+
intent: NeverLoader::UNSUPPORTED,
|
631
|
+
class: EnumLoader.new("Operation"),
|
632
|
+
inputs: InputParameter.loader.as_mapped_array('id', 'type'),
|
633
|
+
outputs: OutputParameter.loader.as_mapped_array('id', 'type'),
|
634
|
+
}
|
635
|
+
|
636
|
+
def id
|
637
|
+
@attributes['id']
|
638
|
+
end
|
639
|
+
end
|
640
|
+
|
641
|
+
class Step < Cwl
|
642
|
+
FIELD_LOADERS = {
|
643
|
+
id: PrimitiveLoader::STRING.optional,
|
644
|
+
label: PrimitiveLoader::STRING.optional,
|
645
|
+
doc: PrimitiveLoader::STRING.optional,
|
646
|
+
in: StepInput.loader.as_mapped_array('id', 'source'),
|
647
|
+
out: StepOutput.loader.or(PrimitiveLoader::STRING.map { |id| StepOutput.loader.load({'id' => id}) }).as_array,
|
648
|
+
requirements: NeverLoader::UNSUPPORTED,
|
649
|
+
hints: NeverLoader::UNSUPPORTED,
|
650
|
+
run: PrimitiveLoader::STRING.map { |id| Operation.loader.load({'id' => id, 'class' => 'Operation', 'inputs' => [], 'outputs' => []}) }.or(Operation.loader),
|
651
|
+
when: NeverLoader::UNSUPPORTED,
|
652
|
+
scatter: NeverLoader::UNSUPPORTED,
|
653
|
+
scatterMethod: NeverLoader::UNSUPPORTED,
|
654
|
+
}
|
655
|
+
|
656
|
+
def id
|
657
|
+
@attributes['id']
|
658
|
+
end
|
659
|
+
|
660
|
+
def in
|
661
|
+
@attributes['in']
|
662
|
+
end
|
663
|
+
|
664
|
+
def out
|
665
|
+
@attributes['out']
|
666
|
+
end
|
667
|
+
end
|
668
|
+
|
669
|
+
class Workflow < Cwl
|
670
|
+
FIELD_LOADERS = {
|
671
|
+
id: PrimitiveLoader::STRING.optional,
|
672
|
+
label: PrimitiveLoader::STRING.optional,
|
673
|
+
doc: PrimitiveLoader::STRING.optional,
|
674
|
+
requirements: NeverLoader::UNSUPPORTED,
|
675
|
+
hints: NeverLoader::UNSUPPORTED,
|
676
|
+
intent: NeverLoader::UNSUPPORTED,
|
677
|
+
class: EnumLoader.new("Workflow"),
|
678
|
+
cwlVersion: EnumLoader.new("v1.0", "v1.1", "v1.2"),
|
679
|
+
inputs: WorkflowInputParameter.loader.as_mapped_array('id', 'type'),
|
680
|
+
outputs: WorkflowOutputParameter.loader.as_mapped_array('id', 'type'),
|
681
|
+
steps: Step.loader.as_mapped_array('id', 'source')
|
682
|
+
}
|
683
|
+
|
684
|
+
def inputs
|
685
|
+
@attributes['inputs']
|
686
|
+
end
|
687
|
+
|
688
|
+
def outputs
|
689
|
+
@attributes['outputs']
|
690
|
+
end
|
691
|
+
|
692
|
+
def steps
|
693
|
+
@attributes['steps']
|
694
|
+
end
|
695
|
+
end
|
696
|
+
end
|
697
|
+
end
|