Statistics
| Branch: | Tag: | Revision:

root / kamaki / cli / commands / pithos.py @ 606f5b54

History | View | Annotate | Download (50.1 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 io import StringIO
35
from pydoc import pager
36
from os import path, walk, makedirs
37

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

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

    
53
file_cmds = CommandTree('file', 'Pithos+/Storage object level API commands')
54
container_cmds = CommandTree(
55
    'container', 'Pithos+/Storage container level API commands')
56
sharers_commands = CommandTree('sharers', 'Pithos+/Storage sharers')
57
_commands = [file_cmds, container_cmds, sharers_commands]
58

    
59

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

    
66
    @DontRaiseKeyError
67
    def _custom_container(self):
68
        return self.config.get_cloud(self.cloud, 'pithos_container')
69

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

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

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

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

    
106
        self._set_account()
107
        self.client = PithosClient(
108
            self.base_url, self.token, self.account, self.container)
109

    
110
    def main(self):
111
        self._run()
112

    
113

    
114
class _pithos_account(_pithos_init):
115
    """Setup account"""
116

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

    
122
    def _run(self):
123
        super(_pithos_account, self)._run()
124
        self.client.account = self['account'] or getattr(
125
            self, 'account', getattr(self.client, 'account', None))
126

    
127

    
128
class _pithos_container(_pithos_account):
129
    """Setup container"""
130

    
131
    def __init__(self, arguments={}, auth_base=None, cloud=None):
132
        super(_pithos_container, self).__init__(arguments, auth_base, cloud)
133
        self['container'] = ValueArgument(
134
            'Use this container (default: pithos)', ('-C', '--container'))
135

    
136
    @staticmethod
137
    def _is_dir(remote_dict):
138
        return 'application/directory' == remote_dict.get(
139
            'content_type', remote_dict.get('content-type', ''))
140

    
141
    @staticmethod
142
    def _resolve_pithos_url(url):
143
        """Match urls of one of the following formats:
144
        pithos://ACCOUNT/CONTAINER/OBJECT_PATH
145
        /CONTAINER/OBJECT_PATH
146
        return account, container, path
147
        """
148
        account, container, obj_path, prefix = '', '', url, 'pithos://'
149
        if url.startswith(prefix):
150
            account, sep, url = url[len(prefix):].partition('/')
151
            url = '/%s' % url
152
        if url.startswith('/'):
153
            container, sep, obj_path = url[1:].partition('/')
154
        return account, container, obj_path
155

    
156
    def _run(self, url=None):
157
        acc, con, self.path = self._resolve_pithos_url(url or '')
158
        self.account = acc or getattr(self, 'account', '')
159
        super(_pithos_container, self)._run()
160
        self.container = con or self['container'] or getattr(
161
            self, 'container', None) or getattr(self.client, 'container', '')
162
        self.client.container = self.container
163

    
164

    
165
@command(file_cmds)
166
class file_info(_pithos_container, _optional_json):
167
    """Get information/details about a file"""
168

    
169
    arguments = dict(
170
        object_version=ValueArgument(
171
            'download a file of a specific version', '--object-version'),
172
        hashmap=FlagArgument(
173
            'Get file hashmap instead of details', '--hashmap'),
174
        matching_etag=ValueArgument(
175
            'show output if ETags match', '--if-match'),
176
        non_matching_etag=ValueArgument(
177
            'show output if ETags DO NOT match', '--if-none-match'),
178
        modified_since_date=DateArgument(
179
            'show output modified since then', '--if-modified-since'),
180
        unmodified_since_date=DateArgument(
181
            'show output unmodified since then', '--if-unmodified-since'),
182
        permissions=FlagArgument(
183
            'show only read/write permissions', '--permissions')
184
    )
185

    
186
    @errors.generic.all
187
    @errors.pithos.connection
188
    @errors.pithos.container
189
    @errors.pithos.object_path
190
    def _run(self):
191
        if self['hashmap']:
192
            r = self.client.get_object_hashmap(
193
                self.path,
194
                version=self['object_version'],
195
                if_match=self['matching_etag'],
196
                if_none_match=self['non_matching_etag'],
197
                if_modified_since=self['modified_since_date'],
198
                if_unmodified_since=self['unmodified_since_date'])
199
        elif self['permissions']:
200
            r = self.client.get_object_sharing(self.path)
201
        else:
202
            r = self.client.get_object_info(
203
                self.path, version=self['object_version'])
204
        self._print(r, self.print_dict)
205

    
206
    def main(self, path_or_url):
207
        super(self.__class__, self)._run(path_or_url)
208
        self._run()
209

    
210

    
211
@command(file_cmds)
212
class file_list(_pithos_container, _optional_json, _name_filter):
213
    """List all objects in a container or a directory object"""
214

    
215
    arguments = dict(
216
        detail=FlagArgument('detailed output', ('-l', '--list')),
217
        limit=IntArgument('limit number of listed items', ('-n', '--number')),
218
        marker=ValueArgument('output greater that marker', '--marker'),
219
        delimiter=ValueArgument('show output up to delimiter', '--delimiter'),
220
        meta=ValueArgument(
221
            'show output with specified meta keys', '--meta',
222
            default=[]),
223
        if_modified_since=ValueArgument(
224
            'show output modified since then', '--if-modified-since'),
225
        if_unmodified_since=ValueArgument(
226
            'show output not modified since then', '--if-unmodified-since'),
227
        until=DateArgument('show metadata until then', '--until'),
228
        format=ValueArgument(
229
            'format to parse until data (default: d/m/Y H:M:S )', '--format'),
230
        shared=FlagArgument('show only shared', '--shared'),
231
        more=FlagArgument('read long results', '--more'),
232
        enum=FlagArgument('Enumerate results', '--enumerate'),
233
        recursive=FlagArgument(
234
            'Recursively list containers and their contents',
235
            ('-R', '--recursive'))
236
    )
237

    
238
    def print_objects(self, object_list):
239
        for index, obj in enumerate(object_list):
240
            pretty_obj = obj.copy()
241
            index += 1
242
            empty_space = ' ' * (len(str(len(object_list))) - len(str(index)))
243
            if 'subdir' in obj:
244
                continue
245
            if self._is_dir(obj):
246
                size = 'D'
247
            else:
248
                size = format_size(obj['bytes'])
249
                pretty_obj['bytes'] = '%s (%s)' % (obj['bytes'], size)
250
            oname = obj['name'] if self['more'] else bold(obj['name'])
251
            prfx = ('%s%s. ' % (empty_space, index)) if self['enum'] else ''
252
            if self['detail']:
253
                self.writeln('%s%s' % (prfx, oname))
254
                self.print_dict(pretty_obj, exclude=('name'))
255
                self.writeln()
256
            else:
257
                oname = '%s%9s %s' % (prfx, size, oname)
258
                oname += '/' if self._is_dir(obj) else u''
259
                self.writeln(oname)
260

    
261
    @errors.generic.all
262
    @errors.pithos.connection
263
    @errors.pithos.container
264
    @errors.pithos.object_path
265
    def _run(self):
266
        r = self.client.container_get(
267
            limit=False if self['more'] else self['limit'],
268
            marker=self['marker'],
269
            prefix=self['name_pref'] or '/',
270
            delimiter=self['delimiter'],
271
            path=self.path or '',
272
            if_modified_since=self['if_modified_since'],
273
            if_unmodified_since=self['if_unmodified_since'],
274
            until=self['until'],
275
            meta=self['meta'],
276
            show_only_shared=self['shared'])
277
        files = self._filter_by_name(r.json)
278
        if self['more']:
279
            outbu, self._out = self._out, StringIO()
280
        try:
281
            if self['json_output'] or self['output_format']:
282
                self._print(files)
283
            else:
284
                self.print_objects(files)
285
        finally:
286
            if self['more']:
287
                pager(self._out.getvalue())
288
                self._out = outbu
289

    
290
    def main(self, path_or_url='/'):
291
        super(self.__class__, self)._run(path_or_url)
292
        self._run()
293

    
294

    
295
@command(file_cmds)
296
class file_modify(_pithos_container):
297
    """Modify the attributes of a file or directory object"""
298

    
299
    arguments = dict(
300
        publish=FlagArgument(
301
            'Make an object public (returns the public URL)', '--publish'),
302
        unpublish=FlagArgument(
303
            'Make an object unpublic', '--unpublish'),
304
        uuid_for_read_permission=RepeatableArgument(
305
            'Give read access to user/group (can be repeated, accumulative). '
306
            'Format for users: UUID . Format for groups: UUID:GROUP . '
307
            'Use * for all users/groups', '--read-permission'),
308
        uuid_for_write_permission=RepeatableArgument(
309
            'Give write access to user/group (can be repeated, accumulative). '
310
            'Format for users: UUID . Format for groups: UUID:GROUP . '
311
            'Use * for all users/groups', '--write-permission'),
312
        no_permissions=FlagArgument('Remove permissions', '--no-permissions')
313
    )
314
    required = [
315
        'publish', 'unpublish', 'uuid_for_read_permission',
316
        'uuid_for_write_permission', 'no_permissions']
317

    
318
    @errors.generic.all
319
    @errors.pithos.connection
320
    @errors.pithos.container
321
    @errors.pithos.object_path
322
    def _run(self):
323
        if self['publish']:
324
            self.writeln(self.client.publish_object(self.path))
325
        if self['unpublish']:
326
            self.client.unpublish_object(self.path)
327
        if self['uuid_for_read_permission'] or self[
328
                'uuid_for_write_permission']:
329
            perms = self.client.get_object_sharing(self.path)
330
            read, write = perms.get('read', ''), perms.get('write', '')
331
            read = read.split(',') if read else []
332
            write = write.split(',') if write else []
333
            read += self['uuid_for_read_permission']
334
            write += self['uuid_for_write_permission']
335
            self.client.set_object_sharing(
336
                self.path, read_permission=read, write_permission=write)
337
            self.print_dict(self.client.get_object_sharing(self.path))
338
        if self['no_permissions']:
339
            self.client.del_object_sharing(self.path)
340

    
341
    def main(self, path_or_url):
342
        super(self.__class__, self)._run(path_or_url)
343
        if self['publish'] and self['unpublish']:
344
            raise CLIInvalidArgument(
345
                'Arguments %s and %s cannot be used together' % (
346
                    '/'.join(self.arguments['publish'].parsed_name),
347
                    '/'.join(self.arguments['publish'].parsed_name)))
348
        if self['no_permissions'] and (
349
                self['uuid_for_read_permission'] or self[
350
                    'uuid_for_write_permission']):
351
            raise CLIInvalidArgument(
352
                '%s cannot be used with other permission arguments' % '/'.join(
353
                    self.arguments['no_permissions'].parsed_name))
354
        self._run()
355

    
356

    
357
@command(file_cmds)
358
class file_create(_pithos_container, _optional_output_cmd):
359
    """Create an empty file"""
360

    
361
    arguments = dict(
362
        content_type=ValueArgument(
363
            'Set content type (default: application/octet-stream)',
364
            '--content-type',
365
            default='application/octet-stream')
366
    )
367

    
368
    @errors.generic.all
369
    @errors.pithos.connection
370
    @errors.pithos.container
371
    def _run(self):
372
        self._optional_output(
373
            self.client.create_object(self.path, self['content_type']))
374

    
375
    def main(self, path_or_url):
376
        super(self.__class__, self)._run(path_or_url)
377
        self._run()
378

    
379

    
380
@command(file_cmds)
381
class file_mkdir(_pithos_container, _optional_output_cmd):
382
    """Create a directory: /file create --content-type='applcation/directory'
383
    """
384

    
385
    @errors.generic.all
386
    @errors.pithos.connection
387
    @errors.pithos.container
388
    def _run(self):
389
        self._optional_output(self.client.create_directory(self.path))
390

    
391
    def main(self, path_or_url):
392
        super(self.__class__, self)._run(path_or_url)
393
        self._run()
394

    
395

    
396
@command(file_cmds)
397
class file_delete(_pithos_container, _optional_output_cmd):
398
    """Delete a file or directory object"""
399

    
400
    arguments = dict(
401
        until_date=DateArgument('remove history until then', '--until'),
402
        yes=FlagArgument('Do not prompt for permission', '--yes'),
403
        recursive=FlagArgument(
404
            'If a directory, empty first', ('-r', '--recursive')),
405
        delimiter=ValueArgument(
406
            'delete objects prefixed with <object><delimiter>', '--delimiter')
407
    )
408

    
409
    @errors.generic.all
410
    @errors.pithos.connection
411
    @errors.pithos.container
412
    @errors.pithos.object_path
413
    def _run(self):
414
        if self.path:
415
            if self['yes'] or self.ask_user(
416
                    'Delete /%s/%s ?' % (self.container, self.path)):
417
                self._optional_output(self.client.del_object(
418
                    self.path,
419
                    until=self['until_date'],
420
                    delimiter='/' if self['recursive'] else self['delimiter']))
421
            else:
422
                self.error('Aborted')
423
        else:
424
            raiseCLIError('Nothing to delete', details=[
425
                'Format for path or url: [/CONTAINER/]path'])
426

    
427
    def main(self, path_or_url):
428
        super(self.__class__, self)._run(path_or_url)
429
        self._run()
430

    
431

    
432
class _source_destination(_pithos_container, _optional_output_cmd):
433

    
434
    sd_arguments = dict(
435
        destination_user_uuid=ValueArgument(
436
            'default: current user uuid', '--to-account'),
437
        destination_container=ValueArgument(
438
            'default: pithos', '--to-container'),
439
        source_prefix=FlagArgument(
440
            'Transfer all files that are prefixed with SOURCE PATH If the '
441
            'destination path is specified, replace SOURCE_PATH with '
442
            'DESTINATION_PATH',
443
            ('-r', '--recursive')),
444
        force=FlagArgument(
445
            'Overwrite destination objects, if needed', ('-f', '--force'))
446
    )
447

    
448
    def __init__(self, arguments={}, auth_base=None, cloud=None):
449
        self.arguments.update(arguments)
450
        self.arguments.update(self.sd_arguments)
451
        super(_source_destination, self).__init__(
452
            self.arguments, auth_base, cloud)
453

    
454
    def _report_transfer(self, src, dst, transfer_name):
455
        if not dst:
456
            if transfer_name in ('move', ):
457
                self.error('  delete source directory %s' % src)
458
            return
459
        dst_prf = '' if self.account == self.dst_client.account else (
460
                'pithos://%s' % self.dst_client.account)
461
        if src:
462
            src_prf = '' if self.account == self.dst_client.account else (
463
                    'pithos://%s' % self.account)
464
            self.error('  %s %s/%s/%s\n  -->  %s/%s/%s' % (
465
                transfer_name,
466
                src_prf, self.container, src,
467
                dst_prf, self.dst_client.container, dst))
468
        else:
469
            self.error('  mkdir %s/%s/%s' % (
470
                dst_prf, self.dst_client.container, dst))
471

    
472
    @errors.generic.all
473
    @errors.pithos.account
474
    def _src_dst(self, version=None):
475
        """Preconditions:
476
        self.account, self.container, self.path
477
        self.dst_acc, self.dst_con, self.dst_path
478
        They should all be configured properly
479
        :returns: [(src_path, dst_path), ...], if src_path is None, create
480
            destination directory
481
        """
482
        src_objects, dst_objects, pairs = dict(), dict(), []
483
        try:
484
            for obj in self.dst_client.list_objects(
485
                    prefix=self.dst_path or self.path or '/'):
486
                dst_objects[obj['name']] = obj
487
        except ClientError as ce:
488
            if ce.status in (404, ):
489
                raise CLIError(
490
                    'Destination container pithos://%s/%s not found' % (
491
                        self.dst_client.account, self.dst_client.container))
492
            raise ce
493
        if self['source_prefix']:
494
            #  Copy and replace prefixes
495
            for src_obj in self.client.list_objects(prefix=self.path or '/'):
496
                src_objects[src_obj['name']] = src_obj
497
            for src_path, src_obj in src_objects.items():
498
                dst_path = '%s%s' % (
499
                    self.dst_path or self.path, src_path[len(self.path):])
500
                dst_obj = dst_objects.get(dst_path, None)
501
                if self['force'] or not dst_obj:
502
                    #  Just do it
503
                    pairs.append((
504
                        None if self._is_dir(src_obj) else src_path, dst_path))
505
                    if self._is_dir(src_obj):
506
                        pairs.append((self.path or dst_path, None))
507
                elif not (self._is_dir(dst_obj) and self._is_dir(src_obj)):
508
                    raise CLIError(
509
                        'Destination object exists', importance=2, details=[
510
                            'Failed while transfering:',
511
                            '    pithos://%s/%s/%s' % (
512
                                    self.account,
513
                                    self.container,
514
                                    src_path),
515
                            '--> pithos://%s/%s/%s' % (
516
                                    self.dst_client.account,
517
                                    self.dst_client.container,
518
                                    dst_path),
519
                            'Use %s to transfer overwrite' % ('/'.join(
520
                                    self.arguments['force'].parsed_name))])
521
        else:
522
            #  One object transfer
523
            try:
524
                src_obj = self.client.get_object_info(self.path)
525
            except ClientError as ce:
526
                if ce.status in (204, ):
527
                    raise CLIError(
528
                        'Missing specific path container %s' % self.container,
529
                        importance=2, details=[
530
                            'To transfer container contents %s' % (
531
                                '/'.join(self.arguments[
532
                                    'source_prefix'].parsed_name))])
533
                raise
534
            dst_path = self.dst_path or self.path
535
            dst_obj = dst_objects.get(dst_path or self.path, None)
536
            if self['force'] or not dst_obj:
537
                pairs.append(
538
                    (None if self._is_dir(src_obj) else self.path, dst_path))
539
                if self._is_dir(src_obj):
540
                    pairs.append((self.path or dst_path, None))
541
            elif self._is_dir(src_obj):
542
                raise CLIError(
543
                    'Cannot transfer an application/directory object',
544
                    importance=2, details=[
545
                        'The object pithos://%s/%s/%s is a directory' % (
546
                            self.account,
547
                            self.container,
548
                            self.path),
549
                        'To recursively copy a directory, use',
550
                        '  %s' % ('/'.join(
551
                            self.arguments['source_prefix'].parsed_name)),
552
                        'To create a file, use',
553
                        '  /file create  (general purpose)',
554
                        '  /file mkdir   (a directory object)'])
555
            else:
556
                raise CLIError(
557
                    'Destination object exists',
558
                    importance=2, details=[
559
                        'Failed while transfering:',
560
                        '    pithos://%s/%s/%s' % (
561
                                self.account,
562
                                self.container,
563
                                self.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' % ('/'.join(
569
                                self.arguments['force'].parsed_name))])
