etna 0.1.20 → 0.1.25

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.
@@ -54,7 +54,7 @@ class EtnaApp
54
54
  boolean_flags << '--ignore-ssl'
55
55
 
56
56
  def execute(host, ignore_ssl: false)
57
- polyphemus_client ||= Etna::Clients::Polyphemus.new(
57
+ polyphemus_client = Etna::Clients::Polyphemus.new(
58
58
  host: host,
59
59
  token: token(ignore_environment: true),
60
60
  ignore_ssl: ignore_ssl)
@@ -62,7 +62,7 @@ class EtnaApp
62
62
  polyphemus_client: polyphemus_client,
63
63
  config_file: EtnaApp.config_file_path)
64
64
  config = workflow.update_configuration_file(ignore_ssl: ignore_ssl)
65
- logger.info("Updated #{config.environment} configuration from #{host}.")
65
+ logger&.info("Updated #{config.environment} configuration from #{host}.")
66
66
  end
67
67
 
68
68
  def setup(config)
@@ -226,7 +226,7 @@ class EtnaApp
226
226
 
227
227
  @last_load = File.stat(file).mtime
228
228
  @changeset = File.open(file, 'r') do |f|
229
- workflow.prepare_changeset_from_csv(f) do |err|
229
+ workflow.prepare_changeset_from_csv(io: f) do |err|
230
230
  @errors << err
231
231
  end
232
232
  end
@@ -299,6 +299,85 @@ class EtnaApp
299
299
  workflow.write_csv_io(filename: file)
300
300
  end
301
301
  end
302
+
303
+ class LoadTableFromCsv < Etna::Command
304
+ include WithEtnaClients
305
+
306
+ boolean_flags << '--execute'
307
+
308
+ def execute(project_name, model_name, file_path, execute: false)
309
+ request = Etna::Clients::Magma::RetrievalRequest.new(project_name: project_name)
310
+ request.model_name = model_name
311
+ request.attribute_names = 'all'
312
+ request.record_names = 'all'
313
+ model = magma_client.retrieve(request).models.model(model_name)
314
+ model_parent_name = model.template.attributes.all.select do |attribute|
315
+ attribute.attribute_type == Etna::Clients::Magma::AttributeType::PARENT
316
+ end.first.name
317
+
318
+ other_attribute_names = model.template.attributes.all.reject do |attribute|
319
+ attribute.attribute_type == Etna::Clients::Magma::AttributeType::PARENT
320
+ end.map do |attribute|
321
+ attribute.name
322
+ end
323
+
324
+ # NOTE: This does not call ensure_parent currently because of MVIR1 consent--
325
+ # if the timepoint doesn't exist, the patient may be no study? (one example, at least)
326
+ update_request = Etna::Clients::Magma::UpdateRequest.new(project_name: project_name)
327
+
328
+ data = CSV.parse(File.read(file_path), headers: true)
329
+
330
+ data.by_row.each do |row|
331
+ revision = {}
332
+ other_attribute_names.each do |attribute_name|
333
+ revision[attribute_name] = row[attribute_name] unless row[attribute_name].nil?
334
+ end
335
+ update_request.append_table(model_parent_name, row[model_parent_name], model_name, revision)
336
+ end
337
+
338
+ puts update_request
339
+
340
+ if execute
341
+ magma_client.update_json(update_request)
342
+ end
343
+ end
344
+ end
345
+ end
346
+
347
+ class LoadFromRedcap < Etna::Command
348
+ include WithEtnaClients
349
+ include WithLogger
350
+ include StrongConfirmation
351
+
352
+ boolean_flags << '--commit'
353
+ string_flags << '--models'
354
+
355
+ def execute(project_name, redcap_tokens, models: "all", commit: false)
356
+ raise "Must provide at least one REDCap token (comma-separated)." unless redcap_tokens.split(',').length > 0
357
+
358
+ puts "NOTE: This is a **preview** of what the data loading will look like. Use the --commit flag to load records into Magma." unless commit
359
+
360
+ polyphemus_client.job(Etna::Clients::Polyphemus::RedcapJobRequest.new(
361
+ model_names: "all" == models ? "all" : models.split(','),
362
+ redcap_tokens: redcap_tokens.split(','),
363
+ project_name: project_name,
364
+ commit: commit
365
+ )) do |response|
366
+ response.read_body do |chunk|
367
+ puts clean_sne_message(chunk)
368
+ end
369
+ end
370
+ end
371
+
372
+ def clean_sne_message(chunk)
373
+ chunk.split("\n").reject do |c|
374
+ c.start_with?("retry:") || c.start_with?("event:")
375
+ end.map do |c|
376
+ c.gsub("data:", "").strip
377
+ end.reject do |c|
378
+ c.empty?
379
+ end
380
+ end
302
381
  end
