Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / commands / pithos.py @ 6893e31c

History | View | Annotate | Download (66.8 kB)

1
# Copyright 2011-2013 GRNET S.A. All rights reserved.
2
#
3
# Redistribution and use in source and binary forms, with or
4
# without modification, are permitted provided that the following
5
# conditions are met:
6
#
7
#   1. Redistributions of source code must retain the above
8
#      copyright notice, this list of conditions and the following
9
#      disclaimer.
10
#
11
#   2. Redistributions in binary form must reproduce the above
12
#      copyright notice, this list of conditions and the following
13
#      disclaimer in the documentation and/or other materials
14
#      provided with the distribution.
15
#
16
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
# POSSIBILITY OF SUCH DAMAGE.
28
#
29
# The views and conclusions contained in the software and
30
# documentation are those of the authors and should not be
31
# interpreted as representing official policies, either expressed
32
# or implied, of GRNET S.A.command
33

    
34
from time import localtime, strftime
35
from io import StringIO
36
from pydoc import pager
37
from os import path, walk, makedirs
38

    
39
from kamaki.clients.pithos import PithosClient, ClientError
40

    
41
from kamaki.cli import command
42
from kamaki.cli.command_tree import CommandTree
43
from kamaki.cli.commands import (
44
    _command_init, errors, addLogSettings, DontRaiseKeyError, _optional_json,
45
    _name_filter, _optional_output_cmd)
46
from kamaki.cli.errors import (
47
    CLIBaseUrlError, CLIError, CLIInvalidArgument, raiseCLIError,
48
    CLISyntaxError)
49
from kamaki.cli.argument import (
50
    FlagArgument, IntArgument, ValueArgument, DateArgument, KeyValueArgument,
51
    ProgressBarArgument, RepeatableArgument, DataSizeArgument)
52
from kamaki.cli.utils import (
53
    format_size, bold, get_path_size, guess_mime_type)
54

    
55
file_cmds = CommandTree('file', 'Pithos+/Storage object level API commands')
56
container_cmds = CommandTree(
57
    'container', 'Pithos+/Storage container level API commands')
58
sharer_cmds = CommandTree('sharer', 'Pithos+/Storage sharers')
59
group_cmds = CommandTree('group', 'Pithos+/Storage user groups')
60
_commands = [file_cmds, container_cmds, sharer_cmds, group_cmds]
61

    
62

    
63
class _pithos_init(_command_init):
64
    """Initilize a pithos+ client
65
    There is always a default account (current user uuid)
66
    There is always a default container (pithos)
67
    """
68

    
69
    @DontRaiseKeyError
70
    def _custom_container(self):
71
        return self.config.get_cloud(self.cloud, 'pithos_container')
72

    
73
    @DontRaiseKeyError
74
    def _custom_uuid(self):
75
        return self.config.get_cloud(self.cloud, 'pithos_uuid')
76

    
77
    def _set_account(self):
78
        self.account = self._custom_uuid()
79
        if self.account:
80
            return
81
        astakos = getattr(self, 'auth_base', None)
82
        if astakos:
83
            self.account = astakos.user_term('id', self.token)
84
        else:
85
            raise CLIBaseUrlError(service='astakos')
86

    
87
    @errors.generic.all
88
    @addLogSettings
89
    def _run(self):
90
        cloud = getattr(self, 'cloud', None)
91
        if cloud:
92
            self.base_url = self._custom_url('pithos')
93
        else:
94
            self.cloud = 'default'
95
        self.token = self._custom_token('pithos')
96
        self.container = self._custom_container() or 'pithos'
97

    
98
        astakos = getattr(self, 'auth_base', None)
99
        if astakos:
100
            self.token = self.token or astakos.token
101
            if not self.base_url:
102
                pithos_endpoints = astakos.get_service_endpoints(
103
                    self._custom_type('pithos') or 'object-store',
104
                    self._custom_version('pithos') or '')
105
                self.base_url = pithos_endpoints['publicURL']
106
        else:
107
            raise CLIBaseUrlError(service='astakos')
108

    
109
        self._set_account()
110
        self.client = PithosClient(
111
            self.base_url, self.token, self.account, self.container)
112

    
113
    def main(self):
114
        self._run()
115

    
116

    
117
class _pithos_account(_pithos_init):
118
    """Setup account"""
119

    
120
    def __init__(self, arguments={}, auth_base=None, cloud=None):
121
        super(_pithos_account, self).__init__(arguments, auth_base, cloud)
122
        self['account'] = ValueArgument(
123
            'Use (a different) user uuid', ('-A', '--account'))
124

    
125
    def print_objects(self, object_list):
126
        for index, obj in enumerate(object_list):
127
            pretty_obj = obj.copy()
128
            index += 1
129
            empty_space = ' ' * (len(str(len(object_list))) - len(str(index)))
130
            if 'subdir' in obj:
131
                continue
132
            if self._is_dir(obj):
133
                size = 'D'
134
            else:
135
                size = format_size(obj['bytes'])
136
                pretty_obj['bytes'] = '%s (%s)' % (obj['bytes'], size)
137
            oname = obj['name'] if self['more'] else bold(obj['name'])
138
            prfx = ('%s%s. ' % (empty_space, index)) if self['enum'] else ''
139
            if self['detail']:
140
                self.writeln('%s%s' % (prfx, oname))
141
                self.print_dict(pretty_obj, exclude=('name'))
142
                self.writeln()
143
            else:
144
                oname = '%s%9s %s' % (prfx, size, oname)
145
                oname += '/' if self._is_dir(obj) else u''
146
                self.writeln(oname)
147

    
148
    @staticmethod
149
    def _is_dir(remote_dict):
150
        return 'application/directory' == remote_dict.get(
151
            'content_type', remote_dict.get('content-type', ''))
152

    
153
    def _run(self):
154
        super(_pithos_account, self)._run()
155
        self.client.account = self['account'] or getattr(
156
            self, 'account', getattr(self.client, 'account', None))
157

    
158

    
159
class _pithos_container(_pithos_account):
160
    """Setup container"""
161

    
162
    def __init__(self, arguments={}, auth_base=None, cloud=None):
163
        super(_pithos_container, self).__init__(arguments, auth_base, cloud)
164
        self['container'] = ValueArgument(
165
            'Use this container (default: pithos)', ('-C', '--container'))
166

    
167
    @staticmethod
168
    def _resolve_pithos_url(url):
169
        """Match urls of one of the following formats:
170
        pithos://ACCOUNT/CONTAINER/OBJECT_PATH
171
        /CONTAINER/OBJECT_PATH
172
        return account, container, path
173
        """
174
        account, container, obj_path, prefix = '', '', url, 'pithos://'
175
        if url.startswith(prefix):
176
            account, sep, url = url[len(prefix):].partition('/')
177
            url = '/%s' % url
178
        if url.startswith('/'):
179
            container, sep, obj_path = url[1:].partition('/')
180
        return account, container, obj_path
181

    
182
    def _run(self, url=None):
183
        acc, con, self.path = self._resolve_pithos_url(url or '')
184
        self.account = acc or getattr(self, 'account', '')
185
        super(_pithos_container, self)._run()
186
        self.container = con or self['container'] or getattr(
187
            self, 'container', None) or getattr(self.client, 'container', '')
188
        self.client.container = self.container
189

    
190

    
191
@command(file_cmds)
192
class file_info(_pithos_container, _optional_json):
193
    """Get information/details about a file"""
194

    
195
    arguments = dict(
196
        object_version=ValueArgument(
197
            'download a file of a specific version', '--object-version'),
198
        hashmap=FlagArgument(
199
            'Get file hashmap instead of details', '--hashmap'),
200
        matching_etag=ValueArgument(
201
            'show output if ETags match', '--if-match'),
202
        non_matching_etag=ValueArgument(
203
            'show output if ETags DO NOT match', '--if-none-match'),
204
        modified_since_date=DateArgument(
205
            'show output modified since then', '--if-modified-since'),
206
        unmodified_since_date=DateArgument(
207
            'show output unmodified since then', '--if-unmodified-since'),
208
        sharing=FlagArgument(
209
            'show object permissions and sharing information', '--sharing'),
210
        metadata=FlagArgument('show only object metadata', '--metadata'),
211
        versions=FlagArgument(
212
            'show the list of versions for the file', '--object-versions')
213
    )
214

    
215
    def version_print(self, versions):
