diff --git a/acd_cli-0.3.2-to-af929608f6279fca56e6c9ac6c48bd76d4ff1dcb.patch b/acd_cli-0.3.2-to-af929608f6279fca56e6c9ac6c48bd76d4ff1dcb.patch new file mode 100644 index 0000000..8e3583c --- /dev/null +++ b/acd_cli-0.3.2-to-af929608f6279fca56e6c9ac6c48bd76d4ff1dcb.patch @@ -0,0 +1,1347 @@ +diff --git a/.travis.yml b/.travis.yml +index b8a50f9..493c3cf 100644 +--- a/.travis.yml ++++ b/.travis.yml +@@ -3,6 +3,8 @@ python: + - "3.2" + - "3.3" + - "3.4" ++ - "3.5" ++ - "3.6" + + addons: + apt: +diff --git a/README.rst b/README.rst +index 4b7b099..d7417d3 100644 +--- a/README.rst ++++ b/README.rst +@@ -98,10 +98,16 @@ Have a look at the `contributing guidelines `_. + Recent Changes + -------------- + ++0.3.3 (upcoming) ++~~~~~~~~~~~~~~~~ ++ ++* stat ++* Appspot OAuth proxy switch ++ + 0.3.2 + ~~~~~ + * added ``--remove-source-files`` argument to upload action +-* added ``--times``` argument to download action for preservation of modification times ++* added ``--times`` argument to download action for preservation of modification times + * added streamed overwrite action + * fixed upload of directories containing broken symlinks + * disabled FUSE autosync by default +diff --git a/acd_cli.py b/acd_cli.py +index 52f4b4b..322963b 100755 +--- a/acd_cli.py ++++ b/acd_cli.py +@@ -69,11 +69,12 @@ for path in paths: + + def_conf = ConfigParser() + def_conf['download'] = dict(keep_corrupt=False, keep_incomplete=True) ++def_conf['upload'] = dict(timeout_wait=10) + conf = None + + # consts + +-MIN_AUTOSYNC_INTERVAL = 60 ++MIN_SYNC_INTERVAL = 300 + MAX_LOG_SIZE = 10 * 2 ** 20 + MAX_LOG_FILES = 5 + +@@ -130,6 +131,14 @@ class CacheConsts(object): + def sync_node_list(full=False, to_file=None, from_file=None) -> 'Union[int, None]': + global cache + cp_ = cache.KeyValueStorage.get(CacheConsts.CHECKPOINT_KEY) if not full else None ++ lst = cache.KeyValueStorage.get(CacheConsts.LAST_SYNC_KEY) ++ lst = float(lst) if lst else 0 ++ ++ wt = min(lst + MIN_SYNC_INTERVAL - time.time(), MIN_SYNC_INTERVAL) ++ if lst and wt > 0: ++ print('Last sync was very recent or has invalid date. Waiting %im %is.' ++ % (wt / 60, wt % 60)) ++ time.sleep(wt) + + print('Getting changes', end='', flush=True) + +@@ -197,14 +206,57 @@ def old_sync() -> 'Union[int, None]': + files = acd_client.get_file_list() + files.extend(acd_client.get_trashed_files()) + except RequestError as e: ++ logger.error(e) + logger.critical('Sync failed.') +- print(e) + return ERROR_RETVAL + + cache.insert_nodes(files + folders, partial=False) + cache.KeyValueStorage['sync_date'] = time.time() + + ++def partial_sync(path: str, recursive: bool) -> 'Union[int|None]': ++ path = '/' + '/'.join(list(filter(bool, path.split('/')))) ++ n = cache.resolve(path, trash=False) ++ fid = None ++ ++ if n: ++ fid = n.id ++ n = acd_client.get_metadata(fid) ++ cache.insert_node(n) ++ else: ++ exc = None ++ try: ++ folder_chain = acd_client.resolve_folder_path(path) ++ except RequestError as e: ++ exc = e ++ ++ if not folder_chain or exc: ++ if exc: ++ logger.error(e) ++ logger.critical('Could not resolve path "%s".' % path) ++ return INVALID_ARG_RETVAL ++ ++ cache.insert_nodes(folder_chain) ++ fid = folder_chain[-1]['id'] ++ ++ try: ++ children = acd_client.list_children(fid) ++ if recursive: ++ recursive_insert(children) ++ else: ++ cache.insert_nodes(children) ++ except RequestError as e: ++ logger.error("Sync failed: %s" % e) ++ return ERROR_RETVAL ++ ++ ++def recursive_insert(nodes: 'List[dict]'): ++ cache.insert_nodes(nodes) ++ for n in nodes: ++ if n['kind'] == 'FOLDER': ++ recursive_insert(acd_client.list_children(n['id'])) ++ ++ + def autosync(interval: int, stop: Event = None): + """Periodically syncs the node cache each *interval* seconds. + +@@ -213,7 +265,7 @@ def autosync(interval: int, stop: Event = None): + if not interval: + return + +- interval = max(MIN_AUTOSYNC_INTERVAL, interval) ++ interval = max(MIN_SYNC_INTERVAL, interval) + while True: + if stop.is_set(): + break +@@ -233,6 +285,7 @@ RetryRetVal = namedtuple('RetryRetVal', ['ret_val', 'retry']) + STD_RETRY_RETVALS = [UL_DL_FAILED] + DL_RETRY_RETVALS = [UL_DL_FAILED, HASH_MISMATCH] + ++ + def retry_on(ret_vals: 'List[int]'): + """Retry decorator that sets the wrapped function's progress handler argument according to its + return value and wraps the return value in RetryRetVal object. +@@ -308,7 +361,7 @@ def upload_complete(node: dict, path: str, hash_: str, size_: int, rsf: bool) -> + + + def upload_timeout(parent_id: str, path: str, hash_: str, size_: int, rsf: bool) -> int: +- minutes = 10 ++ minutes = conf.getint('upload', 'timeout_wait') + while minutes > 0: + time.sleep(60) + minutes -= 1 +@@ -322,7 +375,7 @@ def upload_timeout(parent_id: str, path: str, hash_: str, size_: int, rsf: bool) + + + def overwrite_timeout(initial_node: dict, path: str, hash_: str, size_: int, rsf: bool) -> int: +- minutes = 10 ++ minutes = conf.getint('upload', 'timeout_wait') + while minutes > 0: + time.sleep(60) + minutes -= 1 +@@ -426,19 +479,23 @@ def traverse_ul_dir(dirs: list, directory: str, parent_id: str, overwr: bool, fo + curr_node = cache.get_node(r['id']) + except RequestError as e: + if e.status_code == 409: +- logger.error('Folder "%s" already exists. Please sync.' % short_nm) ++ logger.error('Folder "%s" already exists in %s [%s]. Error message: %s.' ++ 'You may need to sync.' ++ % (short_nm, parent.simple_name, parent_id, e)) + else: +- logger.error('Error creating remote folder "%s": %s.' % (short_nm, e)) ++ logger.error('Error creating remote folder "%s" in %s [%s]. Error message: %s.' ++ % (short_nm, parent.simple_name, parent_id, e)) + return ERR_CR_FOLDER + elif curr_node.is_file: +- logger.error('Cannot create remote folder "%s", ' +- 'because a file of the same name already exists.' % short_nm) ++ logger.error('Cannot create remote folder "%s" in %s [%s], ' ++ 'because a file of the same name already exists.' ++ % (short_nm, parent.simple_name, parent_id)) + return ERR_CR_FOLDER + + try: + entries = sorted(os.listdir(directory)) + except OSError as e: +- logger.error('Skipping directory %s because of an error.' % directory) ++ logger.error('Skipping directory %s because of an error: %s' % (directory, e)) + logger.info(e) + return ERROR_RETVAL + +@@ -460,7 +517,8 @@ def upload_file(path: str, parent_id: str, overwr: bool, force: bool, dedup: boo + nodes = cache.find_by_md5(hashing.hash_file(path)) + nodes = [n for n in cache.path_format(nodes)] + if len(nodes) > 0: +- logger.info('Skipping upload of duplicate file "%s". Location of duplicates: %s' % (short_nm, nodes)) ++ logger.info('Skipping upload of duplicate file "%s". Location of duplicates: %s' ++ % (short_nm, nodes)) + pg_handler.done() + if rsf: + return remove_file(path) +@@ -470,13 +528,17 @@ def upload_file(path: str, parent_id: str, overwr: bool, force: bool, dedup: boo + file_id = None + if conflicting_node: + if conflicting_node.name != short_nm: +- logger.error('File name "%s" collides with remote node "%s".' +- % (short_nm, conflicting_node.name)) ++ logger.error('Name collision in %s [%s]: ' ++ 'File name "%s" collides with existing node "%s".' ++ % (cache.get_node(parent_id).simple_name, parent_id, ++ short_nm, conflicting_node.simple_name)) + return NAME_COLLISION + + if conflicting_node.is_folder: +- logger.error('Name collision with existing folder ' +- 'in the same location: "%s".' % short_nm) ++ logger.error('Name collision in %s [%s]: ' ++ 'File name "%s" collides with existing folder "%s".' ++ % (cache.get_node(parent_id).simple_name, parent_id, ++ short_nm, conflicting_node.simple_name)) + return NAME_COLLISION + + file_id = conflicting_node.id +@@ -492,19 +554,22 @@ def upload_file(path: str, parent_id: str, overwr: bool, force: bool, dedup: boo + except RequestError as e: + if e.status_code == 409: # might happen if cache is outdated + if not dedup: +- logger.error('Uploading "%s" failed. Name collision with non-cached file. ' +- 'If you want to overwrite, please sync and try again.' % short_nm) ++ logger.error('Uploading "%s" failed. Name collision with non-cached file ' ++ 'in folder "%s" [%s]. If you want to overwrite, ' ++ 'please sync and try again. Error message: %s.' ++ % (short_nm, cache.get_node(parent_id).simple_name, parent_id, e)) + else: +- logger.error( +- 'Uploading "%s" failed. ' +- 'Name or hash collision with non-cached file.' % short_nm) ++ logger.error('Uploading "%s" failed. Name or hash collision' ++ 'in folder "%s" [%s] with non-cached file. Error message: %s.' ++ % (short_nm, cache.get_node(parent_id).simple_name, parent_id, e)) + logger.info(e) + # colliding node ID is returned in error message -> could be used to continue + return CACHE_ASYNC + elif e.status_code == 504 or e.status_code == 408: # proxy timeout / request timeout + return upload_timeout(parent_id, path, hasher.get_result(), local_size, rsf) + else: +- logger.error('Uploading "%s" failed. %s.' % (short_nm, str(e))) ++ logger.error('Uploading "%s" to "%s" [%s] failed. Error message: %s.' ++ % (short_nm, cache.get_node(parent_id).simple_name, parent_id, e)) + return UL_DL_FAILED + else: + return upload_complete(r, path, hasher.get_result(), local_size, rsf) +@@ -521,17 +586,16 @@ def upload_file(path: str, parent_id: str, overwr: bool, force: bool, dedup: boo + if not overwr and not force: + logger.info('Skipping upload of existing file "%s".' % short_nm) + pg_handler.done() +- ++ + if not rsf: + return 0 + + if not compare_sizes(os.path.getsize(path), conflicting_node.size, short_nm): + return remove_file(path) +- ++ + logger.info('Keeping "%s" because of remote size mismatch.' % path) + return 0 + +- + # ctime is checked because files can be overwritten by files with older mtime + if rmod < lmod or (rmod < lcre and conflicting_node.size != os.path.getsize(path)) \ + or force: +@@ -621,7 +685,7 @@ def create_dl_jobs(node_id: str, local_path: str, preserve_mtime: bool, rsf: boo + + flp = os.path.join(local_path, loc_name) + if os.path.isfile(flp): +- logger.info('Skipping download of existing file "%s"' % loc_name) ++ logger.info('Skipping download of existing file "%s".' % loc_name) + if os.path.getsize(flp) != node.size: + logger.info('Skipped file "%s" has different size than local file.' % loc_name) + return SIZE_MISMATCH +@@ -718,7 +782,11 @@ def no_autores_trash_action(func): + # actual actions + + def sync_action(args: argparse.Namespace): +- return sync_node_list(args.full, args.to_file, args.from_file) ++ ret = sync_node_list(args.full, args.to_file, args.from_file) ++ if cache.get_root_node() or args.to_file: ++ return ret ++ logger.error("Root node not found. Sync may have been incomplete.") ++ return ret if ret else 0 | ERROR_RETVAL + + + def old_sync_action(args: argparse.Namespace): +@@ -729,6 +797,14 @@ def old_sync_action(args: argparse.Namespace): + return r + + ++def partial_sync_action(args: argparse.Namespace): ++ print('Syncing...') ++ r = partial_sync(args.path, args.recursive) ++ if not r: ++ print('Done.') ++ return r ++ ++ + @nocache_action + @offline_action + def delete_everything_action(args: argparse.Namespace): +@@ -746,9 +822,10 @@ def delete_everything_action(args: argparse.Namespace): + print('Deleting directory failed.') + + ++@nocache_action + @offline_action + def clear_action(args: argparse.Namespace): +- if not cache.drop_all(): ++ if not db.NodeCache.remove_db_file(CACHE_PATH, SETTINGS_PATH): + return ERROR_RETVAL + + +@@ -976,7 +1053,7 @@ def restore_action(args: argparse.Namespace) -> int: + def resolve_action(args: argparse.Namespace) -> int: + node = cache.resolve(args.path) + if node: +- print(node) ++ print(node.id) + else: + return INVALID_ARG_RETVAL + +@@ -1085,7 +1162,8 @@ def mount_action(args: argparse.Namespace): + nothreads=args.single_threaded, + nonempty=args.nonempty, modules=args.modules, + umask=args.umask,gid=args.gid,uid=args.uid, +- allow_root=args.allow_root, allow_other=args.allow_other) ++ allow_root=args.allow_root, allow_other=args.allow_other, ++ volname=args.volname) + + + @offline_action +@@ -1120,6 +1198,9 @@ def resolve_remote_path_args(args: argparse.Namespace, attrs: list, incl_trash: + setattr(args, id_attr, v.id) + setattr(args, id_attr + '_path', val) + elif is_valid_id(val): ++ if not cache.get_node(val): ++ logger.critical('Cannot find node with ID "%s".' % val) ++ sys.exit(INVALID_ARG_RETVAL) + setattr(args, id_attr + '_path', cache.first_path(val)) + else: + logger.critical('Invalid ID format: "%s".' % val) +@@ -1295,7 +1376,7 @@ def get_parser() -> tuple: + vers_sp.set_defaults(func=print_version_action) + + sync_sp = subparsers.add_parser('sync', aliases=['s'], +- help='[+] refresh node list cache; fetches complete node list ' ++ help='[+] refresh node cache; fetches complete node list ' + 'if the cache is empty or incremental changes ' + 'if the cache is non-empty') + sync_sp.add_argument('--full', '-f', action='store_true', +@@ -1309,6 +1390,12 @@ def get_parser() -> tuple: + old_sync_sp = subparsers.add_parser('old-sync', add_help=False) + old_sync_sp.set_defaults(func=old_sync_action) + ++ psync_sp = subparsers.add_parser('psync', help='[+] only refresh the node cache for the ' ++ 'specified folder [does not include trash]') ++ psync_sp.add_argument('--recursive', '-r', action='store_true') ++ psync_sp.add_argument('path') ++ psync_sp.set_defaults(func=partial_sync_action) ++ + clear_sp = subparsers.add_parser('clear-cache', aliases=['cc'], + help='delete node cache file [offline operation]\n\n') + clear_sp.set_defaults(func=clear_action) +@@ -1507,6 +1594,7 @@ def get_parser() -> tuple: + fuse_sp.add_argument('--nlinks', '-n', action='store_true', help='calculate nlinks') + fuse_sp.add_argument('--interval', '-i', type=int, default=0, + help='sync every x seconds [turned off by default]') ++ fuse_sp.add_argument('--volname', '-vn', help='override volume name') + fuse_sp.add_argument('path') + fuse_sp.set_defaults(func=mount_action) + +@@ -1556,11 +1644,13 @@ def main(): + logger.info(msg) + + logger.info('Settings path is "%s".' % SETTINGS_PATH) +- +- conf = get_conf(SETTINGS_PATH, _SETTINGS_FILENAME, def_conf) ++ logger.info('Cache path is "%s".' % CACHE_PATH) + + global acd_client + global cache ++ global conf ++ ++ conf = get_conf(SETTINGS_PATH, _SETTINGS_FILENAME, def_conf) + + if args.func not in offline_actions: + try: +@@ -1576,7 +1666,7 @@ def main(): + raise + sys.exit(INIT_FAILED_RETVAL) + +- if args.func not in [sync_action, old_sync_action, clear_action]: ++ if args.func not in [sync_action, old_sync_action, partial_sync_action, clear_action]: + if not check_cache(): + sys.exit(INIT_FAILED_RETVAL) + pass +@@ -1600,7 +1690,7 @@ def main(): + + ret = args.func(args) + if not ret: +- sys.exit() ++ sys.exit(ret) + + trunc_ret = ret % 256 + if trunc_ret != ret: +diff --git a/acdcli/acd_fuse.py b/acdcli/acd_fuse.py +index 68e026c..2d30158 100644 +--- a/acdcli/acd_fuse.py ++++ b/acdcli/acd_fuse.py +@@ -45,6 +45,7 @@ except: + _SETTINGS_FILENAME = 'fuse.ini' + + _def_conf = configparser.ConfigParser() ++_def_conf['fs'] = dict(block_size=512) + _def_conf['read'] = dict(open_chunk_limit=10, timeout=5) + _def_conf['write'] = dict(buffer_size = 32, timeout=30) + +@@ -381,11 +382,12 @@ class ACDFuse(LoggingMixIn, Operations): + self.acd_client = kwargs['acd_client'] + autosync = kwargs['autosync'] + conf = kwargs['conf'] ++ self.conf = conf + +- self.rp = ReadProxy(self.acd_client, ++ self.rp = ReadProxy(self.acd_client, + conf.getint('read', 'open_chunk_limit'), conf.getint('read', 'timeout')) + """collection of files opened for reading""" +- self.wp = WriteProxy(self.acd_client, self.cache, ++ self.wp = WriteProxy(self.acd_client, self.cache, + conf.getint('write', 'buffer_size'), conf.getint('write', 'timeout')) + """collection of files opened for writing""" + try: +@@ -453,6 +455,8 @@ class ACDFuse(LoggingMixIn, Operations): + return dict(st_mode=stat.S_IFREG | 0o0666, + st_nlink=self.cache.num_parents(node.id) if self.nlinks else 1, + st_size=node.size, ++ st_blksize=self.conf.getint('fs', 'block_size'), ++ st_blocks=(node.size+511)//512, + **times) + + def read(self, path, length, offset, fh) -> bytes: +@@ -476,7 +480,7 @@ class ACDFuse(LoggingMixIn, Operations): + def statfs(self, path) -> dict: + """Gets some filesystem statistics as specified in :manpage:`stat(2)`.""" + +- bs = 512 * 1024 # no effect? ++ bs = self.conf.getint('fs', 'block_size') + return dict(f_bsize=bs, + f_frsize=bs, + f_blocks=self.total // bs, # total no of blocks +@@ -706,8 +710,11 @@ def mount(path: str, args: dict, **kwargs) -> 'Union[int, None]': + return 1 + + opts = dict(auto_cache=True, sync_read=True) +- if sys.platform == 'linux': +- opts['big_writes']=True ++ if sys.platform.startswith('linux'): ++ opts['big_writes'] = True ++ ++ if sys.platform != 'darwin' or kwargs['volname'] is None: ++ del kwargs['volname'] + + kwargs.update(opts) + +diff --git a/acdcli/api/__init__.py b/acdcli/api/__init__.py +index b3ceea1..5e2c76b 100644 +--- a/acdcli/api/__init__.py ++++ b/acdcli/api/__init__.py +@@ -54,7 +54,7 @@ A folder's JSON looks similar, but it lacks the ``contentProperties`` dictionary + + """ + +-__version__ = '0.9.0' ++__version__ = '0.9.3' + + # monkey patch the user agent + try: +diff --git a/acdcli/api/client.py b/acdcli/api/client.py +index 9a2a27f..64fd00f 100644 +--- a/acdcli/api/client.py ++++ b/acdcli/api/client.py +@@ -32,7 +32,6 @@ _def_conf['proxies'] = dict() + class ACDClient(AccountMixin, ContentMixin, MetadataMixin, TrashMixin): + """Provides a client to the Amazon Cloud Drive RESTful interface.""" + +- + def __init__(self, cache_path='', settings_path=''): + """Initializes OAuth and endpoints.""" + +diff --git a/acdcli/api/content.py b/acdcli/api/content.py +index ae5d9fd..901ab43 100644 +--- a/acdcli/api/content.py ++++ b/acdcli/api/content.py +@@ -56,6 +56,7 @@ def _stream_is_empty(stream) -> bool: + 'not contain at least one byte.') + return False + ++ + class ContentMixin(object): + """Implements content portion of the ACD API.""" + +@@ -125,11 +126,8 @@ class ContentMixin(object): + mime_type = _get_mimetype(basename) + f = _tee_open(file_name, callbacks=read_callbacks) + +- # basename is ignored + m = MultipartEncoder(fields=OrderedDict([('metadata', json.dumps(metadata)), +- ( +- 'content', +- (quote_plus(basename), f, mime_type))])) ++ ('content', ('filename', f, mime_type))])) + + ok_codes = [http.CREATED] + r = self.BOReq.post(self.content_url + 'nodes', params=params, data=m, +@@ -305,6 +303,12 @@ class ContentMixin(object): + + dl_chunk_sz = self._conf.getint('transfer', 'dl_chunk_size') + ++ seekable = True ++ try: ++ file.tell() ++ except OSError: ++ seekable = False ++ + retries = 0 + while chunk_start < length: + chunk_end = chunk_start + dl_chunk_sz - 1 +@@ -341,7 +345,10 @@ class ContentMixin(object): + curr_ln += len(chunk) + finally: + r.close() +- chunk_start = file.tell() ++ if seekable: ++ chunk_start = file.tell() ++ else: ++ chunk_start = chunk_start + curr_ln + + retries = 0 + +diff --git a/acdcli/api/metadata.py b/acdcli/api/metadata.py +index fdfc34e..494d6fc 100644 +--- a/acdcli/api/metadata.py ++++ b/acdcli/api/metadata.py +@@ -159,10 +159,8 @@ class MetadataMixin(object): + raise RequestError(r.status_code, r.text) + return r.json() + +- def get_root_id(self) -> str: +- """Gets the ID of the root node +- +- :returns: the topmost folder id""" ++ def get_root_node(self) -> dict: ++ """Gets the root node metadata""" + + params = {'filters': 'isRoot:true'} + r = self.BOReq.get(self.metadata_url + 'nodes', params=params) +@@ -172,13 +170,26 @@ class MetadataMixin(object): + + data = r.json() + +- if 'id' in data['data'][0]: +- return data['data'][0]['id'] ++ return data['data'][0] ++ ++ def get_root_id(self) -> str: ++ """Gets the ID of the root node ++ ++ :returns: the topmost folder id""" ++ ++ r = self.get_root_node() ++ if 'id' in r['data'][0]: ++ return r['data'][0]['id'] + + def list_children(self, node_id: str) -> list: + l = self.BOReq.paginated_get(self.metadata_url + 'nodes/' + node_id + '/children') + return l + ++ def list_child_folders(self, node_id: str) -> list: ++ l = self.BOReq.paginated_get(self.metadata_url + 'nodes/' + node_id + '/children', ++ params={'filters': 'kind:FOLDER'}) ++ return l ++ + def add_child(self, parent_id: str, child_id: str) -> dict: + """Adds node with ID *child_id* to folder with ID *parent_id*. + +@@ -274,3 +285,26 @@ class MetadataMixin(object): + % (self.metadata_url, node_id, owner_id, key), acc_codes=ok_codes) + if r.status_code not in ok_codes: + raise RequestError(r.status_code, r.text) ++ ++ def resolve_folder_path(self, path: str) -> 'List[dict]': ++ """Resolves a non-trash folder path to a list of folder entries.""" ++ segments = list(filter(bool, path.split('/'))) ++ folder_chain = [] ++ ++ root = self.get_root_node() ++ folder_chain.append(root) ++ ++ if not segments: ++ return folder_chain ++ ++ for i, segment in enumerate(segments): ++ dir_entries = self.list_child_folders(folder_chain[-1]['id']) ++ ++ for ent in dir_entries: ++ if ent['status'] == 'AVAILABLE' and ent['name'] == segment: ++ folder_chain.append(ent) ++ break ++ if len(folder_chain) != i + 2: ++ return [] ++ ++ return folder_chain +diff --git a/acdcli/api/oauth.py b/acdcli/api/oauth.py +index 049f4fd..46bf9d9 100644 +--- a/acdcli/api/oauth.py ++++ b/acdcli/api/oauth.py +@@ -29,7 +29,7 @@ def create_handler(path: str): + + + class OAuthHandler(AuthBase): +- OAUTH_DATA_FILE = 'oauth_data' ++ OAUTH_DATA_FILE = 'oauth.json' + + class KEYS(object): + EXP_IN = 'expires_in' +@@ -160,7 +160,7 @@ class OAuthHandler(AuthBase): + + + class AppspotOAuthHandler(OAuthHandler): +- APPSPOT_URL = 'https://tensile-runway-92512.appspot.com/' ++ APPSPOT_URL = 'https://acd-api-oa.appspot.com/' + + def __init__(self, path): + super().__init__(path) +diff --git a/acdcli/cache/cursors.py b/acdcli/cache/cursors.py +old mode 100644 +new mode 100755 +index 7d5d16e..f9db7f2 +--- a/acdcli/cache/cursors.py ++++ b/acdcli/cache/cursors.py +@@ -21,5 +21,8 @@ class mod_cursor(object): + return self.cursor + + def __exit__(self, exc_type, exc_val, exc_tb): +- self.conn.commit() ++ if exc_type is None: ++ self.conn.commit() ++ else: ++ self.conn.rollback() + self.cursor.close() +diff --git a/acdcli/cache/db.py b/acdcli/cache/db.py +index 994a925..efdac5e 100644 +--- a/acdcli/cache/db.py ++++ b/acdcli/cache/db.py +@@ -3,6 +3,7 @@ import logging + import os + import re + import sqlite3 ++import sys + from threading import local + + from acdcli.utils.conf import get_conf +@@ -22,7 +23,7 @@ _SETTINGS_FILENAME = 'cache.ini' + + _def_conf = configparser.ConfigParser() + _def_conf['sqlite'] = dict(filename='nodes.db', busy_timeout=30000, journal_mode='wal') +-_def_conf['blacklist'] = dict(folders= []) ++_def_conf['blacklist'] = dict(folders=[]) + + + +@@ -57,7 +58,10 @@ class NodeCache(SchemaMixin, QueryMixin, SyncMixin, FormatterMixin): + self.tl = local() + + self.integrity_check(check) +- self.init() ++ try: ++ self.init() ++ except sqlite3.DatabaseError as e: ++ raise IntegrityError(e) + + self._conn.create_function('REGEXP', _regex_match.__code__.co_argcount, _regex_match) + +@@ -75,7 +79,8 @@ class NodeCache(SchemaMixin, QueryMixin, SyncMixin, FormatterMixin): + self.root_id = first_id + + self._execute_pragma('busy_timeout', self._conf['sqlite']['busy_timeout']) +- self._execute_pragma('journal_mode', self._conf['sqlite']['journal_mode']) ++ if sys.version_info[:3] != (3, 6, 0): ++ self._execute_pragma('journal_mode', self._conf['sqlite']['journal_mode']) + + @property + def _conn(self) -> sqlite3.Connection: +@@ -91,22 +96,25 @@ class NodeCache(SchemaMixin, QueryMixin, SyncMixin, FormatterMixin): + logger.debug('Set %s to %s. Result: %s.' % (key, value, r[0])) + return r[0] + +- def remove_db_file(self) -> bool: ++ @classmethod ++ def remove_db_file(cls, cache_path='', settings_path='') -> bool: + """Removes database file.""" +- self._conn.close() + + import os + import random + import string + import tempfile + ++ conf = get_conf(settings_path, _SETTINGS_FILENAME, _def_conf) ++ db_path = os.path.join(cache_path, conf['sqlite']['filename']) ++ + tmp_name = ''.join(random.choice(string.ascii_lowercase) for _ in range(16)) + tmp_name = os.path.join(tempfile.gettempdir(), tmp_name) + + try: +- os.rename(self.db_path, tmp_name) ++ os.rename(db_path, tmp_name) + except OSError: +- logger.critical('Error renaming/removing database file "%s".' % self.db_path) ++ logger.critical('Error renaming/removing database file "%s".' % db_path) + return False + else: + try: +diff --git a/acdcli/cache/query.py b/acdcli/cache/query.py +index 44ea869..469b953 100644 +--- a/acdcli/cache/query.py ++++ b/acdcli/cache/query.py +@@ -13,6 +13,12 @@ def datetime_from_string(dt: str) -> datetime: + return dt + + ++CONFLICTING_NODE_SQL = """SELECT n.*, f.* FROM nodes n ++ JOIN parentage p ON n.id = p.child ++ LEFT OUTER JOIN files f ON n.id = f.id ++ WHERE p.parent = (?) AND LOWER(name) = (?) AND status = 'AVAILABLE' ++ ORDER BY n.name""" ++ + CHILDREN_SQL = """SELECT n.*, f.* FROM nodes n + JOIN parentage p ON n.id = p.child + LEFT OUTER JOIN files f ON n.id = f.id +@@ -146,10 +152,11 @@ class QueryMixin(object): + + def get_conflicting_node(self, name: str, parent_id: str): + """Finds conflicting node in folder specified by *parent_id*, if one exists.""" +- folders, files = self.list_children(parent_id) +- for n in folders + files: +- if n.is_available and n.name.lower() == name.lower(): +- return n ++ with cursor(self._conn) as c: ++ c.execute(CONFLICTING_NODE_SQL, [parent_id, name.lower()]) ++ r = c.fetchone() ++ if r: ++ return Node(r) + + def resolve(self, path: str, trash=False) -> 'Union[Node|None]': + segments = list(filter(bool, path.split('/'))) +@@ -176,9 +183,9 @@ class QueryMixin(object): + if not trash: + return + if r2: +- logger.debug('None-unique trash name "%s" in %s.' %(segment, parent)) ++ logger.debug('None-unique trash name "%s" in %s.' % (segment, parent)) + return +- if i + 1 == segments.__len__(): ++ if i + 1 == len(segments): + return r + if r.is_folder: + parent = r.id +diff --git a/acdcli/cache/schema.py b/acdcli/cache/schema.py +old mode 100644 +new mode 100755 +index d5e138b..92a5512 +--- a/acdcli/cache/schema.py ++++ b/acdcli/cache/schema.py +@@ -52,14 +52,25 @@ _CREATION_SCRIPT = """ + FOREIGN KEY(child) REFERENCES nodes (id) + ); + ++ CREATE INDEX ix_parentage_child ON parentage(child); + CREATE INDEX ix_nodes_names ON nodes(name); +- PRAGMA user_version = 2; ++ PRAGMA user_version = 3; + """ + + _GEN_DROP_TABLES_SQL = \ + 'SELECT "DROP TABLE " || name || ";" FROM sqlite_master WHERE type == "table"' + ++_migrations = [] ++"""list of all schema migrations""" + ++ ++def _migration(func): ++ """scheme migration annotation; must be used in correct order""" ++ _migrations.append(func) ++ return func ++ ++ ++@_migration + def _0_to_1(conn): + conn.executescript( + 'ALTER TABLE nodes ADD updated DATETIME;' +@@ -69,6 +80,7 @@ def _0_to_1(conn): + conn.commit() + + ++@_migration + def _1_to_2(conn): + conn.executescript( + 'DROP TABLE IF EXISTS folders;' +@@ -79,12 +91,18 @@ def _1_to_2(conn): + conn.commit() + + +-_migrations = [_0_to_1, _1_to_2] +-"""list of all migrations from index -> index+1""" ++@_migration ++def _2_to_3(conn): ++ conn.executescript( ++ 'CREATE INDEX IF NOT EXISTS ix_parentage_child ON parentage(child);' ++ 'REINDEX;' ++ 'PRAGMA user_version = 3;' ++ ) ++ conn.commit() + + + class SchemaMixin(object): +- _DB_SCHEMA_VER = 2 ++ _DB_SCHEMA_VER = 3 + + def init(self): + try: +diff --git a/acdcli/utils/conf.py b/acdcli/utils/conf.py +index 9ebf249..b11034a 100644 +--- a/acdcli/utils/conf.py ++++ b/acdcli/utils/conf.py +@@ -14,7 +14,7 @@ def get_conf(path, filename, default_conf: configparser.ConfigParser) \ + try: + with open(conffn) as cf: + conf.read_file(cf) +- except OSError: ++ except (OSError, IOError): + pass + + logger.debug('configuration resulting from merging default and %s: %s' % (filename, +diff --git a/docs/FAQ.rst b/docs/FAQ.rst +index 7bccc88..4ad97c4 100644 +--- a/docs/FAQ.rst ++++ b/docs/FAQ.rst +@@ -34,6 +34,9 @@ If the sync times out, consider increasing the idle timeout (refer to the + You may also want to try the deprecated (and undocumented) synchronization method ``acd_cli old-sync`` + if you happen to have only up to a few thousand files and folders in total. + ++If you do not need to synchronize your full Drive hierarchy, consider running a partial sync ++(``psync``). ++ + How Do I Pass a Node ID Starting with ``-`` (dash/minus/hyphen)? + ---------------------------------------------------------------- + +diff --git a/docs/FUSE.rst b/docs/FUSE.rst +index c79dde9..606c88e 100644 +--- a/docs/FUSE.rst ++++ b/docs/FUSE.rst +@@ -86,6 +86,7 @@ may be used, e.g. ``--modules="iconv,to_code=CHARSET"``. + --single-threaded, -st disallow multi-threaded FUSE operations + --uid UID override the user ID (defaults to the user's uid) + --umask UMASK override the standard permission bits ++--volname VN, -vn VN set the volume name to VN (Mac OS) + + Automatic Remount + ~~~~~~~~~~~~~~~~~ +@@ -106,7 +107,7 @@ For this to work, an executable shell script /usr/bin/acdmount must be created + + #!/bin/bash + +- acd_cli mount -nl $1 ++ acd_cli -nl mount $1 + + Library Path + ~~~~~~~~~~~~ +diff --git a/docs/authorization.rst b/docs/authorization.rst +index f032761..b2a97f9 100644 +--- a/docs/authorization.rst ++++ b/docs/authorization.rst +@@ -2,30 +2,43 @@ Authorization + ------------- + + Before you can use the program, you will have to complete the OAuth procedure with Amazon. +-It is necessary to have a Web browser installed in order to do so. +-There is a fast and simple way and a secure way. ++The initially obtained OAuth credentials can subsequently be refreshed automatically when ++necessary, which is at most once an hour. ++ ++It is necessary to have a (preferrably graphical) Web browser installed to complete the procedure. ++You may use another computer for this than the one acd\_cli will run on eventually. ++ ++If you are a new user, your only option is to use the Appspot authentication method ++which relays your OAuth tokens through a small Google Compute Engine app. ++If you have a security profile which was whitelisted for Amazon Drive access (prior to fall 2016), ++please skip to the Security Profile section. + + Simple (Appspot) + ++++++++++++++++ + ++This authorization method was created to remove the initial barrier for most casual users. It will ++forward your authentication data through an external computing platform service (Google App ++Engine) and may be less secure than using your own security profile. Use it at your own risk. ++ ++You may view the source code of the Appspot app that is used to handle the server part ++of the OAuth procedure at https://acd-api-oa.appspot.com/src. ++ + You will not have to prepare anything to initiate this authorization method, just + run, for example, ``acd_cli init``. + + A browser (tab) will open and you will be asked to log into your Amazon account +-or grant access for 'acd\_cli\_oa'. ++or grant access for 'acd-api'. + Signing in or clicking on 'Continue' will download a JSON file named ``oauth_data``, which must be + placed in the cache directory displayed on screen (e.g. ``/home//.cache/acd_cli``). + +-You may view the source code of the Appspot app that is used to handle the server part +-of the OAuth procedure at https://tensile-runway-92512.appspot.com/src. +- + Advanced Users (Security Profile) + +++++++++++++++++++++++++++++++++ + +-You must create a security profile and have it whitelisted. Have a look at Amazon's ++You must have a security profile and have it whitelisted, as described in Amazon's + `ACD getting started guide + `_. +-Select all permissions for your security profile and add a redirect URL to ``http://localhost``. ++The security profile must be whitelisted for read and write aceess and have a redirect ++URL set for ``http://localhost``. + + Put your own security profile data in a file called ``client_data`` in the cache directory + and have it adhere to the following form. +@@ -49,3 +62,19 @@ Changing Authorization Methods + + If you want to change between authorization methods, go to your cache path (it is stated in the + output of ``acd_cli -v init``) and delete the file ``oauth_data`` and, if it exists, ``client_data``. ++ ++Copying Credentials +++++++++++++++++++++ ++ ++The same OAuth credentials may be used on multiple user accounts and multiple machines without a ++problem. To copy them, first look up acd\_cli's source and destination cache path like ++mentioned in the section above. Find the file/s ``oauth_data`` and possibly ``client_data`` in the ++source path and just copy it/them to the destination path. ++ ++Accessing multiple Amazon accounts ++++++++++++++++++++++++++++++++++++ ++ ++It is possible to use the cache path environment variable to set up an additional cache that is ++linked to a different Amazon account by OAuth credentials. Please see the ++:doc:`setup section ` on environment variables. ++ +diff --git a/docs/configuration.rst b/docs/configuration.rst +index 78c5a40..4d8658b 100644 +--- a/docs/configuration.rst ++++ b/docs/configuration.rst +@@ -5,6 +5,22 @@ Some module constants may be set in INI-style configuration files. If you want t + the defaults as described below, create a plain text file for the module using the section heading + as the file name in the settings directory. + ++acd\_cli.ini ++------------ ++ ++:: ++ ++ [download] ++ ;do not delete corrupt files ++ keep_corrupt = False ++ ++ ;do not delete partially downloaded files ++ keep_incomplete = True ++ ++ [upload] ++ ;waiting time for timed-out uploads/overwrittes to appear remotely [minutes] ++ timeout_wait = 10 ++ + acd\_client.ini + --------------- + +@@ -63,6 +79,9 @@ fuse.ini + -------- + + :: ++ [fs] ++ ;block size used for size info ++ block_size = 512 + + [read] + ;maximal number of simultaneously opened chunks per file +diff --git a/docs/contributors.rst b/docs/contributors.rst +index ce1944a..89cd829 100644 +--- a/docs/contributors.rst ++++ b/docs/contributors.rst +@@ -23,6 +23,8 @@ Thanks to + + - `memoz `_ for amending proxy documentation + ++- `gerph `_ for making file searches faster, particularly on large repositories ++ + Also thanks to + + - `fibersnet `_ for pointing out a possible deadlock in ACDFuse. +diff --git a/docs/find.rst b/docs/find.rst +index 73eccb5..02a3b5e 100644 +--- a/docs/find.rst ++++ b/docs/find.rst +@@ -7,7 +7,7 @@ find + ---- + + The find action will perform a case-insensitive search for files and folders that include the +-name or name segment given as argument, so e.g. ``acdcli find foo`` will find "foo" , "Foobar", etc. ++name or name segment given as argument, so e.g. ``acdcli find foo`` will find "foo", "Foobar", etc. + + find-md5 + -------- +diff --git a/docs/index.rst b/docs/index.rst +index 43ce9d6..5f29c0e 100644 +--- a/docs/index.rst ++++ b/docs/index.rst +@@ -14,6 +14,7 @@ Contents: + authorization + usage + configuration ++ sync + transfer + find + FUSE +diff --git a/docs/setup.rst b/docs/setup.rst +index 4020c74..41ee97d 100644 +--- a/docs/setup.rst ++++ b/docs/setup.rst +@@ -8,8 +8,8 @@ Check which Python 3 version is installed on your system, e.g. by running + + If it is Python 3.2.3, 3.3.0 or 3.3.1, you need to upgrade to a higher minor version. + +-You may now proceed to install using PIP, your Arch package manager or build Debian/RedHat +-packages. ++You may now proceed to install using PIP, your package manager if you are using ++Arch Linux/Devuan/Fedora or build Debian/RedHat packages using fpm. + + Installation with PIP + --------------------- +@@ -33,14 +33,15 @@ The recommended and most up-to-date way is to directly install the master branch + + pip3 install --upgrade git+https://github.com/yadayada/acd_cli.git + +-The easiest way is to directly install from PyPI. ++Or use the usual installation method by specifying the PyPI package name. This may not work ++flawlessly on Windows systems. + :: + + pip3 install --upgrade --pre acdcli + + +-PIP Errors +-~~~~~~~~~~ ++PIP Errors on Debian ++~~~~~~~~~~~~~~~~~~~~ + + A version incompatibility may arise with PIP when upgrading the requests package. + PIP will throw the following error: +@@ -57,19 +58,31 @@ Run these commands to fix it: + This will remove the distribution's pip3 package and replace it with a version that is compatible + with the newer requests package. + +-Installation on Arch/Debian/RedHat ++Installation on Arch/Devuan/Fedora + ---------------------------------- + + Arch Linux + ~~~~~~~~~~ + + There are two packages for Arch Linux in the AUR, +-`acd_cli-git `_, which is linked to the ++`acd_cli-git `_, which is linked to the + master branch of the GitHub repository, and + `acd_cli `_, which is linked to the PyPI release. + ++Devuan ++~~~~~~ ++ ++The Devuan package is called "python3-acdcli" and may be installed as usual ++(by running `apt-get install python3-acdcli` as superuser). ++ ++Fedora ++~~~~~~ ++ ++An official `rpm package `_ exists ++that may be installed. ++ + Building deb/rpm packages +-~~~~~~~~~~~~~~~~~~~~~~~~~ ++------------------------- + + You will need to have `fpm `_ installed to build packages. + +@@ -78,7 +91,6 @@ There is a Makefile in the assets directory that includes commands to build Debi + requests-toolbelt package. + fpm may also be able to build packages for other distributions or operating systems. + +- + Environment Variables + --------------------- + +diff --git a/docs/sync.rst b/docs/sync.rst +new file mode 100644 +index 0000000..dd24317 +--- /dev/null ++++ b/docs/sync.rst +@@ -0,0 +1,40 @@ ++Syncing ++======= ++ ++**acd\_cli** keeps a local cache of node metadata to reduce latency. Syncing simply ++means updating the local cache with current data from Amazon Drive. ++[An Amazon Drive `node` may be file or folder.] ++ ++Regular syncing ++--------------- ++ ++Regular syncing ``acd_cli sync`` should be the preferred method to update the metadata for ++your whole Drive account. When invoked for the first time, it will get a complete list of ++the file and folder metadata. For later uses, it will utilize the saved checkpoint from the ++last sync to only fetch the metadata that has changed since then. ++ ++The ``--full`` (``-f``) flag forces the cache to be cleared before syncing, resulting in ++a non-incremental, full sync. ++ ++Sync changesets may also be written to or inserted from a file. ++ ++Incomplete sync +++++++++++++++++ ++ ++For large syncsets, for instance when doing a full sync, you may get the error message ++"Root node not found. Sync may have been incomplete." Please try to resume the sync process ++later, omitting the ``--full`` flag if you had specified it prior. ++ ++Partial syncing ++--------------- ++ ++Partial syncing may be a quick-and-dirty way to synchronize the metadata of a single directory ++with a smallish number of files and folders. E.g. ``acd_cli psync /`` will non-recursively fetch ++the metadata for the root folder. ++ ++The ``--recursive`` (``-r``) flag will also descend into the specified folder's subfolders. ++It is not advisible to use this flag for folders with many subfolders ++ ++The partial sync action will need to fetch node metadata in batches of 200. T ++Please be aware that when using regular and partial syncing alternatingly, your metadata ++may be in an inconsistent state. +diff --git a/docs/transfer.rst b/docs/transfer.rst +index 6a5d88d..b1e7c49 100644 +--- a/docs/transfer.rst ++++ b/docs/transfer.rst +@@ -80,8 +80,8 @@ Abort/Resume + Incomplete file downloads will be resumed automatically. Aborted file uploads are not resumable + at the moment. + +- Folder or directory hierarchies that were created for a transfer do not need to be recreated when +- resuming a transfer. ++ Folder or directory hierarchies that were created for a transfer do not need to be recreated ++ when resuming a transfer. + + Retry + +@@ -101,12 +101,17 @@ Remove Source Files + + #. if the upload succeeds + #. if deduplication is enabled and at least one duplicate is found +- #. if a file of the same name is present in the remote upload path but the file is not to bei ++ #. if a file of the same name is present in the remote upload path but the file is not to be + overwritten (deletion then only occurs if the file sizes match) + + Deduplication + +- Server-side deduplication prevents completely uploaded files from being saved as a node if another +- file with the same MD5 checksum already exists. ++ Server-side deduplication prevents completely uploaded files from being saved as a node if ++ another file with the same MD5 checksum already exists. + acd\_cli can prevent uploading duplicates by checking local files' sizes and MD5s. + Empty files are never regarded duplicates. ++ ++Progress indicator ++ ++ To suppress the progress indicator from being displayed on standard output, use the ``--quiet`` ++ flag. +diff --git a/docs/usage.rst b/docs/usage.rst +index 841a04c..2e3dc0a 100644 +--- a/docs/usage.rst ++++ b/docs/usage.rst +@@ -3,13 +3,16 @@ Usage + + acd_cli may be invoked as ``acd_cli`` or ``acdcli``. + +-Most actions need the node cache to be initialized and up-to-date, so please run a sync. +-A sync will fetch the changes since the last sync or the full node list if the cache is empty. ++Most actions need the node cache to be initialized and up-to-date, so please run a sync. An ordinary ++sync will fetch the changes since the last sync or the full node list if the cache is empty. ++Partially syncing will only fetch the active contents of one folder, optionally recursively. + + The following actions are built in +-:: + +- sync (s) refresh node list cache; necessary for many actions ++.. code-block:: none ++ ++ sync (s) refresh node cache; prerequisite for many actions ++ psync only refresh the contents of the specified folder + clear-cache (cc) clear node cache [offline operation] + + tree (t) print directory tree [offline operation] +@@ -49,8 +52,9 @@ arguments of an action and their order can be printed by calling ``acd_cli [acti + Most node arguments may be specified as a 22 character ID or a UNIX-style path. + Trashed nodes' paths might not be able to be resolved correctly; use their ID instead. + +-There are more detailed instructions for :doc:`file transfer actions `, +-:doc:`find actions ` and :doc:`FUSE documentation `. ++There are more detailed instructions for :doc:`sycing `, ++:doc:`file transfer actions `, ++:doc:`find actions ` and the :doc:`FUSE module `. + + Logs will automatically be saved into the cache directory. + +@@ -102,4 +106,4 @@ error deleting source file 4096 + If multiple errors occur, their respective flag values will be compounded into the exit + status value by a binary OR operation. Because exit status values may not be larger than 255, + flags 256 and above cannot be returned via exit status. +-A warning message will be displayed at the end of execution if those errors occured. ++A warning message will be displayed at the end of execution if those errors occurred. +diff --git a/setup.py b/setup.py +index 97d2a0f..6f66faf 100644 +--- a/setup.py ++++ b/setup.py +@@ -1,5 +1,6 @@ + import os + import re ++import sys + from setuptools import setup, find_packages + from distutils.version import StrictVersion + import acdcli +@@ -15,8 +16,10 @@ repl = ('`([^`]*?) <(docs/)?(.*?)\.rst>`_', + version = acdcli.__version__ + StrictVersion(version) + ++requests_py32 = ',<2.11.0' if sys.version_info[0:2] == (3, 2) else '' ++ + dependencies = ['appdirs', 'colorama', 'fusepy', 'python_dateutil', +- 'requests>=2.1.0,!=2.9.0', 'requests_toolbelt!=0.5.0'] ++ 'requests>=2.1.0,!=2.9.0,!=2.12.0%s' % requests_py32, 'requests_toolbelt!=0.5.0'] + doc_dependencies = ['sphinx_paramlinks'] + test_dependencies = ['httpretty<0.8.11', 'mock'] + +@@ -53,6 +56,7 @@ setup( + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', ++ 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3 :: Only', + 'Development Status :: 4 - Beta', + 'Topic :: System :: Archiving :: Backup', +diff --git a/tests/dummy_files/oauth_data b/tests/dummy_files/oauth.json +similarity index 100% +rename from tests/dummy_files/oauth_data +rename to tests/dummy_files/oauth.json +diff --git a/tests/test_actions.py b/tests/test_actions.py +index e8ea767..96f3ac2 100644 +--- a/tests/test_actions.py ++++ b/tests/test_actions.py +@@ -36,7 +36,7 @@ class ActionTestCase(unittest.TestCase): + self.cache = db.NodeCache(cache_path) + + def tearDown(self): +- self.cache.remove_db_file() ++ db.NodeCache.remove_db_file(cache_path) + + # tests + +diff --git a/tests/test_api.py b/tests/test_api.py +index c788897..477c37b 100644 +--- a/tests/test_api.py ++++ b/tests/test_api.py +@@ -92,7 +92,8 @@ class APITestCase(unittest.TestCase): + '"nodes": [ {"kind": "FILE", "status": "TRASH"} ], ' + '"statusCode": 200}\n' + '{"end": true}') +- changesets = [c for c in self.acd.get_changes()] ++ tmp = self.acd.get_changes() ++ changesets = [c for c in self.acd._iter_changes_lines(tmp)] + self.assertEqual(len(changesets), 1) + changeset = changesets[0] + self.assertEqual(len(changeset.nodes), 1) +@@ -105,7 +106,8 @@ class APITestCase(unittest.TestCase): + httpretty.register_uri(httpretty.POST, self.acd.metadata_url + 'changes', + body='{"checkpoint": "foo", "reset": true, "nodes": [], ' + '"statusCode": 200}\n') +- changesets = [c for c in self.acd.get_changes()] ++ tmp = self.acd.get_changes() ++ changesets = [c for c in self.acd._iter_changes_lines(tmp)] + self.assertEqual(len(changesets), 1) + changeset = changesets[0] + self.assertEqual(len(changeset.nodes), 0) +@@ -118,7 +120,8 @@ class APITestCase(unittest.TestCase): + httpretty.register_uri(httpretty.POST, self.acd.metadata_url + 'changes', + body='{"checkpoint": }') + with self.assertRaises(RequestError): +- [cs for cs in self.acd.get_changes()] ++ tmp = self.acd.get_changes() ++ [cs for cs in self.acd._iter_changes_lines(tmp)] + + # + # oauth +@@ -140,7 +143,8 @@ class APITestCase(unittest.TestCase): + os.path.isfile = MagicMock() + with patch('builtins.open', mock_file, create=True): + with patch('os.fsync', MagicMock): +- h = oauth.AppspotOAuthHandler('') ++ with patch('os.rename', MagicMock): ++ h = oauth.AppspotOAuthHandler('') + + mock_file.assert_any_call(oauth.OAuthHandler.OAUTH_DATA_FILE) + self.assertIn(oauth.OAuthHandler.KEYS.EXP_TIME, h.oauth_data) +diff --git a/tests/test_cache.py b/tests/test_cache.py +index bba08dc..16178db 100644 +--- a/tests/test_cache.py ++++ b/tests/test_cache.py +@@ -12,8 +12,7 @@ class CacheTestCase(unittest.TestCase): + self.cache = db.NodeCache(self.path) + + def tearDown(self): +- self.cache.drop_all() +- self.cache.remove_db_file() ++ db.NodeCache.remove_db_file(self.path) + + def testEmpty(self): + self.assertEqual(self.cache.get_node_count(), 0) diff --git a/acd_cli.spec b/acd_cli.spec index b9ed01b..c7043bf 100644 --- a/acd_cli.spec +++ b/acd_cli.spec @@ -1,16 +1,18 @@ -%global commit 75c5aa4b273d0227497e0daf4da29268bf118ba4 +%global commit cd4a9eea52f1740aa8de10d8c75ab2f6c17de52b %global shortcommit %(c=%{commit}; echo ${c:0:7}) Name: acd_cli Version: 0.3.2 -Release: 5%{?dist} +Release: 0.20170530.git%{shortcommit}%{?dist} Summary: A command line interface and FUSE filesystem for Amazon Cloud Drive License: GPLv2+ URL: https://github.com/yadayada/acd_cli Source0: https://github.com/yadayada/acd_cli/archive/%{version}.tar.gz +Patch0: acd_cli-0.3.2-to-af929608f6279fca56e6c9ac6c48bd76d4ff1dcb.patch + BuildArch: noarch BuildRequires: python3-devel @@ -31,6 +33,8 @@ currently in beta stage. %prep %setup -q +%patch0 -p1 -b git + %build %{__python3} setup.py build @@ -54,6 +58,9 @@ ln -s acd_cli $RPM_BUILD_ROOT/%_bindir/acdcli %_bindir/acd_cli %changelog +* Mon Jun 12 2017 Matthew Miller - 0.3.2-gitcd4a9ee +- because it was entirely broken. + * Fri Feb 10 2017 Fedora Release Engineering - 0.3.2-5 - Rebuilt for https://fedoraproject.org/wiki/Fedora_26_Mass_Rebuild