570
        return pairs
571

    
572
    def _run(self, source_path_or_url, destination_path_or_url=''):
573
        super(_source_destination, self)._run(source_path_or_url)
574
        dst_acc, dst_con, dst_path = self._resolve_pithos_url(
575
            destination_path_or_url)
576
        self.dst_client = PithosClient(
577
            base_url=self.client.base_url, token=self.client.token,
578
            container=self[
579
                'destination_container'] or dst_con or self.client.container,
580
            account=self[
581
                'destination_user_uuid'] or dst_acc or self.client.account)
582
        self.dst_path = dst_path or self.path
583

    
584

    
585
@command(file_cmds)
586
class file_copy(_source_destination):
587
    """Copy objects, even between different accounts or containers"""
588

    
589
    arguments = dict(
590
        public=ValueArgument('publish new object', '--public'),
591
        content_type=ValueArgument(
592
            'change object\'s content type', '--content-type'),
593
        source_version=ValueArgument(
594
            'copy specific version', ('-S', '--source-version'))
595
    )
596

    
597
    @errors.generic.all
598
    @errors.pithos.connection
599
    @errors.pithos.container
600
    @errors.pithos.account
601
    def _run(self):
602
        for src, dst in self._src_dst(self['source_version']):
603
            self._report_transfer(src, dst, 'copy')