216
        return {'/%s/%s' % (self.container, self.path): [
217
            dict(version_id=vitem[0], created=strftime(
218
                '%d-%m-%Y %H:%M:%S',
219
                localtime(float(vitem[1])))) for vitem in versions]}
220

    
221
    @errors.generic.all
222
    @errors.pithos.connection
223
    @errors.pithos.container
224
    @errors.pithos.object_path
225
    def _run(self):
226
        if self['hashmap']:
227
            r = self.client.get_object_hashmap(
228
                self.path,
229
                version=self['object_version'],
230
                if_match=self['matching_etag'],
231
                if_none_match=self['non_matching_etag'],
232
                if_modified_since=self['modified_since_date'],
233
                if_unmodified_since=self['unmodified_since_date'])
234
        elif self['sharing']:
235
            r = self.client.get_object_sharing(self.path)
236
            r['public url'] = self.client.get_object_info(
237
                self.path, version=self['object_version']).get(
238
                    'x-object-public', None)
239
        elif self['metadata']:
240
            r, preflen = dict(), len('x-object-meta-')
241
            for k, v in self.client.get_object_meta(self.path).items():
242
                r[k[preflen:]] = v
243
        elif self['versions']:
244
            r = self.version_print(
245
                self.client.get_object_versionlist(self.path))
246
        else:
247
            r = self.client.get_object_info(
248
                self.path, version=self['object_version'])
249
        self._print(r, self.print_dict)
250

    
251
    def main(self, path_or_url):
252
        super(self.__class__, self)._run(path_or_url)
253
        self._run()
254

    
255

    
256
@command(file_cmds)
257
class file_list(_pithos_container, _optional_json, _name_filter):
258
    """List all objects in a container or a directory object"""
259

    
260
    arguments = dict(
261
        detail=FlagArgument('detailed output', ('-l', '--list')),
262
        limit=IntArgument('limit number of listed items', ('-n', '--number')),
263
        marker=ValueArgument('output greater that marker', '--marker'),
264
        delimiter=ValueArgument('show output up to delimiter', '--delimiter'),
265
        meta=ValueArgument(
266
            'show output with specified meta keys', '--meta',
267
            default=[]),
268
        if_modified_since=ValueArgument(
269
            'show output modified since then', '--if-modified-since'),
270
        if_unmodified_since=ValueArgument(
271
            'show output not modified since then', '--if-unmodified-since'),
272
        until=DateArgument('show metadata until then', '--until'),
273
        format=ValueArgument(
274
            'format to parse until data (default: d/m/Y H:M:S )', '--format'),
275
        shared=FlagArgument('show only shared', '--shared'),
276
        more=FlagArgument('read long results', '--more'),
277
        enum=FlagArgument('Enumerate results', '--enumerate'),
278
        recursive=FlagArgument(
279
            'Recursively list containers and their contents',
280
            ('-R', '--recursive'))
281
    )
282

    
283
    @errors.generic.all
284
    @errors.pithos.connection
285
    @errors.pithos.container
286
    @errors.pithos.object_path
287
    def _run(self):
288
        r = self.client.container_get(
289
            limit=False if self['more'] else self['limit'],
290
            marker=self['marker'],
291
            prefix=self['name_pref'],
292
            delimiter=self['delimiter'],
293
            path=self.path or '',
294
            if_modified_since=self['if_modified_since'],
295
            if_unmodified_since=self['if_unmodified_since'],
296
            until=self['until'],
297
            meta=self['meta'],
298
            show_only_shared=self['shared'])
299
        files = self._filter_by_name(r.json)
300
        if self['more']:
301
            outbu, self._out = self._out, StringIO()
302
        try:
303
            if self['json_output'] or self['output_format']:
304
                self._print(files)
305
            else:
306
                self.print_objects(files)
307
        finally:
308
            if self['more']:
309
                pager(self._out.getvalue())
310
                self._out = outbu
311

    
312
    def main(self, path_or_url=''):
313
        super(self.__class__, self)._run(path_or_url)
314
        self._run()
315

    
316

    
317
@command(file_cmds)
318
class file_modify(_pithos_container):
319
    """Modify the attributes of a file or directory object"""
320

    
321
    arguments = dict(
322
        publish=FlagArgument(
323
            'Make an object public (returns the public URL)', '--publish'),
324
        unpublish=FlagArgument(
325
            'Make an object unpublic', '--unpublish'),
326
        uuid_for_read_permission=RepeatableArgument(
327
            'Give read access to user/group (can be repeated, accumulative). '
328
            'Format for users: UUID . Format for groups: UUID:GROUP . '
329
            'Use * for all users/groups', '--read-permission'),
330
        uuid_for_write_permission=RepeatableArgument(
331
            'Give write access to user/group (can be repeated, accumulative). '
332
            'Format for users: UUID . Format for groups: UUID:GROUP . '
333
            'Use * for all users/groups', '--write-permission'),
334
        no_permissions=FlagArgument('Remove permissions', '--no-permissions'),
335
        metadata_to_set=KeyValueArgument(
336
            'Add metadata (KEY=VALUE) to an object (can be repeated)',
337
            '--metadata-add'),
338
        metadata_key_to_delete=RepeatableArgument(
339
            'Delete object metadata (can be repeated)', '--metadata-del'),
340
    )
341
    required = [
342
        'publish', 'unpublish', 'uuid_for_read_permission', 'metadata_to_set',
343
        'uuid_for_write_permission', 'no_permissions',
344
        'metadata_key_to_delete']
345

    
346
    @errors.generic.all
347
    @errors.pithos.connection
348
    @errors.pithos.container
349
    @errors.pithos.object_path
350
    def _run(self):
351
        if self['publish']:
352
            self.writeln(self.client.publish_object(self.path))
353
        if self['unpublish']:
354
            self.client.unpublish_object(self.path)
355
        if self['uuid_for_read_permission'] or self[
356
                'uuid_for_write_permission']:
357
            perms = self.client.get_object_sharing(self.path)
358
            read, write = perms.get('read', ''), perms.get('write', '')
359
            read = read.split(',') if read else []
360
            write = write.split(',') if write else []
361
            read += self['uuid_for_read_permission']
362
            write += self['uuid_for_write_permission']
363
            self.client.set_object_sharing(
364
                self.path, read_permission=read, write_permission=write)
365
            self.print_dict(self.client.get_object_sharing(self.path))
366
        if self['no_permissions']:
367
            self.client.del_object_sharing(self.path)
368
        metadata = self['metadata_to_set'] or dict()
369
        for k in self['metadata_key_to_delete']:
370
            metadata[k] = ''
371
        if metadata:
372
            self.client.set_object_meta(self.path, metadata)
373
            self.print_dict(self.client.get_object_meta(self.path))
374

    
375
    def main(self, path_or_url):
376
        super(self.__class__, self)._run(path_or_url)
377
        if self['publish'] and self['unpublish']:
378
            raise CLIInvalidArgument(
379
                'Arguments %s and %s cannot be used together' % (
380
                    self.arguments['publish'].lvalue,
381
                    self.arguments['publish'].lvalue))
382
        if self['no_permissions'] and (
383
                self['uuid_for_read_permission'] or self[
384
                    'uuid_for_write_permission']):
385
            raise CLIInvalidArgument(
386
                '%s cannot be used with other permission arguments' % (
387
                    self.arguments['no_permissions'].lvalue))
388
        self._run()
389

    
390

    
391
@command(file_cmds)
392
class file_create(_pithos_container, _optional_output_cmd):
393
    """Create an empty file"""
394

    
395
    arguments = dict(
396
        content_type=ValueArgument(
397
            'Set content type (default: application/octet-stream)',
398
            '--content-type',
399
            default='application/octet-stream')
400
    )
401

    
402
    @errors.generic.all
403
    @errors.pithos.connection
404
    @errors.pithos.container
405
    def _run(self):
406
        self._optional_output(
407
            self.client.create_object(self.path, self['content_type']))
408

    
409
    def main(self, path_or_url):
410
        super(self.__class__, self)._run(path_or_url)
411
        self._run()
412

    
413

    
414
@command(file_cmds)
415
class file_mkdir(_pithos_container, _optional_output_cmd):
416
    """Create a directory: /file create --content-type='applcation/directory'
417
    """
418

    
419
    @errors.generic.all
420
    @errors.pithos.connection
421
    @errors.pithos.container