303
382
  end
304
383
  end
@@ -18,6 +18,7 @@ require_relative './etna/spec'
18
18
  require_relative './etna/clients'
19
19
  require_relative './etna/csvs'
20
20
  require_relative './etna/environment_scoped'
21
+ require_relative './etna/filesystem'
21
22
 
22
23
  class EtnaApp
23
24
  include Etna::Application
@@ -1,14 +1,12 @@
1
- require 'net/http/persistent'
2
1
  require 'net/http/post/multipart'
3
2
  require 'singleton'
4
3
  require 'rack/utils'
5
4
 
6
5
  module Etna
7
6
  class Client
8
- def initialize(host, token, routes_available: true, persistent: true, ignore_ssl: false)
7
+ def initialize(host, token, routes_available: true, ignore_ssl: false)
9
8
  @host = host.sub(%r!/$!, '')
10
9
  @token = token
11
- @persistent = persistent
12
10
  @ignore_ssl = ignore_ssl
13
11
 
14
12
  if routes_available
@@ -38,6 +36,10 @@ module Etna
38
36
  query_request(Net::HTTP::Get, endpoint, params, &block)
39
37
  end
40
38
 
39
+ def head(endpoint, params = {}, &block)
40
+ query_request(Net::HTTP::Head, endpoint, params, &block)
41
+ end
42
+
41
43
  def options(endpoint, params = {}, &block)
42
44
  query_request(Net::HTTP::Options, endpoint, params, &block)
43
45
  end
@@ -79,16 +81,6 @@ module Etna
79
81
  end
80
82
  end
81
83
 
82
- def persistent_connection
83
- @http ||= begin
84
- http = Net::HTTP::Persistent.new
85
- http.read_timeout = 3600
86
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @ignore_ssl
87
- http
88
- end
89
- end
90
-
91
-
92
84
  def body_request(type, endpoint, params = {}, &block)
93
85
  uri = request_uri(endpoint)
94
86
  req = type.new(uri.request_uri, request_params)
@@ -141,36 +133,23 @@ module Etna
141
133
 
142
134
  def request(uri, data)
143
135
  if block_given?
144
- if @persistent
145
- persistent_connection.request(uri, data) do |response|
136
+ verify_mode = @ignore_ssl ?
137
+ OpenSSL::SSL::VERIFY_NONE :
138
+ OpenSSL::SSL::VERIFY_PEER
139
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, verify_mode: verify_mode) do |http|
140
+ http.request(data) do |response|
146
141
  status_check!(response)
147
142
  yield response
148
143
  end
149
- else
150
- verify_mode = @ignore_ssl ?
151
- OpenSSL::SSL::VERIFY_NONE :
152
- OpenSSL::SSL::VERIFY_PEER
153
- Net::HTTP.start(uri.host, uri.port, use_ssl: true, verify_mode: verify_mode) do |http|
154
- http.request(data) do |response|
155
- status_check!(response)
156
- yield response
157
- end
158
- end
159
144
  end
160
145
  else
161
- if @persistent
162
- response = persistent_connection.request(uri, data)
146
+ verify_mode = @ignore_ssl ?
147
+ OpenSSL::SSL::VERIFY_NONE :
148
+ OpenSSL::SSL::VERIFY_PEER
149
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, verify_mode: verify_mode) do |http|
150
+ response = http.request(data)
163
151
  status_check!(response)
