Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / commands / pithos.py @ 6c6abf6e

History | View | Annotate | Download (69.4 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
    UserAccountArgument)
53
from kamaki.cli.utils import (
54
    format_size, bold, get_path_size, guess_mime_type)
55

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

    
63

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

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

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

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

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

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

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

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

    
117

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

    
121
    def __init__(self, arguments={}, auth_base=None, cloud=None):
122
        super(_pithos_account, self).__init__(arguments, auth_base, cloud)
123
        self['account'] = UserAccountArgument(
124
            'A user UUID or name', ('-A', '--account'))
125
        self.arguments['account'].account_client = auth_base
126

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

    
150
    @staticmethod
151
    def _is_dir(remote_dict):
152
        return 'application/directory' in remote_dict.get(
153
            'content_type', remote_dict.get('content-type', ''))
154

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

    
160

    
161
class _pithos_container(_pithos_account):
162
    """Setup container"""
163

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

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

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

    
193

    
194
@command(file_cmds)
195
class file_info(_pithos_container, _optional_json):
196
    """Get information/details about a file"""
197

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

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

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

    
254
    def main(self, path_or_url):
255
        super(self.__class__, self)._run(path_or_url)
256
        self._run()
257

    
258

    
259
@command(file_cmds)
260
class file_list(_pithos_container, _optional_json, _name_filter):
261
    """List all objects in a container or a directory object"""
262

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

    
288
    @errors.generic.all
289
    @errors.pithos.connection
290
    @errors.pithos.container
291
    @errors.pithos.object_path
292
    def _run(self):
293
        r = self.client.container_get(
294
            limit=False if self['more'] else self['limit'],
295
            marker=self['marker'],
296
            prefix=self['name_pref'],
297
            delimiter=self['delimiter'],
298
            path=self.path or '',
299
            show_only_shared=self['shared_by_me'],
300
            public=self['public'],
301
            if_modified_since=self['if_modified_since'],
302
            if_unmodified_since=self['if_unmodified_since'],
303
            until=self['until'],
304
            meta=self['meta'])
305

    
306
        #  REMOVE THIS if version >> 0.12
307
        if not r.json:
308
            self.error('  NOTE: Since v0.12, use / for containers e.g.,')
309
            self.error('    [kamaki] file list /pithos')
310

    
311
        files = self._filter_by_name(r.json)
312
        if self['more']:
313
            outbu, self._out = self._out, StringIO()
314
        try:
315
            if self['json_output'] or self['output_format']:
316
                self._print(files)
317
            else:
318
                self.print_objects(files)
319
        finally:
320
            if self['more']:
321
                pager(self._out.getvalue())
322
                self._out = outbu
323

    
324
    def main(self, path_or_url=''):
325
        super(self.__class__, self)._run(path_or_url)
326
        self._run()
327

    
328

    
329
@command(file_cmds)
330
class file_modify(_pithos_container):
331
    """Modify the attributes of a file or directory object"""
332

    
333
    arguments = dict(
334
        uuid_for_read_permission=RepeatableArgument(
335
            'Give read access to user/group (can be repeated, accumulative). '
336
            'Format for users: UUID . Format for groups: UUID:GROUP . '
337
            'Use * for all users/groups', '--read-permission'),
338
        uuid_for_write_permission=RepeatableArgument(
339
            'Give write access to user/group (can be repeated, accumulative). '
340
            'Format for users: UUID . Format for groups: UUID:GROUP . '
341
            'Use * for all users/groups', '--write-permission'),
342
        no_permissions=FlagArgument('Remove permissions', '--no-permissions'),
343
        metadata_to_set=KeyValueArgument(
344
            'Add metadata (KEY=VALUE) to an object (can be repeated)',
345
            '--metadata-add'),
346
        metadata_key_to_delete=RepeatableArgument(
347
            'Delete object metadata (can be repeated)', '--metadata-del'),
348
    )
349
    required = [
350
        'uuid_for_read_permission', 'metadata_to_set',
351
        'uuid_for_write_permission', 'no_permissions',
352
        'metadata_key_to_delete']
353

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

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

    
389

    
390
@command(file_cmds)
391
class file_publish(_pithos_container):
392
    """Publish an object (creates a public URL)"""
393

    
394
    @errors.generic.all
395
    @errors.pithos.connection
396
    @errors.pithos.container
397
    @errors.pithos.object_path
398
    def _run(self):
399
        self.writeln(self.client.publish_object(self.path))
400

    
401
    def main(self, path_or_url):
402
        super(self.__class__, self)._run(path_or_url)
403
        self._run()
404

    
405

    
406
@command(file_cmds)
407
class file_unpublish(_pithos_container):
408
    """Unpublish an object"""
409

    
410
    @errors.generic.all
411
    @errors.pithos.connection
412
    @errors.pithos.container
413
    @errors.pithos.object_path
414
    def _run(self):
415
        self.client.unpublish_object(self.path)
416

    
417
    def main(self, path_or_url):
418
        super(self.__class__, self)._run(path_or_url)
419
        self._run()
420

    
421

    
422
def _assert_path(self, path_or_url):
423
    if not self.path:
424
        raiseCLIError(
425
            'Directory path is missing in location %s' % path_or_url,
426
            details=['Location format:    [[pithos://UUID]/CONTAINER/]PATH'])
427

    
428

    
429
@command(file_cmds)
430
class file_create(_pithos_container, _optional_output_cmd):
431
    """Create an empty file"""
432

    
433
    arguments = dict(
434
        content_type=ValueArgument(
435
            'Set content type (default: application/octet-stream)',
436
            '--content-type',
437
            default='application/octet-stream')
438
    )
439

    
440
    @errors.generic.all
441
    @errors.pithos.connection
442
    @errors.pithos.container
443
    def _run(self):
444
        self._optional_output(
445
            self.client.create_object(self.path, self['content_type']))
446

    
447
    def main(self, path_or_url):
448
        super(self.__class__, self)._run(path_or_url)
449
        _assert_path(self, path_or_url)
450
        self._run()
451

    
452

    
453
@command(file_cmds)
454
class file_mkdir(_pithos_container, _optional_output_cmd):
455
    """Create a directory: /file create --content-type='application/directory'
456
    """
457

    
458
    @errors.generic.all
459
    @errors.pithos.connection
460
    @errors.pithos.container
461
    def _run(self, path):
462
        self._optional_output(self.client.create_directory(self.path))
463

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

    
469

    
470
@command(file_cmds)
471
class file_delete(_pithos_container):
472
    """Delete a file or directory object"""
473

    
474
    arguments = dict(
475
        until_date=DateArgument('remove history until then', '--until'),
476
        yes=FlagArgument('Do not prompt for permission', '--yes'),
477
        recursive=FlagArgument(
478
            'If a directory, empty first', ('-r', '--recursive')),
479
        delimiter=ValueArgument(
480
            'delete objects prefixed with <object><delimiter>', '--delimiter')
481
    )
482

    
483
    @errors.generic.all
484
    @errors.pithos.connection
485
    @errors.pithos.container
486
    @errors.pithos.object_path
487
    def _run(self):
488
        if self.path:
489
            if self['yes'] or self.ask_user(
490
                    'Delete /%s/%s ?' % (self.container, self.path)):
491
                self.client.del_object(
492
                    self.path,
493
                    until=self['until_date'],
494
                    delimiter='/' if self['recursive'] else self['delimiter'])
495
            else:
496
                self.error('Aborted')
497
        else:
498
            if self['yes'] or self.ask_user(
499
                    'Empty container /%s ?' % self.container):
500
                self.client.container_delete(self.container, delimiter='/')
501
            else:
502
                self.error('Aborted')
503

    
504
    def main(self, path_or_url):
505
        super(self.__class__, self)._run(path_or_url)
506
        self._run()
507

    
508

    
509
class _source_destination(_pithos_container, _optional_output_cmd):
510

    
511
    sd_arguments = dict(
512
        destination_user=UserAccountArgument(
513
            'UUID or username, default: current user', '--to-account'),
514
        destination_container=ValueArgument(
515
            'default: pithos', '--to-container'),
516
        source_prefix=FlagArgument(
517
            'Transfer all files that are prefixed with SOURCE PATH If the '
518
            'destination path is specified, replace SOURCE_PATH with '
519
            'DESTINATION_PATH',
520
            ('-r', '--recursive')),
521
        force=FlagArgument(
522
            'Overwrite destination objects, if needed', ('-f', '--force')),
523
        source_version=ValueArgument(
524
            'The version of the source object', '--source-version')
525
    )
526

    
527
    def __init__(self, arguments={}, auth_base=None, cloud=None):
528
        self.arguments.update(arguments)
529
        self.arguments.update(self.sd_arguments)
530
        super(_source_destination, self).__init__(
531
            self.arguments, auth_base, cloud)
532
        self.arguments['destination_user'].account_client = self.auth_base
533

    
534
    def _report_transfer(self, src, dst, transfer_name):
535
        if not dst:
536
            if transfer_name in ('move', ):
537
                self.error('  delete source directory %s' % src)
538
            return
539
        dst_prf = '' if self.account == self.dst_client.account else (
540
                'pithos://%s' % self.dst_client.account)
541
        if src:
542
            src_prf = '' if self.account == self.dst_client.account else (
543
                    'pithos://%s' % self.account)
544
            self.error('  %s %s/%s/%s\n  -->  %s/%s/%s' % (
545
                transfer_name,
546
                src_prf, self.container, src,
547
                dst_prf, self.dst_client.container, dst))
548
        else:
549
            self.error('  mkdir %s/%s/%s' % (
550
                dst_prf, self.dst_client.container, dst))
551

    
552
    @errors.generic.all
553
    @errors.pithos.account
554
    def _src_dst(self, version=None):
555
        """Preconditions:
556
        self.account, self.container, self.path
557
        self.dst_acc, self.dst_con, self.dst_path
558
        They should all be configured properly
559
        :returns: [(src_path, dst_path), ...], if src_path is None, create
560
            destination directory
561
        """
562
        src_objects, dst_objects, pairs = dict(), dict(), []
563
        try:
564
            for obj in self.dst_client.list_objects(
565
                    prefix=self.dst_path or self.path or '/'):
566
                dst_objects[obj['name']] = obj
567
        except ClientError as ce:
568
            if ce.status in (404, ):
569
                raise CLIError(
570
                    'Destination container pithos://%s/%s not found' % (
571
                        self.dst_client.account, self.dst_client.container))
572
            raise ce
573
        if self['source_prefix']:
574
            #  Copy and replace prefixes
575
            for src_obj in self.client.list_objects(prefix=self.path):
576
                src_objects[src_obj['name']] = src_obj
577
            for src_path, src_obj in src_objects.items():
578
                dst_path = '%s%s' % (
579
                    self.dst_path or self.path, src_path[len(self.path):])
580
                dst_obj = dst_objects.get(dst_path, None)
581
                if self['force'] or not dst_obj:
582
                    #  Just do it
583
                    pairs.append((
584
                        None if self._is_dir(src_obj) else src_path, dst_path))
585
                    if self._is_dir(src_obj):
586
                        pairs.append((self.path or dst_path, None))
587
                elif not (self._is_dir(dst_obj) and self._is_dir(src_obj)):
588
                    raise CLIError(
589
                        'Destination object exists', importance=2, details=[
590
                            'Failed while transfering:',
591
                            '    pithos://%s/%s/%s' % (
592
                                    self.account,
593
                                    self.container,
594
                                    src_path),
595
                            '--> pithos://%s/%s/%s' % (
596
                                    self.dst_client.account,
597
                                    self.dst_client.container,
598
                                    dst_path),
599
                            'Use %s to transfer overwrite' % (
600
                                    self.arguments['force'].lvalue)])
601
        else:
602
            #  One object transfer
603
            try:
604
                src_version_arg = self.arguments.get('source_version', None)
605
                src_obj = self.client.get_object_info(
606
                    self.path,
607
                    version=src_version_arg.value if src_version_arg else None)
608
            except ClientError as ce:
609
                if ce.status in (204, ):
610
                    raise CLIError(
611
                        'Missing specific path container %s' % self.container,
612
                        importance=2, details=[
613
                            'To transfer container contents %s' % (
614
                                self.arguments['source_prefix'].lvalue)])
615
                raise
616
            dst_path = self.dst_path or self.path
617
            dst_obj = dst_objects.get(dst_path or self.path, None)
618
            if self['force'] or not dst_obj:
619
                pairs.append(
620
                    (None if self._is_dir(src_obj) else self.path, dst_path))
621
                if self._is_dir(src_obj):
622
                    pairs.append((self.path or dst_path, None))
623
            elif self._is_dir(src_obj):
624
                raise CLIError(
625
                    'Cannot transfer an application/directory object',
626
                    importance=2, details=[
627
                        'The object pithos://%s/%s/%s is a directory' % (
628
                            self.account,
629
                            self.container,
630
                            self.path),
631
                        'To recursively copy a directory, use',
632
                        '  %s' % self.arguments['source_prefix'].lvalue,
633
                        'To create a file, use',
634
                        '  /file create  (general purpose)',
635
                        '  /file mkdir   (a directory object)'])
636
            else:
637
                raise CLIError(
638
                    'Destination object exists',
639
                    importance=2, details=[
640
                        'Failed while transfering:',
641
                        '    pithos://%s/%s/%s' % (
642
                                self.account,
643
                                self.container,
644
                                self.path),
645
                        '--> pithos://%s/%s/%s' % (
646
                                self.dst_client.account,
647
                                self.dst_client.container,
648
                                dst_path),
649
                        'Use %s to transfer overwrite' % (
650
                                self.arguments['force'].lvalue)])
651
        return pairs
652

    
653
    def _run(self, source_path_or_url, destination_path_or_url=''):
654
        super(_source_destination, self)._run(source_path_or_url)
655
        dst_acc, dst_con, dst_path = self._resolve_pithos_url(
656
            destination_path_or_url)
657
        self.dst_client = PithosClient(
658
            base_url=self.client.base_url, token=self.client.token,
659
            container=self[
660
                'destination_container'] or dst_con or self.client.container,
661
            account=self['destination_user'] or dst_acc or self.account)
