Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / commands / pithos.py @ 38a79780

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
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.account = acc or self.client.account
189
        self.client.container = self.container
190

    
191

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

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

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

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

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

    
256

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

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

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

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

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

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

    
326

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

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

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

    
385
    def main(self, path_or_url):
386
        super(self.__class__, self)._run(path_or_url)
387
        if self['publish'] and self['unpublish']:
388
            raise CLIInvalidArgument(
389
                'Arguments %s and %s cannot be used together' % (
390
                    self.arguments['publish'].lvalue,
391
                    self.arguments['publish'].lvalue))
392
        if self['no_permissions'] and (
393
                self['uuid_for_read_permission'] or self[
394
                    'uuid_for_write_permission']):
395
            raise CLIInvalidArgument(
396
                '%s cannot be used with other permission arguments' % (
397
                    self.arguments['no_permissions'].lvalue))
398
        self._run()
399

    
400

    
401
@command(file_cmds)
402
class file_create(_pithos_container, _optional_output_cmd):
403
    """Create an empty file"""
404

    
405
    arguments = dict(
406
        content_type=ValueArgument(
407
            'Set content type (default: application/octet-stream)',
408
            '--content-type',
409
            default='application/octet-stream')
410
    )
411

    
412
    @errors.generic.all
413
    @errors.pithos.connection
414
    @errors.pithos.container
415
    def _run(self):
416
        self._optional_output(
417
            self.client.create_object(self.path, self['content_type']))
418

    
419
    def main(self, path_or_url):
420
        super(self.__class__, self)._run(path_or_url)
421
        self._run()
422

    
423

    
424
@command(file_cmds)
425
class file_mkdir(_pithos_container, _optional_output_cmd):
426
    """Create a directory: /file create --content-type='applcation/directory'
427
    """
428

    
429
    @errors.generic.all
430
    @errors.pithos.connection
431
    @errors.pithos.container
432
    def _run(self):
433
        self._optional_output(self.client.create_directory(self.path))
434

    
435
    def main(self, path_or_url):
436
        super(self.__class__, self)._run(path_or_url)
437
        self._run()
438

    
439

    
440
@command(file_cmds)
441
class file_delete(_pithos_container):
442
    """Delete a file or directory object"""
443

    
444
    arguments = dict(
445
        until_date=DateArgument('remove history until then', '--until'),
446
        yes=FlagArgument('Do not prompt for permission', '--yes'),
447
        recursive=FlagArgument(
448
            'If a directory, empty first', ('-r', '--recursive')),
449
        delimiter=ValueArgument(
450
            'delete objects prefixed with <object><delimiter>', '--delimiter')
451
    )
452

    
453
    @errors.generic.all
454
    @errors.pithos.connection
455
    @errors.pithos.container
456
    @errors.pithos.object_path
457
    def _run(self):
458
        if self.path:
459
            if self['yes'] or self.ask_user(
460
                    'Delete /%s/%s ?' % (self.container, self.path)):
461
                self.client.del_object(
462
                    self.path,
463
                    until=self['until_date'],
464
                    delimiter='/' if self['recursive'] else self['delimiter'])
465
            else:
466
                self.error('Aborted')
467
        else:
468
            if self['yes'] or self.ask_user(
469
                    'Empty container /%s ?' % self.container):
470
                self.client.container_delete(self.container, delimiter='/')
471
            else:
472
                self.error('Aborted')
473

    
474
    def main(self, path_or_url):
475
        super(self.__class__, self)._run(path_or_url)
476
        self._run()
477

    
478

    
479
class _source_destination(_pithos_container, _optional_output_cmd):
480

    
481
    sd_arguments = dict(
482
        destination_user_uuid=ValueArgument(
483
            'default: current user uuid', '--to-account'),
484
        destination_container=ValueArgument(
485
            'default: pithos', '--to-container'),
486
        source_prefix=FlagArgument(
487
            'Transfer all files that are prefixed with SOURCE PATH If the '
488
            'destination path is specified, replace SOURCE_PATH with '
489
            'DESTINATION_PATH',
490
            ('-r', '--recursive')),
491
        force=FlagArgument(
492
            'Overwrite destination objects, if needed', ('-f', '--force')),
493
        source_version=ValueArgument(
494
            'The version of the source object', '--source-version')
495
    )
496

    
497
    def __init__(self, arguments={}, auth_base=None, cloud=None):
498
        self.arguments.update(arguments)
499
        self.arguments.update(self.sd_arguments)
500
        super(_source_destination, self).__init__(
501
            self.arguments, auth_base, cloud)
502

    
503
    def _report_transfer(self, src, dst, transfer_name):
504
        if not dst:
505
            if transfer_name in ('move', ):
506
                self.error('  delete source directory %s' % src)
507
            return
508
        dst_prf = '' if self.account == self.dst_client.account else (
509
                'pithos://%s' % self.dst_client.account)
510
        if src:
511
            src_prf = '' if self.account == self.dst_client.account else (
512
                    'pithos://%s' % self.account)
513
            self.error('  %s %s/%s/%s\n  -->  %s/%s/%s' % (
514
                transfer_name,
515
                src_prf, self.container, src,
516
                dst_prf, self.dst_client.container, dst))
517
        else:
518
            self.error('  mkdir %s/%s/%s' % (
519
                dst_prf, self.dst_client.container, dst))
520

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

    
622
    def _run(self, source_path_or_url, destination_path_or_url=''):