422
    def _run(self):
423
        self._optional_output(self.client.create_directory(self.path))
424

    
425
    def main(self, path_or_url):
426
        super(self.__class__, self)._run(path_or_url)
427
        self._run()
428

    
429

    
430
@command(file_cmds)
431
class file_delete(_pithos_container):
432
    """Delete a file or directory object"""
433

    
434
    arguments = dict(
435
        until_date=DateArgument('remove history until then', '--until'),
436
        yes=FlagArgument('Do not prompt for permission', '--yes'),
437
        recursive=FlagArgument(
438
            'If a directory, empty first', ('-r', '--recursive')),
439
        delimiter=ValueArgument(
440
            'delete objects prefixed with <object><delimiter>', '--delimiter')
441
    )
442

    
443
    @errors.generic.all
444
    @errors.pithos.connection
445
    @errors.pithos.container
446
    @errors.pithos.object_path
447
    def _run(self):
448
        if self.path:
449
            if self['yes'] or self.ask_user(
450
                    'Delete /%s/%s ?' % (self.container, self.path)):
451
                self.client.del_object(
452
                    self.path,
453
                    until=self['until_date'],
454
                    delimiter='/' if self['recursive'] else self['delimiter'])
455
            else:
456
                self.error('Aborted')
457
        else:
458
            if self['yes'] or self.ask_user(
459
                    'Empty container /%s ?' % self.container):
460
                self.client.container_delete(self.container, delimiter='/')
461
            else:
462
                self.error('Aborted')
463

    
464
    def main(self, path_or_url):
465
        super(self.__class__, self)._run(path_or_url)
466
        self._run()
467

    
468

    
469
class _source_destination(_pithos_container, _optional_output_cmd):
470

    
471
    sd_arguments = dict(
472
        destination_user_uuid=ValueArgument(
473
            'default: current user uuid', '--to-account'),
474
        destination_container=ValueArgument(
475
            'default: pithos', '--to-container'),
476
        source_prefix=FlagArgument(
477
            'Transfer all files that are prefixed with SOURCE PATH If the '
478
            'destination path is specified, replace SOURCE_PATH with '
479
            'DESTINATION_PATH',
480
            ('-r', '--recursive')),
481
        force=FlagArgument(
482
            'Overwrite destination objects, if needed', ('-f', '--force')),
483
        source_version=ValueArgument(
484
            'The version of the source object', '--source-version')
485
    )
486

    
487
    def __init__(self, arguments={}, auth_base=None, cloud=None):
488
        self.arguments.update(arguments)
489
        self.arguments.update(self.sd_arguments)
490
        super(_source_destination, self).__init__(
491
            self.arguments, auth_base, cloud)
492

    
493
    def _report_transfer(self, src, dst, transfer_name):
494
        if not dst:
495
            if transfer_name in ('move', ):
496
                self.error('  delete source directory %s' % src)
497
            return
498
        dst_prf = '' if self.account == self.dst_client.account else (
499
                'pithos://%s' % self.dst_client.account)
500
        if src:
501
            src_prf = '' if self.account == self.dst_client.account else (
502
                    'pithos://%s' % self.account)
503
            self.error('  %s %s/%s/%s\n  -->  %s/%s/%s' % (
504
                transfer_name,
505
                src_prf, self.container, src,
506
                dst_prf, self.dst_client.container, dst))
507
        else:
508
            self.error('  mkdir %s/%s/%s' % (
509
                dst_prf, self.dst_client.container, dst))
510

    
511
    @errors.generic.all
512
    @errors.pithos.account
513
    def _src_dst(self, version=None):
514
        """Preconditions:
515
        self.account, self.container, self.path
516
        self.dst_acc, self.dst_con, self.dst_path
517
        They should all be configured properly
518
        :returns: [(src_path, dst_path), ...], if src_path is None, create
519
            destination directory
520
        """
521
        src_objects, dst_objects, pairs = dict(), dict(), []
522
        try:
523
            for obj in self.dst_client.list_objects(
524
                    prefix=self.dst_path or self.path or '/'):
525
                dst_objects[obj['name']] = obj
526
        except ClientError as ce:
527
            if ce.status in (404, ):
528
                raise CLIError(
529
                    'Destination container pithos://%s/%s not found' % (
530
                        self.dst_client.account, self.dst_client.container))
531
            raise ce
532
        if self['source_prefix']:
533
            #  Copy and replace prefixes
534
            for src_obj in self.client.list_objects(prefix=self.path):
535
                src_objects[src_obj['name']] = src_obj
536
            for src_path, src_obj in src_objects.items():
537
                dst_path = '%s%s' % (
538
                    self.dst_path or self.path, src_path[len(self.path):])
539
                dst_obj = dst_objects.get(dst_path, None)
540
                if self['force'] or not dst_obj:
541
                    #  Just do it
542
                    pairs.append((
543
                        None if self._is_dir(src_obj) else src_path, dst_path))
544
                    if self._is_dir(src_obj):
545
                        pairs.append((self.path or dst_path, None))
546
                elif not (self._is_dir(dst_obj) and self._is_dir(src_obj)):
547
                    raise CLIError(
548
                        'Destination object exists', importance=2, details=[
549
                            'Failed while transfering:',
550
                            '    pithos://%s/%s/%s' % (
551
                                    self.account,
552
                                    self.container,
553
                                    src_path),
554
                            '--> pithos://%s/%s/%s' % (
555
                                    self.dst_client.account,
556
                                    self.dst_client.container,
557
                                    dst_path),
558
                            'Use %s to transfer overwrite' % (
559
                                    self.arguments['force'].lvalue)])
560
        else:
561
            #  One object transfer
562
            try:
563
                src_version_arg = self.arguments.get('source_version', None)
564
                src_obj = self.client.get_object_info(
565
                    self.path,
566
                    version=src_version_arg.value if src_version_arg else None)
567
            except ClientError as ce:
568
                if ce.status in (204, ):
569
                    raise CLIError(
570
                        'Missing specific path container %s' % self.container,
571
                        importance=2, details=[
572
                            'To transfer container contents %s' % (
573
                                self.arguments['source_prefix'].lvalue)])
574
                raise
575
            dst_path = self.dst_path or self.path
576
            dst_obj = dst_objects.get(dst_path or self.path, None)
577
            if self['force'] or not dst_obj:
578
                pairs.append(
579
                    (None if self._is_dir(src_obj) else self.path, dst_path))
580
                if self._is_dir(src_obj):
581
                    pairs.append((self.path or dst_path, None))
582
            elif self._is_dir(src_obj):
583
                raise CLIError(
584
                    'Cannot transfer an application/directory object',
585
                    importance=2, details=[
586
                        'The object pithos://%s/%s/%s is a directory' % (
587
                            self.account,
588
                            self.container,
589
                            self.path),
590
                        'To recursively copy a directory, use',
591
                        '  %s' % self.arguments['source_prefix'].lvalue,
592
                        'To create a file, use',
593
                        '  /file create  (general purpose)',
594
                        '  /file mkdir   (a directory object)'])
595
            else:
596
                raise CLIError(
597
                    'Destination object exists',
598
                    importance=2, details=[
599
                        'Failed while transfering:',
600
                        '    pithos://%s/%s/%s' % (
601
                                self.account,
602
                                self.container,
603
                                self.path),
604
                        '--> pithos://%s/%s/%s' % (
605
                                self.dst_client.account,
606
                                self.dst_client.container,
607
                                dst_path),
608
                        'Use %s to transfer overwrite' % (
609
                                self.arguments['force'].lvalue)])
610
        return pairs
611

    
612
    def _run(self, source_path_or_url, destination_path_or_url=''):
613
        super(_source_destination, self)._run(source_path_or_url)
614
        dst_acc, dst_con, dst_path = self._resolve_pithos_url(
615
            destination_path_or_url)
616
        self.dst_client = PithosClient(
617
            base_url=self.client.base_url, token=self.client.token,
618
            container=self[
619
                'destination_container'] or dst_con or self.client.container,
620
            account=self[
621
                'destination_user_uuid'] or dst_acc or self.client.account)
622
        self.dst_path = dst_path or self.path
623

    
624

    
625
@command(file_cmds)
626
class file_copy(_source_destination):
627
    """Copy objects, even between different accounts or containers"""