164
152
  return response
165
- else
166
- verify_mode = @ignore_ssl ?
167
- OpenSSL::SSL::VERIFY_NONE :
168
- OpenSSL::SSL::VERIFY_PEER
169
- Net::HTTP.start(uri.host, uri.port, use_ssl: true, verify_mode: verify_mode) do |http|
170
- response = http.request(data)
171
- status_check!(response)
172
- return response
173
- end
174
153
  end
175
154
  end
176
155
  end
@@ -0,0 +1,39 @@
1
+ require 'base64'
2
+ require 'json'
3
+ require 'date'
4
+
5
+ module Etna
6
+ module Clients
7
+ class BaseClient
8
+ attr_reader :host, :token, :ignore_ssl
9
+ def initialize(host:, token:, ignore_ssl: false)
10
+ raise "#{self.class.name} client configuration is missing host." unless host
11
+ raise "#{self.class.name} client configuration is missing token." unless token
12
+
13
+ @token = token
14
+ raise "Your token is expired." if token_expired?
15
+
16
+ @etna_client = ::Etna::Client.new(
17
+ host,
18
+ token,
19
+ routes_available: false,
20
+ ignore_ssl: ignore_ssl)
21
+ @host = host
22
+ @ignore_ssl = ignore_ssl
23
+ end
24
+
25
+ def token_expired?
26
+ # Has the token already expired?
27
+ token_will_expire?(0)
28
+ end
29
+
30
+ def token_will_expire?(offset=3000)
31
+ # offset in seconds
32
+ # Will the user's token expire in the given amount of time?
33
+ epoch_seconds = JSON.parse(Base64.urlsafe_decode64(token.split('.')[1]))["exp"]
34
+ expiration = DateTime.strptime(epoch_seconds.to_s, "%s")
35
+ expiration <= DateTime.now.new_offset + offset
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,23 +1,12 @@
1
- require 'net/http/persistent'
2
1
  require 'net/http/post/multipart'
3
2
  require 'singleton'
4
3
  require_relative '../../client'
5
4
  require_relative './models'
5
+ require_relative '../base_client'
6
6
 
7
7
  module Etna
8
8
  module Clients
9
- class Janus
10
- def initialize(host:, token:, persistent: true, ignore_ssl: false)
11
- raise 'Janus client configuration is missing host.' unless host
12
- raise 'Janus client configuration is missing token.' unless token
13
- @etna_client = ::Etna::Client.new(
14
- host,
15
- token,
16
- routes_available: false,
17
- persistent: persistent,
18
- ignore_ssl: ignore_ssl)
19
- end
20
-
9
+ class Janus < Etna::Clients::BaseClient
21
10
  def get_project(get_project_request = GetProjectRequest.new)
22
11
  html = nil