623
        super(_source_destination, self)._run(source_path_or_url)
624
        dst_acc, dst_con, dst_path = self._resolve_pithos_url(
625
            destination_path_or_url)
626
        self.dst_client = PithosClient(
627
            base_url=self.client.base_url, token=self.client.token,
628
            container=self[
629
                'destination_container'] or dst_con or self.client.container,
630
            account=self[
631
                'destination_user_uuid'] or dst_acc or self.account)
632
        self.dst_path = dst_path or self.path
633

    
634

    
635
@command(file_cmds)
636
class file_copy(_source_destination):
637
    """Copy objects, even between different accounts or containers"""
638

    
639
    arguments = dict(
640
        public=ValueArgument('publish new object', '--public'),
641
        content_type=ValueArgument(
642
            'change object\'s content type', '--content-type'),
643
        source_version=ValueArgument(
644
            'The version of the source object', '--object-version')
645
    )
646

    
647
    @errors.generic.all
648
    @errors.pithos.connection
649
    @errors.pithos.container
650
    @errors.pithos.account
651
    def _run(self):
652
        for src, dst in self._src_dst(self['source_version']):
653
            self._report_transfer(src, dst, 'copy')
654
            if src and dst:
655
                self.dst_client.copy_object(
656
                    src_container=self.client.container,
657
                    src_object=src,
658
                    dst_container=self.dst_client.container,
659
                    dst_object=dst,
660
                    source_account=self.client.account,
661
                    source_version=self['source_version'],
662
                    public=self['public'],
663
                    content_type=self['content_type'])
664
            elif dst:
665
                self.dst_client.create_directory(dst)
666

    
667
    def main(self, source_path_or_url, destination_path_or_url=None):
668
        super(file_copy, self)._run(
669
            source_path_or_url, destination_path_or_url or '')
670
        self._run()
671

    
672

    
673
@command(file_cmds)
674
class file_move(_source_destination):
675
    """Move objects, even between different accounts or containers"""
676

    
677
    arguments = dict(
678
        public=ValueArgument('publish new object', '--public'),
679
        content_type=ValueArgument(
680
            'change object\'s content type', '--content-type')
681
    )
682

    
683
    @errors.generic.all
684
    @errors.pithos.connection
685
    @errors.pithos.container
686
    @errors.pithos.account
687
    def _run(self):
688
        for src, dst in self._src_dst():
689
            self._report_transfer(src, dst, 'move')
690
            if src and dst:
691
                self.dst_client.move_object(
692
                    src_container=self.client.container,
693
                    src_object=src,
694
                    dst_container=self.dst_client.container,
695
                    dst_object=dst,
696
                    source_account=self.account,
697
                    public=self['public'],
698
                    content_type=self['content_type'])
699
            elif dst:
700
                self.dst_client.create_directory(dst)
701
            else:
702
                self.client.del_object(src)
703

    
704
    def main(self, source_path_or_url, destination_path_or_url=None):
705
        super(file_move, self)._run(
706
            source_path_or_url, destination_path_or_url or '')
707
        self._run()
708

    
709

    
710
@command(file_cmds)
711
class file_append(_pithos_container, _optional_output_cmd):
712
    """Append local file to (existing) remote object
713
    The remote object should exist.
714
    If the remote object is a directory, it is transformed into a file.
715
    In the later case, objects under the directory remain intact.
716
    """
717

    
718
    arguments = dict(
719
        progress_bar=ProgressBarArgument(
720
            'do not show progress bar', ('-N', '--no-progress-bar'),
721
            default=False),
722
        max_threads=IntArgument('default: 1', '--threads'),
723
    )
724

    
725
    @errors.generic.all
726
    @errors.pithos.connection
727
    @errors.pithos.container
728
    @errors.pithos.object_path
729
    def _run(self, local_path):
730
        if self['max_threads'] > 0:
731
            self.client.MAX_THREADS = int(self['max_threads'])
732
        (progress_bar, upload_cb) = self._safe_progress_bar('Appending')
733
        try:
734
            with open(local_path, 'rb') as f:
735
                self._optional_output(
736
                    self.client.append_object(self.path, f, upload_cb))
737
        finally:
738
            self._safe_progress_bar_finish(progress_bar)
739

    
740
    def main(self, local_path, remote_path_or_url):
741
        super(self.__class__, self)._run(remote_path_or_url)
742
        self._run(local_path)
743

    
744

    
745
@command(file_cmds)
746
class file_truncate(_pithos_container, _optional_output_cmd):
747
    """Truncate remote file up to size"""
748

    
749
    arguments = dict(
750
        size_in_bytes=IntArgument('Length of file after truncation', '--size')
751
    )
752
    required = ('size_in_bytes', )
753

    
754
    @errors.generic.all
755
    @errors.pithos.connection
756
    @errors.pithos.container
757
    @errors.pithos.object_path
758
    @errors.pithos.object_size
759
    def _run(self, size):
760
        self._optional_output(self.client.truncate_object(self.path, size))
761

    
762
    def main(self, path_or_url):
763
        super(self.__class__, self)._run(path_or_url)
764
        self._run(size=self['size_in_bytes'])
765

    
766

    
767
@command(file_cmds)
768
class file_overwrite(_pithos_container, _optional_output_cmd):
769
    """Overwrite part of a remote file"""