628

    
629
    arguments = dict(
630
        public=ValueArgument('publish new object', '--public'),
631
        content_type=ValueArgument(
632
            'change object\'s content type', '--content-type'),
633
        source_version=ValueArgument(
634
            'The version of the source object', '--object-version')
635
    )
636

    
637
    @errors.generic.all
638
    @errors.pithos.connection
639
    @errors.pithos.container
640
    @errors.pithos.account
641
    def _run(self):
642
        for src, dst in self._src_dst(self['source_version']):
643
            self._report_transfer(src, dst, 'copy')
644
            if src and dst:
645
                self.dst_client.copy_object(
646
                    src_container=self.client.container,
647
                    src_object=src,
648
                    dst_container=self.dst_client.container,
649
                    dst_object=dst,
650
                    source_account=self.account,
651
                    source_version=self['source_version'],
652
                    public=self['public'],
653
                    content_type=self['content_type'])
654
            elif dst:
655
                self.dst_client.create_directory(dst)
656

    
657
    def main(self, source_path_or_url, destination_path_or_url=None):
658
        super(file_copy, self)._run(
659
            source_path_or_url, destination_path_or_url or '')
660
        self._run()
661

    
662

    
663
@command(file_cmds)
664
class file_move(_source_destination):
665
    """Move objects, even between different accounts or containers"""
666

    
667
    arguments = dict(
668
        public=ValueArgument('publish new object', '--public'),
669
        content_type=ValueArgument(
670
            'change object\'s content type', '--content-type')
671
    )
672

    
673
    @errors.generic.all
674
    @errors.pithos.connection
675
    @errors.pithos.container
676
    @errors.pithos.account
677
    def _run(self):
678
        for src, dst in self._src_dst():
679
            self._report_transfer(src, dst, 'move')
680
            if src and dst:
681
                self.dst_client.move_object(
682
                    src_container=self.client.container,
683
                    src_object=src,
684
                    dst_container=self.dst_client.container,
685
                    dst_object=dst,
686
                    source_account=self.account,
687
                    public=self['public'],
688
                    content_type=self['content_type'])
689
            elif dst:
690
                self.dst_client.create_directory(dst)
691
            else:
692
                self.client.del_object(src)
693

    
694
    def main(self, source_path_or_url, destination_path_or_url=None):
695
        super(file_move, self)._run(
696
            source_path_or_url, destination_path_or_url or '')
697
        self._run()
698

    
699

    
700
@command(file_cmds)
701
class file_append(_pithos_container, _optional_output_cmd):
702
    """Append local file to (existing) remote object
703
    The remote object should exist.
704
    If the remote object is a directory, it is transformed into a file.
705
    In the later case, objects under the directory remain intact.
706
    """
707

    
708
    arguments = dict(
709
        progress_bar=ProgressBarArgument(
710
            'do not show progress bar', ('-N', '--no-progress-bar'),
711
            default=False),
712
        max_threads=IntArgument('default: 1', '--threads'),
713
    )
714

    
715
    @errors.generic.all
716
    @errors.pithos.connection
717
    @errors.pithos.container
718
    @errors.pithos.object_path
719
    def _run(self, local_path):
720
        if self['max_threads'] > 0:
721
            self.client.MAX_THREADS = int(self['max_threads'])
722
        (progress_bar, upload_cb) = self._safe_progress_bar('Appending')
723
        try:
724
            with open(local_path, 'rb') as f:
725
                self._optional_output(
726
                    self.client.append_object(self.path, f, upload_cb))
727
        finally:
728
            self._safe_progress_bar_finish(progress_bar)
729

    
730
    def main(self, local_path, remote_path_or_url):
731
        super(self.__class__, self)._run(remote_path_or_url)
732
        self._run(local_path)
733

    
734

    
735
@command(file_cmds)
736
class file_truncate(_pithos_container, _optional_output_cmd):
737
    """Truncate remote file up to size"""
738

    
739
    arguments = dict(
740
        size_in_bytes=IntArgument('Length of file after truncation', '--size')
741
    )
742
    required = ('size_in_bytes', )
743

    
744
    @errors.generic.all
745
    @errors.pithos.connection
746
    @errors.pithos.container
747
    @errors.pithos.object_path
748
    @errors.pithos.object_size
749
    def _run(self, size):
750
        self._optional_output(self.client.truncate_object(self.path, size))
751

    
752
    def main(self, path_or_url):
753
        super(self.__class__, self)._run(path_or_url)
754
        self._run(size=self['size_in_bytes'])
755

    
756

    
757
@command(file_cmds)
758
class file_overwrite(_pithos_container, _optional_output_cmd):
759
    """Overwrite part of a remote file"""
760

    
761
    arguments = dict(
762
        progress_bar=ProgressBarArgument(
763
            'do not show progress bar', ('-N', '--no-progress-bar'),
764
            default=False),
765
        start_position=IntArgument('File position in bytes', '--from'),
766
        end_position=IntArgument('File position in bytes', '--to')
767
    )
768
    required = ('start_position', 'end_position')
769

    
770
    @errors.generic.all
771
    @errors.pithos.connection
772
    @errors.pithos.container
773
    @errors.pithos.object_path
774
    @errors.pithos.object_size
775
    def _run(self, local_path, start, end):
776
        start, end = int(start), int(end)
777
        (progress_bar, upload_cb) = self._safe_progress_bar(
778
            'Overwrite %s bytes' % (end - start))
779
        try:
780
            with open(path.abspath(local_path), 'rb') as f:
781
                self._optional_output(self.client.overwrite_object(
782
                    obj=self.path,
783
                    start=start,
784
                    end=end,
785
                    source_file=f,
786
                    upload_cb=upload_cb))
787
        finally:
788
            self._safe_progress_bar_finish(progress_bar)
789

    
790
    def main(self, local_path, path_or_url):
791
        super(self.__class__, self)._run(path_or_url)
792
        self.path = self.path or path.basename(local_path)
793
        self._run(
794
            local_path=local_path,
795
            start=self['start_position'],
796
            end=self['end_position'])
797

    
798

    
799
@command(file_cmds)
800
class file_upload(_pithos_container, _optional_output_cmd):
801
    """Upload a file"""
802

    
803
    arguments = dict(
804
        max_threads=IntArgument('default: 5', '--threads'),
805
        content_encoding=ValueArgument(
806
            'set MIME content type', '--content-encoding'),
807
        content_disposition=ValueArgument(
808
            'specify objects presentation style', '--content-disposition'),
809
        content_type=ValueArgument('specify content type', '--content-type'),
810
        uuid_for_read_permission=RepeatableArgument(
811
            'Give read access to a user or group (can be repeated) '
812
            'Use * for all users',
813
            '--read-permission'),
814
        uuid_for_write_permission=RepeatableArgument(
815
            'Give write access to a user or group (can be repeated) '
816
            'Use * for all users',
817
            '--write-permission'),
818
        public=FlagArgument('make object publicly accessible', '--public'),
819
        progress_bar=ProgressBarArgument(
820
            'do not show progress bar',
821
            ('-N', '--no-progress-bar'),
822
            default=False),
823
        overwrite=FlagArgument('Force (over)write', ('-f', '--force')),
824
        recursive=FlagArgument(
825
            'Recursively upload directory *contents* + subdirectories',
826
            ('-r', '--recursive')),
827
        unchunked=FlagArgument(
828
            'Upload file as one block (not recommended)', '--unchunked'),
829
        md5_checksum=ValueArgument(
830
            'Confirm upload with a custom checksum (MD5)', '--etag'),
831
        use_hashes=FlagArgument(
832
            'Source file contains hashmap not data', '--source-is-hashmap'),
833
    )
834

    
835
    def _sharing(self):
836
        sharing = dict()
837
        readlist = self['uuid_for_read_permission']
838
        if readlist:
839
            sharing['read'] = self['uuid_for_read_permission']
840
        writelist = self['uuid_for_write_permission']
841
        if writelist:
842
            sharing['write'] = self['uuid_for_write_permission']
843
        return sharing or None
844

    
845
    def _check_container_limit(self, path):
846
        cl_dict = self.client.get_container_limit()
847
        container_limit = int(cl_dict['x-container-policy-quota'])
848
        r = self.client.container_get()
849
        used_bytes = sum(int(o['bytes']) for o in r.json)
850
        path_size = get_path_size(path)
851
        if container_limit and path_size > (container_limit - used_bytes):