604
            if src and dst:
605
                self.dst_client.copy_object(
606
                    src_container=self.client.container,
607
                    src_object=src,
608
                    dst_container=self.dst_client.container,
609
                    dst_object=dst,
610
                    source_account=self.account,
611
                    source_version=self['source_version'],
612
                    public=self['public'],
613
                    content_type=self['content_type'])
614
            elif dst:
615
                self.dst_client.create_directory(dst)
616

    
617
    def main(self, source_path_or_url, destination_path_or_url=None):
618
        super(file_copy, self)._run(
619
            source_path_or_url, destination_path_or_url or '')
620
        self._run()
621

    
622

    
623
@command(file_cmds)
624
class file_move(_source_destination):
625
    """Move objects, even between different accounts or containers"""
626

    
627
    arguments = dict(
628
        public=ValueArgument('publish new object', '--public'),
629
        content_type=ValueArgument(
630
            'change object\'s content type', '--content-type')
631
    )
632

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

    
654
    def main(self, source_path_or_url, destination_path_or_url=None):
655
        super(file_move, self)._run(
656
            source_path_or_url, destination_path_or_url or '')
657
        self._run()
658

    
659

    
660
@command(file_cmds)
661
class file_append(_pithos_container, _optional_output_cmd):
662
    """Append local file to (existing) remote object
663
    The remote object should exist.
664
    If the remote object is a directory, it is transformed into a file.
665
    In the later case, objects under the directory remain intact.
666
    """