770

    
771
    arguments = dict(
772
        progress_bar=ProgressBarArgument(
773
            'do not show progress bar', ('-N', '--no-progress-bar'),
774
            default=False),
775
        start_position=IntArgument('File position in bytes', '--from'),
776
        end_position=IntArgument('File position in bytes', '--to'),
777
        content_type=ValueArgument(
778
            'default: application/octet-stream', '--content-type'),
779
    )
780
    required = ('start_position', 'end_position')
781

    
782
    @errors.generic.all
783
    @errors.pithos.connection
784
    @errors.pithos.container
785
    @errors.pithos.object_path
786
    @errors.pithos.object_size
787
    def _run(self, local_path, start, end):
788
        start, end = int(start), int(end)
789
        (progress_bar, upload_cb) = self._safe_progress_bar(
790
            'Overwrite %s bytes' % (end - start))
791
        try:
792
            with open(path.abspath(local_path), 'rb') as f:
793
                self._optional_output(self.client.overwrite_object(
794
                    obj=self.path,
795
                    start=start,
796
                    end=end,
797
                    source_file=f,
798
                    content_type=self['content_type'],
799
                    upload_cb=upload_cb))
800
        finally:
801
            self._safe_progress_bar_finish(progress_bar)
802

    
803
    def main(self, local_path, path_or_url):
804
        super(self.__class__, self)._run(path_or_url)
805
        self.path = self.path or path.basename(local_path)
806
        self._run(
807
            local_path=local_path,
808
            start=self['start_position'],
809
            end=self['end_position'])
810

    
811

    
812
@command(file_cmds)
813
class file_upload(_pithos_container, _optional_output_cmd):
814
    """Upload a file"""
815

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

    
848
    def _sharing(self):
849
        sharing = dict()
850
        readlist = self['uuid_for_read_permission']
851
        if readlist:
852
            sharing['read'] = self['uuid_for_read_permission']
853
        writelist = self['uuid_for_write_permission']
854
        if writelist:
855
            sharing['write'] = self['uuid_for_write_permission']
856
        return sharing or None
857

    
858
    def _check_container_limit(self, path):
859
        cl_dict = self.client.get_container_limit()
860
        container_limit = int(cl_dict['x-container-policy-quota'])
861
        r = self.client.container_get()
862
        used_bytes = sum(int(o['bytes']) for o in r.json)
863
        path_size = get_path_size(path)
864
        if container_limit and path_size > (container_limit - used_bytes):
865
            raise CLIError(
866
                'Container %s (limit(%s) - used(%s)) < (size(%s) of %s)' % (
867
                    self.client.container,
868
                    format_size(container_limit),
869
                    format_size(used_bytes),
870
                    format_size(path_size),
871
                    path),
872
                details=[
873
                    'Check accound limit: /file quota',
874
                    'Check container limit:',
875
                    '\t/file containerlimit get %s' % self.client.container,
876
                    'Increase container limit:',
877
                    '\t/file containerlimit set <new limit> %s' % (
878
                        self.client.container)])
879

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

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

    
1001
    def main(self, local_path, remote_path_or_url):
1002
        super(self.__class__, self)._run(remote_path_or_url)
1003
        remote_path = self.path or path.basename(path.abspath(local_path))
1004
        self._run(local_path=local_path, remote_path=remote_path)
1005

    
1006

    
1007
class RangeArgument(ValueArgument):
1008
    """
1009
    :value type: string of the form <start>-<end> where <start> and <end> are
1010
        integers
1011
    :value returns: the input string, after type checking <start> and <end>
1012
    """
1013

    
1014
    @property
1015
    def value(self):
1016
        return getattr(self, '_value', self.default)
1017

    
1018
    @value.setter
1019
    def value(self, newvalues):
1020
        if newvalues:
1021
            self._value = getattr(self, '_value', self.default)
1022
            for newvalue in newvalues.split(','):
1023
                self._value = ('%s,' % self._value) if self._value else ''
1024
                start, sep, end = newvalue.partition('-')
1025
                if sep:
1026
                    if start:
1027
                        start, end = (int(start), int(end))
1028
                        if start > end:
1029
                            raise CLIInvalidArgument(
1030
                                'Invalid range %s' % newvalue, details=[
1031
                                'Valid range formats',
1032
                                '  START-END', '  UP_TO', '  -FROM',
1033
                                'where all values are integers',
1034
                                'OR a compination (csv), e.g.,',
1035
                                '  %s=5,10-20,-5' % self.lvalue])
1036
                        self._value += '%s-%s' % (start, end)
1037
                    else:
1038
                        self._value += '-%s' % int(end)
1039
                else:
1040
                    self._value += '%s' % int(start)
1041

    
1042

    
1043
@command(file_cmds)
1044
class file_cat(_pithos_container):
1045
    """Fetch remote file contents"""
1046

    
1047
    arguments = dict(
1048
        range=RangeArgument('show range of data e.g., 5,10-20,-5', '--range'),
1049
        if_match=ValueArgument('show output if ETags match', '--if-match'),
1050
        if_none_match=ValueArgument(
1051
            'show output if ETags match', '--if-none-match'),
1052
        if_modified_since=DateArgument(
1053
            'show output modified since then', '--if-modified-since'),
1054
        if_unmodified_since=DateArgument(
1055
            'show output unmodified since then', '--if-unmodified-since'),
1056
        object_version=ValueArgument(
1057
            'Get contents of the chosen version', '--object-version')
1058
    )
1059

    
1060
    @errors.generic.all
1061
    @errors.pithos.connection
1062
    @errors.pithos.container
