Skip to content

Commit 0635ee8

Browse files
committed
Merge branch 'develop'
2 parents 7b3691a + 9605155 commit 0635ee8

15 files changed

+223
-98
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ build/
1111

1212
venv
1313
/env
14-
/.DS_Store
14+
.DS_Store

arcsecond/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
"ArcsecondConnectionError",
88
"ArcsecondInvalidEndpointError"]
99

10-
__version__ = '0.7.4'
10+
__version__ = '0.7.5'

arcsecond/api/endpoints/_base.py

Lines changed: 139 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
API_AUTH_PATH_REGISTER)
1818

1919
from 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
2121
from arcsecond.config import config_file_read_api_key, config_file_read_organisation_memberships
2222
from arcsecond.options import State
2323

@@ -30,6 +30,61 @@
3030
EVENT_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+
3388
class 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))

arcsecond/api/error.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def __init__(self, endpoint, endpoints):
2323
class ArcsecondConnectionError(ArcsecondError):
2424
def __init__(self, url):
2525
msg = "Unable to connect to API server {}.\n".format(url)
26-
msg += "Suggestion: Test whether it's reachable, by typing for instance: 'ping {}'".format(url)
26+
msg += "Suggestion: Test whether it's reachable, by typing for instance: 'curl -Is {} | head -n 1'".format(url)
2727
super(ArcsecondConnectionError, self).__init__(msg)
2828

2929

arcsecond/api/helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def make_file_upload_multipart_dict(filepath):
77
return {'fields': {'file': (os.path.basename(filepath), open(os.path.abspath(filepath), 'rb'))}}
88

99

10-
def transform_payload_for_multipart_encoder_fields(payload):
10+
def extract_multipart_encoder_file_fields(payload):
1111
if isinstance(payload, str) and os.path.exists(payload) and os.path.isfile(payload):
1212
payload = make_file_upload_multipart_dict(payload) # transform a str into a dict
1313

arcsecond/api/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@
2727
PersonalProfileAPIEndPoint, ProfileAPIEndPoint, ProfileAPIKeyAPIEndPoint, SatellitesAPIEndPoint,
2828
StandardStarsAPIEndPoint, TelegramsATelAPIEndPoint, TelescopesAPIEndPoint)
2929

30+
from .endpoints._base import AsyncFileUploader
31+
3032
pp = pprint.PrettyPrinter(indent=4, depth=5)
3133
ECHO_PREFIX = u' • '
3234
ECHO_ERROR_PREFIX = u' • [error] '
3335

34-
__all__ = ["ArcsecondAPI"]
36+
__all__ = ["ArcsecondAPI", "AsyncFileUploader"]
3537

3638
ENDPOINTS = [ActivitiesAPIEndPoint,
3739
CataloguesAPIEndPoint,
@@ -156,6 +158,8 @@ def _echo_error(cls, state, error):
156158
click.echo(ECHO_PREFIX + str(error))
157159

158160
def _echo_response(self, response):
161+
if isinstance(response, AsyncFileUploader):
162+
return response
159163
result, error = response
160164
if result is not None: # check against None, to avoid skipping empty lists.
161165
return ArcsecondAPI._echo_result(self.state, result)

tests/cli/__init__.py

Whitespace-only changes.

tests/test_activities.py renamed to tests/cli/test_activities.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from arcsecond import cli
88
from arcsecond.api.constants import ARCSECOND_API_URL_DEV
99
from arcsecond.api.error import ArcsecondInputValueError
10-
from .utils import register_successful_personal_login
10+
from tests.utils import register_successful_personal_login
1111

1212

1313
@httpretty.activate

tests/test_auth.py renamed to tests/cli/test_auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import httpretty
33
from click.testing import CliRunner
44
from arcsecond import cli
5-
from arcsecond.api.constants import API_AUTH_PATH_LOGIN, ARCSECOND_API_URL_DEV, API_AUTH_PATH_REGISTER
5+
from arcsecond.api.constants import API_AUTH_PATH_LOGIN, ARCSECOND_API_URL_DEV
66

77
python_version = sys.version_info.major
88

File renamed without changes.

0 commit comments

Comments
 (0)