1717 API_AUTH_PATH_REGISTER )
1818
1919from arcsecond .api .error import ArcsecondConnectionError , ArcsecondError
20- from arcsecond .api .helpers import transform_payload_for_multipart_encoder_fields
20+ from arcsecond .api .helpers import extract_multipart_encoder_file_fields
2121from arcsecond .config import config_file_read_api_key , config_file_read_organisation_memberships
2222from arcsecond .options import State
2323
3030EVENT_METHOD_PROGRESS_PERCENT = 'EVENT_METHOD_PROGRESS_PERCENT'
3131
3232
33+ class AsyncFileUploader (object ):
34+ """AsyncFileUploader is a helper class used when uploading files to the cloud.
35+
36+ Technically speaking, it can handle any http request in a background thread.
37+ It is however named like this because it is returned in place of a standard
38+ response payload when a file is to be uploaded.
39+ """
40+
41+ def __init__ (self , url , method , data = None , payload = None , ** headers ):
42+ self .url = url
43+ self .method = method
44+ self .payload = payload
45+ self .data = data
46+ self .headers = headers
47+ self ._storage = {}
48+ self ._thread = None
49+
50+ def start (self ):
51+ if self ._thread is None :
52+ args = (self .url , self .method , self .data , self .payload , self .headers )
53+ self ._thread = threading .Thread (target = self ._target , args = args )
54+ self ._thread .start ()
55+
56+ def _target (self , url , method , data , payload , headers ):
57+ try :
58+ self ._storage ['response' ] = method (url , data = data , json = payload , headers = headers )
59+ except requests .exceptions .ConnectionError :
60+ self ._storage ['error' ] = ArcsecondConnectionError (url )
61+ except Exception as e :
62+ self ._storage ['error' ] = ArcsecondError (str (e ))
63+
64+ def finish (self ):
65+ self .join ()
66+ return self .get_results ()
67+
68+ def join (self ):
69+ self ._thread .join ()
70+
71+ def is_alive (self ):
72+ return self ._thread .is_alive ()
73+
74+ def get_results (self ):
75+ response = self ._storage .get ('response' )
76+ if isinstance (response , dict ):
77+ # Responses of standard JSON payload requests are dict
78+ return response
79+ elif response is not None :
80+ if 200 <= response .status_code < 300 :
81+ return response .json () if response .text else {}, None
82+ else :
83+ return None , response .text
84+ else :
85+ return None , self ._storage .get ('error' )
86+
87+
3388class APIEndPoint (object ):
3489 name = None
3590
@@ -72,62 +127,50 @@ def _check_uuid(self, uuid_str):
72127 except ValueError :
73128 raise ArcsecondError ('Invalid UUID {}.' .format (uuid_str ))
74129
75- def _check_and_set_api_key (self , headers , url ):
76- if API_AUTH_PATH_REGISTER in url or API_AUTH_PATH_LOGIN in url or 'Authorization' in headers .keys ():
77- return headers
78-
79- if self .state .verbose :
80- click .echo ('Checking local API key... ' , nl = False )
81-
82- api_key = config_file_read_api_key (self .state .config_section ())
83- if not api_key :
84- raise ArcsecondError ('Missing API key. You must login first: $ arcsecond login' )
85-
86- headers ['X-Arcsecond-API-Authorization' ] = 'Key ' + api_key
130+ def list (self , name = '' , ** headers ):
131+ return self ._perform_request (self ._list_url (name ), 'get' , None , None , ** headers )
87132
88- if self .state .verbose :
89- click .echo ('OK' )
90- return headers
133+ def create (self , payload , callback = None , ** headers ):
134+ # If a file is provided as part of the payload, a instance of AsyncFileUploader is returned
135+ # in place of a standard JSON body response.
136+ return self ._perform_request (self ._list_url (), 'post' , payload , callback , ** headers )
91137
92- def _check_organisation_membership_and_permission (self , method_name , organisation ):
93- memberships = config_file_read_organisation_memberships (self .state .config_section ())
94- if self .state .organisation not in memberships .keys ():
95- raise ArcsecondError ('No membership found for organisation {}' .format (organisation ))
138+ def read (self , id_name_uuid , ** headers ):
139+ return self ._perform_request (self ._detail_url (id_name_uuid ), 'get' , None , None , ** headers )
96140
97- membership = memberships [self .state .organisation ]
98- if method_name not in SAFE_METHODS and membership not in WRITABLE_MEMBERSHIPS :
99- raise ArcsecondError ('Membership for organisation {} has no write permission' .format (organisation ))
141+ def update (self , id_name_uuid , payload , ** headers ):
142+ return self ._perform_request (self ._detail_url (id_name_uuid ), 'put' , payload , None , ** headers )
100143
101- def _async_perform_request (self , url , method , payload = None , ** headers ):
102- def _async_perform_request_store_response (storage , method , url , payload , headers ):
103- try :
104- storage ['response' ] = method (url , json = payload , headers = headers )
105- except requests .exceptions .ConnectionError :
106- storage ['error' ] = ArcsecondConnectionError (self ._get_base_url ())
107- except Exception as e :
108- storage ['error' ] = ArcsecondError (str (e ))
144+ def delete (self , id_name_uuid , ** headers ):
145+ return self ._perform_request (self ._detail_url (id_name_uuid ), 'delete' , None , None , ** headers )
109146
110- storage = {}
111- thread = threading .Thread (target = _async_perform_request_store_response ,
112- args = (storage , method , url , payload , headers ))
113- thread .start ()
147+ def _perform_request (self , url , method , payload , callback = None , ** headers ):
148+ method_name , method , payload , headers = self ._prepare_request (url , method , payload , ** headers )
114149
115- spinner = Spinner ()
116- while thread .is_alive ():
117- if self .state .verbose :
118- spinner .next ()
119- thread .join ()
120- if self .state .verbose :
121- click .echo ()
150+ payload , fields = extract_multipart_encoder_file_fields (payload )
151+ if fields is None :
152+ # Standard JSON sync request
153+ return self ._perform_spinner_request (url , method , method_name , None , payload , ** headers )
154+ else :
155+ # Process payload synchronously nonetheless
156+ if payload :
157+ self ._perform_spinner_request (url , method , method_name , None , payload , ** headers )
122158
123- if 'error' in storage .keys ():
124- raise storage .get ('error' )
159+ # File upload
160+ upload_monitor = self ._build_dynamic_upload_data (fields , callback )
161+ headers .update (** {'Content-Type' : upload_monitor .content_type })
125162
126- return storage .get ('response' , None )
163+ if self .state .is_using_cli :
164+ return self ._perform_spinner_request (url , method , method_name , upload_monitor , None , ** headers )
165+ else :
166+ return AsyncFileUploader (url , method , data = upload_monitor , payload = None , ** headers )
127167
128168 def _prepare_request (self , url , method , payload , ** headers ):
129169 assert (url and method )
130170
171+ if self .state .verbose :
172+ click .echo ('Preparing request...' )
173+
131174 if not isinstance (method , str ) or callable (method ):
132175 raise ArcsecondError ('Invalid HTTP request method {}. ' .format (str (method )))
133176
@@ -145,62 +188,73 @@ def _prepare_request(self, url, method, payload, **headers):
145188 # Filtering None values out of payload.
146189 payload = {k : v for k , v in payload .items () if v is not None }
147190
148- return url , method_name , method , payload , headers
191+ return method_name , method , payload , headers
149192
150- def _perform_request (self , url , method , payload , callback = None , ** headers ):
151- if self . state . verbose :
152- click . echo ( 'Preparing request...' )
193+ def _build_dynamic_upload_data (self , fields , callback = None ):
194+ # The monitor is the data!
195+ encoded_data = encoder . MultipartEncoder ( fields = fields )
153196
154- url , method_name , method , payload , headers = self ._prepare_request (url , method , payload , ** headers )
197+ if self .state .is_using_cli is True and self .state .verbose :
198+ bar = Bar ('Uploading ' + fields ['file' ][0 ], suffix = '%(percent)d%%' )
199+ return encoder .MultipartEncoderMonitor (encoded_data , lambda m : bar .goto (m .bytes_read / m .len * 100 ))
200+ elif self .state .is_using_cli is False and callback :
201+ return encoder .MultipartEncoderMonitor (encoded_data , lambda m : callback (EVENT_METHOD_PROGRESS_PERCENT ,
202+ m .bytes_read / m .len * 100 ))
203+ else :
204+ return encoder .MultipartEncoderMonitor (encoded_data , None )
155205
206+ def _perform_spinner_request (self , url , method , method_name , data = None , payload = None , ** headers ):
156207 if self .state .verbose :
157208 click .echo ('Sending {} request to {}' .format (method_name , url ))
209+ click .echo ('Payload: {}' .format (payload ))
158210
159- payload , fields = transform_payload_for_multipart_encoder_fields (payload )
160- if fields :
161- encoded_data = encoder .MultipartEncoder (fields = fields )
162- bar , upload_callback = None , None
211+ performer = AsyncFileUploader (url , method , data = data , payload = payload , ** headers )
212+ performer .start ()
163213
164- if self .state .is_using_cli is False and callback :
165- upload_callback = lambda m : callback (EVENT_METHOD_PROGRESS_PERCENT , m .bytes_read / m .len * 100 )
166- elif self .state .verbose :
167- bar = Bar ('Uploading ' + fields ['file' ][0 ], suffix = '%(percent)d%%' )
168- upload_callback = lambda m : bar .goto (m .bytes_read / m .len * 100 )
169-
170- upload_monitor = encoder .MultipartEncoderMonitor (encoded_data , upload_callback )
171- headers .update (** {'Content-Type' : upload_monitor .content_type })
172- response = method (url , data = upload_monitor , headers = headers )
173-
174- if self .state .verbose :
175- bar .finish ()
176- else :
214+ spinner = Spinner ()
215+ while performer .is_alive ():
177216 if self .state .verbose :
178- click .echo ('Payload: {}' .format (payload ))
217+ spinner .next ()
218+
219+ response , error = performer .finish ()
179220
180- response = self ._async_perform_request (url , method , payload , ** headers )
221+ # If we have an error and it is an ArcsecondError, raise it.
222+ # As for now, only ArcsecondError could be returned, and there is no
223+ # real point of returning both response and error below. But
224+ # methods in main.py expect them both.
181225
182- if response is None :
183- raise ArcsecondConnectionError ( url )
226+ if error and isinstance ( error , ArcsecondError ) :
227+ raise error
184228
185229 if self .state .verbose :
230+ click .echo ()
186231 click .echo ('Request status code ' + str (response .status_code ))
187232
188- if 200 <= response .status_code < 300 :
189- return response .json () if response .text else {}, None
190- else :
191- return None , response .text
233+ return response , error
192234
193- def list (self , name = '' , ** headers ):
194- return self ._perform_request (self ._list_url (name ), 'get' , None , None , ** headers )
235+ def _check_and_set_api_key (self , headers , url ):
236+ if API_AUTH_PATH_REGISTER in url or API_AUTH_PATH_LOGIN in url or 'Authorization' in headers .keys ():
237+ return headers
195238
196- def create ( self , payload , callback = None , ** headers ) :
197- return self . _perform_request ( self . _list_url (), 'post' , payload , callback , ** headers )
239+ if self . state . verbose :
240+ click . echo ( 'Checking local API key... ' , nl = False )
198241
199- def read (self , id_name_uuid , ** headers ):
200- return self ._perform_request (self ._detail_url (id_name_uuid ), 'get' , None , None , ** headers )
242+ api_key = config_file_read_api_key (self .state .config_section ())
243+ if not api_key :
244+ raise ArcsecondError ('Missing API key. You must login first: $ arcsecond login' )
201245
202- def update (self , id_name_uuid , payload , ** headers ):
203- return self ._perform_request (self ._detail_url (id_name_uuid ), 'put' , payload , None , ** headers )
246+ headers ['X-Arcsecond-API-Authorization' ] = 'Key ' + api_key
204247
205- def delete (self , id_name_uuid , ** headers ):
206- return self ._perform_request (self ._detail_url (id_name_uuid ), 'delete' , None , None , ** headers )
248+ if self .state .verbose :
249+ click .echo ('OK' )
250+
251+ return headers
252+
253+ def _check_organisation_membership_and_permission (self , method_name , organisation ):
254+ memberships = config_file_read_organisation_memberships (self .state .config_section ())
255+ if self .state .organisation not in memberships .keys ():
256+ raise ArcsecondError ('No membership found for organisation {}' .format (organisation ))
257+
258+ membership = memberships [self .state .organisation ]
259+ if method_name not in SAFE_METHODS and membership not in WRITABLE_MEMBERSHIPS :
260+ raise ArcsecondError ('Membership for organisation {} has no write permission' .format (organisation ))
0 commit comments