Skip to content

Commit 4edc124

Browse files
committed
Merge pull request #112 from opdemand/24-cli-progress
Fixed #24 -- added progress animation for long CLI commands.
2 parents 2d2c65d + e26da5e commit 4edc124

1 file changed

Lines changed: 138 additions & 47 deletions

File tree

client/deis.py

Lines changed: 138 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,12 @@
4040
4141
"""
4242

43+
from __future__ import print_function
4344
from cookielib import MozillaCookieJar
4445
from getpass import getpass
46+
from itertools import cycle
47+
from threading import Event
48+
from threading import Thread
4549
import glob
4650
import json
4751
import os.path
@@ -180,6 +184,65 @@ def save(self):
180184
return data
181185

182186

187+
_counter = 0
188+
189+
190+
def _newname(template="Thread-{}"):
191+
"""Generate a new thread name."""
192+
global _counter
193+
_counter += 1
194+
return template.format(_counter)
195+
196+
197+
FRAMES = {
198+
'arrow': ['^', '>', 'v', '<'],
199+
'dots': ['...', 'o..', '.o.', '..o'],
200+
'ligatures': ['bq', 'dp', 'qb', 'pd'],
201+
'lines': [' ', '-', '=', '#', '=', '-'],
202+
'slash': ['-', '\\', '|', '/'],
203+
}
204+
205+
206+
class TextProgress(Thread):
207+
"""Show progress for a long-running operation on the command-line."""
208+
209+
def __init__(self, group=None, target=None, name=None, args=(), kwargs={}):
210+
name = name or _newname("TextProgress-Thread-{}")
211+
style = kwargs.get('style', 'dots')
212+
super(TextProgress, self).__init__(
213+
group, target, name, args, kwargs)
214+
self.daemon = True
215+
self.cancelled = Event()
216+
self.frames = cycle(FRAMES[style])
217+
218+
def run(self):
219+
"""Write ASCII progress animation frames to stdout."""
220+
time.sleep(0.5)
221+
self._write_frame(self.frames.next(), erase=False)
222+
while not self.cancelled.is_set():
223+
time.sleep(0.4)
224+
self._write_frame(self.frames.next())
225+
226+
def cancel(self):
227+
"""Set the animation thread as cancelled."""
228+
self.cancelled.set()
229+
# clear the animation
230+
sys.stdout.write('\b' * (len(self.frames.next()) + 2))
231+
sys.stdout.flush()
232+
233+
def _write_frame(self, frame, erase=True):
234+
if erase:
235+
backspaces = '\b' * (len(frame) + 2)
236+
else:
237+
backspaces = ''
238+
sys.stdout.write("{} {} ".format(backspaces, frame))
239+
# flush stdout or we won't see the frame
240+
sys.stdout.flush()
241+
242+
243+
progress = TextProgress()
244+
245+
183246
def dictify(args):
184247
"""Converts a list of key=val strings into a python dict.
185248
@@ -289,12 +352,12 @@ def auth_register(self, args):
289352
if self.auth_login(login_args) is False:
290353
print('Login failed')
291354
return
292-
print
355+
print()
293356
self.keys_add({})
294-
print
357+
print()
295358
self.providers_discover({})
296-
print
297-
print 'Use `deis create --flavor=ec2-us-east-1` to create a new formation'
359+
print()
360+
print('Use `deis create --flavor=ec2-us-east-1` to create a new formation')
298361
else:
299362
print('Registration failed', response.content)
300363
return False
@@ -516,13 +579,13 @@ def containers_list(self, args):
516579
c_map = {}
517580
for item in data['results']:
518581
c_map.setdefault(item['type'], []).append(item)
519-
print
582+
print()
520583
for c_type in c_map.keys():
521584
command = procfile.get(c_type, '<none>')
522585
print("--- {c_type}: `{command}`".format(**locals()))
523586
for c in c_map[c_type]:
524587
print("{type}.{num} up {created} ({node})".format(**c))
525-
print
588+
print()
526589
else:
527590
print('Error!', response.text)
528591