1063
    @errors.pithos.object_path
1064
    def _run(self):
1065
        self.client.download_object(
1066
            self.path, self._out,
1067
            range_str=self['range'],
1068
            version=self['object_version'],
1069
            if_match=self['if_match'],
1070
            if_none_match=self['if_none_match'],
1071
            if_modified_since=self['if_modified_since'],
1072
            if_unmodified_since=self['if_unmodified_since'])
1073
        self._out.flush()
1074

    
1075
    def main(self, path_or_url):
1076
        super(self.__class__, self)._run(path_or_url)
1077
        self._run()
1078

    
1079

    
1080
@command(file_cmds)
1081
class file_download(_pithos_container):
1082
    """Download a remove file or directory object to local file system"""
1083

    
1084
    arguments = dict(
1085
        resume=FlagArgument(
1086
            'Resume/Overwrite (attempt resume, else overwrite)',
1087
            ('-f', '--resume')),
1088
        range=RangeArgument(
1089
            'Download only that range of data e.g., 5,10-20,-5', '--range'),
1090
        matching_etag=ValueArgument('download iff ETag match', '--if-match'),
1091
        non_matching_etag=ValueArgument(
1092
            'download iff ETags DO NOT match', '--if-none-match'),
1093
        modified_since_date=DateArgument(
1094
            'download iff remote file is modified since then',
1095
            '--if-modified-since'),
1096
        unmodified_since_date=DateArgument(
1097
            'show output iff remote file is unmodified since then',
1098
            '--if-unmodified-since'),
1099
        object_version=ValueArgument(
1100
            'download a file of a specific version', '--object-version'),
1101
        max_threads=IntArgument('default: 5', '--threads'),
1102
        progress_bar=ProgressBarArgument(
1103
            'do not show progress bar', ('-N', '--no-progress-bar'),
1104
            default=False),
1105
        recursive=FlagArgument(
1106
            'Download a remote directory object and its contents',
1107
            ('-r', '--recursive'))
1108
        )
1109

    
1110
    def _src_dst(self, local_path):
1111
        """Create a list of (src, dst) where src is a remote location and dst
1112
        is an open file descriptor. Directories are denoted as (None, dirpath)
1113
        and they are pretended to other objects in a very strict order (shorter
1114
        to longer path)."""
1115
        ret = []
1116
        try:
1117
            if self.path:
1118
                obj = self.client.get_object_info(
1119
                    self.path, version=self['object_version'])
1120
                obj.setdefault('name', self.path.strip('/'))
1121
            else:
1122
                obj = None
1123
        except ClientError as ce:
1124
            if ce.status in (404, ):
1125
                raiseCLIError(ce, details=[
1126
                    'To download an object, it must exist either as a file or'
1127
                    ' as a directory.',
1128
                    'For example, to download everything under prefix/ the '
1129
                    'directory "prefix" must exist.',
1130
                    'To see if an remote object is actually there:',
1131
                    '  /file info [/CONTAINER/]OBJECT',
1132
                    'To create a directory object:',
1133
                    '  /file mkdir [/CONTAINER/]OBJECT'])
1134
            if ce.status in (204, ):
1135
                raise CLIError(
1136
                    'No file or directory objects to download',
1137
                    details=[
1138
                        'To download a container (e.g., %s):' % self.container,
1139
                        '  [kamaki] container download %s [LOCAL_PATH]' % (
1140
                            self.container)])
1141
            raise
1142
        rpath = self.path.strip('/')
1143
        if local_path and self.path and local_path.endswith('/'):
1144
            local_path = local_path[-1:]
1145

    
1146
        if (not obj) or self._is_dir(obj):
1147
            if self['recursive']:
1148
                if not (self.path or local_path.endswith('/')):
1149
                    #  Download the whole container
1150
                    local_path = '' if local_path in ('.', ) else local_path
1151
                    local_path = '%s/' % (local_path or self.container)
1152
                obj = obj or dict(
1153
                    name='', content_type='application/directory')
1154
                dirs, files = [obj, ], []
1155
                objects = self.client.container_get(
1156
                    path=self.path,
1157
                    if_modified_since=self['modified_since_date'],
1158
                    if_unmodified_since=self['unmodified_since_date'])
1159
                for o in objects.json:
1160
                    (dirs if self._is_dir(o) else files).append(o)
1161

    
1162
                #  Put the directories on top of the list
1163
                for dpath in sorted(['%s%s' % (
1164
                        local_path, d['name'][len(rpath):]) for d in dirs]):
1165
                    if path.exists(dpath):
1166
                        if path.isdir(dpath):
1167
                            continue
1168
                        raise CLIError(
1169
                            'Cannot replace local file %s with a directory '
1170
                            'of the same name' % dpath,
1171
                            details=[
1172
                                'Either remove the file or specify a'
1173
                                'different target location'])
1174
                    ret.append((None, dpath, None))
1175

    
1176
                #  Append the file objects
1177
                for opath in [o['name'] for o in files]:
1178
                    lpath = '%s%s' % (local_path, opath[len(rpath):])
1179
                    if self['resume']:
1180
                        fxists = path.exists(lpath)
1181
                        if fxists and path.isdir(lpath):
1182
                            raise CLIError(
1183
                                'Cannot change local dir %s info file' % (
1184
                                    lpath),
1185
                                details=[
1186
                                    'Either remove the file or specify a'
1187
                                    'different target location'])
