# Copyright 2011-2013 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above # copyright notice, this list of conditions and the following # disclaimer. # # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials # provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # The views and conclusions contained in the software and # documentation are those of the authors and should not be # interpreted as representing official policies, either expressed # or implied, of GRNET S.A.command from time import localtime, strftime from io import StringIO from pydoc import pager from os import path, walk, makedirs from kamaki.clients.pithos import PithosClient, ClientError from kamaki.cli import command from kamaki.cli.command_tree import CommandTree from kamaki.cli.commands import ( _command_init, errors, addLogSettings, DontRaiseKeyError, _optional_json, _name_filter, _optional_output_cmd) from kamaki.cli.errors import ( CLIBaseUrlError, CLIError, CLIInvalidArgument, raiseCLIError, CLISyntaxError) from kamaki.cli.argument import ( FlagArgument, IntArgument, ValueArgument, DateArgument, KeyValueArgument, ProgressBarArgument, RepeatableArgument, DataSizeArgument) from kamaki.cli.utils import ( format_size, bold, get_path_size, guess_mime_type) file_cmds = CommandTree('file', 'Pithos+/Storage object level API commands') container_cmds = CommandTree( 'container', 'Pithos+/Storage container level API commands') sharer_cmds = CommandTree('sharer', 'Pithos+/Storage sharers') group_cmds = CommandTree('group', 'Pithos+/Storage user groups') _commands = [file_cmds, container_cmds, sharer_cmds, group_cmds] class _pithos_init(_command_init): """Initilize a pithos+ client There is always a default account (current user uuid) There is always a default container (pithos) """ @DontRaiseKeyError def _custom_container(self): return self.config.get_cloud(self.cloud, 'pithos_container') @DontRaiseKeyError def _custom_uuid(self): return self.config.get_cloud(self.cloud, 'pithos_uuid') def _set_account(self): self.account = self._custom_uuid() if self.account: return astakos = getattr(self, 'auth_base', None) if astakos: self.account = astakos.user_term('id', self.token) else: raise CLIBaseUrlError(service='astakos') @errors.generic.all @addLogSettings def _run(self): cloud = getattr(self, 'cloud', None) if cloud: self.base_url = self._custom_url('pithos') else: self.cloud = 'default' self.token = self._custom_token('pithos') self.container = self._custom_container() or 'pithos' astakos = getattr(self, 'auth_base', None) if astakos: self.token = self.token or astakos.token if not self.base_url: pithos_endpoints = astakos.get_service_endpoints( self._custom_type('pithos') or 'object-store', self._custom_version('pithos') or '') self.base_url = pithos_endpoints['publicURL'] else: raise CLIBaseUrlError(service='astakos') self._set_account() self.client = PithosClient( self.base_url, self.token, self.account, self.container) def main(self): self._run() class _pithos_account(_pithos_init): """Setup account""" def __init__(self, arguments={}, auth_base=None, cloud=None): super(_pithos_account, self).__init__(arguments, auth_base, cloud) self['account'] = ValueArgument( 'Use (a different) user uuid', ('-A', '--account')) def print_objects(self, object_list): for index, obj in enumerate(object_list): pretty_obj = obj.copy() index += 1 empty_space = ' ' * (len(str(len(object_list))) - len(str(index))) if 'subdir' in obj: continue if self._is_dir(obj): size = 'D' else: size = format_size(obj['bytes']) pretty_obj['bytes'] = '%s (%s)' % (obj['bytes'], size) oname = obj['name'] if self['more'] else bold(obj['name']) prfx = ('%s%s. ' % (empty_space, index)) if self['enum'] else '' if self['detail']: self.writeln('%s%s' % (prfx, oname)) self.print_dict(pretty_obj, exclude=('name')) self.writeln() else: oname = '%s%9s %s' % (prfx, size, oname) oname += '/' if self._is_dir(obj) else u'' self.writeln(oname) @staticmethod def _is_dir(remote_dict): return 'application/directory' == remote_dict.get( 'content_type', remote_dict.get('content-type', '')) def _run(self): super(_pithos_account, self)._run() self.client.account = self['account'] or getattr( self, 'account', getattr(self.client, 'account', None)) class _pithos_container(_pithos_account): """Setup container""" def __init__(self, arguments={}, auth_base=None, cloud=None): super(_pithos_container, self).__init__(arguments, auth_base, cloud) self['container'] = ValueArgument( 'Use this container (default: pithos)', ('-C', '--container')) @staticmethod def _resolve_pithos_url(url): """Match urls of one of the following formats: pithos://ACCOUNT/CONTAINER/OBJECT_PATH /CONTAINER/OBJECT_PATH return account, container, path """ account, container, obj_path, prefix = '', '', url, 'pithos://' if url.startswith(prefix): account, sep, url = url[len(prefix):].partition('/') url = '/%s' % url if url.startswith('/'): container, sep, obj_path = url[1:].partition('/') return account, container, obj_path def _run(self, url=None): acc, con, self.path = self._resolve_pithos_url(url or '') # self.account = acc or getattr(self, 'account', '') super(_pithos_container, self)._run() self.container = con or self['container'] or getattr( self, 'container', None) or getattr(self.client, 'container', '') self.client.account = acc or self.client.account self.client.container = self.container @command(file_cmds) class file_info(_pithos_container, _optional_json): """Get information/details about a file""" arguments = dict( object_version=ValueArgument( 'download a file of a specific version', '--object-version'), hashmap=FlagArgument( 'Get file hashmap instead of details', '--hashmap'), matching_etag=ValueArgument( 'show output if ETags match', '--if-match'), non_matching_etag=ValueArgument( 'show output if ETags DO NOT match', '--if-none-match'), modified_since_date=DateArgument( 'show output modified since then', '--if-modified-since'), unmodified_since_date=DateArgument( 'show output unmodified since then', '--if-unmodified-since'), sharing=FlagArgument( 'show object permissions and sharing information', '--sharing'), metadata=FlagArgument('show only object metadata', '--metadata'), versions=FlagArgument( 'show the list of versions for the file', '--object-versions') ) def version_print(self, versions): return {'/%s/%s' % (self.container, self.path): [ dict(version_id=vitem[0], created=strftime( '%d-%m-%Y %H:%M:%S', localtime(float(vitem[1])))) for vitem in versions]} @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.object_path def _run(self): if self['hashmap']: r = self.client.get_object_hashmap( self.path, version=self['object_version'], if_match=self['matching_etag'], if_none_match=self['non_matching_etag'], if_modified_since=self['modified_since_date'], if_unmodified_since=self['unmodified_since_date']) elif self['sharing']: r = self.client.get_object_sharing(self.path) r['public url'] = self.client.get_object_info( self.path, version=self['object_version']).get( 'x-object-public', None) elif self['metadata']: r, preflen = dict(), len('x-object-meta-') for k, v in self.client.get_object_meta(self.path).items(): r[k[preflen:]] = v elif self['versions']: r = self.version_print( self.client.get_object_versionlist(self.path)) else: r = self.client.get_object_info( self.path, version=self['object_version']) self._print(r, self.print_dict) def main(self, path_or_url): super(self.__class__, self)._run(path_or_url) self._run() @command(file_cmds) class file_list(_pithos_container, _optional_json, _name_filter): """List all objects in a container or a directory object""" arguments = dict( detail=FlagArgument('detailed output', ('-l', '--list')), limit=IntArgument('limit number of listed items', ('-n', '--number')), marker=ValueArgument('output greater that marker', '--marker'), delimiter=ValueArgument('show output up to delimiter', '--delimiter'), meta=ValueArgument( 'show output with specified meta keys', '--meta', default=[]), if_modified_since=ValueArgument( 'show output modified since then', '--if-modified-since'), if_unmodified_since=ValueArgument( 'show output not modified since then', '--if-unmodified-since'), until=DateArgument('show metadata until then', '--until'), format=ValueArgument( 'format to parse until data (default: d/m/Y H:M:S )', '--format'), shared_by_me=FlagArgument( 'show only files shared to other users', '--shared-by-me'), public=FlagArgument('show only published objects', '--public'), more=FlagArgument('read long results', '--more'), enum=FlagArgument('Enumerate results', '--enumerate'), recursive=FlagArgument( 'Recursively list containers and their contents', ('-R', '--recursive')) ) @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.object_path def _run(self): r = self.client.container_get( limit=False if self['more'] else self['limit'], marker=self['marker'], prefix=self['name_pref'], delimiter=self['delimiter'], path=self.path or '', show_only_shared=self['shared_by_me'], public=self['public'], if_modified_since=self['if_modified_since'], if_unmodified_since=self['if_unmodified_since'], until=self['until'], meta=self['meta']) # REMOVE THIS if version >> 0.12 if not r.json: self.error(' NOTE: Since v0.12, use / for containers e.g.,') self.error(' [kamaki] file list /pithos') files = self._filter_by_name(r.json) if self['more']: outbu, self._out = self._out, StringIO() try: if self['json_output'] or self['output_format']: self._print(files) else: self.print_objects(files) finally: if self['more']: pager(self._out.getvalue()) self._out = outbu def main(self, path_or_url=''): super(self.__class__, self)._run(path_or_url) self._run() @command(file_cmds) class file_modify(_pithos_container): """Modify the attributes of a file or directory object""" arguments = dict( publish=FlagArgument( 'Make an object public (returns the public URL)', '--publish'), unpublish=FlagArgument( 'Make an object unpublic', '--unpublish'), uuid_for_read_permission=RepeatableArgument( 'Give read access to user/group (can be repeated, accumulative). ' 'Format for users: UUID . Format for groups: UUID:GROUP . ' 'Use * for all users/groups', '--read-permission'), uuid_for_write_permission=RepeatableArgument( 'Give write access to user/group (can be repeated, accumulative). ' 'Format for users: UUID . Format for groups: UUID:GROUP . ' 'Use * for all users/groups', '--write-permission'), no_permissions=FlagArgument('Remove permissions', '--no-permissions'), metadata_to_set=KeyValueArgument( 'Add metadata (KEY=VALUE) to an object (can be repeated)', '--metadata-add'), metadata_key_to_delete=RepeatableArgument( 'Delete object metadata (can be repeated)', '--metadata-del'), ) required = [ 'publish', 'unpublish', 'uuid_for_read_permission', 'metadata_to_set', 'uuid_for_write_permission', 'no_permissions', 'metadata_key_to_delete'] @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.object_path def _run(self): if self['publish']: self.writeln(self.client.publish_object(self.path)) if self['unpublish']: self.client.unpublish_object(self.path) if self['uuid_for_read_permission'] or self[ 'uuid_for_write_permission']: perms = self.client.get_object_sharing(self.path) read, write = perms.get('read', ''), perms.get('write', '') read = read.split(',') if read else [] write = write.split(',') if write else [] read += (self['uuid_for_read_permission'] or []) write += (self['uuid_for_write_permission'] or []) self.client.set_object_sharing( self.path, read_permission=read, write_permission=write) self.print_dict(self.client.get_object_sharing(self.path)) if self['no_permissions']: self.client.del_object_sharing(self.path) metadata = self['metadata_to_set'] or dict() for k in (self['metadata_key_to_delete'] or []): metadata[k] = '' if metadata: self.client.set_object_meta(self.path, metadata) self.print_dict(self.client.get_object_meta(self.path)) def main(self, path_or_url): super(self.__class__, self)._run(path_or_url) if self['publish'] and self['unpublish']: raise CLIInvalidArgument( 'Arguments %s and %s cannot be used together' % ( self.arguments['publish'].lvalue, self.arguments['publish'].lvalue)) if self['no_permissions'] and ( self['uuid_for_read_permission'] or self[ 'uuid_for_write_permission']): raise CLIInvalidArgument( '%s cannot be used with other permission arguments' % ( self.arguments['no_permissions'].lvalue)) self._run() @command(file_cmds) class file_create(_pithos_container, _optional_output_cmd): """Create an empty file""" arguments = dict( content_type=ValueArgument( 'Set content type (default: application/octet-stream)', '--content-type', default='application/octet-stream') ) @errors.generic.all @errors.pithos.connection @errors.pithos.container def _run(self): self._optional_output( self.client.create_object(self.path, self['content_type'])) def main(self, path_or_url): super(self.__class__, self)._run(path_or_url) self._run() @command(file_cmds) class file_mkdir(_pithos_container, _optional_output_cmd): """Create a directory: /file create --content-type='applcation/directory' """ @errors.generic.all @errors.pithos.connection @errors.pithos.container def _run(self): self._optional_output(self.client.create_directory(self.path)) def main(self, path_or_url): super(self.__class__, self)._run(path_or_url) self._run() @command(file_cmds) class file_delete(_pithos_container): """Delete a file or directory object""" arguments = dict( until_date=DateArgument('remove history until then', '--until'), yes=FlagArgument('Do not prompt for permission', '--yes'), recursive=FlagArgument( 'If a directory, empty first', ('-r', '--recursive')), delimiter=ValueArgument( 'delete objects prefixed with ', '--delimiter') ) @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.object_path def _run(self): if self.path: if self['yes'] or self.ask_user( 'Delete /%s/%s ?' % (self.container, self.path)): self.client.del_object( self.path, until=self['until_date'], delimiter='/' if self['recursive'] else self['delimiter']) else: self.error('Aborted') else: if self['yes'] or self.ask_user( 'Empty container /%s ?' % self.container): self.client.container_delete(self.container, delimiter='/') else: self.error('Aborted') def main(self, path_or_url): super(self.__class__, self)._run(path_or_url) self._run() class _source_destination(_pithos_container, _optional_output_cmd): sd_arguments = dict( destination_user_uuid=ValueArgument( 'default: current user uuid', '--to-account'), destination_container=ValueArgument( 'default: pithos', '--to-container'), source_prefix=FlagArgument( 'Transfer all files that are prefixed with SOURCE PATH If the ' 'destination path is specified, replace SOURCE_PATH with ' 'DESTINATION_PATH', ('-r', '--recursive')), force=FlagArgument( 'Overwrite destination objects, if needed', ('-f', '--force')), source_version=ValueArgument( 'The version of the source object', '--source-version') ) def __init__(self, arguments={}, auth_base=None, cloud=None): self.arguments.update(arguments) self.arguments.update(self.sd_arguments) super(_source_destination, self).__init__( self.arguments, auth_base, cloud) def _report_transfer(self, src, dst, transfer_name): if not dst: if transfer_name in ('move', ): self.error(' delete source directory %s' % src) return dst_prf = '' if self.account == self.dst_client.account else ( 'pithos://%s' % self.dst_client.account) if src: src_prf = '' if self.account == self.dst_client.account else ( 'pithos://%s' % self.account) self.error(' %s %s/%s/%s\n --> %s/%s/%s' % ( transfer_name, src_prf, self.container, src, dst_prf, self.dst_client.container, dst)) else: self.error(' mkdir %s/%s/%s' % ( dst_prf, self.dst_client.container, dst)) @errors.generic.all @errors.pithos.account def _src_dst(self, version=None): """Preconditions: self.account, self.container, self.path self.dst_acc, self.dst_con, self.dst_path They should all be configured properly :returns: [(src_path, dst_path), ...], if src_path is None, create destination directory """ src_objects, dst_objects, pairs = dict(), dict(), [] try: for obj in self.dst_client.list_objects( prefix=self.dst_path or self.path or '/'): dst_objects[obj['name']] = obj except ClientError as ce: if ce.status in (404, ): raise CLIError( 'Destination container pithos://%s/%s not found' % ( self.dst_client.account, self.dst_client.container)) raise ce if self['source_prefix']: # Copy and replace prefixes for src_obj in self.client.list_objects(prefix=self.path): src_objects[src_obj['name']] = src_obj for src_path, src_obj in src_objects.items(): dst_path = '%s%s' % ( self.dst_path or self.path, src_path[len(self.path):]) dst_obj = dst_objects.get(dst_path, None) if self['force'] or not dst_obj: # Just do it pairs.append(( None if self._is_dir(src_obj) else src_path, dst_path)) if self._is_dir(src_obj): pairs.append((self.path or dst_path, None)) elif not (self._is_dir(dst_obj) and self._is_dir(src_obj)): raise CLIError( 'Destination object exists', importance=2, details=[ 'Failed while transfering:', ' pithos://%s/%s/%s' % ( self.account, self.container, src_path), '--> pithos://%s/%s/%s' % ( self.dst_client.account, self.dst_client.container, dst_path), 'Use %s to transfer overwrite' % ( self.arguments['force'].lvalue)]) else: # One object transfer try: src_version_arg = self.arguments.get('source_version', None) src_obj = self.client.get_object_info( self.path, version=src_version_arg.value if src_version_arg else None) except ClientError as ce: if ce.status in (204, ): raise CLIError( 'Missing specific path container %s' % self.container, importance=2, details=[ 'To transfer container contents %s' % ( self.arguments['source_prefix'].lvalue)]) raise dst_path = self.dst_path or self.path dst_obj = dst_objects.get(dst_path or self.path, None) if self['force'] or not dst_obj: pairs.append( (None if self._is_dir(src_obj) else self.path, dst_path)) if self._is_dir(src_obj): pairs.append((self.path or dst_path, None)) elif self._is_dir(src_obj): raise CLIError( 'Cannot transfer an application/directory object', importance=2, details=[ 'The object pithos://%s/%s/%s is a directory' % ( self.account, self.container, self.path), 'To recursively copy a directory, use', ' %s' % self.arguments['source_prefix'].lvalue, 'To create a file, use', ' /file create (general purpose)', ' /file mkdir (a directory object)']) else: raise CLIError( 'Destination object exists', importance=2, details=[ 'Failed while transfering:', ' pithos://%s/%s/%s' % ( self.account, self.container, self.path), '--> pithos://%s/%s/%s' % ( self.dst_client.account, self.dst_client.container, dst_path), 'Use %s to transfer overwrite' % ( self.arguments['force'].lvalue)]) return pairs def _run(self, source_path_or_url, destination_path_or_url=''): super(_source_destination, self)._run(source_path_or_url) dst_acc, dst_con, dst_path = self._resolve_pithos_url( destination_path_or_url) self.dst_client = PithosClient( base_url=self.client.base_url, token=self.client.token, container=self[ 'destination_container'] or dst_con or self.client.container, account=self[ 'destination_user_uuid'] or dst_acc or self.account) self.dst_path = dst_path or self.path @command(file_cmds) class file_copy(_source_destination): """Copy objects, even between different accounts or containers""" arguments = dict( public=ValueArgument('publish new object', '--public'), content_type=ValueArgument( 'change object\'s content type', '--content-type'), source_version=ValueArgument( 'The version of the source object', '--object-version') ) @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.account def _run(self): for src, dst in self._src_dst(self['source_version']): self._report_transfer(src, dst, 'copy') if src and dst: self.dst_client.copy_object( src_container=self.client.container, src_object=src, dst_container=self.dst_client.container, dst_object=dst, source_account=self.client.account, source_version=self['source_version'], public=self['public'], content_type=self['content_type']) elif dst: self.dst_client.create_directory(dst) def main(self, source_path_or_url, destination_path_or_url=None): super(file_copy, self)._run( source_path_or_url, destination_path_or_url or '') self._run() @command(file_cmds) class file_move(_source_destination): """Move objects, even between different accounts or containers""" arguments = dict( public=ValueArgument('publish new object', '--public'), content_type=ValueArgument( 'change object\'s content type', '--content-type') ) @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.account def _run(self): for src, dst in self._src_dst(): self._report_transfer(src, dst, 'move') if src and dst: self.dst_client.move_object( src_container=self.client.container, src_object=src, dst_container=self.dst_client.container, dst_object=dst, source_account=self.account, public=self['public'], content_type=self['content_type']) elif dst: self.dst_client.create_directory(dst) else: self.client.del_object(src) def main(self, source_path_or_url, destination_path_or_url=None): super(file_move, self)._run( source_path_or_url, destination_path_or_url or '') self._run() @command(file_cmds) class file_append(_pithos_container, _optional_output_cmd): """Append local file to (existing) remote object The remote object should exist. If the remote object is a directory, it is transformed into a file. In the later case, objects under the directory remain intact. """ arguments = dict( progress_bar=ProgressBarArgument( 'do not show progress bar', ('-N', '--no-progress-bar'), default=False), max_threads=IntArgument('default: 1', '--threads'), ) @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.object_path def _run(self, local_path): if self['max_threads'] > 0: self.client.MAX_THREADS = int(self['max_threads']) (progress_bar, upload_cb) = self._safe_progress_bar('Appending') try: with open(local_path, 'rb') as f: self._optional_output( self.client.append_object(self.path, f, upload_cb)) finally: self._safe_progress_bar_finish(progress_bar) def main(self, local_path, remote_path_or_url): super(self.__class__, self)._run(remote_path_or_url) self._run(local_path) @command(file_cmds) class file_truncate(_pithos_container, _optional_output_cmd): """Truncate remote file up to size""" arguments = dict( size_in_bytes=IntArgument('Length of file after truncation', '--size') ) required = ('size_in_bytes', ) @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.object_path @errors.pithos.object_size def _run(self, size): self._optional_output(self.client.truncate_object(self.path, size)) def main(self, path_or_url): super(self.__class__, self)._run(path_or_url) self._run(size=self['size_in_bytes']) @command(file_cmds) class file_overwrite(_pithos_container, _optional_output_cmd): """Overwrite part of a remote file""" arguments = dict( progress_bar=ProgressBarArgument( 'do not show progress bar', ('-N', '--no-progress-bar'), default=False), start_position=IntArgument('File position in bytes', '--from'), end_position=IntArgument('File position in bytes', '--to') ) required = ('start_position', 'end_position') @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.object_path @errors.pithos.object_size def _run(self, local_path, start, end): start, end = int(start), int(end) (progress_bar, upload_cb) = self._safe_progress_bar( 'Overwrite %s bytes' % (end - start)) try: with open(path.abspath(local_path), 'rb') as f: self._optional_output(self.client.overwrite_object( obj=self.path, start=start, end=end, source_file=f, upload_cb=upload_cb)) finally: self._safe_progress_bar_finish(progress_bar) def main(self, local_path, path_or_url): super(self.__class__, self)._run(path_or_url) self.path = self.path or path.basename(local_path) self._run( local_path=local_path, start=self['start_position'], end=self['end_position']) @command(file_cmds) class file_upload(_pithos_container, _optional_output_cmd): """Upload a file""" arguments = dict( max_threads=IntArgument('default: 5', '--threads'), content_encoding=ValueArgument( 'set MIME content type', '--content-encoding'), content_disposition=ValueArgument( 'specify objects presentation style', '--content-disposition'), content_type=ValueArgument('specify content type', '--content-type'), uuid_for_read_permission=RepeatableArgument( 'Give read access to a user or group (can be repeated) ' 'Use * for all users', '--read-permission'), uuid_for_write_permission=RepeatableArgument( 'Give write access to a user or group (can be repeated) ' 'Use * for all users', '--write-permission'), public=FlagArgument('make object publicly accessible', '--public'), progress_bar=ProgressBarArgument( 'do not show progress bar', ('-N', '--no-progress-bar'), default=False), overwrite=FlagArgument('Force (over)write', ('-f', '--force')), recursive=FlagArgument( 'Recursively upload directory *contents* + subdirectories', ('-r', '--recursive')), unchunked=FlagArgument( 'Upload file as one block (not recommended)', '--unchunked'), md5_checksum=ValueArgument( 'Confirm upload with a custom checksum (MD5)', '--etag'), use_hashes=FlagArgument( 'Source file contains hashmap not data', '--source-is-hashmap'), ) def _sharing(self): sharing = dict() readlist = self['uuid_for_read_permission'] if readlist: sharing['read'] = self['uuid_for_read_permission'] writelist = self['uuid_for_write_permission'] if writelist: sharing['write'] = self['uuid_for_write_permission'] return sharing or None def _check_container_limit(self, path): cl_dict = self.client.get_container_limit() container_limit = int(cl_dict['x-container-policy-quota']) r = self.client.container_get() used_bytes = sum(int(o['bytes']) for o in r.json) path_size = get_path_size(path) if container_limit and path_size > (container_limit - used_bytes): raise CLIError( 'Container %s (limit(%s) - used(%s)) < (size(%s) of %s)' % ( self.client.container, format_size(container_limit), format_size(used_bytes), format_size(path_size), path), details=[ 'Check accound limit: /file quota', 'Check container limit:', '\t/file containerlimit get %s' % self.client.container, 'Increase container limit:', '\t/file containerlimit set %s' % ( self.client.container)]) def _src_dst(self, local_path, remote_path, objlist=None): lpath = path.abspath(local_path) short_path = path.basename(path.abspath(local_path)) rpath = remote_path or short_path if path.isdir(lpath): if not self['recursive']: raise CLIError('%s is a directory' % lpath, details=[ 'Use %s to upload directories & contents' % ( self.arguments['recursive'].lvalue)]) robj = self.client.container_get(path=rpath) if not self['overwrite']: if robj.json: raise CLIError( 'Objects/files prefixed as %s already exist' % rpath, details=['Existing objects:'] + ['\t/%s/\t%s' % ( o['name'], o['content_type'][12:]) for o in robj.json] + [ 'Use -f to add, overwrite or resume']) else: try: topobj = self.client.get_object_info(rpath) if not self._is_dir(topobj): raise CLIError( 'Object /%s/%s exists but not a directory' % ( self.container, rpath), details=['Use -f to overwrite']) except ClientError as ce: if ce.status not in (404, ): raise self._check_container_limit(lpath) prev = '' for top, subdirs, files in walk(lpath): if top != prev: prev = top try: rel_path = rpath + top.split(lpath)[1] except IndexError: rel_path = rpath self.error('mkdir /%s/%s' % ( self.client.container, rel_path)) self.client.create_directory(rel_path) for f in files: fpath = path.join(top, f) if path.isfile(fpath): rel_path = rel_path.replace(path.sep, '/') pathfix = f.replace(path.sep, '/') yield open(fpath, 'rb'), '%s/%s' % (rel_path, pathfix) else: self.error('%s is not a regular file' % fpath) else: if not path.isfile(lpath): raise CLIError(('%s is not a regular file' % lpath) if ( path.exists(lpath)) else '%s does not exist' % lpath) try: robj = self.client.get_object_info(rpath) if remote_path and self._is_dir(robj): rpath += '/%s' % (short_path.replace(path.sep, '/')) self.client.get_object_info(rpath) if not self['overwrite']: raise CLIError( 'Object /%s/%s already exists' % ( self.container, rpath), details=['use -f to overwrite / resume']) except ClientError as ce: if ce.status not in (404, ): raise self._check_container_limit(lpath) yield open(lpath, 'rb'), rpath def _run(self, local_path, remote_path): self.client.MAX_THREADS = int(self['max_threads'] or 5) params = dict( content_encoding=self['content_encoding'], content_type=self['content_type'], content_disposition=self['content_disposition'], sharing=self._sharing(), public=self['public']) uploaded, container_info_cache = list, dict() rpref = 'pithos://%s' if self['account'] else '' for f, rpath in self._src_dst(local_path, remote_path): self.error('%s --> %s/%s/%s' % ( f.name, rpref, self.client.container, rpath)) if not (self['content_type'] and self['content_encoding']): ctype, cenc = guess_mime_type(f.name) params['content_type'] = self['content_type'] or ctype params['content_encoding'] = self['content_encoding'] or cenc if self['unchunked']: r = self.client.upload_object_unchunked( rpath, f, etag=self['md5_checksum'], withHashFile=self['use_hashes'], **params) if self['with_output'] or self['json_output']: r['name'] = '/%s/%s' % (self.client.container, rpath) uploaded.append(r) else: try: (progress_bar, upload_cb) = self._safe_progress_bar( 'Uploading %s' % f.name.split(path.sep)[-1]) if progress_bar: hash_bar = progress_bar.clone() hash_cb = hash_bar.get_generator( 'Calculating block hashes') else: hash_cb = None r = self.client.upload_object( rpath, f, hash_cb=hash_cb, upload_cb=upload_cb, container_info_cache=container_info_cache, **params) if self['with_output'] or self['json_output']: r['name'] = '/%s/%s' % (self.client.container, rpath) uploaded.append(r) except Exception: self._safe_progress_bar_finish(progress_bar) raise finally: self._safe_progress_bar_finish(progress_bar) self._optional_output(uploaded) self.error('Upload completed') def main(self, local_path, remote_path_or_url): super(self.__class__, self)._run(remote_path_or_url) remote_path = self.path or path.basename(path.abspath(local_path)) self._run(local_path=local_path, remote_path=remote_path) class RangeArgument(ValueArgument): """ :value type: string of the form - where and are integers :value returns: the input string, after type checking and """ @property def value(self): return getattr(self, '_value', self.default) @value.setter def value(self, newvalues): if newvalues: self._value = getattr(self, '_value', self.default) for newvalue in newvalues.split(','): self._value = ('%s,' % self._value) if self._value else '' start, sep, end = newvalue.partition('-') if sep: if start: start, end = (int(start), int(end)) if start > end: raise CLIInvalidArgument( 'Invalid range %s' % newvalue, details=[ 'Valid range formats', ' START-END', ' UP_TO', ' -FROM', 'where all values are integers', 'OR a compination (csv), e.g.,', ' %s=5,10-20,-5' % self.lvalue]) self._value += '%s-%s' % (start, end) else: self._value += '-%s' % int(end) else: self._value += '%s' % int(start) @command(file_cmds) class file_cat(_pithos_container): """Fetch remote file contents""" arguments = dict( range=RangeArgument('show range of data e.g., 5,10-20,-5', '--range'), if_match=ValueArgument('show output if ETags match', '--if-match'), if_none_match=ValueArgument( 'show output if ETags match', '--if-none-match'), if_modified_since=DateArgument( 'show output modified since then', '--if-modified-since'), if_unmodified_since=DateArgument( 'show output unmodified since then', '--if-unmodified-since'), object_version=ValueArgument( 'Get contents of the chosen version', '--object-version') ) @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.object_path def _run(self): r = self.client.download_object( self.path, self._out, range_str=self['range'], version=self['object_version'], if_match=self['if_match'], if_none_match=self['if_none_match'], if_modified_since=self['if_modified_since'], if_unmodified_since=self['if_unmodified_since']) print r def main(self, path_or_url): super(self.__class__, self)._run(path_or_url) self._run() @command(file_cmds) class file_download(_pithos_container): """Download a remove file or directory object to local file system""" arguments = dict( resume=FlagArgument( 'Resume/Overwrite (attempt resume, else overwrite)', ('-f', '--resume')), range=RangeArgument( 'Download only that range of data e.g., 5,10-20,-5', '--range'), matching_etag=ValueArgument('download iff ETag match', '--if-match'), non_matching_etag=ValueArgument( 'download iff ETags DO NOT match', '--if-none-match'), modified_since_date=DateArgument( 'download iff remote file is modified since then', '--if-modified-since'), unmodified_since_date=DateArgument( 'show output iff remote file is unmodified since then', '--if-unmodified-since'), object_version=ValueArgument( 'download a file of a specific version', '--object-version'), max_threads=IntArgument('default: 5', '--threads'), progress_bar=ProgressBarArgument( 'do not show progress bar', ('-N', '--no-progress-bar'), default=False), recursive=FlagArgument( 'Download a remote directory object and its contents', ('-r', '--recursive')) ) def _src_dst(self, local_path): """Create a list of (src, dst) where src is a remote location and dst is an open file descriptor. Directories are denoted as (None, dirpath) and they are pretended to other objects in a very strict order (shorter to longer path).""" ret = [] try: if self.path: obj = self.client.get_object_info( self.path, version=self['object_version']) obj.setdefault('name', self.path.strip('/')) else: obj = None except ClientError as ce: if ce.status in (404, ): raiseCLIError(ce, details=[ 'To download an object, it must exist either as a file or' ' as a directory.', 'For example, to download everything under prefix/ the ' 'directory "prefix" must exist.', 'To see if an remote object is actually there:', ' /file info [/CONTAINER/]OBJECT', 'To create a directory object:', ' /file mkdir [/CONTAINER/]OBJECT']) if ce.status in (204, ): raise CLIError( 'No file or directory objects to download', details=[ 'To download a container (e.g., %s):' % self.container, ' [kamaki] container download %s [LOCAL_PATH]' % ( self.container)]) raise rpath = self.path.strip('/') if local_path and self.path and local_path.endswith('/'): local_path = local_path[-1:] if (not obj) or self._is_dir(obj): if self['recursive']: if not (self.path or local_path.endswith('/')): # Download the whole container local_path = '' if local_path in ('.', ) else local_path local_path = '%s/' % (local_path or self.container) obj = obj or dict( name='', content_type='application/directory') dirs, files = [obj, ], [] objects = self.client.container_get( path=self.path, if_modified_since=self['modified_since_date'], if_unmodified_since=self['unmodified_since_date']) for o in objects.json: (dirs if self._is_dir(o) else files).append(o) # Put the directories on top of the list for dpath in sorted(['%s%s' % ( local_path, d['name'][len(rpath):]) for d in dirs]): if path.exists(dpath): if path.isdir(dpath): continue raise CLIError( 'Cannot replace local file %s with a directory ' 'of the same name' % dpath, details=[ 'Either remove the file or specify a' 'different target location']) ret.append((None, dpath, None)) # Append the file objects for opath in [o['name'] for o in files]: lpath = '%s%s' % (local_path, opath[len(rpath):]) if self['resume']: fxists = path.exists(lpath) if fxists and path.isdir(lpath): raise CLIError( 'Cannot change local dir %s info file' % ( lpath), details=[ 'Either remove the file or specify a' 'different target location']) ret.append((opath, lpath, fxists)) elif path.exists(lpath): raise CLIError( 'Cannot overwrite %s' % lpath, details=['To overwrite/resume, use %s' % ( self.arguments['resume'].lvalue)]) else: ret.append((opath, lpath, None)) elif self.path: raise CLIError( 'Remote object /%s/%s is a directory' % ( self.container, local_path), details=['Use %s to download directories' % ( self.arguments['recursive'].lvalue)]) else: parsed_name = self.arguments['recursive'].lvalue raise CLIError( 'Cannot download container %s' % self.container, details=[ 'Use %s to download containers' % parsed_name, ' [kamaki] file download %s /%s [LOCAL_PATH]' % ( parsed_name, self.container)]) else: # Remote object is just a file if path.exists(local_path) and not self['resume']: raise CLIError( 'Cannot overwrite local file %s' % (lpath), details=['To overwrite/resume, use %s' % ( self.arguments['resume'].lvalue)]) ret.append((rpath, local_path, self['resume'])) for r, l, resume in ret: if r: with open(l, 'rwb+' if resume else 'wb+') as f: yield (r, f) else: yield (r, l) @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.object_path @errors.pithos.local_path @errors.pithos.local_path_download def _run(self, local_path): self.client.MAX_THREADS = int(self['max_threads'] or 5) progress_bar = None try: for rpath, output_file in self._src_dst(local_path): if not rpath: self.error('Create local directory %s' % output_file) makedirs(output_file) continue self.error('/%s/%s --> %s' % ( self.container, rpath, output_file.name)) progress_bar, download_cb = self._safe_progress_bar( ' download') self.client.download_object( rpath, output_file, download_cb=download_cb, range_str=self['range'], version=self['object_version'], if_match=self['matching_etag'], resume=self['resume'], if_none_match=self['non_matching_etag'], if_modified_since=self['modified_since_date'], if_unmodified_since=self['unmodified_since_date']) except KeyboardInterrupt: from threading import activeCount, enumerate as activethreads timeout = 0.5 while activeCount() > 1: self._out.write('\nCancel %s threads: ' % (activeCount() - 1)) self._out.flush() for thread in activethreads(): try: thread.join(timeout) self._out.write('.' if thread.isAlive() else '*') except RuntimeError: continue finally: self._out.flush() timeout += 0.1 self.error('\nDownload canceled by user') if local_path is not None: self.error('to resume, re-run with --resume') except Exception: self._safe_progress_bar_finish(progress_bar) raise finally: self._safe_progress_bar_finish(progress_bar) def main(self, remote_path_or_url, local_path=None): super(self.__class__, self)._run(remote_path_or_url) local_path = local_path or self.path or '.' self._run(local_path=local_path) @command(container_cmds) class container_info(_pithos_account, _optional_json): """Get information about a container""" arguments = dict( until_date=DateArgument('show metadata until then', '--until'), metadata=FlagArgument('Show only container metadata', '--metadata'), sizelimit=FlagArgument( 'Show the maximum size limit for container', '--size-limit'), in_bytes=FlagArgument('Show size limit in bytes', ('-b', '--bytes')) ) @errors.generic.all @errors.pithos.connection @errors.pithos.container @errors.pithos.object_path def _run(self): if self['metadata']: r, preflen = dict(), len('x-container-meta-') for k, v in self.client.get_container_meta( until=self['until_date']).items(): r[k[preflen:]] = v elif self['sizelimit']: r = self.client.get_container_limit( self.container)['x-container-policy-quota'] r = {'size limit': 'unlimited' if r in ('0', ) else ( int(r) if self['in_bytes'] else format_size(r))} else: r = self.client.get_container_info(self.container) self._print(r, self.print_dict) def main(self, container): super(self.__class__, self)._run() self.container, self.client.container = container, container self._run() class VersioningArgument(ValueArgument): schemes = ('auto', 'none') @property def value(self): return getattr(self, '_value', None) @value.setter def value(self, new_scheme): if new_scheme: new_scheme = new_scheme.lower() if new_scheme not in self.schemes: raise CLIInvalidArgument('Invalid versioning value', details=[ 'Valid versioning values are %s' % ', '.join( self.schemes)]) self._value = new_scheme @command(container_cmds) class container_modify(_pithos_account, _optional_json): """Modify the properties of a container""" arguments = dict( metadata_to_add=KeyValueArgument( 'Add metadata in the form KEY=VALUE (can be repeated)', '--metadata-add'), metadata_to_delete=RepeatableArgument( 'Delete metadata by KEY (can be repeated)', '--metadata-del'), sizelimit=DataSizeArgument( 'Set max size limit (0 for unlimited, ' 'use units B, KiB, KB, etc.)', '--size-limit'), versioning=VersioningArgument( 'Set a versioning scheme (%s)' % ', '.join( VersioningArgument.schemes), '--versioning') ) required = [ 'metadata_to_add', 'metadata_to_delete', 'sizelimit', 'versioning'] @errors.generic.all @errors.pithos.connection @errors.pithos.container def _run(self, container): metadata = self['metadata_to_add'] for k in (self['metadata_to_delete'] or []): metadata[k] = '' if metadata: self.client.set_container_meta(metadata) self._print(self.client.get_container_meta(), self.print_dict) if self['sizelimit'] is not None: self.client.set_container_limit(self['sizelimit']) r = self.client.get_container_limit()['x-container-policy-quota'] r = 'unlimited' if r in ('0', ) else format_size(r) self.writeln('new size limit: %s' % r) if self['versioning']: self.client.set_container_versioning(self['versioning']) self.writeln('new versioning scheme: %s' % ( self.client.get_container_versioning(self.container)[ 'x-container-policy-versioning'])) def main(self, container): super(self.__class__, self)._run() self.client.container, self.container = container, container self._run(container=container) @command(container_cmds) class container_list(_pithos_account, _optional_json, _name_filter): """List all containers, or their contents""" arguments = dict( detail=FlagArgument('Containers with details', ('-l', '--list')), limit=IntArgument('limit number of listed items', ('-n', '--number')), marker=ValueArgument('output greater that marker', '--marker'), modified_since_date=ValueArgument( 'show output modified since then', '--if-modified-since'), unmodified_since_date=ValueArgument( 'show output not modified since then', '--if-unmodified-since'), until_date=DateArgument('show metadata until then', '--until'), shared=FlagArgument('show only shared', '--shared'), more=FlagArgument('read long results', '--more'), enum=FlagArgument('Enumerate results', '--enumerate'), recursive=FlagArgument( 'Recursively list containers and their contents', ('-r', '--recursive')), shared_by_me=FlagArgument( 'show only files shared to other users', '--shared-by-me'), public=FlagArgument('show only published objects', '--public'), ) def print_containers(self, container_list): for index, container in enumerate(container_list): if 'bytes' in container: size = format_size(container['bytes']) prfx = ('%s. ' % (index + 1)) if self['enum'] else '' _cname = container['name'] if ( self['more']) else bold(container['name']) cname = u'%s%s' % (prfx, _cname) if self['detail']: self.writeln(cname) pretty_c = container.copy() if 'bytes' in container: pretty_c['bytes'] = '%s (%s)' % (container['bytes'], size) self.print_dict(pretty_c, exclude=('name')) self.writeln() else: if 'count' in container and 'bytes' in container: self.writeln('%s (%s, %s objects)' % ( cname, size, container['count'])) else: self.writeln(cname) objects = container.get('objects', []) if objects: self.print_objects(objects) self.writeln('') def _create_object_forest(self, container_list): try: for container in container_list: self.client.container = container['name'] objects = self.client.container_get( limit=False if self['more'] else self['limit'], if_modified_since=self['modified_since_date'], if_unmodified_since=self['unmodified_since_date'], until=self['until_date'], show_only_shared=self['shared_by_me'], public=self['public']) container['objects'] = objects.json finally: self.client.container = None @errors.generic.all @errors.pithos.connection @errors.pithos.object_path @errors.pithos.container def _run(self, container): if container: r = self.client.container_get( limit=False if self['more'] else self['limit'], marker=self['marker'], if_modified_since=self['modified_since_date'], if_unmodified_since=self['unmodified_since_date'], until=self['until_date'], show_only_shared=self['shared_by_me'], public=self['public']) else: r = self.client.account_get( limit=False if self['more'] else self['limit'], marker=self['marker'], if_modified_since=self['modified_since_date'], if_unmodified_since=self['unmodified_since_date'], until=self['until_date'], show_only_shared=self['shared_by_me'], public=self['public']) files = self._filter_by_name(r.json) if self['recursive'] and not container: self._create_object_forest(files) if self['more']: outbu, self._out = self._out, StringIO() try: if self['json_output'] or self['output_format']: self._print(files) else: (self.print_objects if container else self.print_containers)( files) finally: if self['more']: pager(self._out.getvalue()) self._out = outbu def main(self, container=None): super(self.__class__, self)._run() self.client.container, self.container = container, container self._run(container) @command(container_cmds) class container_create(_pithos_account): """Create a new container""" arguments = dict( versioning=ValueArgument( 'set container versioning (auto/none)', '--versioning'), limit=IntArgument('set default container limit', '--limit'), meta=KeyValueArgument( 'set container metadata (can be repeated)', '--meta') ) @errors.generic.all @errors.pithos.connection @errors.pithos.container def _run(self, container): try: self.client.create_container( container=container, sizelimit=self['limit'], versioning=self['versioning'], metadata=self['meta'], success=(201, )) except ClientError as ce: if ce.status in (202, ): raise CLIError( 'Container %s alread exists' % container, details=[ 'Either delete %s or choose another name' % (container)]) raise def main(self, new_container): super(self.__class__, self)._run() self._run(container=new_container) @command(container_cmds) class container_delete(_pithos_account): """Delete a container""" arguments = dict( yes=FlagArgument('Do not prompt for permission', '--yes'), recursive=FlagArgument( 'delete container even if not empty', ('-r', '--recursive')) ) @errors.generic.all @errors.pithos.connection @errors.pithos.container def _run(self, container): num_of_contents = int(self.client.get_container_info(container)[ 'x-container-object-count']) delimiter, msg = None, 'Delete container %s ?' % container if self['recursive']: delimiter, msg = '/', 'Empty and d%s' % msg[1:] elif num_of_contents: raise CLIError('Container %s is not empty' % container, details=[ 'Use %s to delete non-empty containers' % ( self.arguments['recursive'].lvalue)]) if self['yes'] or self.ask_user(msg): if num_of_contents: self.client.del_container(delimiter=delimiter) self.client.purge_container() def main(self, container): super(self.__class__, self)._run() self.container, self.client.container = container, container self._run(container) @command(container_cmds) class container_empty(_pithos_account): """Empty a container""" arguments = dict(yes=FlagArgument('Do not prompt for permission', '--yes')) @errors.generic.all @errors.pithos.connection @errors.pithos.container def _run(self, container): if self['yes'] or self.ask_user('Empty container %s ?' % container): self.client.del_container(delimiter='/') def main(self, container): super(self.__class__, self)._run() self.container, self.client.container = container, container self._run(container) @command(sharer_cmds) class sharer_list(_pithos_account, _optional_json): """List accounts who share file objects with current user""" arguments = dict( detail=FlagArgument('show detailed output', ('-l', '--details')), marker=ValueArgument('show output greater then marker', '--marker') ) @errors.generic.all @errors.pithos.connection def _run(self): accounts = self.client.get_sharing_accounts(marker=self['marker']) if not (self['json_output'] or self['output_format']): usernames = self._uuids2usernames( [acc['name'] for acc in accounts]) for item in accounts: uuid = item['name'] item['id'], item['name'] = uuid, usernames[uuid] if not self['detail']: item.pop('last_modified') self._print(accounts) def main(self): super(self.__class__, self)._run() self._run() @command(sharer_cmds) class sharer_info(_pithos_account, _optional_json): """Details on a Pithos+ sharer account (default: current account)""" @errors.generic.all @errors.pithos.connection def _run(self): self._print(self.client.get_account_info(), self.print_dict) def main(self, account_uuid=None): super(self.__class__, self)._run() if account_uuid: self.client.account, self.account = account_uuid, account_uuid self._run() class _pithos_group(_pithos_account): prefix = 'x-account-group-' preflen = len(prefix) def _groups(self): groups = dict() for k, v in self.client.get_account_group().items(): groups[k[self.preflen:]] = v return groups @command(group_cmds) class group_list(_pithos_group, _optional_json): """list all groups and group members""" @errors.generic.all @errors.pithos.connection def _run(self): self._print(self._groups(), self.print_dict) def main(self): super(self.__class__, self)._run() self._run() @command(group_cmds) class group_create(_pithos_group, _optional_json): """Create a group of users""" arguments = dict( user_uuid=RepeatableArgument('Add a user to the group', '--uuid'), username=RepeatableArgument('Add a user to the group', '--username') ) required = ['user_uuid', 'username'] @errors.generic.all @errors.pithos.connection def _run(self, groupname, *users): if groupname in self._groups() and not self.ask_user( 'Group %s already exists, overwrite?' % groupname): self.error('Aborted') return self.client.set_account_group(groupname, users) self._print(self._groups(), self.print_dict) def main(self, groupname): super(self.__class__, self)._run() users = (self['user_uuid'] or []) + self._usernames2uuids( self['username'] or []).values() if users: self._run(groupname, *users) else: raise CLISyntaxError( 'No valid users specified, use %s or %s' % ( self.arguments['user_uuid'].lvalue, self.arguments['username'].lvalue), details=[ 'Check if a username or uuid is valid with', ' user uuid2username', 'OR', ' user username2uuid']) @command(group_cmds) class group_delete(_pithos_group, _optional_json): """Delete a user group""" @errors.generic.all @errors.pithos.connection def _run(self, groupname): self.client.del_account_group(groupname) self._print(self._groups(), self.print_dict) def main(self, groupname): super(self.__class__, self)._run() self._run(groupname) # Deprecated commands @command(file_cmds) class file_publish(_pithos_init): """DEPRECATED, replaced by [kamaki] file modify OBJECT --publish""" def main(self, *args): raise CLISyntaxError('DEPRECATED', details=[ 'This command is replaced by:', ' [kamaki] file modify OBJECT --publish']) @command(file_cmds) class file_unpublish(_pithos_init): """DEPRECATED, replaced by [kamaki] file modify OBJECT --unpublish""" def main(self, *args): raise CLISyntaxError('DEPRECATED', details=[ 'This command is replaced by:', ' [kamaki] file modify OBJECT --unpublish'])