@@ -542,10 +605,14 @@ def containers_scale(self, args):
542605
typ, count = type_num.split('=')
543606
body.update({typ: int(count)})
544607
print('Scaling containers... but first, coffee!')
545-
before = time.time()
546-
response = self._dispatch('post',
547-
"/api/formations/{}/scale/containers".format(formation),
548-
json.dumps(body))
608+
try:
609+
progress.start()
610+
before = time.time()
611+
response = self._dispatch('post',
612+
"/api/formations/{}/scale/containers".format(formation),
613+
json.dumps(body))
614+
finally:
615+
progress.cancel()
549616
if response.status_code == requests.codes.ok: # @UndefinedVariable
550617
print('done in {}s\n'.format(int(time.time() - before)))
551618
self.containers_list({})
@@ -625,7 +692,7 @@ def flavors_list(self, args):
625692
if response.status_code == requests.codes.ok: # @UndefinedVariable
626693
data = response.json()
627694
if data['count'] == 0:
628-
print 'No flavors found'
695+
print('No flavors found')
629696
return
630697
print("=== {owner} Flavors".format(**data['results'][0]))
631698
for item in data['results']:
@@ -664,7 +731,7 @@ def formations_create(self, args):
664731
try:
665732
self._session.git_root() # check for a git repository
666733
except EnvironmentError:
667-
print 'No git repository found, use `git init` to create one.'
734+
print('No git repository found, use `git init` to create one')
668735
return
669736
for opt in ('--id',):
670737
o = args.get(opt)
@@ -675,7 +742,7 @@ def formations_create(self, args):
675742
if flavor:
676743
response = self._dispatch('get', '/api/flavors/{}'.format(flavor))
677744
if response.status_code != 200:
678-
print 'Flavor not found'
745+
print('Flavor not found')
679746
return
680747
sys.stdout.write('Creating formation... ')
681748
sys.stdout.flush()
@@ -697,7 +764,7 @@ def formations_create(self, args):
697764
print('Git remote deis added')
698765
# create default layers if a flavor was provided
699766
if flavor:
700-
print
767+
print()
701768
self.layers_create({'<id>': 'runtime', '<flavor>': flavor})
702769
self.layers_create({'<id>': 'proxy', '<flavor>': flavor})
703770
print('\nUse `deis layers:scale proxy=1 runtime=1` to scale a basic formation')
@@ -717,12 +784,12 @@ def formations_info(self, args):
717784
if response.status_code == requests.codes.ok: # @UndefinedVariable
718785
data = response.json()
719786
print("=== {} Formation".format(formation))
720-
print
787+
print()
721788
args = {'<formation>': data['id']}
722789
self.layers_list(args)
723-
print
790+
print()
724791
self.nodes_list(args)
725-
print
792+
print()
726793
self.containers_list(args)
727794
else:
728795
print('Error!', response.text)
@@ -737,7 +804,7 @@ def formations_list(self, args):
737804
if response.status_code == requests.codes.ok: # @UndefinedVariable
738805
data = response.json()
739806
if data['count'] == 0:
740-
print 'No formations found'
807+
print('No formations found')
741808
return
742809
print("=== {owner} Formations".format(**data['results'][0]))
743810
for item in data['results']:
@@ -762,19 +829,23 @@ def formations_destroy(self, args):
762829
if confirm == formation:
763830
pass
764831
else:
765-
print """
832+
print("""
766833
! WARNING: Potentially Destructive Action
767834
! This command will destroy: {formation}
768835
! To proceed, type "{formation}" or re-run this command with --confirm={formation}
769-
""".format(**locals())
836+
""".format(**locals()))
770837
confirm = raw_input('> ').strip('\n')
771838
if confirm != formation:
772839
print('Destroy aborted')
773840
return
774841
sys.stdout.write("Destroying {}... ".format(formation))
775842
sys.stdout.flush()
776-
before = time.time()
777-
response = self._dispatch('delete', "/api/formations/{}".format(formation))
843+
try:
844+
progress.start()
845+
before = time.time()
846+
response = self._dispatch('delete', "/api/formations/{}".format(formation))
847+
finally:
848+
progress.cancel()
778849
if response.status_code in (requests.codes.no_content, # @UndefinedVariable
779850
requests.codes.not_found): # @UndefinedVariable
780851
print('done in {}s'.format(int(time.time() - before)))
@@ -825,9 +896,13 @@ def formations_converge(self, args):
825896
formation = self._session.formation
826897
sys.stdout.write('Converging {}... '.format(formation))
827898
sys.stdout.flush()
828-
before = time.time()
829-
response = self._dispatch('post',
830-
"/api/formations/{}/converge".format(formation))
899+
try:
900+
progress.start()
901+
before = time.time()
902+
response = self._dispatch('post',
903+
"/api/formations/{}/converge".format(formation))
904+
finally:
905+
progress.cancel()
831906
if response.status_code == requests.codes.ok: # @UndefinedVariable
832907
print('done in {}s'.format(int(time.time() - before)))
833908
databag = json.loads(response.content)
@@ -866,13 +941,13 @@ def keys_add(self, args):
866941
path = pubkeys[int(inp) - 1]
867942
key_id = path.split(os.path.sep)[-1].replace('.pub', '')
868943
except:
869-
print 'Aborting'
944+
print('Aborting')
870945
return
871946
with open(path) as f:
872947
data = f.read()
873948
match = re.match(r'^(ssh-...) ([^ ]+) (.+)', data)
874949
if not match:
875-
print 'Could not parse public key material'
950+
print('Could not parse public key material')
876951
return
877952
key_type, key_str, _key_comment = match.groups()
878953
body = {'id': key_id, 'public': "{0} {1}".format(key_type, key_str)}
@@ -894,7 +969,7 @@ def keys_list(self, args):
894969
if response.status_code == requests.codes.ok: # @UndefinedVariable
895970
data = response.json()
896971
if data['count'] == 0:
897-
print 'No keys found'
972+
print('No keys found')
898973
return
899974
print("=== {owner} Keys".format(**data['results'][0]))
900975
for key in data['results']:
@@ -968,9 +1043,13 @@ def layers_create(self, args):
9681043
body['run_list'] = 'recipe[deis],recipe[deis::proxy]'
9691044
sys.stdout.write("Creating {} layer... ".format(args['<id>']))
9701045
sys.stdout.flush()
971-
before = time.time()
972-
response = self._dispatch('post', "/api/formations/{}/layers".format(formation),
973-
json.dumps(body))
1046+
try:
1047+
progress.start()
1048+
before = time.time()
1049+
response = self._dispatch('post', "/api/formations/{}/layers".format(formation),
1050+
json.dumps(body))
1051+
finally:
1052+
progress.cancel()
9741053
if response.status_code == requests.codes.created: # @UndefinedVariable
9751054
print('done in {}s'.format(int(time.time() - before)))
9761055
else:
@@ -988,9 +1067,13 @@ def layers_destroy(self, args):
9881067
layer = args['<id>'] # noqa
9891068
sys.stdout.write("Destroying {layer} layer... ".format(**locals()))
9901069
sys.stdout.flush()
991-
before = time.time()
992-
response = self._dispatch(
993-
'delete', "/api/formations/{formation}/layers/{layer}".format(**locals()))
1070+
try:
1071+
progress.start()
1072+
before = time.time()
1073+
response = self._dispatch(
1074+
'delete', "/api/formations/{formation}/layers/{layer}".format(**locals()))
1075+
finally:
1076+
progress.cancel()
9941077
if response.status_code == requests.codes.no_content: # @UndefinedVariable
9951078
print('done in {}s'.format(int(time.time() - before)))
9961079
else:
@@ -1033,11 +1116,15 @@ def layers_scale(self, args):
10331116
typ, count = type_num.split('=')
10341117
body.update({typ: int(count)})
10351118
print('Scaling layers... but first, coffee!')
1036-
before = time.time()
1037-
# TODO: add threaded spinner to print dots
1038-
response = self._dispatch('post',
1039-
"/api/formations/{}/scale/layers".format(formation),
1040-
json.dumps(body))
1119+
try:
1120+
progress.start()
1121+
before = time.time()
1122+
# TODO: add threaded spinner to print dots
1123+
response = self._dispatch('post',
1124+
"/api/formations/{}/scale/layers".format(formation),
1125+
json.dumps(body))
1126+
finally:
1127+
progress.cancel()
10411128
if response.status_code == requests.codes.ok: # @UndefinedVariable
10421129
print('done in {}s\n'.format(int(time.time() - before)))
10431130
print('Use `git push deis master` to deploy to your formation')
@@ -1128,9 +1215,13 @@ def nodes_destroy(self, args):
11281215
node = args['<id>']
11291216
sys.stdout.write("Destroying {}... ".format(node))
11301217
sys.stdout.flush()
1131-
before = time.time()
1132-
response = self._dispatch('delete',
1133-
"/api/formations/{formation}/nodes/{node}".format(**locals()))
1218+
try:
1219+
progress.start()
1220+
before = time.time()
1221+
response = self._dispatch(
1222+
'delete', "/api/formations/{formation}/nodes/{node}".format(**locals()))
1223+
finally:
1224+
progress.cancel()
11341225
if response.status_code == requests.codes.no_content: # @UndefinedVariable
11351226
print('done in {}s\n'.format(int(time.time() - before)))
11361227
else:
@@ -1222,7 +1313,7 @@ def providers_discover(self, args):
12221313
print("Found EC2 credentials: {}".format(os.environ['AWS_ACCESS_KEY']))
12231314
inp = raw_input('Import these credentials? (y/n) : ')
12241315
if inp.lower().strip('\n') != 'y':
1225-
print 'Aborting.'
1316+
print('Aborting.')
12261317
return
12271318
creds = {'access_key': os.environ['AWS_ACCESS_KEY'],
12281319
'secret_key': os.environ['AWS_SECRET_KEY']}
@@ -1232,11 +1323,11 @@ def providers_discover(self, args):
12321323
response = self._dispatch('patch', '/api/providers/ec2',
12331324
json.dumps(body))
12341325
if response.status_code == requests.codes.ok: # @UndefinedVariable
1235-
print 'done'
1326+
print('done')
12361327
else:
12371328
print('Error!', response.text)
12381329
else:
1239-
print 'No credentials discovered, did you install the EC2 Command Line tools?'
1330+
print('No credentials discovered, did you install the EC2 Command Line tools?')
12401331
return
12411332

12421333
def providers_info(self, args):
@@ -1262,7 +1353,7 @@ def providers_list(self, args):
12621353
if response.status_code == requests.codes.ok: # @UndefinedVariable
12631354
data = response.json()
12641355
if data['count'] == 0:
1265-
print 'No providers found'
1356+
print('No providers found')
12661357
return
12671358
print("=== {owner} Providers".format(**data['results'][0]))
12681359
for item in data['results']:
@@ -1372,7 +1463,7 @@ def main():
13721463
if help_flag:
13731464
if cmd != 'help':
13741465
if cmd in dir(cli):
1375-
print trim(getattr(cli, cmd).__doc__)
1466+
print(trim(getattr(cli, cmd).__doc__))
13761467
return
13771468
docopt(__doc__, argv=['--help'])
13781469
# re-parse docopt with the relevant docstring

0 commit comments

Comments
 (0)