1188
                        ret.append((opath, lpath, fxists))
1189
                    elif path.exists(lpath):
1190
                        raise CLIError(
1191
                            'Cannot overwrite %s' % lpath,
1192
                            details=['To overwrite/resume, use  %s' % (
1193
                                self.arguments['resume'].lvalue)])
1194
                    else:
1195
                        ret.append((opath, lpath, None))
1196
            elif self.path:
1197
                raise CLIError(
1198
                    'Remote object /%s/%s is a directory' % (
1199
                        self.container, local_path),
1200
                    details=['Use %s to download directories' % (
1201
                        self.arguments['recursive'].lvalue)])
1202
            else:
1203
                parsed_name = self.arguments['recursive'].lvalue
1204
                raise CLIError(
1205
                    'Cannot download container %s' % self.container,
1206
                    details=[
1207
                        'Use %s to download containers' % parsed_name,
1208
                        '  [kamaki] file download %s /%s [LOCAL_PATH]' % (
1209
                            parsed_name, self.container)])
1210
        else:
1211
            #  Remote object is just a file
1212
            if path.exists(local_path):
1213
                if not self['resume']:
1214
                    raise CLIError(
1215
                        'Cannot overwrite local file %s' % (local_path),
1216
                        details=['To overwrite/resume, use  %s' % (
1217
                            self.arguments['resume'].lvalue)])
1218
            elif '/' in local_path[1:-1]:
1219
                dirs = [p for p in local_path.split('/') if p]
1220
                pref = '/' if local_path.startswith('/') else ''
1221
                for d in dirs[:-1]:
1222
                    pref += d
1223
                    if not path.exists(pref):
1224
                        ret.append((None, d, None))
1225
                    elif not path.isdir(pref):
1226
                        raise CLIError(
1227
                            'Failed to use %s as a destination' % local_path,
1228
                            importance=3,
1229
                            details=[
1230
                                'Local file %s is not a directory' % pref,
1231
                                'Destination prefix must consist of '
1232
                                'directories or non-existing names',
1233
                                'Either remove the file, or choose another '
1234
                                'destination'])
1235
            ret.append((rpath, local_path, self['resume']))
1236
        for r, l, resume in ret:
1237
            if r:
1238
                with open(l, 'rwb+' if resume else 'wb+') as f:
1239
                    yield (r, f)
1240
            else:
1241
                yield (r, l)
1242

    
1243
    @errors.generic.all
1244
    @errors.pithos.connection
1245
    @errors.pithos.container
1246
    @errors.pithos.object_path
1247
    @errors.pithos.local_path
1248
    @errors.pithos.local_path_download
1249
    def _run(self, local_path):
1250
        self.client.MAX_THREADS = int(self['max_threads'] or 5)
1251
        progress_bar = None
1252
        try:
1253
            for rpath, output_file in self._src_dst(local_path):
1254
                if not rpath:
1255
                    self.error('Create local directory %s' % output_file)
1256
                    makedirs(output_file)
1257
                    continue
1258
                self.error('/%s/%s --> %s' % (
1259
                    self.container, rpath, output_file.name))
1260
                progress_bar, download_cb = self._safe_progress_bar(
1261
                    '  download')
1262
                self.client.download_object(
1263
                    rpath, output_file,
1264
                    download_cb=download_cb,
1265
                    range_str=self['range'],
1266
                    version=self['object_version'],
1267
                    if_match=self['matching_etag'],
1268
                    resume=self['resume'],
1269
                    if_none_match=self['non_matching_etag'],
1270
                    if_modified_since=self['modified_since_date'],
1271
                    if_unmodified_since=self['unmodified_since_date'])
1272
        except KeyboardInterrupt:
1273
            from threading import activeCount, enumerate as activethreads
1274
            timeout = 0.5
1275
            while activeCount() > 1:
1276
                self._out.write('\nCancel %s threads: ' % (activeCount() - 1))
1277
                self._out.flush()
1278
                for thread in activethreads():
1279
                    try:
1280
                        thread.join(timeout)
1281
                        self._out.write('.' if thread.isAlive() else '*')
1282
                    except RuntimeError:
1283
                        continue
1284
                    finally:
1285
                        self._out.flush()
1286
                        timeout += 0.1
1287
            self.error('\nDownload canceled by user')
1288
            if local_path is not None:
1289
                self.error('to resume, re-run with --resume')
1290
        finally:
1291
            self._safe_progress_bar_finish(progress_bar)
1292

    
1293
    def main(self, remote_path_or_url, local_path=None):
1294
        super(self.__class__, self)._run(remote_path_or_url)
1295
        local_path = local_path or self.path or '.'
1296
        self._run(local_path=local_path)
1297

    
1298

    
1299
@command(container_cmds)
1300
class container_info(_pithos_account, _optional_json):
1301
    """Get information about a container"""
1302

    
1303
    arguments = dict(
1304
        until_date=DateArgument('show metadata until then', '--until'),
1305
        metadata=FlagArgument('Show only container metadata', '--metadata'),
1306
        sizelimit=FlagArgument(
1307
            'Show the maximum size limit for container', '--size-limit'),
1308
        in_bytes=FlagArgument('Show size limit in bytes', ('-b', '--bytes'))
1309
    )
1310

    
1311
    @errors.generic.all
1312
    @errors.pithos.connection
1313
    @errors.pithos.container
1314
    @errors.pithos.object_path
1315
    def _run(self):
1316
        if self['metadata']:
