Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / commands / pithos.py @ 3042fac1

History | View | Annotate | Download (69.5 kB)

1
# Copyright 2011-2014 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
        object_version=ValueArgument('File to overwrite', '--object-version'),
808
    )
809
    required = ('start_position', 'end_position')
810

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

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

    
840

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

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

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

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

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

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

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

    
1035

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

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

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

    
1071

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

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

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

    
1104
    def main(self, path_or_url):
1105
        super(self.__class__, self)._run(path_or_url)
1106
        self._run()
1107

    
1108

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

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

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

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

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

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

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

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

    
1327

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

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

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

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

    
1364

    
1365
class VersioningArgument(ValueArgument):
1366

    
1367
    schemes = ('auto', 'none')
1368

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

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

    
1383

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

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

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

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

    
1430

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

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

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

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

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

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

    
1540

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

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

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

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

    
1575

    
1576
@command(container_cmds)
1577
class container_delete(_pithos_account):
1578
    """Delete a container"""
1579

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

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

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

    
1609

    
1610
@command(container_cmds)
1611
class container_empty(_pithos_account):
1612
    """Empty a container"""
1613

    
1614
    arguments = dict(yes=FlagArgument('Do not prompt for permission', '--yes'))
1615

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

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

    
1628

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

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

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

    
1652
    def main(self):
1653
        super(self.__class__, self)._run()
1654
        self._run()
1655

    
1656

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

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

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

    
1675

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

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

    
1686

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

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

    
1696
    def main(self):
1697
        super(self.__class__, self)._run()
1698
        self._run()
1699

    
1700

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

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

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

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

    
1736

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

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

    
1747
    def main(self, groupname):
1748
        super(self.__class__, self)._run()
1749
        self._run(groupname)