667

    
668
    arguments = dict(
669
        progress_bar=ProgressBarArgument(
670
            'do not show progress bar', ('-N', '--no-progress-bar'),
671
            default=False),
672
        max_threads=IntArgument('default: 1', '--threads'),
673
    )
674

    
675
    @errors.generic.all
676
    @errors.pithos.connection
677
    @errors.pithos.container
678
    @errors.pithos.object_path
679
    def _run(self, local_path):
680
        if self['max_threads'] > 0:
681
            self.client.MAX_THREADS = int(self['max_threads'])
682
        (progress_bar, upload_cb) = self._safe_progress_bar('Appending')
683
        try:
684
            with open(local_path, 'rb') as f:
685
                self._optional_output(
686
                    self.client.append_object(self.path, f, upload_cb))
687
        finally:
688
            self._safe_progress_bar_finish(progress_bar)
689

    
690
    def main(self, local_path, remote_path_or_url):
691
        super(self.__class__, self)._run(remote_path_or_url)
692
        self._run(local_path)
693

    
694

    
695
@command(file_cmds)
696
class file_truncate(_pithos_container, _optional_output_cmd):
697
    """Truncate remote file up to size"""
698

    
699
    arguments = dict(
700
        size_in_bytes=IntArgument('Length of file after truncation', '--size')
701
    )
