etna 0.1.28 → 0.1.33

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/etna.completion +115 -1
  3. data/lib/commands.rb +30 -0
  4. data/lib/etna/application.rb +4 -0
  5. data/lib/etna/auth.rb +25 -0
  6. data/lib/etna/client.rb +43 -6
  7. data/lib/etna/clients/base_client.rb +2 -3
  8. data/lib/etna/clients/janus.rb +1 -0
  9. data/lib/etna/clients/janus/client.rb +19 -0
  10. data/lib/etna/clients/janus/models.rb +7 -1
  11. data/lib/etna/clients/janus/workflows.rb +1 -0
  12. data/lib/etna/clients/janus/workflows/generate_token_workflow.rb +77 -0
  13. data/lib/etna/clients/magma/models.rb +13 -1
  14. data/lib/etna/clients/magma/workflows/create_project_workflow.rb +1 -1
  15. data/lib/etna/clients/magma/workflows/crud_workflow.rb +19 -2
  16. data/lib/etna/clients/magma/workflows/file_linking_workflow.rb +3 -1
  17. data/lib/etna/clients/magma/workflows/materialize_magma_record_files_workflow.rb +43 -28
  18. data/lib/etna/clients/magma/workflows/model_synchronization_workflow.rb +1 -1
  19. data/lib/etna/clients/magma/workflows/update_attributes_from_csv_workflow.rb +19 -6
  20. data/lib/etna/clients/magma/workflows/walk_model_tree_workflow.rb +33 -6
  21. data/lib/etna/clients/metis/client.rb +6 -1
  22. data/lib/etna/clients/metis/models.rb +15 -0
  23. data/lib/etna/clients/metis/workflows/metis_download_workflow.rb +15 -11
  24. data/lib/etna/clients/metis/workflows/metis_upload_workflow.rb +83 -13
  25. data/lib/etna/clients/metis/workflows/sync_metis_data_workflow.rb +43 -79
  26. data/lib/etna/command.rb +1 -0
  27. data/lib/etna/cwl.rb +4 -0
  28. data/lib/etna/directed_graph.rb +88 -5
  29. data/lib/etna/filesystem.rb +143 -15
  30. data/lib/etna/hmac.rb +2 -2
  31. data/lib/etna/route.rb +4 -0
  32. data/lib/etna/spec/auth.rb +6 -6
  33. data/lib/etna/user.rb +15 -11
  34. data/lib/helpers.rb +2 -2
  35. metadata +18 -2
@@ -519,6 +519,18 @@ module Etna
519
519
  @raw['desc'] = val
520
520
  end
521
521
 
522
+ # description and description= are needed
523
+ # to make UpdateAttribute actions
524
+ # work in the model_synchronization_workflow for
525
+ # desc.
526
+ def description
527
+ raw['desc']
528
+ end
529
+
530
+ def description=(val)
531
+ @raw['desc'] = val
532
+ end
533
+
522
534
  def display_name
523
535
  raw['display_name']
524
536
  end
@@ -589,7 +601,7 @@ module Etna
589
601
  COPYABLE_ATTRIBUTE_ATTRIBUTES = [
590
602
  :attribute_name, :attribute_type, :desc, :display_name, :format_hint,
591
603
  :hidden, :link_model_name, :read_only, :attribute_group, :unique, :validation,
592
- :restricted
604
+ :restricted, :description
593
605
  ]
594
606
 
595
607
  EDITABLE_ATTRIBUTE_ATTRIBUTES = UpdateAttributeAction.members & COPYABLE_ATTRIBUTE_ATTRIBUTES
@@ -70,7 +70,7 @@ module Etna
70
70
  puts "Creating Janus project."
71
71
  create_janus_project!
72
72
  puts "Done! Adding you as an administrator on the project."
73
- add_janus_user(user['email'], "#{user['first']} #{user['last']}", 'editor')
73
+ add_janus_user(user['email'], "#{user['name']}", 'editor')
74
74
  promote_to_administrator(user['email'])
75
75
  update_magma_client_token!
76
76
 
@@ -36,11 +36,28 @@ module Etna
36
36
  documents = Documents.new({})
37
37
  last_page = nil
38
38
  while last_page.nil? || last_page.models.model_keys.map { |k| last_page.models.model(k).documents.raw.length }.sum > 0