852
            raise CLIError(
853
                'Container %s (limit(%s) - used(%s)) < (size(%s) of %s)' % (
854
                    self.client.container,
855
                    format_size(container_limit),
856
                    format_size(used_bytes),
857
                    format_size(path_size),
858
                    path),
859
                details=[
860
                    'Check accound limit: /file quota',
861
                    'Check container limit:',
862
                    '\t/file containerlimit get %s' % self.client.container,
863
                    'Increase container limit:',
864
                    '\t/file containerlimit set <new limit> %s' % (
865
                        self.client.container)])
866

    
867
    def _src_dst(self, local_path, remote_path, objlist=None):
868
        lpath = path.abspath(local_path)
869
        short_path = path.basename(path.abspath(local_path))
870
        rpath = remote_path or short_path
871
        if path.isdir(lpath):
872
            if not self['recursive']:
873
                raise CLIError('%s is a directory' % lpath, details=[
874
                    'Use %s to upload directories & contents' % (
875
                        self.arguments['recursive'].lvalue)])
876
            robj = self.client.container_get(path=rpath)
877
            if not self['overwrite']:
878
                if robj.json:
879
                    raise CLIError(
880
                        'Objects/files prefixed as %s already exist' % rpath,
881
                        details=['Existing objects:'] + ['\t/%s/\t%s' % (
882
                            o['name'],
883
                            o['content_type'][12:]) for o in robj.json] + [
884
                            'Use -f to add, overwrite or resume'])
885
                else:
886
                    try:
887
                        topobj = self.client.get_object_info(rpath)
888
                        if not self._is_dir(topobj):
889
                            raise CLIError(
890
                                'Object /%s/%s exists but not a directory' % (
891
                                    self.container, rpath),
892
                                details=['Use -f to overwrite'])
893
                    except ClientError as ce:
894
                        if ce.status not in (404, ):
895
                            raise
896
            self._check_container_limit(lpath)
897
            prev = ''
898
            for top, subdirs, files in walk(lpath):
899
                if top != prev:
900
                    prev = top
901
                    try:
902
                        rel_path = rpath + top.split(lpath)[1]
903
                    except IndexError:
904
                        rel_path = rpath
905
                    self.error('mkdir /%s/%s' % (
906
                        self.client.container, rel_path))
907
                    self.client.create_directory(rel_path)
908
                for f in files:
909
                    fpath = path.join(top, f)
910
                    if path.isfile(fpath):
911
                        rel_path = rel_path.replace(path.sep, '/')
912
                        pathfix = f.replace(path.sep, '/')
913
                        yield open(fpath, 'rb'), '%s/%s' % (rel_path, pathfix)
914
                    else:
915
                        self.error('%s is not a regular file' % fpath)
916
        else:
917
            if not path.isfile(lpath):
918
                raise CLIError(('%s is not a regular file' % lpath) if (
919
                    path.exists(lpath)) else '%s does not exist' % lpath)
920
            try:
921
                robj = self.client.get_object_info(rpath)
922
                if remote_path and self._is_dir(robj):
923
                    rpath += '/%s' % (short_path.replace(path.sep, '/'))
924
                    self.client.get_object_info(rpath)
925
                if not self['overwrite']:
926
                    raise CLIError(
927
                        'Object /%s/%s already exists' % (
928
                            self.container, rpath),
929
                        details=['use -f to overwrite / resume'])
930
            except ClientError as ce:
931
                if ce.status not in (404, ):
932
                    raise
933
            self._check_container_limit(lpath)
934
            yield open(lpath, 'rb'), rpath
935

    
936
    def _run(self, local_path, remote_path):
937
        self.client.MAX_THREADS = int(self['max_threads'] or 5)
938
        params = dict(
939
            content_encoding=self['content_encoding'],
940
            content_type=self['content_type'],
941
            content_disposition=self['content_disposition'],
942
            sharing=self._sharing(),
943
            public=self['public'])
944
        uploaded, container_info_cache = list, dict()
945
        rpref = 'pithos://%s' if self['account'] else ''
946
        for f, rpath in self._src_dst(local_path, remote_path):
947
            self.error('%s --> %s/%s/%s' % (
948
                f.name, rpref, self.client.container, rpath))
949
            if not (self['content_type'] and self['content_encoding']):
950
                ctype, cenc = guess_mime_type(f.name)
951
                params['content_type'] = self['content_type'] or ctype
952
                params['content_encoding'] = self['content_encoding'] or cenc
953
            if self['unchunked']:
954
                r = self.client.upload_object_unchunked(
955
                    rpath, f,
956
                    etag=self['md5_checksum'], withHashFile=self['use_hashes'],
957
                    **params)
958
                if self['with_output'] or self['json_output']:
959
                    r['name'] = '/%s/%s' % (self.client.container, rpath)
960
                    uploaded.append(r)
961
            else:
962
                try:
963
                    (progress_bar, upload_cb) = self._safe_progress_bar(
964
                        'Uploading %s' % f.name.split(path.sep)[-1])
965
                    if progress_bar:
966
                        hash_bar = progress_bar.clone()
967
                        hash_cb = hash_bar.get_generator(
968
                            'Calculating block hashes')
969
                    else:
970
                        hash_cb = None
971
                    r = self.client.upload_object(
972
                        rpath, f,
973
                        hash_cb=hash_cb,
974
                        upload_cb=upload_cb,
975
                        container_info_cache=container_info_cache,
976
                        **params)
977
                    if self['with_output'] or self['json_output']:
978
                        r['name'] = '/%s/%s' % (self.client.container, rpath)
979
                        uploaded.append(r)
980
                except Exception:
981
                    self._safe_progress_bar_finish(progress_bar)
982
                    raise
983
                finally:
984
                    self._safe_progress_bar_finish(progress_bar)
985
        self._optional_output(uploaded)
986
        self.error('Upload completed')
987

    
988
    def main(self, local_path, remote_path_or_url):
989
        super(self.__class__, self)._run(remote_path_or_url)
990
        remote_path = self.path or path.basename(path.abspath(local_path))
991
        self._run(local_path=local_path, remote_path=remote_path)
992

    
993

    
994
class RangeArgument(ValueArgument):
995
    """
996
    :value type: string of the form <start>-<end> where <start> and <end> are
997
        integers
998
    :value returns: the input string, after type checking <start> and <end>
999
    """
1000

    
1001
    @property
1002
    def value(self):
1003
        return getattr(self, '_value', self.default)
1004

    
1005
    @value.setter
1006
    def value(self, newvalues):
1007
        if newvalues:
1008
            self._value = getattr(self, '_value', self.default)
1009
            for newvalue in newvalues.split(','):
1010
                self._value = ('%s,' % self._value) if self._value else ''
1011
                start, sep, end = newvalue.partition('-')
1012
                if sep:
1013
                    if start:
1014
                        start, end = (int(start), int(end))
1015
                        if start > end:
1016
                            raise CLIInvalidArgument(
1017
                                'Invalid range %s' % newvalue, details=[
1018
                                'Valid range formats',
1019
                                '  START-END', '  UP_TO', '  -FROM',
1020
                                'where all values are integers'])
1021
                        self._value += '%s-%s' % (start, end)
1022
                    else:
1023
                        self._value += '-%s' % int(end)
1024
                else:
1025
                    self._value += '%s' % int(start)
1026

    
1027

    
1028
@command(file_cmds)
1029
class file_cat(_pithos_container):
1030
    """Fetch remote file contents"""
1031

    
1032
    arguments = dict(
1033
        range=RangeArgument('show range of data', '--range'),
1034
        if_match=ValueArgument('show output if ETags match', '--if-match'),
1035
        if_none_match=ValueArgument(
1036
            'show output if ETags match', '--if-none-match'),
1037
        if_modified_since=DateArgument(
1038
            'show output modified since then', '--if-modified-since'),
1039
        if_unmodified_since=DateArgument(
1040
            'show output unmodified since then', '--if-unmodified-since'),
1041
        object_version=ValueArgument(
1042
            'Get contents of the chosen version', '--object-version')
1043
    )
1044

    
1045
    @errors.generic.all
1046
    @errors.pithos.connection
1047
    @errors.pithos.container
1048
    @errors.pithos.object_path
1049
    def _run(self):
1050
        self.client.download_object(
1051
            self.path, self._out,
1052
            range_str=self['range'],
1053
            version=self['object_version'],
1054
            if_match=self['if_match'],
1055
            if_none_match=self['if_none_match'],
1056
            if_modified_since=self['if_modified_since'],
1057
            if_unmodified_since=self['if_unmodified_since'])