662
        self.dst_path = dst_path or self.path
663

    
664

    
665
@command(file_cmds)
666
class file_copy(_source_destination):
667
    """Copy objects, even between different accounts or containers"""
668

    
669
    arguments = dict(
670
        public=ValueArgument('publish new object', '--public'),
671
        content_type=ValueArgument(
672
            'change object\'s content type', '--content-type'),
673
        source_version=ValueArgument(
674
            'The version of the source object', '--object-version')
675
    )
676

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

    
697
    def main(self, source_path_or_url, destination_path_or_url=None):
698
        super(file_copy, self)._run(
699
            source_path_or_url, destination_path_or_url or '')
700
        self._run()
701

    
702

    
703
@command(file_cmds)
704
class file_move(_source_destination):
705
    """Move objects, even between different accounts or containers"""
706

    
707
    arguments = dict(
708
        public=ValueArgument('publish new object', '--public'),
709
        content_type=ValueArgument(
710
            'change object\'s content type', '--content-type')
711
    )
712

    
713
    @errors.generic.all
714
    @errors.pithos.connection
715
    @errors.pithos.container
716
    @errors.pithos.account
717
    def _run(self):
718
        for src, dst in self._src_dst():
719
            self._report_transfer(src, dst, 'move')
720
            if src and dst:
721
                self.dst_client.move_object(
722
                    src_container=self.client.container,
723
                    src_object=src,
724
                    dst_container=self.dst_client.container,
725
                    dst_object=dst,
726
                    source_account=self.account,
727
                    public=self['public'],
728
                    content_type=self['content_type'])
729
            elif dst:
730
                self.dst_client.create_directory(dst)
731
            else:
732
                self.client.del_object(src)
733

    
734
    def main(self, source_path_or_url, destination_path_or_url=None):
735
        super(file_move, self)._run(
736
            source_path_or_url, destination_path_or_url or '')
737
        self._run()
738

    
739

    
740
@command(file_cmds)
741
class file_append(_pithos_container, _optional_output_cmd):
742
    """Append local file to (existing) remote object
743
    The remote object should exist.
744
    If the remote object is a directory, it is transformed into a file.
745
    In the later case, objects under the directory remain intact.
746
    """
747

    
748
    arguments = dict(
749
        progress_bar=ProgressBarArgument(
750
            'do not show progress bar', ('-N', '--no-progress-bar'),
751
            default=False),
752
        max_threads=IntArgument('default: 1', '--threads'),
753
    )
754

    
755
    @errors.generic.all
756
    @errors.pithos.connection
757
    @errors.pithos.container
758
    @errors.pithos.object_path
759
    def _run(self, local_path):
760
        if self['max_threads'] > 0:
761
            self.client.MAX_THREADS = int(self['max_threads'])
762
        (progress_bar, upload_cb) = self._safe_progress_bar('Appending')
763
        try:
764
            with open(local_path, 'rb') as f:
765
                self._optional_output(
766
                    self.client.append_object(self.path, f, upload_cb))
767
        finally:
768
            self._safe_progress_bar_finish(progress_bar)
769

    
770
    def main(self, local_path, remote_path_or_url):
771
        super(self.__class__, self)._run(remote_path_or_url)
772
        self._run(local_path)
773

    
774

    
775
@command(file_cmds)
776
class file_truncate(_pithos_container, _optional_output_cmd):
777
    """Truncate remote file up to size"""
778

    
779
    arguments = dict(
780
        size_in_bytes=IntArgument('Length of file after truncation', '--size')
781
    )
782
    required = ('size_in_bytes', )
783

    
784
    @errors.generic.all
785
    @errors.pithos.connection
786
    @errors.pithos.container
787
    @errors.pithos.object_path
788
    @errors.pithos.object_size
789
    def _run(self, size):
790
        self._optional_output(self.client.truncate_object(self.path, size))
791

    
792
    def main(self, path_or_url):
793
        super(self.__class__, self)._run(path_or_url)
794
        self._run(size=self['size_in_bytes'])
795

    
796

    
797
@command(file_cmds)
798
class file_overwrite(_pithos_container, _optional_output_cmd):
799
    """Overwrite part of a remote file"""
800

    
801
    arguments = dict(
802
        progress_bar=ProgressBarArgument(
803
            'do not show progress bar', ('-N', '--no-progress-bar'),
804
            default=False),
805
        start_position=IntArgument('File position in bytes', '--from'),
806
        end_position=IntArgument('File position in bytes', '--to'),
807
    )
808
    required = ('start_position', 'end_position')
809

    
810
    @errors.generic.all
811
    @errors.pithos.connection
812
    @errors.pithos.container
813
    @errors.pithos.object_path
814
    @errors.pithos.object_size
815
    def _run(self, local_path, start, end):
816
        start, end = int(start), int(end)
817
        (progress_bar, upload_cb) = self._safe_progress_bar(
818
            'Overwrite %s bytes' % (end - start))
819
        try:
820
            with open(path.abspath(local_path), 'rb') as f:
821
                self._optional_output(self.client.overwrite_object(
822
                    obj=self.path,
823
                    start=start,
824
                    end=end,
825
                    source_file=f,
826
                    upload_cb=upload_cb))
827
        finally:
828
            self._safe_progress_bar_finish(progress_bar)
829

    
830
    def main(self, local_path, path_or_url):
831
        super(self.__class__, self)._run(path_or_url)
832
        self.path = self.path or path.basename(local_path)
833
        self._run(
834
            local_path=local_path,
835
            start=self['start_position'],
836
            end=self['end_position'])
837

    
838

    
839
@command(file_cmds)
840
class file_upload(_pithos_container, _optional_output_cmd):
841
    """Upload a file"""
842

    
843
    arguments = dict(
844
        max_threads=IntArgument('default: 5', '--threads'),
845
        content_encoding=ValueArgument(
846
            'set MIME content type', '--content-encoding'),
847
        content_disposition=ValueArgument(
848
            'specify objects presentation style', '--content-disposition'),
849
        content_type=ValueArgument('specify content type', '--content-type'),
850
        uuid_for_read_permission=RepeatableArgument(
851
            'Give read access to a user or group (can be repeated) '
852
            'Use * for all users',
853
            '--read-permission'),
854
        uuid_for_write_permission=RepeatableArgument(
855
            'Give write access to a user or group (can be repeated) '
856
            'Use * for all users',
857
            '--write-permission'),
858
        public=FlagArgument('make object publicly accessible', '--public'),
859
        progress_bar=ProgressBarArgument(
860
            'do not show progress bar',
861
            ('-N', '--no-progress-bar'),
862
            default=False),
863
        overwrite=FlagArgument('Force (over)write', ('-f', '--force')),
864
        recursive=FlagArgument(
865
            'Recursively upload directory *contents* + subdirectories',
866
            ('-r', '--recursive')),
867
        unchunked=FlagArgument(
868
            'Upload file as one block (not recommended)', '--unchunked'),
869
        md5_checksum=ValueArgument(
870
            'Confirm upload with a custom checksum (MD5)', '--etag'),
871
        use_hashes=FlagArgument(
872
            'Source file contains hashmap not data', '--source-is-hashmap'),
873
    )