39
+ attempts = 0
39
40
  begin
41
+ attempts += 1
40
42
  last_page = magma_client.retrieve(request)
43
+ # Unfortunately, paging in magma is not great and times out from time to time.
44
+ rescue Net::ReadTimeout => e
45
+ if attempts > 5
46
+ raise e
47
+ end
48
+
49
+ retry
41
50
  rescue Etna::Error => e
42
- raise e unless e.message.include?('not found')
43
- break
51
+ if e.status === 502
52
+ if attempts > 5
53
+ raise e
54
+ end
55
+
56
+ retry
57
+ else
58
+ raise e unless e.message.include?('not found')
59
+ break
60
+ end
44
61
  end
45
62
 
46
63
  documents += last_page.models.model(model_name).documents unless block_given?
@@ -95,7 +95,9 @@ module Etna
95
95
  file_path = ::File.dirname(file_path)
96
96
  {attribute_name => "https://metis.ucsf.edu/#{project_name}/browse/#{bucket_name}/#{file_path}"}
97
97
  else
98
- {attribute_name => {path: "metis://#{project_name}/#{bucket_name}/#{file_path}"}}
98
+ {attribute_name => {
99
+ path: "metis://#{project_name}/#{bucket_name}/#{file_path}",
100
+ original_filename: File.basename(file_path)}}
99
101
  end
100
102
  end
101
103
 
@@ -1,5 +1,4 @@
1
1
  require 'ostruct'
2
- require 'digest'
3
2
  require 'fileutils'
4
3
  require 'tempfile'
5
4
 
@@ -9,11 +8,11 @@ module Etna
9
8
  class MaterializeDataWorkflow < Struct.new(
10
9
  :metis_client, :magma_client, :project_name,
11
10
  :model_name, :model_filters, :model_attributes_mask,
12
- :filesystem, :logger, :stub_files,
13
- :skip_tmpdir, keyword_init: true)
11
+ :filesystem, :logger, :stub_files, :concurrency,
12
+ :record_names, keyword_init: true)
14
13
 
15
14
  def initialize(**kwds)
16
- super(**({filesystem: Etna::Filesystem.new}.update(kwds)))
15
+ super(**({filesystem: Etna::Filesystem.new, concurrency: 10, record_names: "all"}.update(kwds)))
17
16
  end
18
17
 
19
18
  def magma_crud
@@ -25,31 +24,47 @@ module Etna
25
24
  end
26
25
 
27
26
  def materialize_all(dest)
28
- tmpdir = skip_tmpdir ? nil : filesystem.tmpdir
27
+ templates = {}
28
+
29
+ semaphore = Concurrent::Semaphore.new(concurrency)
30
+ errors = Queue.new
31
+
32
+ model_walker.walk_from(
33
+ model_name,
34
+ record_names,
35
+ model_attributes_mask: model_attributes_mask,
36
+ model_filters: model_filters,
37
+ page_size: 20,
38
+ ) do |template, document|
39
+ logger&.info("Materializing #{template.name}##{document[template.identifier]}")
40
+ templates[template.name] = template
41
+
42
+ begin
43
+ if (error = errors.pop(true))
44
+ raise error
45
+ end
46
+ rescue ThreadError
47
+ end
29
48
 
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(dest, tmpdir, template, document)
49
+ semaphore.acquire
50
+ Thread.new do
51
+ begin
52
+ materialize_record(dest, template, document)
53
+ rescue => e
54
+ errors << e
55
+ ensure
56
+ semaphore.release
57
+ end
38
58
  end
39
- ensure
40
- filesystem.rm_rf(tmpdir) unless skip_tmpdir
41
59
  end
42
- end
43
60
 
44
- def each_root_record
45
- request = RetrievalRequest.new(project_name: project_name, model_name: model_name, record_names: "all",
46
- filter: filter, page_size: 100, page: 1)
47
- magma_crud.page_records(model_name, request) do |response|
48
- model = response.models.model(model_name)
49
- template = model.template
50
- model.documents.document_keys.each do |key|
51
- yield template, model.documents.document(key)
61
+ semaphore.acquire(concurrency)
62
+
63
+ begin
64
+ if (error = errors.pop(true))
65
+ raise error
52
66
  end
67
+ rescue ThreadError
53
68
  end