1058

    
1059
    def main(self, path_or_url):
1060
        super(self.__class__, self)._run(path_or_url)
1061
        self._run()
1062

    
1063

    
1064
@command(file_cmds)
1065
class file_download(_pithos_container):
1066
    """Download a remove file or directory object to local file system"""
1067

    
1068
    arguments = dict(
1069
        resume=FlagArgument(
1070
            'Resume/Overwrite (attempt resume, else overwrite)',
1071
            ('-f', '--resume')),
1072
        range=RangeArgument('Download only that range of data', '--range'),
1073
        matching_etag=ValueArgument('download iff ETag match', '--if-match'),
1074
        non_matching_etag=ValueArgument(
1075
            'download iff ETags DO NOT match', '--if-none-match'),
1076
        modified_since_date=DateArgument(
1077
            'download iff remote file is modified since then',
1078
            '--if-modified-since'),
1079
        unmodified_since_date=DateArgument(
1080
            'show output iff remote file is unmodified since then',
1081
            '--if-unmodified-since'),
1082
        object_version=ValueArgument(
1083
            'download a file of a specific version', '--object-version'),
1084
        max_threads=IntArgument('default: 5', '--threads'),
1085
        progress_bar=ProgressBarArgument(
1086
            'do not show progress bar', ('-N', '--no-progress-bar'),
1087
            default=False),
1088
        recursive=FlagArgument(
1089
            'Download a remote directory object and its contents',
1090
            ('-r', '--recursive'))
1091
        )
1092

    
1093
    def _src_dst(self, local_path):
1094
        """Create a list of (src, dst) where src is a remote location and dst
1095
        is an open file descriptor. Directories are denoted as (None, dirpath)
1096
        and they are pretended to other objects in a very strict order (shorter
1097
        to longer path)."""
1098
        ret = []
1099
        try:
1100
            if self.path:
1101
                obj = self.client.get_object_info(
1102
                    self.path, version=self['object_version'])
1103
                obj.setdefault('name', self.path.strip('/'))
1104
            else:
1105
                obj = None
1106
        except ClientError as ce:
1107
            if ce.status in (404, ):
1108
                raiseCLIError(ce, details=[
1109
                    'To download an object, it must exist either as a file or'
1110
                    ' as a directory.',
1111
                    'For example, to download everything under prefix/ the '
1112
                    'directory "prefix" must exist.',
1113
                    'To see if an remote object is actually there:',
1114
                    '  /file info [/CONTAINER/]OBJECT',
1115
                    'To create a directory object:',
1116
                    '  /file mkdir [/CONTAINER/]OBJECT'])
1117
            if ce.status in (204, ):
1118
                raise CLIError(
1119
                    'No file or directory objects to download',
1120
                    details=[
1121
                        'To download a container (e.g., %s):' % self.container,
1122
                        '  [kamaki] container download %s [LOCAL_PATH]' % (
1123
                            self.container)])
1124
            raise
1125
        rpath = self.path.strip('/')
1126
        if local_path and self.path and local_path.endswith('/'):
1127
            local_path = local_path[-1:]
1128

    
1129
        if (not obj) or self._is_dir(obj):
1130
            if self['recursive']:
1131
                if not (self.path or local_path.endswith('/')):
1132
                    #  Download the whole container
1133
                    local_path = '' if local_path in ('.', ) else local_path
1134
                    local_path = '%s/' % (local_path or self.container)
1135
                obj = obj or dict(
1136
                    name='', content_type='application/directory')
1137
                dirs, files = [obj, ], []
1138
                objects = self.client.container_get(
1139
                    path=self.path,
1140
                    if_modified_since=self['modified_since_date'],
1141
                    if_unmodified_since=self['unmodified_since_date'])
1142
                for o in objects.json:
1143
                    (dirs if self._is_dir(o) else files).append(o)
1144

    
1145
                #  Put the directories on top of the list
1146
                for dpath in sorted(['%s%s' % (
1147
                        local_path, d['name'][len(rpath):]) for d in dirs]):
1148
                    if path.exists(dpath):
1149
                        if path.isdir(dpath):
1150
                            continue
1151
                        raise CLIError(
1152
                            'Cannot replace local file %s with a directory '
1153
                            'of the same name' % dpath,
1154
                            details=[
1155
                                'Either remove the file or specify a'
1156
                                'different target location'])
1157
                    ret.append((None, dpath, None))
1158

    
1159
                #  Append the file objects
1160
                for opath in [o['name'] for o in files]:
1161
                    lpath = '%s%s' % (local_path, opath[len(rpath):])
1162
                    if self['resume']:
1163
                        fxists = path.exists(lpath)
1164
                        if fxists and path.isdir(lpath):
1165
                            raise CLIError(
1166
                                'Cannot change local dir %s info file' % (
1167
                                    lpath),
1168
                                details=[
1169
                                    'Either remove the file or specify a'
1170
                                    'different target location'])
1171
                        ret.append((opath, lpath, fxists))
1172
                    elif path.exists(lpath):
1173
                        raise CLIError(
1174
                            'Cannot overwrite %s' % lpath,
1175
                            details=['To overwrite/resume, use  %s' % (
1176
                                self.arguments['resume'].lvalue)])
1177
                    else:
1178
                        ret.append((opath, lpath, None))
1179
            elif self.path:
1180
                raise CLIError(
1181
                    'Remote object /%s/%s is a directory' % (
1182
                        self.container, local_path),
1183
                    details=['Use %s to download directories' % (
1184
                        self.arguments['recursive'].lvalue)])
1185
            else:
1186
                parsed_name = self.arguments['recursive'].lvalue
1187
                raise CLIError(
1188
                    'Cannot download container %s' % self.container,
1189
                    details=[
1190
                        'Use %s to download containers' % parsed_name,
1191
                        '  [kamaki] file download %s /%s [LOCAL_PATH]' % (
1192
                            parsed_name, self.container)])
1193
        else:
1194
            #  Remote object is just a file
1195
            if path.exists(local_path) and not self['resume']:
1196
                raise CLIError(
1197
                    'Cannot overwrite local file %s' % (lpath),
1198
                    details=['To overwrite/resume, use  %s' % (
1199
                        self.arguments['resume'].lvalue)])
1200
            ret.append((rpath, local_path, self['resume']))
1201
        for r, l, resume in ret:
1202
            if r:
1203
                with open(l, 'rwb+' if resume else 'wb+') as f:
1204
                    yield (r, f)
1205
            else:
1206
                yield (r, l)
1207

    
1208
    @errors.generic.all
1209
    @errors.pithos.connection
1210
    @errors.pithos.container
1211
    @errors.pithos.object_path
1212
    @errors.pithos.local_path
1213
    @errors.pithos.local_path_download
1214
    def _run(self, local_path):
1215
        self.client.MAX_THREADS = int(self['max_threads'] or 5)
1216
        progress_bar = None
1217
        try:
1218
            for rpath, output_file in self._src_dst(local_path):
1219
                if not rpath:
1220
                    self.error('Create local directory %s' % output_file)
1221
                    makedirs(output_file)
1222
                    continue
1223
                self.error('/%s/%s --> %s' % (
1224
                    self.container, rpath, output_file.name))
1225
                progress_bar, download_cb = self._safe_progress_bar(
1226
                    '  download')
1227
                self.client.download_object(
1228
                    rpath, output_file,
1229
                    download_cb=download_cb,
1230
                    range_str=self['range'],
1231
                    version=self['object_version'],
1232
                    if_match=self['matching_etag'],
1233
                    resume=self['resume'],
1234
                    if_none_match=self['non_matching_etag'],
1235
                    if_modified_since=self['modified_since_date'],
1236
                    if_unmodified_since=self['unmodified_since_date'])
1237
        except KeyboardInterrupt:
1238
            from threading import activeCount, enumerate as activethreads
1239
            timeout = 0.5
1240
            while activeCount() > 1:
1241
                self._out.write('\nCancel %s threads: ' % (activeCount() - 1))
1242
                self._out.flush()
1243
                for thread in activethreads():
1244
                    try:
1245
                        thread.join(timeout)
1246
                        self._out.write('.' if thread.isAlive() else '*')
1247
                    except RuntimeError:
1248
                        continue