874

    
875
    def _sharing(self):
876
        sharing = dict()
877
        readlist = self['uuid_for_read_permission']
878
        if readlist:
879
            sharing['read'] = self['uuid_for_read_permission']
880
        writelist = self['uuid_for_write_permission']
881
        if writelist:
882
            sharing['write'] = self['uuid_for_write_permission']
883
        return sharing or None
884

    
885
    def _check_container_limit(self, path):
886
        cl_dict = self.client.get_container_limit()
887
        container_limit = int(cl_dict['x-container-policy-quota'])
888
        r = self.client.container_get()
889
        used_bytes = sum(int(o['bytes']) for o in r.json)
890
        path_size = get_path_size(path)
891
        if container_limit and path_size > (container_limit - used_bytes):
892
            raise CLIError(
893
                'Container %s (limit(%s) - used(%s)) < (size(%s) of %s)' % (
894
                    self.client.container,
895
                    format_size(container_limit),
896
                    format_size(used_bytes),
897
                    format_size(path_size),
898
                    path),
899
                details=[
900
                    'Check accound limit: /file quota',
901
                    'Check container limit:',
902
                    '\t/file containerlimit get %s' % self.client.container,
903
                    'Increase container limit:',
904
                    '\t/file containerlimit set <new limit> %s' % (
905
                        self.client.container)])
906

    
907
    def _src_dst(self, local_path, remote_path, objlist=None):
908
        lpath = path.abspath(local_path)
909
        short_path = path.basename(path.abspath(local_path))
910
        rpath = remote_path or short_path
911
        if path.isdir(lpath):
912
            if not self['recursive']:
913
                raise CLIError('%s is a directory' % lpath, details=[
914
                    'Use %s to upload directories & contents' % (
915
                        self.arguments['recursive'].lvalue)])
916
            robj = self.client.container_get(path=rpath)
917
            if not self['overwrite']:
918
                if robj.json:
919
                    raise CLIError(
920
                        'Objects/files prefixed as %s already exist' % rpath,
921
                        details=['Existing objects:'] + ['\t/%s/\t%s' % (
922
                            o['name'],
923
                            o['content_type'][12:]) for o in robj.json] + [
924
                            'Use -f to add, overwrite or resume'])
925
                else:
926
                    try:
927
                        topobj = self.client.get_object_info(rpath)
928
                        if not self._is_dir(topobj):
929
                            raise CLIError(
930
                                'Object /%s/%s exists but not a directory' % (
931
                                    self.container, rpath),
932
                                details=['Use -f to overwrite'])
933
                    except ClientError as ce:
934
                        if ce.status not in (404, ):
935
                            raise
936
            self._check_container_limit(lpath)
937
            prev = ''
938
            for top, subdirs, files in walk(lpath):
939
                if top != prev:
940
                    prev = top
941
                    try:
942
                        rel_path = rpath + top.split(lpath)[1]
943
                    except IndexError:
944
                        rel_path = rpath
945
                    self.error('mkdir /%s/%s' % (
946
                        self.client.container, rel_path))
947
                    self.client.create_directory(rel_path)
948
                for f in files:
949
                    fpath = path.join(top, f)
950
                    if path.isfile(fpath):
951
                        rel_path = rel_path.replace(path.sep, '/')
952
                        pathfix = f.replace(path.sep, '/')
953
                        yield open(fpath, 'rb'), '%s/%s' % (rel_path, pathfix)
954
                    else:
955
                        self.error('%s is not a regular file' % fpath)
956
        else:
957
            if not path.isfile(lpath):
958
                raise CLIError(('%s is not a regular file' % lpath) if (
959
                    path.exists(lpath)) else '%s does not exist' % lpath)
960
            try:
961
                robj = self.client.get_object_info(rpath)
962
                if remote_path and self._is_dir(robj):
963
                    rpath += '/%s' % (short_path.replace(path.sep, '/'))
964
                    self.client.get_object_info(rpath)
965
                if not self['overwrite']:
966
                    raise CLIError(
967
                        'Object /%s/%s already exists' % (
968
                            self.container, rpath),
969
                        details=['use -f to overwrite / resume'])
970
            except ClientError as ce:
971
                if ce.status not in (404, ):
972
                    raise
973
            self._check_container_limit(lpath)
974
            yield open(lpath, 'rb'), rpath
975

    
976
    def _run(self, local_path, remote_path):
977
        self.client.MAX_THREADS = int(self['max_threads'] or 5)
978
        params = dict(
979
            content_encoding=self['content_encoding'],
980
            content_type=self['content_type'],
981
            content_disposition=self['content_disposition'],
982
            sharing=self._sharing(),
983
            public=self['public'])
984
        uploaded, container_info_cache = list, dict()
985
        rpref = 'pithos://%s' if self['account'] else ''
986
        for f, rpath in self._src_dst(local_path, remote_path):
987
            self.error('%s --> %s/%s/%s' % (
988
                f.name, rpref, self.client.container, rpath))
989
            if not (self['content_type'] and self['content_encoding']):
990
                ctype, cenc = guess_mime_type(f.name)
991
                params['content_type'] = self['content_type'] or ctype
992
                params['content_encoding'] = self['content_encoding'] or cenc
993
            if self['unchunked']:
994
                r = self.client.upload_object_unchunked(
995
                    rpath, f,
996
                    etag=self['md5_checksum'], withHashFile=self['use_hashes'],
997
                    **params)
998
                if self['with_output'] or self['json_output']:
999
                    r['name'] = '/%s/%s' % (self.client.container, rpath)
1000
                    uploaded.append(r)
1001
            else:
1002
                try:
1003
                    (progress_bar, upload_cb) = self._safe_progress_bar(
1004
                        'Uploading %s' % f.name.split(path.sep)[-1])
1005
                    if progress_bar:
1006
                        hash_bar = progress_bar.clone()
1007
                        hash_cb = hash_bar.get_generator(
1008
                            'Calculating block hashes')
1009
                    else:
1010
                        hash_cb = None
1011
                    r = self.client.upload_object(
1012
                        rpath, f,
1013
                        hash_cb=hash_cb,
1014
                        upload_cb=upload_cb,
1015
                        container_info_cache=container_info_cache,
1016
                        **params)
1017
                    if self['with_output'] or self['json_output']:
1018
                        r['name'] = '/%s/%s' % (self.client.container, rpath)
1019
                        uploaded.append(r)
1020
                except Exception:
1021
                    self._safe_progress_bar_finish(progress_bar)
1022
                    raise
1023
                finally:
1024
                    self._safe_progress_bar_finish(progress_bar)
1025
        self._optional_output(uploaded)
1026
        self.error('Upload completed')
1027

    
1028
    def main(self, local_path, remote_path_or_url):
1029
        super(self.__class__, self)._run(remote_path_or_url)
1030
        remote_path = self.path or path.basename(path.abspath(local_path))
1031
        self._run(local_path=local_path, remote_path=remote_path)
1032

    
1033

    
1034
class RangeArgument(ValueArgument):
1035
    """
1036
    :value type: string of the form <start>-<end> where <start> and <end> are
1037
        integers
1038
    :value returns: the input string, after type checking <start> and <end>
1039
    """