23
12
  @etna_client.get(
@@ -1,11 +1,12 @@
1
1
  require 'ostruct'
2
2
  require_relative '../../json_serializable_struct'
3
+ require_relative '../base_client'
3
4
 
4
5
  # TODO: In the near future, I'd like to transition to specifying apis via SWAGGER and generating model stubs from the
5
6
  # common definitions. For nowe I've written them out by hand here.
6
7
  module Etna
7
8
  module Clients
8
- class Janus
9
+ class Janus < Etna::Clients::BaseClient
9
10
  class GetProjectRequest < Struct.new(:project_name, keyword_init: true)
10
11
  include JsonSerializableStruct
11
12
 
@@ -1,27 +1,12 @@
1
- require 'net/http/persistent'
2
1
  require 'net/http/post/multipart'
3
2
  require 'singleton'
3
+ require_relative '../base_client'
4
4
  require_relative '../../client'
5
5
  require_relative './models'
6
6
 
7
7
  module Etna
8
8
  module Clients
9
- class Magma
10
- attr_reader :host, :token, :ignore_ssl
11
- def initialize(host:, token:, persistent: true, ignore_ssl: false)
12
- raise 'Magma client configuration is missing host.' unless host
13
- raise 'Magma client configuration is missing token.' unless token
14
- @etna_client = ::Etna::Client.new(
15
- host,
16
- token,
17
- routes_available: false,
18
- persistent: persistent,
19
- ignore_ssl: ignore_ssl)
20
- @host = host
21
- @token = token
22
- @ignore_ssl = ignore_ssl
23
- end
24
-
9
+ class Magma < Etna::Clients::BaseClient
25
10
  # This endpoint returns models and records by name:
26
11
  # e.g. params:
27
12
  # {
@@ -3,12 +3,13 @@ require_relative '../../json_serializable_struct'
3
3
  require_relative '../../multipart_serializable_nested_hash'
4
4
  require_relative '../../directed_graph'
5
5
  require_relative '../enum'
6
+ require_relative '../base_client'
6
7
 
7
8
  # TODO: In the near future, I'd like to transition to specifying apis via SWAGGER and generating model stubs from the
8
9
  # common definitions. For nowe I've written them out by hand here.
9
10
  module Etna
10
11
  module Clients
11
- class Magma
12
+ class Magma < Etna::Clients::BaseClient
12
13
  class RetrievalRequest < Struct.new(:model_name, :attribute_names, :record_names, :project_name, :page, :page_size, :order, :filter, keyword_init: true)
13
14
  include JsonSerializableStruct
14
15
 
@@ -316,8 +317,11 @@ module Etna
316
317
  end
317
318
 
318
319
  def document(document_key)
319
- return nil unless raw.include?(document_key)
320
- raw[document_key]
320
+ if document_key.is_a?(String)
321
+ raw[document_key]
322
+ else
323
+ raw[document_key&.to_s]
324
+ end
321
325
  end
322
326
  end
323
327
 
@@ -8,3 +8,5 @@ require_relative './workflows/update_attributes_from_csv_workflow'
8
8
  require_relative './workflows/create_project_workflow'
9
9
  require_relative './workflows/add_project_models_workflow'
10
10
  require_relative './workflows/attribute_actions_from_json_workflow'
11
+ require_relative './workflows/materialize_magma_record_files_workflow'
12
+ require_relative './workflows/walk_model_tree_workflow'
@@ -31,13 +31,19 @@ module Etna
31
31
  record_names: 'all',
32
32
  page_size: 20,
33
33
  page: 1,
34
- ))
34
+ ), &block)
35
35
 
36
36
  documents = Documents.new({})
37
37
  last_page = nil
38
- while last_page.nil? || last_page.raw.length >= 20
39
- last_page = magma_client.retrieve(request).models.model(model_name).documents
40
- documents += last_page unless block_given?
38
+ while last_page.nil? || last_page.models.model_keys.map { |k| last_page.models.model(k).documents.raw.length }.sum > 0
39
+ begin
40
+ last_page = magma_client.retrieve(request)
41
+ rescue Etna::Error => e
42
+ raise e unless e.message.include?('not found')
43
+ break
44
+ end
45
+
46
+ documents += last_page.models.model(model_name).documents unless block_given?
41
47
  yield last_page if block_given?
42
48
  request.page += 1
43
49
  end