1249
                    finally:
1250
                        self._out.flush()
1251
                        timeout += 0.1
1252
            self.error('\nDownload canceled by user')
1253
            if local_path is not None:
1254
                self.error('to resume, re-run with --resume')
1255
        except Exception:
1256
            self._safe_progress_bar_finish(progress_bar)
1257
            raise
1258
        finally:
1259
            self._safe_progress_bar_finish(progress_bar)
1260

    
1261
    def main(self, remote_path_or_url, local_path=None):
1262
        super(self.__class__, self)._run(remote_path_or_url)
1263
        local_path = local_path or self.path or '.'
1264
        self._run(local_path=local_path)
1265

    
1266

    
1267
@command(container_cmds)
1268
class container_info(_pithos_account, _optional_json):
1269
    """Get information about a container"""
1270

    
1271
    arguments = dict(
1272
        until_date=DateArgument('show metadata until then', '--until'),
1273
        metadata=FlagArgument('Show only container metadata', '--metadata'),
1274
        sizelimit=FlagArgument(
1275
            'Show the maximum size limit for container', '--size-limit'),
1276
        in_bytes=FlagArgument('Show size limit in bytes', ('-b', '--bytes'))
1277
    )
1278

    
1279
    @errors.generic.all
1280
    @errors.pithos.connection
1281
    @errors.pithos.container
1282
    @errors.pithos.object_path
1283
    def _run(self):
1284
        if self['metadata']:
1285
            r, preflen = dict(), len('x-container-meta-')
1286
            for k, v in self.client.get_container_meta(
1287
                    until=self['until_date']).items():
1288
                r[k[preflen:]] = v
1289
        elif self['sizelimit']:
1290
            r = self.client.get_container_limit(
1291
                self.container)['x-container-policy-quota']
1292
            r = {'size limit': 'unlimited' if r in ('0', ) else (
1293
                int(r) if self['in_bytes'] else format_size(r))}
1294
        else:
1295
            r = self.client.get_container_info(self.container)
1296
        self._print(r, self.print_dict)
1297

    
1298
    def main(self, container):
1299
        super(self.__class__, self)._run()
1300
        self.container, self.client.container = container, container
1301
        self._run()
1302

    
1303

    
1304
class VersioningArgument(ValueArgument):
1305

    
1306
    schemes = ('auto', 'none')
1307

    
1308
    @property
1309
    def value(self):
1310
        return getattr(self, '_value', None)
1311

    
1312
    @value.setter
1313
    def value(self, new_scheme):
1314
        if new_scheme:
1315
            new_scheme = new_scheme.lower()
1316
            if new_scheme not in self.schemes:
1317
                raise CLIInvalidArgument('Invalid versioning value', details=[
1318
                    'Valid versioning values are %s' % ', '.join(
1319
                        self.schemes)])
1320
            self._value = new_scheme
1321

    
1322

    
1323
@command(container_cmds)
1324
class container_modify(_pithos_account, _optional_json):
1325
    """Modify the properties of a container"""
1326

    
1327
    arguments = dict(
1328
        metadata_to_add=KeyValueArgument(
1329
            'Add metadata in the form KEY=VALUE (can be repeated)',
1330
            '--metadata-add'),
1331
        metadata_to_delete=RepeatableArgument(
1332
            'Delete metadata by KEY (can be repeated)', '--metadata-del'),
1333
        sizelimit=DataSizeArgument(
1334
            'Set max size limit (0 for unlimited, '
1335
            'use units B, KiB, KB, etc.)', '--size-limit'),
1336
        versioning=VersioningArgument(
1337
            'Set a versioning scheme (%s)' % ', '.join(
1338
                VersioningArgument.schemes), '--versioning')
1339
    )
1340
    required = ['metadata_to_add', 'metadata_to_delete', 'sizelimit']
1341

    
1342
    @errors.generic.all
1343
    @errors.pithos.connection
1344
    @errors.pithos.container
1345
    def _run(self, container):
1346
        metadata = self['metadata_to_add']
1347
        for k in self['metadata_to_delete']:
1348
            metadata[k] = ''
1349
        if metadata:
1350
            self.client.set_container_meta(metadata)
1351
            self._print(self.client.get_container_meta(), self.print_dict)
1352
        if self['sizelimit'] is not None:
1353
            self.client.set_container_limit(self['sizelimit'])
1354
            r = self.client.get_container_limit()['x-container-policy-quota']
1355
            r = 'unlimited' if r in ('0', ) else format_size(r)
1356
            self.writeln('new size limit: %s' % r)
1357
        if self['versioning']:
1358
            self.client.set_container_versioning(self['versioning'])
1359
            self.writeln('new versioning scheme: %s' % (
1360
                self.client.get_container_versioning(self.container)[
1361
                    'x-container-policy-versioning']))
1362

    
1363
    def main(self, container):
1364
        super(self.__class__, self)._run()
1365
        self.client.container, self.container = container, container
1366
        self._run(container=container)
1367

    
1368

    
1369
@command(container_cmds)
1370
class container_list(_pithos_account, _optional_json, _name_filter):
1371
    """List all containers, or their contents"""
1372

    
1373
    arguments = dict(
1374
        detail=FlagArgument('Containers with details', ('-l', '--list')),
1375
        limit=IntArgument('limit number of listed items', ('-n', '--number')),
1376
        marker=ValueArgument('output greater that marker', '--marker'),
1377
        modified_since_date=ValueArgument(
1378
            'show output modified since then', '--if-modified-since'),
1379
        unmodified_since_date=ValueArgument(
1380
            'show output not modified since then', '--if-unmodified-since'),
1381
        until_date=DateArgument('show metadata until then', '--until'),
1382
        shared=FlagArgument('show only shared', '--shared'),
1383
        more=FlagArgument('read long results', '--more'),
1384
        enum=FlagArgument('Enumerate results', '--enumerate'),
1385
        recursive=FlagArgument(
1386
            'Recursively list containers and their contents',
1387
            ('-r', '--recursive'))
1388
    )
1389

    
1390
    def print_containers(self, container_list):
1391
        for index, container in enumerate(container_list):
1392
            if 'bytes' in container:
1393
                size = format_size(container['bytes'])
1394
            prfx = ('%s. ' % (index + 1)) if self['enum'] else ''
1395
            _cname = container['name'] if (
1396
                self['more']) else bold(container['name'])
1397
            cname = u'%s%s' % (prfx, _cname)
1398
            if self['detail']:
1399
                self.writeln(cname)
1400
                pretty_c = container.copy()
1401
                if 'bytes' in container:
1402
                    pretty_c['bytes'] = '%s (%s)' % (container['bytes'], size)
1403
                self.print_dict(pretty_c, exclude=('name'))
1404
                self.writeln()
1405
            else:
1406
                if 'count' in container and 'bytes' in container:
1407
                    self.writeln('%s (%s, %s objects)' % (
1408
                        cname, size, container['count']))
1409
                else:
1410
                    self.writeln(cname)
1411
            objects = container.get('objects', [])
1412
            if objects:
1413
                self.print_objects(objects)
1414
                self.writeln('')
1415

    
1416
    def _create_object_forest(self, container_list):
1417
        try:
1418
            for container in container_list:
1419
                self.client.container = container['name']
1420
                objects = self.client.container_get(
1421
                    limit=False if self['more'] else self['limit'],
1422
                    if_modified_since=self['modified_since_date'],
1423
                    if_unmodified_since=self['unmodified_since_date'],
1424
                    until=self['until_date'],
1425
                    show_only_shared=self['shared'])
1426
                container['objects'] = objects.json
1427
        finally:
1428
            self.client.container = None
1429

    
1430
    @errors.generic.all
1431
    @errors.pithos.connection
1432
    @errors.pithos.object_path
1433
    @errors.pithos.container
1434
    def _run(self, container):
1435
        if container:
1436
            r = self.client.container_get(
1437
                limit=False if self['more'] else self['limit'],
1438
                marker=self['marker'],
1439
                if_modified_since=self['modified_since_date'],
1440
                if_unmodified_since=self['unmodified_since_date'],
1441
                until=self['until_date'],
1442
                show_only_shared=self['shared'])
1443
        else:
1444
            r = self.client.account_get(
1445
                limit=False if self['more'] else self['limit'],
1446
                marker=self['marker'],
1447
                if_modified_since=self['modified_since_date'],
1448
                if_unmodified_since=self['unmodified_since_date'],
1449
                until=self['until_date'],
1450
                show_only_shared=self['shared'])