1040

    
1041
    @property
1042
    def value(self):
1043
        return getattr(self, '_value', self.default)
1044

    
1045
    @value.setter
1046
    def value(self, newvalues):
1047
        if newvalues:
1048
            self._value = getattr(self, '_value', self.default)
1049
            for newvalue in newvalues.split(','):
1050
                self._value = ('%s,' % self._value) if self._value else ''
1051
                start, sep, end = newvalue.partition('-')
1052
                if sep:
1053
                    if start:
1054
                        start, end = (int(start), int(end))
1055
                        if start > end:
1056
                            raise CLIInvalidArgument(
1057
                                'Invalid range %s' % newvalue, details=[
1058
                                'Valid range formats',
1059
                                '  START-END', '  UP_TO', '  -FROM',
1060
                                'where all values are integers',
1061
                                'OR a compination (csv), e.g.,',
1062
                                '  %s=5,10-20,-5' % self.lvalue])
1063
                        self._value += '%s-%s' % (start, end)
1064
                    else:
1065
                        self._value += '-%s' % int(end)
1066
                else:
1067
                    self._value += '%s' % int(start)
1068

    
1069

    
1070
@command(file_cmds)
1071
class file_cat(_pithos_container):
1072
    """Fetch remote file contents"""
1073

    
1074
    arguments = dict(
1075
        range=RangeArgument('show range of data e.g., 5,10-20,-5', '--range'),
1076
        if_match=ValueArgument('show output if ETags match', '--if-match'),
1077
        if_none_match=ValueArgument(
1078
            'show output if ETags match', '--if-none-match'),
1079
        if_modified_since=DateArgument(
1080
            'show output modified since then', '--if-modified-since'),
1081
        if_unmodified_since=DateArgument(
1082
            'show output unmodified since then', '--if-unmodified-since'),
1083
        object_version=ValueArgument(
1084
            'Get contents of the chosen version', '--object-version')
1085
    )
1086

    
1087
    @errors.generic.all
1088
    @errors.pithos.connection
1089
    @errors.pithos.container
1090
    @errors.pithos.object_path
1091
    def _run(self):
1092
        self.client.download_object(
1093
            self.path, self._out,
1094
            range_str=self['range'],
1095
            version=self['object_version'],
1096
            if_match=self['if_match'],
1097
            if_none_match=self['if_none_match'],
1098
            if_modified_since=self['if_modified_since'],
1099
            if_unmodified_since=self['if_unmodified_since'])
1100
        self._out.flush()
1101

    
1102
    def main(self, path_or_url):
1103
        super(self.__class__, self)._run(path_or_url)
1104
        self._run()
1105

    
1106

    
1107
@command(file_cmds)
1108
class file_download(_pithos_container):
1109
    """Download a remove file or directory object to local file system"""
1110

    
1111
    arguments = dict(
1112
        resume=FlagArgument(
1113
            'Resume/Overwrite (attempt resume, else overwrite)',
1114
            ('-f', '--resume')),
1115
        range=RangeArgument(
1116
            'Download only that range of data e.g., 5,10-20,-5', '--range'),
1117
        matching_etag=ValueArgument('download iff ETag match', '--if-match'),
1118
        non_matching_etag=ValueArgument(
1119
            'download iff ETags DO NOT match', '--if-none-match'),
1120
        modified_since_date=DateArgument(
1121
            'download iff remote file is modified since then',
1122
            '--if-modified-since'),
1123
        unmodified_since_date=DateArgument(
1124
            'show output iff remote file is unmodified since then',
1125
            '--if-unmodified-since'),
1126
        object_version=ValueArgument(
1127
            'download a file of a specific version', '--object-version'),
1128
        max_threads=IntArgument('default: 5', '--threads'),
1129
        progress_bar=ProgressBarArgument(
1130
            'do not show progress bar', ('-N', '--no-progress-bar'),
1131
            default=False),
1132
        recursive=FlagArgument(
1133
            'Download a remote directory object and its contents',
1134
            ('-r', '--recursive'))
1135
        )
1136

    
1137
    def _src_dst(self, local_path):
1138
        """Create a list of (src, dst) where src is a remote location and dst
1139
        is an open file descriptor. Directories are denoted as (None, dirpath)
1140
        and they are pretended to other objects in a very strict order (shorter
1141
        to longer path)."""
1142
        ret = []
1143
        try:
1144
            if self.path:
1145
                obj = self.client.get_object_info(
1146
                    self.path, version=self['object_version'])
1147
                obj.setdefault('name', self.path.strip('/'))
1148
            else:
1149
                obj = None
1150
        except ClientError as ce:
1151
            if ce.status in (404, ):
1152
                raiseCLIError(ce, details=[
1153
                    'To download an object, it must exist either as a file or'
1154
                    ' as a directory.',
1155
                    'For example, to download everything under prefix/ the '
1156
                    'directory "prefix" must exist.',
1157
                    'To see if an remote object is actually there:',
1158
                    '  /file info [/CONTAINER/]OBJECT',
1159
                    'To create a directory object:',
1160
                    '  /file mkdir [/CONTAINER/]OBJECT'])
1161
            if ce.status in (204, ):
1162
                raise CLIError(
1163
                    'No file or directory objects to download',
1164
                    details=[
1165
                        'To download a container (e.g., %s):' % self.container,
1166
                        '  [kamaki] container download %s [LOCAL_PATH]' % (
1167
                            self.container)])
1168
            raise
1169
        rpath = self.path.strip('/')
1170
        if local_path and self.path and local_path.endswith('/'):
1171
            local_path = local_path[-1:]
1172

    
1173
        if (not obj) or self._is_dir(obj):
1174
            if self['recursive']:
1175
                if not (self.path or local_path.endswith('/')):
1176
                    #  Download the whole container
1177
                    local_path = '' if local_path in ('.', ) else local_path
1178
                    local_path = '%s/' % (local_path or self.container)
1179
                obj = obj or dict(
1180
                    name='', content_type='application/directory')
1181
                dirs, files = [obj, ], []
1182
                objects = self.client.container_get(
1183
                    path=self.path,
1184
                    if_modified_since=self['modified_since_date'],
1185
                    if_unmodified_since=self['unmodified_since_date'])
1186
                for o in objects.json:
1187
                    (dirs if self._is_dir(o) else files).append(o)
1188

    
1189
                #  Put the directories on top of the list
1190
                for dpath in sorted(['%s%s' % (
1191
                        local_path, d['name'][len(rpath):]) for d in dirs]):
1192
                    if path.exists(dpath):
1193
                        if path.isdir(dpath):
1194
                            continue
1195
                        raise CLIError(
1196
                            'Cannot replace local file %s with a directory '
1197
                            'of the same name' % dpath,
1198
                            details=[
1199
                                'Either remove the file or specify a'
1200
                                'different target location'])
1201
                    ret.append((None, dpath, None))
1202

    
1203
                #  Append the file objects
1204
                for opath in [o['name'] for o in files]:
1205
                    lpath = '%s%s' % (local_path, opath[len(rpath):])
1206
                    if self['resume']:
1207
                        fxists = path.exists(lpath)
1208
                        if fxists and path.isdir(lpath):