54
69
  end
55
70
 
@@ -78,11 +93,10 @@ module Etna
78
93
  @sync_metis_data_workflow ||= Etna::Clients::Metis::SyncMetisDataWorkflow.new(
79
94
  metis_client: metis_client,
80
95
  logger: logger,
81
- skip_tmpdir: skip_tmpdir,
82
96
  filesystem: filesystem)
83
97
  end
84
98
 
85
- def materialize_record(dest_dir, tmpdir, template, record)
99
+ def materialize_record(dest_dir, template, record)
86
100
  record_to_serialize = record.dup
87
101
 
88
102
  each_file(template, record) do |attr_name, url, filename, idx|
@@ -91,13 +105,14 @@ module Etna
91
105
  end
92
106
 
93
107
  dest_file = File.join(dest_dir, metadata_file_name(record_name: record[template.identifier], record_model_name: template.name, ext: "_#{attr_name}_#{idx}#{File.extname(filename)}"))
94
- sync_metis_data_workflow.copy_file(bin_root_dir: dest_dir, tmpdir: tmpdir, dest: dest_file, url: url, stub: stub_files)
95
- record_to_serialize[attr_name] << { file: dest_file, original_filename: filename }
108
+ sync_metis_data_workflow.copy_file(dest: dest_file, url: url, stub: stub_files)
109
+ record_to_serialize[attr_name] << {file: dest_file, original_filename: filename}
96
110
  end
97
111
 
98
112
  dest_file = File.join(dest_dir, metadata_file_name(record_name: record[template.identifier], record_model_name: template.name, ext: '.json'))
99
113
  filesystem.mkdir_p(File.dirname(dest_file))
100
114
  json = record_to_serialize.to_json
115
+
101
116
  filesystem.with_writeable(dest_file, "w", size_hint: json.bytes.length) do |io|
102
117
  io.write(json)
103
118
  end
@@ -226,7 +226,7 @@ module Etna
226
226
  if renames && (attribute_renames = renames[model_name]) && (new_name = attribute_renames[attribute_name])
227
227
  new_name = target_attribute_of_source(model_name, new_name)
228
228
 
229
- unless target_model.template.attributes.include?(new_name)
229
+ unless target_model.template.attributes.attribute_keys.include?(new_name)
230
230
  if target_original_attribute
231
231
  rename = RenameAttributeAction.new(model_name: target_model_name, attribute_name: target_attribute_name, new_attribute_name: new_name)
232
232
  queue_update(rename)
@@ -37,8 +37,7 @@ module Etna
37
37
  end
38
38
 
39
39
  def update_attributes
40
- method = json_values ? :update_json : :update
41
- magma_crud.update_records(method: method) do |update_request|
40
+ magma_crud.update_records(method: :update_json) do |update_request|
42
41
  each_revision do |model_name, record_name, revision|
43
42
  update_request.update_revision(model_name, record_name, revision)
44
43
  end
@@ -53,10 +52,18 @@ module Etna
53
52
  end
54
53
 
55
54
  class RowBase
56
- def stripped_value(attribute_value)
55
+ def attribute_is_json?(attribute)
56
+ [Etna::Clients::Magma::AttributeType::FILE,
57
+ Etna::Clients::Magma::AttributeType::FILE_COLLECTION,
58
+ Etna::Clients::Magma::AttributeType::IMAGE].include?(attribute.attribute_type)
59
+ end
60
+
61
+ def stripped_value(attribute, attribute_value)
57
62
  attribute_value = attribute_value&.strip
58
63
 
59
- if attribute_value && @workflow.json_values && attribute_value != @workflow.hole_value
64
+ if attribute_value &&
65
+ ( @workflow.json_values || attribute_is_json?(attribute) ) &&
66
+ attribute_value != @workflow.hole_value
60
67
  attribute_value = JSON.parse(attribute_value)
61
68
  end
62
69
  attribute_value
@@ -123,7 +130,7 @@ module Etna
123
130
  raise "Invalid attribute #{attribute_name} for model #{model_name}."
124
131
  end
125
132
 
126
- stripped = stripped_value(@raw[index + 1])
133
+ stripped = stripped_value(attribute, @raw[index + 1])
127
134
  unless @workflow.hole_value.nil?
128
135
  next if stripped == @workflow.hole_value