@@ -0,0 +1,135 @@
1
+ require 'ostruct'
2
+ require 'digest'
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+
6
+ module Etna
7
+ module Clients
8
+ class Magma
9
+ class MaterializeDataWorkflow < Struct.new(
10
+ :metis_client, :magma_client, :project_name,
11
+ :model_name, :model_filters, :model_attributes_mask,
12
+ :filesystem, :logger, :stub_files,
13
+ keyword_init: true)
14
+
15
+ def initialize(**kwds)
16
+ super(**({filesystem: Etna::Filesystem.new}.update(kwds)))
17
+ end
18
+
19
+ def magma_crud
20
+ @magma_crud ||= Etna::Clients::Magma::MagmaCrudWorkflow.new(magma_client: magma_client, project_name: project_name)
21
+ end
22
+
23
+ def model_walker
24
+ @model_walker ||= WalkModelTreeWorkflow.new(magma_crud: magma_crud, logger: logger)
25
+ end
26
+
27
+ def with_materialized_dir(&block)
28
+ tmp_dir = filesystem.tmpdir
29
+
30
+ begin
31
+ model_walker.walk_from(
32
+ model_name,
33
+ model_attributes_mask: model_attributes_mask,
34
+ model_filters: model_filters,
35
+ ) do |template, document|
36
+ logger&.info("Materializing #{template.name}##{document[template.identifier]}")
37
+ materialize_record(tmp_dir, template, document)
38
+ end
39
+
40
+ yield tmp_dir
41
+ ensure
42
+ filesystem.rm_rf(tmp_dir)
43
+ end
44
+ end
45
+
46
+ def each_root_record
47
+ request = RetrievalRequest.new(project_name: project_name, model_name: model_name, record_names: "all",
48
+ filter: filter, page_size: 100, page: 1)
49
+ magma_crud.page_records(model_name, request) do |response|
50
+ model = response.models.model(model_name)
51
+ template = model.template
52
+ model.documents.document_keys.each do |key|
53
+ yield template, model.documents.document(key)
54
+ end
55
+ end
56
+ end
57
+
58
+ def each_file(template, record, &block)
59
+ results = []
60
+
61
+ template.attributes.all.each do |attribute|
62
+ if attribute.attribute_type == AttributeType::FILE_COLLECTION
63
+ record[attribute.name]&.each_with_index do |file, i|
64
+ results << [attribute, file, i]
65
+ end
66
+ elsif attribute.attribute_type == AttributeType::FILE
67
+ results << [attribute, record[attribute.name], 0]
68
+ end
69
+ end
70
+
71
+ results.each do |attr, file, idx|
72
+ next if file.nil?
73
+ next unless file.is_a?(Hash)
74
+ next unless file['url']
75
+ yield attr.name, file['url'], (file['original_filename'] || File.basename(file['path'])), idx
76
+ end
77
+ end
78
+
79
+ def materialize_record(dest_dir, template, record)
80
+ record_to_serialize = record.dup
81
+ metadata_path = metadata_file_name(record_name: record[template.identifier], record_model_name: template.name)
82
+
83
+ each_file(template, record) do |attr_name, url, filename, idx|
84
+ metadata = metis_client.file_metadata(url)
85
+ etag = metadata[:etag]
86
+ size = metadata[:size]
87
+
88
+ if idx == 0
89
+ record_to_serialize[attr_name] = []
90
+ end
91
+
92
+ dest_file = bin_file_name(etag: etag)
93
+ record_to_serialize[attr_name] << { file: dest_file, original_filename: filename }
94
+
95
+ # Already materialized, continue
96
+ if filesystem.exist?(dest_file)
97
+ next
98
+ end
99
+
100
+ logger&.info("materializing file #{filename} (#{size} bytes)")
101
+ filesystem.mkdir_p(File.dirname(File.join(dest_dir, dest_file)))
102
+
103
+ filesystem.with_writeable(File.join(dest_dir, dest_file), "w") do |io|
104
+ if stub_files
105
+ io.write("(stub) #{filename}: #{size} bytes")
106
+ else
107
+ metis_client.download_file(url) do |chunk|
108
+ if Random.rand < 0.1
109
+ logger&.info("Writing #{chunk.length} bytes into #{dest_file}")
110
+ end
111
+
112
+ io.write(chunk)
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ dest_file = File.join(dest_dir, metadata_path)
119
+ filesystem.mkdir_p(File.dirname(dest_file))
120
+ filesystem.with_writeable(dest_file, "w") do |io|
121
+ io.write(record_to_serialize.to_json)
122
+ end
123
+ end
124
+
125
+ def metadata_file_name(record_name:, record_model_name:)
126
+ "#{record_model_name}/#{record_name.gsub(/\s/, '_')}.json"
127
+ end
128
+
129
+ def bin_file_name(etag:)
130
+ "bin/#{etag}"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end