702
    required = ('size_in_bytes', )
703

    
704
    @errors.generic.all
705
    @errors.pithos.connection
706
    @errors.pithos.container
707
    @errors.pithos.object_path
708
    @errors.pithos.object_size
709
    def _run(self, size):
710
        self._optional_output(self.client.truncate_object(self.path, size))
711

    
712
    def main(self, path_or_url):
713
        super(self.__class__, self)._run(path_or_url)
714
        self._run(size=self['size_in_bytes'])
715

    
716

    
717
@command(file_cmds)
718
class file_overwrite(_pithos_container, _optional_output_cmd):
719
    """Overwrite part of a remote file"""
720

    
721
    arguments = dict(
722
        progress_bar=ProgressBarArgument(
723
            'do not show progress bar', ('-N', '--no-progress-bar'),
724
            default=False),
725
        start_position=IntArgument('File position in bytes', '--from'),
726
        end_position=IntArgument('File position in bytes', '--to')
727
    )
728
    required = ('start_position', 'end_position')
729

    
730
    @errors.generic.all
731
    @errors.pithos.connection
732
    @errors.pithos.container
733
    @errors.pithos.object_path
734
    @errors.pithos.object_size
735
    def _run(self, local_path, start, end):
736
        start, end = int(start), int(end)
737
        (progress_bar, upload_cb) = self._safe_progress_bar(
738
            'Overwrite %s bytes' % (end - start))
739
        try:
740
            with open(path.abspath(local_path), 'rb') as f:
741
                self._optional_output(self.client.overwrite_object(
742
                    obj=self.path,
743
                    start=start,
744
                    end=end,
745
                    source_file=f,
746
                    upload_cb=upload_cb))
747
        finally:
748
            self._safe_progress_bar_finish(progress_bar)
749

    
750
    def main(self, local_path, path_or_url):
751
        super(self.__class__, self)._run(path_or_url)
752
        self.path = self.path or path.basename(local_path)
753
        self._run(
754
            local_path=local_path,
755
            start=self['start_position'],
756
            end=self['end_position'])
757

    
758

    
759
@command(file_cmds)
760
class file_upload(_pithos_container, _optional_output_cmd):
761
    """Upload a file"""
762

    
763
    arguments = dict(
764
        max_threads=IntArgument('default: 5', '--threads'),
765
        content_encoding=ValueArgument(
766
            'set MIME content type', '--content-encoding'),
767
        content_disposition=ValueArgument(
768
            'specify objects presentation style', '--content-disposition'),
769
        content_type=ValueArgument('specify content type', '--content-type'),
770
        uuid_for_read_permission=RepeatableArgument(
771
            'Give read access to a user or group (can be repeated) '
772
            'Use * for all users',
773
            '--read-permission'),
774
        uuid_for_write_permission=RepeatableArgument(
775
            'Give write access to a user or group (can be repeated) '
776
            'Use * for all users',
777
            '--write-permission'),
778
        public=FlagArgument('make object publicly accessible', '--public'),
779
        overwrite=FlagArgument('Force (over)write', ('-f', '--force')),
780
        recursive=FlagArgument(
781
            'Recursively upload directory *contents* + subdirectories',
782
            ('-r', '--recursive')),
783
        unchunked=FlagArgument(
784
            'Upload file as one block (not recommended)', '--unchunked'),
785
        md5_checksum=ValueArgument(
786
            'Confirm upload with a custom checksum (MD5)', '--etag'),
787
        use_hashes=FlagArgument(
788
            'Source file contains hashmap not data', '--source-is-hashmap'),
789
    )
790

    
791
    def _sharing(self):
792
        sharing = dict()
793
        readlist = self['uuid_for_read_permission']
794
        if readlist:
795
            sharing['read'] = self['uuid_for_read_permission']
796
        writelist = self['uuid_for_write_permission']
797
        if writelist:
798
            sharing['write'] = self['uuid_for_write_permission']
799
        return sharing or None
800

    
801
    def _check_container_limit(self, path):
802
        cl_dict = self.client.get_container_limit()
803
        container_limit = int(cl_dict['x-container-policy-quota'])
804
        r = self.client.container_get()
805
        used_bytes = sum(int(o['bytes']) for o in r.json)
806
        path_size = get_path_size(path)
807
        if container_limit and path_size > (container_limit - used_bytes):
808
            raise CLIError(
809
                'Container %s (limit(%s) - used(%s)) < (size(%s) of %s)' % (
810
                    self.client.container,
811
                    format_size(container_limit),
812
                    format_size(used_bytes),
813
                    format_size(path_size),
814
                    path),
815
                details=[
816
                    'Check accound limit: /file quota',
817
                    'Check container limit:',
818
                    '\t/file containerlimit get %s' % self.client.container,
819
                    'Increase container limit:',
820
                    '\t/file containerlimit set <new limit> %s' % (
821
                        self.client.container)])
822

    
823
    def _src_dst(self, local_path, remote_path, objlist=None):