1209
                            raise CLIError(
1210
                                'Cannot change local dir %s info file' % (
1211
                                    lpath),
1212
                                details=[
1213
                                    'Either remove the file or specify a'
1214
                                    'different target location'])
1215
                        ret.append((opath, lpath, fxists))
1216
                    elif path.exists(lpath):
1217
                        raise CLIError(
1218
                            'Cannot overwrite %s' % lpath,
1219
                            details=['To overwrite/resume, use  %s' % (
1220
                                self.arguments['resume'].lvalue)])
1221
                    else:
1222
                        ret.append((opath, lpath, None))
1223
            elif self.path:
1224
                raise CLIError(
1225
                    'Remote object /%s/%s is a directory' % (
1226
                        self.container, local_path),
1227
                    details=['Use %s to download directories' % (
1228
                        self.arguments['recursive'].lvalue)])
1229
            else:
1230
                parsed_name = self.arguments['recursive'].lvalue
1231
                raise CLIError(
1232
                    'Cannot download container %s' % self.container,
1233
                    details=[
1234
                        'Use %s to download containers' % parsed_name,
1235
                        '  [kamaki] file download %s /%s [LOCAL_PATH]' % (
1236
                            parsed_name, self.container)])
1237
        else:
1238
            #  Remote object is just a file
1239
            if path.exists(local_path):
1240
                if not self['resume']:
1241
                    raise CLIError(
1242
                        'Cannot overwrite local file %s' % (local_path),
1243
                        details=['To overwrite/resume, use  %s' % (
1244
                            self.arguments['resume'].lvalue)])
1245
            elif '/' in local_path[1:-1]:
1246
                dirs = [p for p in local_path.split('/') if p]
1247
                pref = '/' if local_path.startswith('/') else ''
1248
                for d in dirs[:-1]:
1249
                    pref += d
1250
                    if not path.exists(pref):
1251
                        ret.append((None, d, None))
1252
                    elif not path.isdir(pref):
1253
                        raise CLIError(
1254
                            'Failed to use %s as a destination' % local_path,
1255
                            importance=3,
1256
                            details=[
1257
                                'Local file %s is not a directory' % pref,
1258
                                'Destination prefix must consist of '
1259
                                'directories or non-existing names',
1260
                                'Either remove the file, or choose another '
1261
                                'destination'])
1262
            ret.append((rpath, local_path, self['resume']))
1263
        for r, l, resume in ret:
1264
            if r:
1265
                with open(l, 'rwb+' if resume else 'wb+') as f:
1266
                    yield (r, f)
1267
            else:
1268
                yield (r, l)
1269

    
1270
    @errors.generic.all
1271
    @errors.pithos.connection
1272
    @errors.pithos.container
1273
    @errors.pithos.object_path
1274
    @errors.pithos.local_path
1275
    @errors.pithos.local_path_download
1276
    def _run(self, local_path):
1277
        self.client.MAX_THREADS = int(self['max_threads'] or 5)
1278
        progress_bar = None
1279
        try:
1280
            for rpath, output_file in self._src_dst(local_path):
1281
                if not rpath:
1282
                    self.error('Create local directory %s' % output_file)
1283
                    makedirs(output_file)
1284
                    continue
1285
                self.error('/%s/%s --> %s' % (
1286
                    self.container, rpath, output_file.name))
1287
                progress_bar, download_cb = self._safe_progress_bar(
1288
                    '  download')
1289
                self.client.download_object(
1290
                    rpath, output_file,
1291
                    download_cb=download_cb,
1292
                    range_str=self['range'],
1293
                    version=self['object_version'],
1294
                    if_match=self['matching_etag'],
1295
                    resume=self['resume'],
1296
                    if_none_match=self['non_matching_etag'],
1297
                    if_modified_since=self['modified_since_date'],
1298
                    if_unmodified_since=self['unmodified_since_date'])
1299
        except KeyboardInterrupt:
1300
            from threading import activeCount, enumerate as activethreads
1301
            timeout = 0.5
1302
            while activeCount() > 1:
1303
                self._out.write('\nCancel %s threads: ' % (activeCount() - 1))
1304
                self._out.flush()
1305
                for thread in activethreads():
1306
                    try:
1307
                        thread.join(timeout)
1308
                        self._out.write('.' if thread.isAlive() else '*')
1309
                    except RuntimeError:
1310
                        continue
1311
                    finally:
1312
                        self._out.flush()
1313
                        timeout += 0.1
1314
            self.error('\nDownload canceled by user')
1315
            if local_path is not None:
1316
                self.error('to resume, re-run with --resume')
1317
        finally:
1318
            self._safe_progress_bar_finish(progress_bar)
1319

    
1320
    def main(self, remote_path_or_url, local_path=None):
1321
        super(self.__class__, self)._run(remote_path_or_url)
1322
        local_path = local_path or self.path or '.'
1323
        self._run(local_path=local_path)
1324

    
1325

    
1326
@command(container_cmds)
1327
class container_info(_pithos_account, _optional_json):
1328
    """Get information about a container"""
1329

    
1330
    arguments = dict(
1331
        until_date=DateArgument('show metadata until then', '--until'),
1332
        metadata=FlagArgument('Show only container metadata', '--metadata'),
1333
        sizelimit=FlagArgument(
1334
            'Show the maximum size limit for container', '--size-limit'),
1335
        in_bytes=FlagArgument('Show size limit in bytes', ('-b', '--bytes'))
1336
    )
1337

    
1338
    @errors.generic.all
1339
    @errors.pithos.connection
1340
    @errors.pithos.container
1341
    @errors.pithos.object_path
1342
    def _run(self):
1343
        if self['metadata']:
1344
            r, preflen = dict(), len('x-container-meta-')
1345
            for k, v in self.client.get_container_meta(
1346
                    until=self['until_date']).items():
1347
                r[k[preflen:]] = v
1348
        elif self['sizelimit']:
1349
            r = self.client.get_container_limit(
1350
                self.container)['x-container-policy-quota']
1351
            r = {'size limit': 'unlimited' if r in ('0', ) else (
1352
                int(r) if self['in_bytes'] else format_size(r))}
1353
        else:
1354
            r = self.client.get_container_info(self.container)
1355
        self._print(r, self.print_dict)
1356

    
1357
    def main(self, container):
1358
        super(self.__class__, self)._run()
1359
        self.container, self.client.container = container, container
1360
        self._run()
1361

    
1362

    
1363
class VersioningArgument(ValueArgument):
1364

    
1365
    schemes = ('auto', 'none')
1366

    
1367
    @property
1368
    def value(self):
1369
        return getattr(self, '_value', None)
1370

    
1371
    @value.setter
1372
    def value(self, new_scheme):
1373
        if new_scheme:
1374
            new_scheme = new_scheme.lower()
1375
            if new_scheme not in self.schemes:
1376
                raise CLIInvalidArgument('Invalid versioning value', details=[
1377
                    'Valid versioning values are %s' % ', '.join(
1378
                        self.schemes)])
1379
            self._value = new_scheme
1380

    
1381

    
1382
@command(container_cmds)
1383
class container_modify(_pithos_account, _optional_json):
1384
    """Modify the properties of a container"""