1317
            r, preflen = dict(), len('x-container-meta-')
1318
            for k, v in self.client.get_container_meta(
1319
                    until=self['until_date']).items():
1320
                r[k[preflen:]] = v
1321
        elif self['sizelimit']:
1322
            r = self.client.get_container_limit(
1323
                self.container)['x-container-policy-quota']
1324
            r = {'size limit': 'unlimited' if r in ('0', ) else (
1325
                int(r) if self['in_bytes'] else format_size(r))}
1326
        else:
1327
            r = self.client.get_container_info(self.container)
1328
        self._print(r, self.print_dict)
1329

    
1330
    def main(self, container):
1331
        super(self.__class__, self)._run()
1332
        self.container, self.client.container = container, container
1333
        self._run()
1334

    
1335

    
1336
class VersioningArgument(ValueArgument):
1337

    
1338
    schemes = ('auto', 'none')
1339

    
1340
    @property
1341
    def value(self):
1342
        return getattr(self, '_value', None)
1343

    
1344
    @value.setter
1345
    def value(self, new_scheme):
1346
        if new_scheme:
1347
            new_scheme = new_scheme.lower()
1348
            if new_scheme not in self.schemes:
1349
                raise CLIInvalidArgument('Invalid versioning value', details=[
1350
                    'Valid versioning values are %s' % ', '.join(
1351
                        self.schemes)])
1352
            self._value = new_scheme
1353

    
1354

    
1355
@command(container_cmds)
1356
class container_modify(_pithos_account, _optional_json):
1357
    """Modify the properties of a container"""
1358

    
1359
    arguments = dict(
1360
        metadata_to_add=KeyValueArgument(
1361
            'Add metadata in the form KEY=VALUE (can be repeated)',
1362
            '--metadata-add'),
1363
        metadata_to_delete=RepeatableArgument(
1364
            'Delete metadata by KEY (can be repeated)', '--metadata-del'),
1365
        sizelimit=DataSizeArgument(
1366
            'Set max size limit (0 for unlimited, '
1367
            'use units B, KiB, KB, etc.)', '--size-limit'),
1368
        versioning=VersioningArgument(
1369
            'Set a versioning scheme (%s)' % ', '.join(
1370
                VersioningArgument.schemes), '--versioning')
1371
    )
1372
    required = [
1373
        'metadata_to_add', 'metadata_to_delete', 'sizelimit', 'versioning']
1374

    
1375
    @errors.generic.all
1376
    @errors.pithos.connection
1377
    @errors.pithos.container
1378
    def _run(self, container):
1379
        metadata = self['metadata_to_add']
1380
        for k in (self['metadata_to_delete'] or []):
1381
            metadata[k] = ''
1382
        if metadata:
1383
            self.client.set_container_meta(metadata)
1384
            self._print(self.client.get_container_meta(), self.print_dict)
1385
        if self['sizelimit'] is not None:
1386
            self.client.set_container_limit(self['sizelimit'])
1387
            r = self.client.get_container_limit()['x-container-policy-quota']
1388
            r = 'unlimited' if r in ('0', ) else format_size(r)
1389
            self.writeln('new size limit: %s' % r)
1390
        if self['versioning']:
1391
            self.client.set_container_versioning(self['versioning'])
1392
            self.writeln('new versioning scheme: %s' % (
1393
                self.client.get_container_versioning(self.container)[
1394
                    'x-container-policy-versioning']))
1395

    
1396
    def main(self, container):
1397
        super(self.__class__, self)._run()
1398
        self.client.container, self.container = container, container
1399
        self._run(container=container)
1400

    
1401

    
1402
@command(container_cmds)
1403
class container_list(_pithos_account, _optional_json, _name_filter):
1404
    """List all containers, or their contents"""
1405

    
1406
    arguments = dict(
1407
        detail=FlagArgument('Containers with details', ('-l', '--list')),
1408
        limit=IntArgument('limit number of listed items', ('-n', '--number')),
1409
        marker=ValueArgument('output greater that marker', '--marker'),
1410
        modified_since_date=ValueArgument(
1411
            'show output modified since then', '--if-modified-since'),
1412
        unmodified_since_date=ValueArgument(
1413
            'show output not modified since then', '--if-unmodified-since'),
1414
        until_date=DateArgument('show metadata until then', '--until'),
1415
        shared=FlagArgument('show only shared', '--shared'),
1416
        more=FlagArgument('read long results', '--more'),
1417
        enum=FlagArgument('Enumerate results', '--enumerate'),
1418
        recursive=FlagArgument(
1419
            'Recursively list containers and their contents',
1420
            ('-r', '--recursive')),
1421
        shared_by_me=FlagArgument(
1422
            'show only files shared to other users', '--shared-by-me'),
1423
        public=FlagArgument('show only published objects', '--public'),
1424
    )
1425

    
1426
    def print_containers(self, container_list):
1427
        for index, container in enumerate(container_list):
1428
            if 'bytes' in container:
1429
                size = format_size(container['bytes'])
1430
            prfx = ('%s. ' % (index + 1)) if self['enum'] else ''
1431
            _cname = container['name'] if (
1432
                self['more']) else bold(container['name'])
1433
            cname = u'%s%s' % (prfx, _cname)
1434
            if self['detail']:
1435
                self.writeln(cname)
1436
                pretty_c = container.copy()
1437
                if 'bytes' in container:
1438
                    pretty_c['bytes'] = '%s (%s)' % (container['bytes'], size)