824
        lpath = path.abspath(local_path)
825
        short_path = path.basename(path.abspath(local_path))
826
        rpath = remote_path or short_path
827
        if path.isdir(lpath):
828
            if not self['recursive']:
829
                raise CLIError('%s is a directory' % lpath, details=[
830
                    'Use %s to upload directories & contents' % '/'.join(
831
                        self.arguments['recursive'].parsed_name)])
832
            robj = self.client.container_get(path=rpath)
833
            if not self['overwrite']:
834
                if robj.json:
835
                    raise CLIError(
836
                        'Objects/files prefixed as %s already exist' % rpath,
837
                        details=['Existing objects:'] + ['\t%s:\t%s' % (
838
                            o['name'],
839
                            o['content_type'][12:]) for o in robj.json] + [
840
                            'Use -f to add, overwrite or resume'])
841
                else:
842
                    try:
843
                        topobj = self.client.get_object_info(rpath)
844
                        if not self._is_dir(topobj):
845
                            raise CLIError(
846
                                'Object /%s/%s exists but not a directory' % (
847
                                    self.container, rpath),
848
                                details=['Use -f to overwrite'])
849
                    except ClientError as ce:
850
                        if ce.status not in (404, ):
851
                            raise
852
            self._check_container_limit(lpath)
853
            prev = ''
854
            for top, subdirs, files in walk(lpath):
855
                if top != prev:
856
                    prev = top
857
                    try:
858
                        rel_path = rpath + top.split(lpath)[1]
859
                    except IndexError:
860
                        rel_path = rpath
861
                    self.error('mkdir %s:%s' % (
862
                        self.client.container, rel_path))
863
                    self.client.create_directory(rel_path)
864
                for f in files:
865
                    fpath = path.join(top, f)
866
                    if path.isfile(fpath):
867
                        rel_path = rel_path.replace(path.sep, '/')
868
                        pathfix = f.replace(path.sep, '/')
869
                        yield open(fpath, 'rb'), '%s/%s' % (rel_path, pathfix)
870
                    else:
871
                        self.error('%s is not a regular file' % fpath)
872
        else:
873
            if not path.isfile(lpath):
874
                raise CLIError(('%s is not a regular file' % lpath) if (
875
                    path.exists(lpath)) else '%s does not exist' % lpath)
876
            try:
877
                robj = self.client.get_object_info(rpath)
878
                if remote_path and self._is_dir(robj):
879
                    rpath += '/%s' % (short_path.replace(path.sep, '/'))
880
                    self.client.get_object_info(rpath)
881
                if not self['overwrite']:
882
                    raise CLIError(
883
                        'Object /%s/%s already exists' % (
884
                            self.container, rpath),
885
                        details=['use -f to overwrite / resume'])
886
            except ClientError as ce:
887
                if ce.status not in (404, ):
888
                    raise
889
            self._check_container_limit(lpath)
890
            yield open(lpath, 'rb'), rpath
891

    
892
    def _run(self, local_path, remote_path):
893
        if self['max_threads'] > 0:
894
            self.client.MAX_THREADS = int(self['max_threads'])
895
        params = dict(
896
            content_encoding=self['content_encoding'],
897
            content_type=self['content_type'],
898
            content_disposition=self['content_disposition'],
899
            sharing=self._sharing(),
900
            public=self['public'])
901
        uploaded, container_info_cache = list, dict()
902
        rpref = 'pithos://%s' if self['account'] else ''
903
        for f, rpath in self._src_dst(local_path, remote_path):
904
            self.error('%s --> %s/%s/%s' % (
905
                f.name, rpref, self.client.container, rpath))
906
            if not (self['content_type'] and self['content_encoding']):
907
                ctype, cenc = guess_mime_type(f.name)
908
                params['content_type'] = self['content_type'] or ctype
909
                params['content_encoding'] = self['content_encoding'] or cenc
910
            if self['unchunked']:
911
                r = self.client.upload_object_unchunked(
912
                    rpath, f,
913
                    etag=self['md5_checksum'], withHashFile=self['use_hashes'],
914
                    **params)
915
                if self['with_output'] or self['json_output']:
916
                    r['name'] = '/%s/%s' % (self.client.container, rpath)
917
                    uploaded.append(r)
918
            else:
919
                try:
920
                    (progress_bar, upload_cb) = self._safe_progress_bar(
921
                        'Uploading %s' % f.name.split(path.sep)[-1])
922
                    if progress_bar:
923
                        hash_bar = progress_bar.clone()
924
                        hash_cb = hash_bar.get_generator(
925
                            'Calculating block hashes')
926
                    else:
927
                        hash_cb = None
928
                    r = self.client.upload_object(
929
                        rpath, f,
930
                        hash_cb=hash_cb,
931
                        upload_cb=upload_cb,
932
                        container_info_cache=container_info_cache,
933
                        **params)
934
                    if self['with_output'] or self['json_output']:
935
                        r['name'] = '/%s/%s' % (self.client.container, rpath)
936
                        uploaded.append(r)
937
                except Exception:
938
                    self._safe_progress_bar_finish(progress_bar)
939
                    raise
940
                finally:
941
                    self._safe_progress_bar_finish(progress_bar)
942
        self._optional_output(uploaded)
943
        self.error('Upload completed')
944

    
945
    def main(self, local_path, remote_path_or_url):
946
        super(self.__class__, self)._run(remote_path_or_url)
947
        remote_path = self.path or path.basename(path.abspath(local_path))
948
        self._run(local_path=local_path, remote_path=remote_path)
949

    
950

    
951
class RangeArgument(ValueArgument):
952
    """
953
    :value type: string of the form <start>-<end> where <start> and <end> are
954
        integers
955
    :value returns: the input string, after type checking <start> and <end>
956
    """