1451
        files = self._filter_by_name(r.json)
1452
        if self['recursive'] and not container:
1453
            self._create_object_forest(files)
1454
        if self['more']:
1455
            outbu, self._out = self._out, StringIO()
1456
        try:
1457
            if self['json_output'] or self['output_format']:
1458
                self._print(files)
1459
            else:
1460
                (self.print_objects if container else self.print_containers)(
1461
                    files)
1462
        finally:
1463
            if self['more']:
1464
                pager(self._out.getvalue())
1465
                self._out = outbu
1466

    
1467
    def main(self, container=None):
1468
        super(self.__class__, self)._run()
1469
        self.client.container, self.container = container, container
1470
        self._run(container)
1471

    
1472

    
1473
@command(container_cmds)
1474
class container_create(_pithos_account):
1475
    """Create a new container"""
1476

    
1477
    arguments = dict(
1478
        versioning=ValueArgument(
1479
            'set container versioning (auto/none)', '--versioning'),
1480
        limit=IntArgument('set default container limit', '--limit'),
1481
        meta=KeyValueArgument(
1482
            'set container metadata (can be repeated)', '--meta')
1483
    )
1484

    
1485
    @errors.generic.all
1486
    @errors.pithos.connection
1487
    @errors.pithos.container
1488
    def _run(self, container):
1489
        try:
1490
            self.client.create_container(
1491
                container=container,
1492
                sizelimit=self['limit'],
1493
                versioning=self['versioning'],
1494
                metadata=self['meta'],
1495
                success=(201, ))
1496
        except ClientError as ce:
1497
            if ce.status in (202, ):
1498
                raise CLIError(
1499
                    'Container %s alread exists' % container, details=[
1500
                    'Either delete %s or choose another name' % (container)])
1501
            raise
1502

    
1503
    def main(self, new_container):
1504
        super(self.__class__, self)._run()
1505
        self._run(container=new_container)
1506

    
1507

    
1508
@command(container_cmds)
1509
class container_delete(_pithos_account):
1510
    """Delete a container"""
1511

    
1512
    arguments = dict(
1513
        yes=FlagArgument('Do not prompt for permission', '--yes'),
1514
        recursive=FlagArgument(
1515
            'delete container even if not empty', ('-r', '--recursive'))
1516
    )
1517

    
1518
    @errors.generic.all
1519
    @errors.pithos.connection
1520
    @errors.pithos.container
1521
    def _run(self, container):
1522
        num_of_contents = int(self.client.get_container_info(container)[
1523
            'x-container-object-count'])
1524
        delimiter, msg = None, 'Delete container %s ?' % container
1525
        if self['recursive']:
1526
            delimiter, msg = '/', 'Empty and d%s' % msg[1:]
1527
        elif num_of_contents:
1528
            raise CLIError('Container %s is not empty' % container, details=[
1529
                'Use %s to delete non-empty containers' % (
1530
                    self.arguments['recursive'].lvalue)])
1531
        if self['yes'] or self.ask_user(msg):
1532
            if num_of_contents:
1533
                self.client.del_container(delimiter=delimiter)
1534
            self.client.purge_container()
1535

    
1536
    def main(self, container):
1537
        super(self.__class__, self)._run()
1538
        self.container, self.client.container = container, container
1539
        self._run(container)
1540

    
1541

    
1542
@command(container_cmds)
1543
class container_empty(_pithos_account):
1544
    """Empty a container"""
1545

    
1546
    arguments = dict(yes=FlagArgument('Do not prompt for permission', '--yes'))
1547

    
1548
    @errors.generic.all
1549
    @errors.pithos.connection
1550
    @errors.pithos.container
1551
    def _run(self, container):
1552
        if self['yes'] or self.ask_user('Empty container %s ?' % container):
1553
            self.client.del_container(delimiter='/')
1554

    
1555
    def main(self, container):
1556
        super(self.__class__, self)._run()
1557
        self.container, self.client.container = container, container
1558
        self._run(container)
1559

    
1560

    
1561
@command(sharer_cmds)
1562
class sharer_list(_pithos_account, _optional_json):
1563
    """List accounts who share file objects with current user"""
1564

    
1565
    arguments = dict(
1566
        detail=FlagArgument('show detailed output', ('-l', '--details')),
1567
        marker=ValueArgument('show output greater then marker', '--marker')
1568
    )
1569

    
1570
    @errors.generic.all
1571
    @errors.pithos.connection
1572
    def _run(self):
1573
        accounts = self.client.get_sharing_accounts(marker=self['marker'])
1574
        if not (self['json_output'] or self['output_format']):
1575
            usernames = self._uuids2usernames(
1576
                [acc['name'] for acc in accounts])
1577
            for item in accounts:
1578
                uuid = item['name']
1579
                item['id'], item['name'] = uuid, usernames[uuid]
1580
                if not self['detail']:
1581
                    item.pop('last_modified')
1582
        self._print(accounts)
1583

    
1584
    def main(self):
1585
        super(self.__class__, self)._run()
1586
        self._run()
1587

    
1588

    
1589
@command(sharer_cmds)
1590
class sharer_info(_pithos_account, _optional_json):
1591
    """Details on a Pithos+ sharer account (default: current account)"""
1592

    
1593
    @errors.generic.all
1594
    @errors.pithos.connection
1595
    def _run(self):
1596
        self._print(self.client.get_account_info(), self.print_dict)
1597

    
1598
    def main(self, account_uuid=None):
1599
        super(self.__class__, self)._run()
1600
        if account_uuid:
1601
            self.client.account, self.account = account_uuid, account_uuid
1602
        self._run()
1603

    
1604

    
1605
class _pithos_group(_pithos_account):
1606
    prefix = 'x-account-group-'
1607
    preflen = len(prefix)
1608

    
1609
    def _groups(self):
1610
        groups = dict()
1611
        for k, v in self.client.get_account_group().items():
1612
            groups[k[self.preflen:]] = v
1613
        return groups
1614

    
1615

    
1616
@command(group_cmds)
1617
class group_list(_pithos_group, _optional_json):
1618
    """list all groups and group members"""
1619

    
1620
    @errors.generic.all
1621
    @errors.pithos.connection
1622
    def _run(self):
1623
        self._print(self._groups(), self.print_dict)
1624

    
1625
    def main(self):
1626
        super(self.__class__, self)._run()
1627
        self._run()
1628

    
1629

    
1630
@command(group_cmds)
1631
class group_create(_pithos_group, _optional_json):
1632
    """Create a group of users"""
1633

    
1634
    arguments = dict(
1635
        user_uuid=RepeatableArgument('Add a user to the group', '--uuid'),
1636
        username=RepeatableArgument('Add a user to the group', '--username')
1637
    )
1638
    required = ['user_uuid', 'user_name']
1639

    
1640
    @errors.generic.all
1641
    @errors.pithos.connection
1642
    def _run(self, groupname, *users):
1643
        if groupname in self._groups() and not self.ask_user(
1644
                'Group %s already exists, overwrite?' % groupname):
1645
            self.error('Aborted')
1646
            return
1647
        self.client.set_account_group(groupname, users)
1648
        self._print(self._groups(), self.print_dict)
1649

    
1650
    def main(self, groupname):
1651
        super(self.__class__, self)._run()
1652
        users = self['user_uuid'] + self._usernames2uuids(
1653
            self['username']).values()
1654
        if users:
1655
            self._run(groupname, *users)
1656
        else:
1657
            raise CLISyntaxError(
1658
                'No valid users specified, use %s or %s' % (
1659
                    self.arguments['user_uuid'].lvalue,
1660
                    self.arguments['username'].lvalue),
1661
                details=[
1662
                    'Check if a username or uuid is valid with',
1663
                    '  user uuid2username', 'OR', '  user username2uuid'])
1664

    
1665

    
1666
@command(group_cmds)
1667
class group_delete(_pithos_group, _optional_json):
1668
    """Delete a user group"""
1669

    
1670
    @errors.generic.all
1671
    @errors.pithos.connection
1672
    def _run(self, groupname):
1673
        self.client.del_account_group(groupname)
1674
        self._print(self._groups(), self.print_dict)
1675

    
1676
    def main(self, groupname):
1677
        super(self.__class__, self)._run()
1678
        self._run(groupname)