129
136
  end
@@ -234,7 +241,13 @@ module Etna
234
241
  attribute_name_clean = attribute_name.strip
235
242
  raise "Invalid attribute \"#{attribute_name_clean}\" for model #{@model_name}." unless attribute = @workflow.find_attribute(@model_name, attribute_name_clean)
236
243
 
237
- attributes[attribute_name_clean] = stripped_value(@raw[attribute_name])
244
+ stripped = stripped_value(attribute, @raw[attribute_name])
245
+
246
+ unless @workflow.hole_value.nil?
247
+ next if stripped == @workflow.hole_value
248
+ end
249
+
250
+ attributes[attribute_name_clean] = stripped
238
251
  end
239
252
  end
240
253
  end
@@ -7,6 +7,31 @@ module Etna
7
7
  module Clients
8
8
  class Magma
9
9
  class WalkModelTreeWorkflow < Struct.new(:magma_crud, :logger, keyword_init: true)
10
+ def initialize(**args)
11
+ super(**({}.update(args)))
12
+ @template_for = {}
13
+ end
14
+
15
+ def masked_attributes(template:, model_attributes_mask:, model_name:)
16
+ attributes_mask = model_attributes_mask[model_name]
17
+ return ["all", "all"] if attributes_mask.nil?
18
+ [(attributes_mask + [template.identifier, 'parent']).uniq, attributes_mask]
19
+ end
20
+
21
+ def attribute_included?(mask, attribute_name)
22
+ return true if mask == "all"
23
+ mask.include?(attribute_name)
24
+ end
25
+
26
+ def template_for(model_name)
27
+ @template_for[model_name] ||= magma_crud.magma_client.retrieve(RetrievalRequest.new(
28
+ project_name: magma_crud.project_name,
29
+ model_name: model_name,
30
+ record_names: [],
31
+ attribute_names: [],
32
+ )).models.model(model_name).template
33
+ end
34
+
10
35
  def walk_from(
11
36
  model_name,
12
37
  record_names = 'all',
@@ -22,28 +47,30 @@ module Etna
22
47
  next if seen.include?([path[:from], model_name])
23
48
  seen.add([path[:from], model_name])
24
49
 
50
+ template = template_for(model_name)
51
+ query_attributes, walk_attributes = masked_attributes(template: template, model_attributes_mask: model_attributes_mask, model_name: model_name)
52
+
25
53
  request = RetrievalRequest.new(
26
54
  project_name: magma_crud.project_name,
27
55
  model_name: model_name,
28
56
  record_names: path[:record_names],
29
57
  filter: model_filters[model_name],
58
+ attribute_names: query_attributes,
30
59
  page_size: page_size, page: 1
31
60
  )
32
61
 
33
62
  related_models = {}
34
63
 
35
64
  magma_crud.page_records(model_name, request) do |response|
36
- model = response.models.model(model_name)
37
- template = model.template
38
-
39
65
  tables = []
40
66
  collections = []
41
67
  links = []
42
68
  attributes = []
43
69
 
70
+ model = response.models.model(model_name)
71
+
44
72
  template.attributes.attribute_keys.each do |attr_name|
45
- attributes_mask = model_attributes_mask[model_name]
46
- next if !attributes_mask.nil? && !attributes_mask.include?(attr_name) && attr_name != template.identifier
73
+ next unless attribute_included?(query_attributes, attr_name)
47
74
  attributes << attr_name
48
75
 
49
76
  attr = template.attributes.attribute(attr_name)
@@ -58,7 +85,7 @@ module Etna
58
85
  elsif attr.attribute_type == AttributeType::CHILD
59
86
  related_models[attr.link_model_name] ||= Set.new
60
87
  links << attr_name
61
- elsif attr.attribute_type == AttributeType::PARENT
88
+ elsif attr.attribute_type == AttributeType::PARENT && attribute_included?(walk_attributes, attr_name)
62
89
  related_models[attr.link_model_name] ||= Set.new
63
90
  links << attr_name
64
91
  end
@@ -69,6 +69,11 @@ module Etna
69
69
  @etna_client.folder_remove(delete_folder_request.to_h))
70
70
  end
71
71
 
72
+ def delete_file(delete_file_request)
73
+ FilesResponse.new(
74
+ @etna_client.file_remove(delete_file_request.to_h))
75
+ end
76
+
72
77
  def find(find_request)