957

    
958
    @property
959
    def value(self):
960
        return getattr(self, '_value', self.default)
961

    
962
    @value.setter
963
    def value(self, newvalues):
964
        if newvalues:
965
            self._value = getattr(self, '_value', self.default)
966
            for newvalue in newvalues.split(','):
967
                self._value = ('%s,' % self._value) if self._value else ''
968
                start, sep, end = newvalue.partition('-')
969
                if sep:
970
                    if start:
971
                        start, end = (int(start), int(end))
972
                        if start > end:
973
                            raise CLIInvalidArgument(
974
                                'Invalid range %s' % newvalue, details=[
975
                                'Valid range formats',
976
                                '  START-END', '  UP_TO', '  -FROM',
977
                                'where all values are integers'])
978
                        self._value += '%s-%s' % (start, end)
979
                    else:
980
                        self._value += '-%s' % int(end)
981
                else:
982
                    self._value += '%s' % int(start)
983

    
984

    
985
@command(file_cmds)
986
class file_cat(_pithos_container):
987
    """Fetch remote file contents"""
988

    
989
    arguments = dict(
990
        range=RangeArgument('show range of data', '--range'),
991
        if_match=ValueArgument('show output if ETags match', '--if-match'),
992
        if_none_match=ValueArgument(
993
            'show output if ETags match', '--if-none-match'),
994
        if_modified_since=DateArgument(
995
            'show output modified since then', '--if-modified-since'),
996
        if_unmodified_since=DateArgument(
997
            'show output unmodified since then', '--if-unmodified-since'),
998
        object_version=ValueArgument(
999
            'Get contents of the chosen version', '--object-version')
1000
    )
1001

    
1002
    @errors.generic.all
1003
    @errors.pithos.connection
1004
    @errors.pithos.container
1005
    @errors.pithos.object_path
1006
    def _run(self):
1007
        self.client.download_object(
1008
            self.path, self._out,
1009
            range_str=self['range'],
1010
            version=self['object_version'],
1011
            if_match=self['if_match'],
1012
            if_none_match=self['if_none_match'],
1013
            if_modified_since=self['if_modified_since'],
1014
            if_unmodified_since=self['if_unmodified_since'])
1015

    
1016
    def main(self, path_or_url):
1017
        super(self.__class__, self)._run(path_or_url)
1018
        self._run()
1019

    
1020

    
1021
@command(file_cmds)
1022
class file_download(_pithos_container):
1023
    """Download a remove file or directory object to local file system"""
1024

    
1025
    arguments = dict(
1026
        resume=FlagArgument(
1027
            'Resume/Overwrite (attempt resume, else overwrite)',
1028
            ('-f', '--resume')),
1029
        range=RangeArgument('Download only that range of data', '--range'),
1030
        matching_etag=ValueArgument('download iff ETag match', '--if-match'),
1031
        non_matching_etag=ValueArgument(
1032
            'download iff ETags DO NOT match', '--if-none-match'),
1033
        modified_since_date=DateArgument(
1034
            'download iff remote file is modified since then',
1035
            '--if-modified-since'),
1036
        unmodified_since_date=DateArgument(
1037
            'show output iff remote file is unmodified since then',
1038
            '--if-unmodified-since'),
1039
        object_version=ValueArgument(
1040
            'download a file of a specific version', '--object-version'),
1041
        max_threads=IntArgument('default: 5', '--threads'),
1042
        progress_bar=ProgressBarArgument(
1043
            'do not show progress bar', ('-N', '--no-progress-bar'),
1044
            default=False),
1045
        recursive=FlagArgument(
1046
            'Download a remote directory object and its contents',
1047
            ('-r', '--recursive'))
1048
        )
1049

    
1050
    def _src_dst(self, local_path):
1051
        """Create a list of (src, dst) where src is a remote location and dst
1052
        is an open file descriptor. Directories are denoted as (None, dirpath)
1053
        and they are pretended to other objects in a very strict order (shorter
1054
        to longer path)."""
1055
        ret = []
1056
        try:
1057
            if self.path:
1058
                obj = self.client.get_object_info(
1059
                    self.path, version=self['object_version'])
1060
                obj.setdefault('name', self.path.strip('/'))
1061
            else:
1062
                obj = None
1063
        except ClientError as ce:
1064
            if ce.status in (404, ):
1065
                raiseCLIError(ce, details=[
1066
                    'To download an object, it must exist either as a file or'
1067
                    ' as a directory.',
1068
                    'For example, to download everything under prefix/ the '
1069
                    'directory "prefix" must exist.',
1070
                    'To see if an remote object is actually there:',
1071
                    '  /file info [/CONTAINER/]OBJECT',
1072
                    'To create a directory object:',
1073
                    '  /file mkdir [/CONTAINER/]OBJECT'])
1074
            if ce.status in (204, ):
1075
                raise CLIError(
1076
                    'No file or directory objects to download',
1077
                    details=[
1078
                        'To download a container (e.g., %s):' % self.container,
1079
                        '  [kamaki] container download %s [LOCAL_PATH]' % (
1080
                            self.container)])
1081
            raise
1082
        rpath = self.path.strip('/')
1083
        if local_path and self.path and local_path.endswith('/'):
1084
            local_path = local_path[-1:]
1085

    
1086
        if (not obj) or self._is_dir(obj):
1087
            if self['recursive']:
1088
                if not (self.path or local_path.endswith('/')):
1089
                    #  Download the whole container
1090
                    local_path = '' if local_path in ('.', ) else local_path