1439
                self.print_dict(pretty_c, exclude=('name'))
1440
                self.writeln()
1441
            else:
1442
                if 'count' in container and 'bytes' in container:
1443
                    self.writeln('%s (%s, %s objects)' % (
1444
                        cname, size, container['count']))
1445
                else:
1446
                    self.writeln(cname)
1447
            objects = container.get('objects', [])
1448
            if objects:
1449
                self.print_objects(objects)
1450
                self.writeln('')
1451

    
1452
    def _create_object_forest(self, container_list):
1453
        try:
1454
            for container in container_list:
1455
                self.client.container = container['name']
1456
                objects = self.client.container_get(
1457
                    limit=False if self['more'] else self['limit'],
1458
                    if_modified_since=self['modified_since_date'],
1459
                    if_unmodified_since=self['unmodified_since_date'],
1460
                    until=self['until_date'],
1461
                    show_only_shared=self['shared_by_me'],
1462
                    public=self['public'])
1463
                container['objects'] = objects.json
1464
        finally:
1465
            self.client.container = None
1466

    
1467
    @errors.generic.all
1468
    @errors.pithos.connection
1469
    @errors.pithos.object_path
1470
    @errors.pithos.container
1471
    def _run(self, container):
1472
        if container:
1473
            r = self.client.container_get(
1474
                limit=False if self['more'] else self['limit'],
1475
                marker=self['marker'],
1476
                if_modified_since=self['modified_since_date'],
1477
                if_unmodified_since=self['unmodified_since_date'],
1478
                until=self['until_date'],
1479
                show_only_shared=self['shared_by_me'],
1480
                public=self['public'])
1481
        else:
1482
            r = self.client.account_get(
1483
                limit=False if self['more'] else self['limit'],
1484
                marker=self['marker'],
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
        files = self._filter_by_name(r.json)
1491
        if self['recursive'] and not container:
1492
            self._create_object_forest(files)
1493
        if self['more']:
1494
            outbu, self._out = self._out, StringIO()
1495
        try:
1496
            if self['json_output'] or self['output_format']:
1497
                self._print(files)
1498
            else:
1499
                (self.print_objects if container else self.print_containers)(
1500
                    files)
1501
        finally:
1502
            if self['more']:
1503
                pager(self._out.getvalue())
1504
                self._out = outbu
1505

    
1506
    def main(self, container=None):
1507
        super(self.__class__, self)._run()
1508
        self.client.container, self.container = container, container
1509
        self._run(container)
1510

    
1511

    
1512
@command(container_cmds)
1513
class container_create(_pithos_account):
1514
    """Create a new container"""
1515

    
1516
    arguments = dict(
1517
        versioning=ValueArgument(
1518
            'set container versioning (auto/none)', '--versioning'),
1519
        limit=IntArgument('set default container limit', '--limit'),
1520
        meta=KeyValueArgument(
1521
            'set container metadata (can be repeated)', '--meta')
1522
    )
1523

    
1524
    @errors.generic.all
1525
    @errors.pithos.connection
1526
    @errors.pithos.container
1527
    def _run(self, container):
1528
        try:
1529
            self.client.create_container(
1530
                container=container,
1531
                sizelimit=self['limit'],
1532
                versioning=self['versioning'],
1533
                metadata=self['meta'],
1534
                success=(201, ))
1535
        except ClientError as ce:
1536
            if ce.status in (202, ):
1537
                raise CLIError(
1538
                    'Container %s alread exists' % container, details=[
1539
                    'Either delete %s or choose another name' % (container)])
1540
            raise
1541

    
1542
    def main(self, new_container):
1543
        super(self.__class__, self)._run()
1544
        self._run(container=new_container)
1545

    
1546

    
1547
@command(container_cmds)
1548
class container_delete(_pithos_account):
1549
    """Delete a container"""
1550

    
1551
    arguments = dict(
1552
        yes=FlagArgument('Do not prompt for permission', '--yes'),
1553
        recursive=FlagArgument(
1554
            'delete container even if not empty', ('-r', '--recursive'))
1555
    )
1556

    
1557
    @errors.generic.all
1558
    @errors.pithos.connection
1559
    @errors.pithos.container
1560
    def _run(self, container):
1561
        num_of_contents = int(self.client.get_container_info(container)[
1562
            'x-container-object-count'])
1563
        delimiter, msg = None, 'Delete container %s ?' % container
1564
        if self['recursive']:
1565
            delimiter, msg = '/', 'Empty and d%s' % msg[1:]
1566
        elif num_of_contents:
1567
            raise CLIError('Container %s is not empty' % container, details=[
1568
                'Use %s to delete non-empty containers' % (
1569
                    self.arguments['recursive'].lvalue)])
1570
        if self['yes'] or self.ask_user(msg):
1571
            if num_of_contents:
1572
                self.client.del_container(delimiter=delimiter)
1573
            self.client.purge_container()
1574

    
1575
    def main(self, container):
1576
        super(self.__class__, self)._run()
1577
        self.container, self.client.container = container, container
1578
        self._run(container)
1579

    
1580

    
1581
@command(container_cmds)
1582
class container_empty(_pithos_account):
1583
    """Empty a container"""
1584

    
1585
    arguments = dict(yes=FlagArgument('Do not prompt for permission', '--yes'))
1586

    
1587
    @errors.generic.all
1588
    @errors.pithos.connection
1589
    @errors.pithos.container
1590
    def _run(self, container):
1591
        if self['yes'] or self.ask_user('Empty container %s ?' % container):
1592
            self.client.del_container(delimiter='/')
1593

    
1594
    def main(self, container):
1595
        super(self.__class__, self)._run()
1596
        self.container, self.client.container = container, container
1597
        self._run(container)
1598

    
1599

    
1600
@command(sharer_cmds)
1601
class sharer_list(_pithos_account, _optional_json):
1602
    """List accounts who share file objects with current user"""
1603

    
1604
    arguments = dict(
1605
        detail=FlagArgument('show detailed output', ('-l', '--details')),
1606
        marker=ValueArgument('show output greater then marker', '--marker')
1607
    )
1608

    
1609
    @errors.generic.all
1610
    @errors.pithos.connection
1611
    def _run(self):
1612
        accounts = self.client.get_sharing_accounts(marker=self['marker'])
1613
        if not (self['json_output'] or self['output_format']):
1614
            usernames = self._uuids2usernames(
1615
                [acc['name'] for acc in accounts])
1616
            for item in accounts:
1617
                uuid = item['name']
1618
                item['id'], item['name'] = uuid, usernames[uuid]
1619
                if not self['detail']:
1620
                    item.pop('last_modified')
1621
        self._print(accounts)
1622

    
1623
    def main(self):
1624
        super(self.__class__, self)._run()
1625
        self._run()
1626

    
1627

    
1628
@command(sharer_cmds)
1629
class sharer_info(_pithos_account, _optional_json):
1630
    """Details on a Pithos+ sharer account (default: current account)"""
1631

    
1632
    @errors.generic.all
1633
    @errors.pithos.connection
1634
    def _run(self):
1635
        self._print(self.client.get_account_info(), self.print_dict)
1636

    
1637
    def main(self, account_uuid=None):
1638
        super(self.__class__, self)._run()
1639
        if account_uuid:
1640
            self.client.account, self.account = account_uuid, account_uuid
1641
        self._run()
1642

    
1643

    
1644
class _pithos_group(_pithos_account):
1645
    prefix = 'x-account-group-'
1646
    preflen = len(prefix)
1647

    
1648
    def _groups(self):
1649
        groups = dict()
1650
        for k, v in self.client.get_account_group().items():
1651
            groups[k[self.preflen:]] = v
1652
        return groups
1653

    
1654

    
1655
@command(group_cmds)
1656
class group_list(_pithos_group, _optional_json):
1657
    """list all groups and group members"""
1658

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

    
1664
    def main(self):
1665
        super(self.__class__, self)._run()
1666
        self._run()
1667

    
1668

    
1669
@command(group_cmds)
1670
class group_create(_pithos_group, _optional_json):
1671
    """Create a group of users"""
1672

    
1673
    arguments = dict(
1674
        user_uuid=RepeatableArgument('Add a user to the group', '--uuid'),
1675
        username=RepeatableArgument('Add a user to the group', '--username')
1676
    )
1677
    required = ['user_uuid', 'username']
1678

    
1679
    @errors.generic.all
1680
    @errors.pithos.connection
1681
    def _run(self, groupname, *users):
1682
        if groupname in self._groups() and not self.ask_user(
1683
                'Group %s already exists, overwrite?' % groupname):
1684
            self.error('Aborted')
1685
            return
1686
        self.client.set_account_group(groupname, users)
1687
        self._print(self._groups(), self.print_dict)
1688

    
1689
    def main(self, groupname):
1690
        super(self.__class__, self)._run()
1691
        users = (self['user_uuid'] or []) + self._usernames2uuids(
1692
            self['username'] or []).values()
1693
        if users:
1694
            self._run(groupname, *users)
1695
        else:
1696
            raise CLISyntaxError(
1697
                'No valid users specified, use %s or %s' % (
1698
                    self.arguments['user_uuid'].lvalue,
1699
                    self.arguments['username'].lvalue),
1700
                details=[
1701
                    'Check if a username or uuid is valid with',
1702
                    '  user uuid2username', 'OR', '  user username2uuid'])
1703

    
1704

    
1705
@command(group_cmds)
1706
class group_delete(_pithos_group, _optional_json):
1707
    """Delete a user group"""
1708

    
1709
    @errors.generic.all
1710
    @errors.pithos.connection
1711
    def _run(self, groupname):
1712
        self.client.del_account_group(groupname)
1713
        self._print(self._groups(), self.print_dict)
1714

    
1715
    def main(self, groupname):
1716
        super(self.__class__, self)._run()
1717
        self._run(groupname)
1718

    
1719

    
1720
#  Deprecated commands
1721

    
1722
@command(file_cmds)
1723
class file_publish(_pithos_init):
1724
    """DEPRECATED, replaced by [kamaki] file modify OBJECT --publish"""
1725

    
1726
    def main(self, *args):
1727
        raise CLISyntaxError('DEPRECATED', details=[
1728
            'This command is replaced by:',
1729
            '  [kamaki] file modify OBJECT --publish'])
1730

    
1731

    
1732
@command(file_cmds)
1733
class file_unpublish(_pithos_init):
1734
    """DEPRECATED, replaced by [kamaki] file modify OBJECT --unpublish"""
1735

    
1736
    def main(self, *args):
1737
        raise CLISyntaxError('DEPRECATED', details=[
1738
            'This command is replaced by:',
1739
            '  [kamaki] file modify OBJECT --unpublish'])