1385

    
1386
    arguments = dict(
1387
        metadata_to_add=KeyValueArgument(
1388
            'Add metadata in the form KEY=VALUE (can be repeated)',
1389
            '--metadata-add'),
1390
        metadata_to_delete=RepeatableArgument(
1391
            'Delete metadata by KEY (can be repeated)', '--metadata-del'),
1392
        sizelimit=DataSizeArgument(
1393
            'Set max size limit (0 for unlimited, '
1394
            'use units B, KiB, KB, etc.)', '--size-limit'),
1395
        versioning=VersioningArgument(
1396
            'Set a versioning scheme (%s)' % ', '.join(
1397
                VersioningArgument.schemes), '--versioning')
1398
    )
1399
    required = [
1400
        'metadata_to_add', 'metadata_to_delete', 'sizelimit', 'versioning']
1401

    
1402
    @errors.generic.all
1403
    @errors.pithos.connection
1404
    @errors.pithos.container
1405
    def _run(self, container):
1406
        metadata = self['metadata_to_add']
1407
        for k in (self['metadata_to_delete'] or []):
1408
            metadata[k] = ''
1409
        if metadata:
1410
            self.client.set_container_meta(metadata)
1411
            self._print(self.client.get_container_meta(), self.print_dict)
1412
        if self['sizelimit'] is not None:
1413
            self.client.set_container_limit(self['sizelimit'])
1414
            r = self.client.get_container_limit()['x-container-policy-quota']
1415
            r = 'unlimited' if r in ('0', ) else format_size(r)
1416
            self.writeln('new size limit: %s' % r)
1417
        if self['versioning']:
1418
            self.client.set_container_versioning(self['versioning'])
1419
            self.writeln('new versioning scheme: %s' % (
1420
                self.client.get_container_versioning(self.container)[
1421
                    'x-container-policy-versioning']))
1422

    
1423
    def main(self, container):
1424
        super(self.__class__, self)._run()
1425
        self.client.container, self.container = container, container
1426
        self._run(container=container)
1427

    
1428

    
1429
@command(container_cmds)
1430
class container_list(_pithos_account, _optional_json, _name_filter):
1431
    """List all containers, or their contents"""
1432

    
1433
    arguments = dict(
1434
        detail=FlagArgument('Containers with details', ('-l', '--list')),
1435
        limit=IntArgument('limit number of listed items', ('-n', '--number')),
1436
        marker=ValueArgument('output greater that marker', '--marker'),
1437
        modified_since_date=ValueArgument(
1438
            'show output modified since then', '--if-modified-since'),
1439
        unmodified_since_date=ValueArgument(
1440
            'show output not modified since then', '--if-unmodified-since'),
1441
        until_date=DateArgument('show metadata until then', '--until'),
1442
        shared=FlagArgument('show only shared', '--shared'),
1443
        more=FlagArgument('read long results', '--more'),
1444
        enum=FlagArgument('Enumerate results', '--enumerate'),
1445
        recursive=FlagArgument(
1446
            'Recursively list containers and their contents',
1447
            ('-r', '--recursive')),
1448
        shared_by_me=FlagArgument(
1449
            'show only files shared to other users', '--shared-by-me'),
1450
        public=FlagArgument('show only published objects', '--public'),
1451
    )
1452

    
1453
    def print_containers(self, container_list):
1454
        for index, container in enumerate(container_list):
1455
            if 'bytes' in container:
1456
                size = format_size(container['bytes'])
1457
            prfx = ('%s. ' % (index + 1)) if self['enum'] else ''
1458
            _cname = container['name'] if (
1459
                self['more']) else bold(container['name'])
1460
            cname = u'%s%s' % (prfx, _cname)
1461
            if self['detail']:
1462
                self.writeln(cname)
1463
                pretty_c = container.copy()
1464
                if 'bytes' in container:
1465
                    pretty_c['bytes'] = '%s (%s)' % (container['bytes'], size)
1466
                self.print_dict(pretty_c, exclude=('name'))
1467
                self.writeln()
1468
            else:
1469
                if 'count' in container and 'bytes' in container:
1470
                    self.writeln('%s (%s, %s objects)' % (
1471
                        cname, size, container['count']))
1472
                else:
1473
                    self.writeln(cname)
1474
            objects = container.get('objects', [])
1475
            if objects:
1476
                self.print_objects(objects)
1477
                self.writeln('')
1478

    
1479
    def _create_object_forest(self, container_list):
1480
        try:
1481
            for container in container_list:
1482
                self.client.container = container['name']
1483
                objects = self.client.container_get(
1484
                    limit=False if self['more'] else self['limit'],
1485
                    if_modified_since=self['modified_since_date'],
1486
                    if_unmodified_since=self['unmodified_since_date'],
1487
                    until=self['until_date'],
1488
                    show_only_shared=self['shared_by_me'],
1489
                    public=self['public'])
1490
                container['objects'] = objects.json
1491
        finally:
1492
            self.client.container = None
1493

    
1494
    @errors.generic.all
1495
    @errors.pithos.connection
1496
    @errors.pithos.object_path
1497
    @errors.pithos.container
1498
    def _run(self, container):
1499
        if container:
1500
            r = self.client.container_get(
1501
                limit=False if self['more'] else self['limit'],
1502
                marker=self['marker'],
1503
                if_modified_since=self['modified_since_date'],
1504
                if_unmodified_since=self['unmodified_since_date'],
1505
                until=self['until_date'],
1506
                show_only_shared=self['shared_by_me'],
1507
                public=self['public'])
1508
        else:
1509
            r = self.client.account_get(
1510
                limit=False if self['more'] else self['limit'],
1511
                marker=self['marker'],
1512
                if_modified_since=self['modified_since_date'],
1513
                if_unmodified_since=self['unmodified_since_date'],
1514
                until=self['until_date'],
1515
                show_only_shared=self['shared_by_me'],
1516
                public=self['public'])
1517
        files = self._filter_by_name(r.json)
1518
        if self['recursive'] and not container:
1519
            self._create_object_forest(files)
1520
        if self['more']:
1521
            outbu, self._out = self._out, StringIO()
1522
        try:
1523
            if self['json_output'] or self['output_format']:
1524
                self._print(files)
1525
            else:
1526
                (self.print_objects if container else self.print_containers)(
1527
                    files)
1528
        finally:
1529
            if self['more']:
1530
                pager(self._out.getvalue())
1531
                self._out = outbu
1532

    
1533
    def main(self, container=None):
1534
        super(self.__class__, self)._run()
1535
        self.client.container, self.container = container, container
1536
        self._run(container)
1537

    
1538

    
1539
@command(container_cmds)
1540
class container_create(_pithos_account):
1541
    """Create a new container"""
1542

    
1543
    arguments = dict(
1544
        versioning=ValueArgument(
1545
            'set container versioning (auto/none)', '--versioning'),
1546
        limit=IntArgument('set default container limit', '--limit'),
1547
        meta=KeyValueArgument(
1548
            'set container metadata (can be repeated)', '--meta')
1549
    )
1550

    
1551
    @errors.generic.all
1552
    @errors.pithos.connection
1553
    @errors.pithos.container
1554
    def _run(self, container):