1091
                    local_path = '%s/' % (local_path or self.container)
1092
                obj = obj or dict(
1093
                    name='', content_type='application/directory')
1094
                dirs, files = [obj, ], []
1095
                objects = self.client.container_get(
1096
                    path=self.path,
1097
                    if_modified_since=self['modified_since_date'],
1098
                    if_unmodified_since=self['unmodified_since_date'])
1099
                for o in objects.json:
1100
                    (dirs if self._is_dir(o) else files).append(o)
1101

    
1102
                #  Put the directories on top of the list
1103
                for dpath in sorted(['%s%s' % (
1104
                        local_path, d['name'][len(rpath):]) for d in dirs]):
1105
                    if path.exists(dpath):
1106
                        if path.isdir(dpath):
1107
                            continue
1108
                        raise CLIError(
1109
                            'Cannot replace local file %s with a directory '
1110
                            'of the same name' % dpath,
1111
                            details=[
1112
                                'Either remove the file or specify a'
1113
                                'different target location'])
1114
                    ret.append((None, dpath, None))
1115

    
1116
                #  Append the file objects
1117
                for opath in [o['name'] for o in files]:
1118
                    lpath = '%s%s' % (local_path, opath[len(rpath):])
1119
                    if self['resume']:
1120
                        fxists = path.exists(lpath)
1121
                        if fxists and path.isdir(lpath):
1122
                            raise CLIError(
1123
                                'Cannot change local dir %s info file' % (
1124
                                    lpath),
1125
                                details=[
1126
                                    'Either remove the file or specify a'
1127
                                    'different target location'])
1128
                        ret.append((opath, lpath, fxists))
1129
                    elif path.exists(lpath):
1130
                        raise CLIError(
1131
                            'Cannot overwrite %s' % lpath,
1132
                            details=['To overwrite/resume, use  %s' % '/'.join(
1133
                                self.arguments['resume'].parsed_name)])
1134
                    else:
1135
                        ret.append((opath, lpath, None))
1136
            elif self.path:
1137
                raise CLIError(
1138
                    'Remote object /%s/%s is a directory' % (
1139
                        self.container, local_path),
1140
                    details=['Use %s to download directories' % '/'.join(
1141
                        self.arguments['recursive'].parsed_name)])
1142
            else:
1143
                parsed_name = '/'.join(self.arguments['recursive'].parsed_name)
1144
                raise CLIError(
1145
                    'Cannot download container %s' % self.container,
1146
                    details=[
1147
                        'Use %s to download containers' % parsed_name,
1148
                        '  [kamaki] file download %s /%s [LOCAL_PATH]' % (
1149
                            parsed_name, self.container)])
1150
        else:
1151
            #  Remote object is just a file
1152
            if path.exists(local_path) and not self['resume']:
1153
                raise CLIError(
1154
                    'Cannot overwrite local file %s' % (lpath),
1155
                    details=['To overwrite/resume, use  %s' % '/'.join(
1156
                        self.arguments['resume'].parsed_name)])
1157
            ret.append((rpath, local_path, self['resume']))
1158
        for r, l, resume in ret:
1159
            if r:
1160
                with open(l, 'rwb+' if resume else 'wb+') as f:
1161
                    yield (r, f)
1162
            else:
1163
                yield (r, l)
1164

    
1165
    @errors.generic.all
1166
    @errors.pithos.connection
1167
    @errors.pithos.container
1168
    @errors.pithos.object_path
1169
    @errors.pithos.local_path
1170
    @errors.pithos.local_path_download
1171
    def _run(self, local_path):
1172
        self.client.MAX_THREADS = self['max_threads'] or 5
1173
        progress_bar = None
1174
        try:
1175
            for rpath, output_file in self._src_dst(local_path):
1176
                if not rpath:
1177
                    self.error('Create local directory %s' % output_file)
1178
                    makedirs(output_file)
1179
                    continue
1180
                self.error('/%s/%s --> %s' % (
1181
                    self.container, rpath, output_file.name))
1182
                progress_bar, download_cb = self._safe_progress_bar(
1183
                    '  download')
1184
                self.client.download_object(
1185
                    rpath, output_file,
1186
                    download_cb=download_cb,
1187
                    range_str=self['range'],
1188
                    version=self['object_version'],
1189
                    if_match=self['matching_etag'],
1190
                    resume=self['resume'],
1191
                    if_none_match=self['non_matching_etag'],
1192
                    if_modified_since=self['modified_since_date'],
1193
                    if_unmodified_since=self['unmodified_since_date'])
1194
        except KeyboardInterrupt:
1195
            from threading import activeCount, enumerate as activethreads
1196
            timeout = 0.5
1197
            while activeCount() > 1:
1198
                self._out.write('\nCancel %s threads: ' % (activeCount() - 1))
1199
                self._out.flush()
1200
                for thread in activethreads():
1201
                    try:
1202
                        thread.join(timeout)
1203
                        self._out.write('.' if thread.isAlive() else '*')
1204
                    except RuntimeError:
1205
                        continue
1206
                    finally:
1207
                        self._out.flush()
1208
                        timeout += 0.1
1209
            self.error('\nDownload canceled by user')
1210
            if local_path is not None:
1211
                self.error('to resume, re-run with --resume')
1212
        except Exception:
1213
            self._safe_progress_bar_finish(progress_bar)
1214
            raise
1215
        finally:
1216
            self._safe_progress_bar_finish(progress_bar)
1217

    
1218
    def main(self, remote_path_or_url, local_path=None):
1219
        super(self.__class__, self)._run(remote_path_or_url)
1220
        local_path = local_path or self.path or '.'
1221
        self._run(local_path=local_path)