73
78
  FoldersAndFilesResponse.new(
74
79
  @etna_client.bucket_find(find_request.to_h))
@@ -98,7 +103,7 @@ module Etna
98
103
  @etna_client.get(download_path) do |response|
99
104
  return {
100
105
  etag: response['ETag'].gsub(/"/, ''),
101
- size: response['Content-Length'],
106
+ size: response['Content-Length'].to_i,
102
107
  }
103
108
  end
104
109
  end
@@ -95,6 +95,21 @@ module Etna
95
95
  end
96
96
  end
97
97
 
98
+ class DeleteFileRequest < Struct.new(:project_name, :bucket_name, :file_path, keyword_init: true)
99
+ include JsonSerializableStruct
100
+
101
+ def initialize(**params)
102
+ super({}.update(params))
103
+ end
104
+
105
+ def to_h
106
+ # The :project_name comes in from Polyphemus as a symbol value,
107
+ # we need to make sure it's a string because it's going
108
+ # in the URL.
109
+ super().compact.transform_values(&:to_s)
110
+ end
111
+ end
112
+
98
113
  class FindRequest < Struct.new(:project_name, :bucket_name, :limit, :offset, :params, keyword_init: true)
99
114
  include JsonSerializableStruct
100
115
 
@@ -13,23 +13,27 @@ module Etna
13
13
  end
14
14
 
15
15
  # TODO: Might be possible to use range headers to select and resume downloads on failure in the future.
16
- def do_download(dest_file, metis_file, &block)
16
+ def do_download(dest_file_or_io, metis_file, &block)
17
17
  size = metis_file.size
18
18
  completed = 0.0
19
19
  start = Time.now
20
20
 
21
- ::File.open(dest_file, "w") do |io|
22
- metis_client.download_file(metis_file) do |chunk|
23
- io.write chunk
24
- completed += chunk.size
25
-
26
- block.call([
27
- :progress,
28
- size == 0 ? 1 : completed / size,
29
- (completed / (Time.now - start)).round(2),
30
- ]) unless block.nil?
21
+ unless dest_file_or_io.is_a?(IO)
22
+ ::File.open(dest_file_or_io, 'w') do |io|
23
+ return do_download(dest_file_or_io, metis_file, &block)
31
24
  end
32
25
  end
26
+
27
+ metis_client.download_file(metis_file) do |chunk|
28
+ dest_file_or_io.write chunk
29
+ completed += chunk.size
30
+
31
+ block.call([
32
+ :progress,
33
+ size == 0 ? 1 : completed / size,
34
+ (completed / (Time.now - start)).round(2),
35
+ ]) unless block.nil?
36
+ end
33
37
  end
34
38
  end
35
39
  end
@@ -4,17 +4,26 @@ require 'fileutils'
4
4
  require 'tempfile'
5
5
  require 'securerandom'
6
6
 
7
+ $digest_mutx = Mutex.new
8
+
7
9
  module Etna
8
10
  module Clients
9
11
  class Metis
10
12
  class MetisUploadWorkflow < Struct.new(:metis_client, :metis_uid, :project_name, :bucket_name, :max_attempts, keyword_init: true)
13
+ class StreamingUploadError < StandardError
14
+ end
15
+
11
16
 
12
17
  def initialize(args)
13
18
  super({max_attempts: 3, metis_uid: SecureRandom.hex}.update(args))
14
19
  end
15
20
 
16
- def do_upload(source_file, dest_path, &block)
17
- upload = Upload.new(source_file: source_file)
21
+ def do_upload(source_file_or_upload, dest_path, &block)
22
+ unless source_file_or_upload.is_a?(Upload)
23
+ upload = Upload.new(source_file: source_file_or_upload)
24
+ else
25
+ upload = source_file_or_upload
26
+ end
18
27
 
19
28
  dir = ::File.dirname(dest_path)
20
29
  metis_client.create_folder(CreateFolderRequest.new(
@@ -68,9 +77,11 @@ module Etna
68
77
 
69
78
  unsent_zero_byte_file = false
70
79
  rescue Etna::Error => e
71
- m = yield [:error, e] unless block.nil?
72
- if m == false
73
- raise e
80
+ unless block.nil?
81
+ m = yield [:error, e]
82
+ if m == false
83
+ raise e
84
+ end
74
85
  end
75
86
 
76
87
  if e.status == 422
@@ -90,13 +101,17 @@ module Etna
90
101
  end
91
102
  end
92
103
 
93
- class Upload < Struct.new(:source_file, :next_blob_size, :current_byte_position, keyword_init: true)
104
+ class Upload
94
105
  INITIAL_BLOB_SIZE = 2 ** 10
95
106
  MAX_BLOB_SIZE = 2 ** 22
96
107
  ZERO_HASH = 'd41d8cd98f00b204e9800998ecf8427e'
97
108
 
98
- def initialize(**args)
99
- super
109
+ attr_accessor :source_file, :next_blob_size, :current_byte_position
110
+
111
+ def initialize(source_file: nil, next_blob_size: nil, current_byte_position: nil)
112
+ self.source_file = source_file
113
+ self.next_blob_size = next_blob_size
114
+ self.current_byte_position = current_byte_position
100
115
  self.next_blob_size = [file_size, INITIAL_BLOB_SIZE].min
101
116
  self.current_byte_position = 0
102
117
  end
@@ -108,10 +123,10 @@ module Etna
108
123
  def advance_position!
109
124
  self.current_byte_position = self.current_byte_position + self.next_blob_size
110
125
  self.next_blob_size = [
111
- MAX_BLOB_SIZE,
112
- # in fact we should stop when we hit the end of the file
113
- file_size - current_byte_position
114
- ].min
126
+ MAX_BLOB_SIZE,
127
+ # in fact we should stop when we hit the end of the file
128
+ file_size - current_byte_position
129
+ ].min
115
130
  end
116
131
 
117
132
  def complete?
@@ -119,7 +134,14 @@ module Etna
119
134
  end
120
135
 
121
136
  def next_blob_hash
122
- Digest::MD5.hexdigest(next_blob_bytes)
137
+ bytes = next_blob_bytes
138
+ if bytes.empty?
139
+ return ZERO_HASH
140
+ end
141
+
142
+ $digest_mutx.synchronize do
143
+ return Digest::MD5.hexdigest(bytes)
144
+ end
123
145
  end
124
146
 
125
147
  def next_blob_bytes
@@ -131,6 +153,54 @@ module Etna
131
153
  self.next_blob_size = upload_response.next_blob_size
132
154
  end
133
155
  end
156
+
157
+ class StreamingIOUpload < Upload
158
+ def initialize(readable_io:, size_hint: 0, **args)
159
+ @readable_io = readable_io
160
+ @size_hint = size_hint
161
+ @read_position = 0
162
+ @last_bytes = ""
163
+ super(**args)
164
+ end
165
+
166
+ def file_size
167
+ @size_hint
168
+ end
169
+
170
+ def next_blob_bytes
171
+ next_left = current_byte_position
172
+ next_right = current_byte_position + next_blob_size
173
+
174
+ if next_right < @read_position
175
+ raise StreamingUploadError.new("Upload needs restart, but source is streaming and ephemeral. #{next_right} #{@read_position} You need to restart the source stream and create a new upload.")
176
+ elsif @read_position < next_left
177
+ # read from the stream and discard until we are positioned for the next read.
178
+ data = @readable_io.read(next_left - @read_position)
179
+ raise StreamingUploadError.new("Unexpected EOF in read stream") if data.nil?
180
+
181
+ @read_position += data.bytes.length
182
+ end
183
+
184
+ # If we have consumed all requested data, return what we have consumed.
185
+ # If we have requested no data, make sure to provide "" as the result.
186
+ if next_right == @read_position
187
+ return @last_bytes
188
+ end
189
+
190
+ if @read_position != next_left
191
+ raise StreamingUploadError.new("Alignment error, source data does not match expected upload resume. #{@read_position} #{next_left} Restart the upload to address.")
192
+ end
193
+
194
+ @last_bytes = "".tap do |bytes|
195
+ while @read_position < next_right
196
+ bytes << @readable_io.read(next_right - @read_position).tap do |data|
197
+ raise StreamingUploadError.new("Unexpected EOF in read stream") if data.nil?
198
+ @read_position += data.bytes.length
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
134
204
  end
135
205
  end
136
206
  end