1555
        try:
1556
            self.client.create_container(
1557
                container=container,
1558
                sizelimit=self['limit'],
1559
                versioning=self['versioning'],
1560
                metadata=self['meta'],
1561
                success=(201, ))
1562
        except ClientError as ce:
1563
            if ce.status in (202, ):
1564
                raise CLIError(
1565
                    'Container %s alread exists' % container, details=[
1566
                    'Either delete %s or choose another name' % (container)])
1567
            raise
1568

    
1569
    def main(self, new_container):
1570
        super(self.__class__, self)._run()
1571
        self._run(container=new_container)
1572

    
1573

    
1574
@command(container_cmds)
1575
class container_delete(_pithos_account):
1576
    """Delete a container"""
1577

    
1578
    arguments = dict(
1579
        yes=FlagArgument('Do not prompt for permission', '--yes'),
1580
        recursive=FlagArgument(
1581
            'delete container even if not empty', ('-r', '--recursive'))
1582
    )
1583

    
1584
    @errors.generic.all
1585
    @errors.pithos.connection
1586
    @errors.pithos.container
1587
    def _run(self, container):
1588
        num_of_contents = int(self.client.get_container_info(container)[
1589
            'x-container-object-count'])
1590
        delimiter, msg = None, 'Delete container %s ?' % container
1591
        if self['recursive']:
1592
            delimiter, msg = '/', 'Empty and d%s' % msg[1:]
1593
        elif num_of_contents:
1594
            raise CLIError('Container %s is not empty' % container, details=[
1595
                'Use %s to delete non-empty containers' % (
1596
                    self.arguments['recursive'].lvalue)])
1597
        if self['yes'] or self.ask_user(msg):
1598
            if num_of_contents:
1599
                self.client.del_container(delimiter=delimiter)
1600
            self.client.purge_container()
1601

    
1602
    def main(self, container):
1603
        super(self.__class__, self)._run()
1604
        self.container, self.client.container = container, container
1605
        self._run(container)
1606

    
1607

    
1608
@command(container_cmds)
1609
class container_empty(_pithos_account):
1610
    """Empty a container"""
1611

    
1612
    arguments = dict(yes=FlagArgument('Do not prompt for permission', '--yes'))
1613

    
1614
    @errors.generic.all
1615
    @errors.pithos.connection
1616
    @errors.pithos.container
1617
    def _run(self, container):
1618
        if self['yes'] or self.ask_user('Empty container %s ?' % container):
1619
            self.client.del_container(delimiter='/')
1620

    
1621
    def main(self, container):
1622
        super(self.__class__, self)._run()
1623
        self.container, self.client.container = container, container
1624
        self._run(container)
1625

    
1626

    
1627
@command(sharer_cmds)
1628
class sharer_list(_pithos_account, _optional_json):
1629
    """List accounts who share file objects with current user"""
1630

    
1631
    arguments = dict(
1632
        detail=FlagArgument('show detailed output', ('-l', '--details')),
1633
        marker=ValueArgument('show output greater then marker', '--marker')
1634
    )
1635

    
1636
    @errors.generic.all
1637
    @errors.pithos.connection
1638
    def _run(self):
1639
        accounts = self.client.get_sharing_accounts(marker=self['marker'])
1640
        if not (self['json_output'] or self['output_format']):
1641
            usernames = self._uuids2usernames(
1642
                [acc['name'] for acc in accounts])
1643
            for item in accounts:
1644
                uuid = item['name']
1645
                item['id'], item['name'] = uuid, usernames[uuid]
1646
                if not self['detail']:
1647
                    item.pop('last_modified')
1648
        self._print(accounts)
1649

    
1650
    def main(self):
1651
        super(self.__class__, self)._run()
1652
        self._run()
1653

    
1654

    
1655
@command(sharer_cmds)
1656
class sharer_info(_pithos_account, _optional_json):
1657
    """Details on a Pithos+ sharer account (default: current account)"""
1658

    
1659
    @errors.generic.all
1660
    @errors.pithos.connection
1661
    def _run(self):
1662
        self._print(self.client.get_account_info(), self.print_dict)
1663

    
1664
    def main(self, account_uuid_or_name=None):
1665
        super(self.__class__, self)._run()
1666
        if account_uuid_or_name:
1667
            arg = UserAccountArgument('Check', ' ')
1668
            arg.account_client = self.auth_base
1669
            arg.value = account_uuid_or_name
1670
            self.client.account, self.account = arg.value, arg.value
1671
        self._run()
1672

    
1673

    
1674
class _pithos_group(_pithos_account):
1675
    prefix = 'x-account-group-'
1676
    preflen = len(prefix)
1677

    
1678
    def _groups(self):
1679
        groups = dict()
1680
        for k, v in self.client.get_account_group().items():
1681
            groups[k[self.preflen:]] = v
1682
        return groups
1683

    
1684

    
1685
@command(group_cmds)
1686
class group_list(_pithos_group, _optional_json):
1687
    """list all groups and group members"""
1688

    
1689
    @errors.generic.all
1690
    @errors.pithos.connection
1691
    def _run(self):
1692
        self._print(self._groups(), self.print_dict)
1693

    
1694
    def main(self):
1695
        super(self.__class__, self)._run()
1696
        self._run()
1697

    
1698

    
1699
@command(group_cmds)
1700
class group_create(_pithos_group, _optional_json):
1701
    """Create a group of users"""
1702

    
1703
    arguments = dict(
1704
        user_uuid=RepeatableArgument('Add a user to the group', '--uuid'),
1705
        username=RepeatableArgument('Add a user to the group', '--username')
1706
    )
1707
    required = ['user_uuid', 'username']
1708

    
1709
    @errors.generic.all
1710
    @errors.pithos.connection
1711
    def _run(self, groupname, *users):
1712
        if groupname in self._groups() and not self.ask_user(
1713
                'Group %s already exists, overwrite?' % groupname):
1714
            self.error('Aborted')
1715
            return
1716
        self.client.set_account_group(groupname, users)
1717
        self._print(self._groups(), self.print_dict)
1718

    
1719
    def main(self, groupname):
1720
        super(self.__class__, self)._run()
1721
        users = (self['user_uuid'] or []) + self._usernames2uuids(
1722
            self['username'] or []).values()
1723
        if users:
1724
            self._run(groupname, *users)
1725
        else:
1726
            raise CLISyntaxError(
1727
                'No valid users specified, use %s or %s' % (
1728
                    self.arguments['user_uuid'].lvalue,
1729
                    self.arguments['username'].lvalue),
1730
                details=[
1731
                    'Check if a username or uuid is valid with',
1732
                    '  user uuid2username', 'OR', '  user username2uuid'])
1733

    
1734

    
1735
@command(group_cmds)
1736
class group_delete(_pithos_group, _optional_json):
1737
    """Delete a user group"""
1738

    
1739
    @errors.generic.all
1740
    @errors.pithos.connection
1741
    def _run(self, groupname):
1742
        self.client.del_account_group(groupname)
1743
        self._print(self._groups(), self.print_dict)
1744

    
1745
    def main(self, groupname):
1746
        super(self.__class__, self)._run()
1747
        self._run(groupname)