From cb4173957c0d0a590a870afa7329d01f6c648e1f Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Thu, 26 May 2016 19:54:30 -0400 Subject: [PATCH 01/21] Initial commit of extras/archive module. This manages compressed files or archives of many compressed files. You can maintain or update .gz, .bz2 compressed files, .zip archives, or tarballs compressed with gzip or bzip2. Possible use cases: * Back up user home directories * Ensure large text files are always compressed * Archive trees for distribution --- files/archive.py | 285 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 files/archive.py diff --git a/files/archive.py b/files/archive.py new file mode 100644 index 00000000000..540840b1dac --- /dev/null +++ b/files/archive.py @@ -0,0 +1,285 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +DOCUMENTATION = ''' +--- +module: archive +version_added: 2.2 +short_description: Creates a compressed archive of one or more files or trees. +extends_documentation_fragment: files +description: + - The M(archive) module packs an archive. It is the opposite of the unarchive module. By default, it assumes the compression source exists on the target. It will not copy the source file from the local system to the target before archiving - set copy=yes to pack an archive which does not already exist on the target. The source files are deleted after archiving. +options: + path: + description: + - Remote absolute path, glob, or list of paths or globs for the file or files to archive or compress. + required: false + default: null + compression: + description: + - "The type of compression to use. Can be 'gz', 'bz2', or 'zip'. + choices: [ 'gz', 'bz2', 'zip' ] + creates: + description: + - The file name of the destination archive. When it already exists, this step will B(not) be run. This is required when 'path' refers to multiple files by either specifying a glob, a directory or multiple paths in a list. + required: false + default: null +author: "Ben Doherty (@bendoh)" +notes: + - requires tarfile, zipfile, gzip, and bzip2 packages on target host + - can product I(gzip), I(bzip2) and I(zip) compressed files or archives + - removes source files by default +''' + +EXAMPLES = ''' +# Compress directory /path/to/foo/ into /path/to/foo.tgz +- archive: path=/path/to/foo creates=/path/to/foo.tgz + +# Compress regular file /path/to/foo into /path/to/foo.gz +- archive: path=/path/to/foo + +# Create a zip archive of /path/to/foo +- archive: path=/path/to/foo compression=zip + +# Create a bz2 archive of multiple files, rooted at /path +- archive: + path: + - /path/to/foo + - /path/wong/foo + creates: /path/file.tar.bz2 + compression: bz2 +''' + +import stat +import os +import errno +import glob +import shutil +import gzip +import bz2 +import zipfile +import tarfile + +def main(): + module = AnsibleModule( + argument_spec = dict( + path = dict(required=True), + compression = dict(choices=['gz', 'bz2', 'zip'], default='gz', required=False), + creates = dict(required=False), + remove = dict(required=False, default=True, type='bool'), + ), + add_file_common_args=True, + supports_check_mode=True, + ) + + params = module.params + paths = params['path'] + creates = params['creates'] + remove = params['remove'] + expanded_paths = [] + compression = params['compression'] + globby = False + changed = False + state = 'absent' + + # Simple or archive file compression (inapplicable with 'zip') + archive = False + successes = [] + + if isinstance(paths, basestring): + paths = [paths] + + for i, path in enumerate(paths): + path = os.path.expanduser(params['path']) + + # Detect glob-like characters + if any((c in set('*?')) for c in path): + expanded_paths = expanded_paths + glob.glob(path) + else: + expanded_paths.append(path) + + if len(expanded_paths) == 0: + module.fail_json(path, msg='Error, no source paths were found') + + # If we actually matched multiple files or TRIED to, then + # treat this as a multi-file archive + archive = globby or len(expanded_paths) > 1 or any(os.path.isdir(path) for path in expanded_paths) + + # Default created file name (for single-file archives) to + # . + if not archive and not creates: + creates = '%s.%s' % (expanded_paths[0], compression) + + # Force archives to specify 'creates' + if archive and not creates: + module.fail_json(creates=creates, path=', '.join(paths), msg='Error, must specify "creates" when archiving multiple files or trees') + + archive_paths = [] + missing = [] + arcroot = '' + + for path in expanded_paths: + # Use the longest common directory name among all the files + # as the archive root path + if arcroot == '': + arcroot = os.path.dirname(path) + else: + for i in xrange(len(arcroot)): + if path[i] != arcroot[i]: + break + + if i < len(arcroot): + arcroot = os.path.dirname(arcroot[0:i+1]) + + if path == creates: + # Don't allow the archive to specify itself! this is an error. + module.fail_json(path=', '.join(paths), msg='Error, created archive would be included in archive') + + if os.path.lexists(path): + archive_paths.append(path) + else: + missing.append(path) + + # No source files were found but the named archive exists: are we 'compress' or 'archive' now? + if len(missing) == len(expanded_paths) and creates and os.path.exists(creates): + # Just check the filename to know if it's an archive or simple compressed file + if re.search(r'(\.tar\.gz|\.tgz|.tbz2|\.tar\.bz2|\.zip)$', os.path.basename(creates), re.IGNORECASE): + state = 'archive' + else: + state = 'compress' + + # Multiple files, or globbiness + elif archive: + if len(archive_paths) == 0: + # No source files were found, but the archive is there. + if os.path.lexists(creates): + state = 'archive' + elif len(missing) > 0: + # SOME source files were found, but not all of them + state = 'incomplete' + + archive = None + size = 0 + errors = [] + + if os.path.lexists(creates): + size = os.path.getsize(creates) + + if state != 'archive': + try: + if compression == 'gz' or compression == 'bz2': + archive = tarfile.open(creates, 'w|' + compression) + + for path in archive_paths: + archive.add(path, path[len(arcroot):]) + successes.append(path) + + elif compression == 'zip': + archive = zipfile.ZipFile(creates, 'wb') + + for path in archive_paths: + archive.write(path, path[len(arcroot):]) + successes.append(path) + + except OSError: + e = get_exception() + module.fail_json(msg='Error when writing zip archive at %s: %s' % (creates, str(e))) + + if archive: + archive.close() + + if state != 'archive' and remove: + for path in successes: + try: + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + except OSError: + e = get_exception() + errors.append(path) + + if len(errors) > 0: + module.fail_json(creates=creates, msg='Error deleting some source files: ' + str(e), files=errors) + + # Rudimentary check: If size changed then file changed. Not perfect, but easy. + if os.path.getsize(creates) != size: + changed = True + + if len(successes) and state != 'incomplete': + state = 'archive' + + # Simple, single-file compression + else: + path = expanded_paths[0] + + # No source or compressed file + if not (os.path.exists(path) or os.path.lexists(creates)): + state = 'absent' + + # if it already exists and the source file isn't there, consider this done + elif not os.path.lexists(path) and os.path.lexists(creates): + state = 'compress' + + else: + if module.check_mode: + if not os.path.exists(creates): + changed = True + else: + size = 0 + f_in = f_out = archive = None + + if os.path.lexists(creates): + size = os.path.getsize(creates) + + try: + if compression == 'zip': + archive = zipfile.ZipFile(creates, 'wb') + archive.write(path, path[len(arcroot):]) + archive.close() + state = 'archive' # because all zip files are archives + + else: + f_in = open(path, 'rb') + + if compression == 'gz': + f_out = gzip.open(creates, 'wb') + elif compression == 'bz2': + f_out = bz2.BZ2File(creates, 'wb') + else: + raise OSError("Invalid compression") + + shutil.copyfileobj(f_in, f_out) + + except OSError: + e = get_exception() + + module.fail_json(path=path, creates=creates, msg='Unable to write to compressed file: %s' % str(e)) + + if archive: + archive.close() + if f_in: + f_in.close() + if f_out: + f_out.close() + + # Rudimentary check: If size changed then file changed. Not perfect, but easy. + if os.path.getsize(creates) != size: + changed = True + + state = 'compress' + + if remove: + try: + os.remove(path) + + except OSError: + e = get_exception() + module.fail_json(path=path, msg='Unable to remove source file: %s' % str(e)) + + module.exit_json(archived=successes, creates=creates, changed=changed, state=state, arcroot=arcroot, missing=missing, expanded_paths=expanded_paths) + +# import module snippets +from ansible.module_utils.basic import * +if __name__ == '__main__': + main() From 431d8c9a8f14e783e8d99c9888aa71ae9e326125 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Thu, 26 May 2016 20:36:14 -0400 Subject: [PATCH 02/21] Drop extra double-quote from documentation --- files/archive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files/archive.py b/files/archive.py index 540840b1dac..f6791ff53d5 100644 --- a/files/archive.py +++ b/files/archive.py @@ -17,7 +17,7 @@ options: default: null compression: description: - - "The type of compression to use. Can be 'gz', 'bz2', or 'zip'. + - The type of compression to use. Can be 'gz', 'bz2', or 'zip'. choices: [ 'gz', 'bz2', 'zip' ] creates: description: From e9b85326a653c9c9e325a8fe1b1c7714c05a29ad Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Thu, 26 May 2016 21:33:27 -0400 Subject: [PATCH 03/21] Fix write mode for ZipFiles ('wb' is invalid!) --- files/archive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/files/archive.py b/files/archive.py index f6791ff53d5..76dcb9cf084 100644 --- a/files/archive.py +++ b/files/archive.py @@ -175,7 +175,7 @@ def main(): successes.append(path) elif compression == 'zip': - archive = zipfile.ZipFile(creates, 'wb') + archive = zipfile.ZipFile(creates, 'w') for path in archive_paths: archive.write(path, path[len(arcroot):]) From 9cde150bd12b771ba607b9e62918c57ca724ef88 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Thu, 26 May 2016 21:38:31 -0400 Subject: [PATCH 04/21] Add RETURN documentation --- files/archive.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/files/archive.py b/files/archive.py index 76dcb9cf084..e64a9197bfe 100644 --- a/files/archive.py +++ b/files/archive.py @@ -50,6 +50,27 @@ EXAMPLES = ''' compression: bz2 ''' +RETURN = ''' +state: + description: The current state of the archived file. + type: string + returned: always +missing: + description: Any files that were missing from the source. + type: list + returned: success +archived: + description: Any files that were compressed or added to the archive. + type: list + returned: success +arcroot: + description: The archive root. + type: string +expanded_paths: + description: The list of matching paths from paths argument. + type: list +''' + import stat import os import errno From ecd60f48398ed7adacccc29801896c95dab0ae97 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Thu, 26 May 2016 21:38:36 -0400 Subject: [PATCH 05/21] Add compressed file source to successes when succeeds! --- files/archive.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/files/archive.py b/files/archive.py index e64a9197bfe..42814027938 100644 --- a/files/archive.py +++ b/files/archive.py @@ -272,6 +272,8 @@ def main(): shutil.copyfileobj(f_in, f_out) + successes.append(path) + except OSError: e = get_exception() From f482cb4790fead81f1146fd9cc89a91e1c0ad12e Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Thu, 26 May 2016 21:41:40 -0400 Subject: [PATCH 06/21] Add license --- files/archive.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/files/archive.py b/files/archive.py index 42814027938..fecd7a45813 100644 --- a/files/archive.py +++ b/files/archive.py @@ -1,6 +1,26 @@ #!/usr/bin/python # -*- coding: utf-8 -*- +""" +(c) 2016, Ben Doherty +Sponsored by Oomph, Inc. http://www.oomphinc.com + +This file is part of Ansible + +Ansible is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +Ansible is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with Ansible. If not, see . +""" + DOCUMENTATION = ''' --- module: archive From cca70b7c9131627681bed77f4fbee5bb6e4823af Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Thu, 26 May 2016 23:09:35 -0400 Subject: [PATCH 07/21] Fix up for zip files and nesting logic. * Don't include the archive in the archive if it falls within an archived path * If remove=True and the archive would be in an archived path, fail. * Fix single-file zip file compression * Add more documentation about 'state' return --- files/archive.py | 49 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/files/archive.py b/files/archive.py index fecd7a45813..2dba54a300b 100644 --- a/files/archive.py +++ b/files/archive.py @@ -72,7 +72,12 @@ EXAMPLES = ''' RETURN = ''' state: - description: The current state of the archived file. + description: + The current state of the archived file. + If 'absent', then no source files were found and the archive does not exist. + If 'compress', then the file source file is in the compressed state. + If 'archive', then the source file or paths are currently archived. + If 'incomplete', then an archive was created, but not all source paths were found. type: string returned: always missing: @@ -98,6 +103,7 @@ import glob import shutil import gzip import bz2 +import filecmp import zipfile import tarfile @@ -157,6 +163,7 @@ def main(): archive_paths = [] missing = [] + exclude = [] arcroot = '' for path in expanded_paths: @@ -172,9 +179,9 @@ def main(): if i < len(arcroot): arcroot = os.path.dirname(arcroot[0:i+1]) - if path == creates: - # Don't allow the archive to specify itself! this is an error. - module.fail_json(path=', '.join(paths), msg='Error, created archive would be included in archive') + # Don't allow archives to be created anywhere within paths to be removed + if remove and os.path.isdir(path) and creates.startswith(path): + module.fail_json(path=', '.join(paths), msg='Error, created archive can not be contained in source paths when remove=True') if os.path.lexists(path): archive_paths.append(path) @@ -208,18 +215,40 @@ def main(): if state != 'archive': try: + # Easier compression using tarfile module if compression == 'gz' or compression == 'bz2': archive = tarfile.open(creates, 'w|' + compression) for path in archive_paths: - archive.add(path, path[len(arcroot):]) + basename = '' + + # Prefix trees in the archive with their basename, unless specifically prevented with '.' + if os.path.isdir(path) and not path.endswith(os.sep + '.'): + basename = os.path.basename(path) + os.sep + + archive.add(path, path[len(arcroot):], filter=lambda f: f if f.name != creates else None) successes.append(path) + # Slightly more difficult (and less efficient!) compression using zipfile module elif compression == 'zip': - archive = zipfile.ZipFile(creates, 'w') + archive = zipfile.ZipFile(creates, 'w', zipfile.ZIP_DEFLATED) for path in archive_paths: - archive.write(path, path[len(arcroot):]) + basename = '' + + # Prefix trees in the archive with their basename, unless specifically prevented with '.' + if os.path.isdir(path) and not path.endswith(os.sep + '.'): + basename = os.path.basename(path) + os.sep + + for dirpath, dirnames, filenames in os.walk(path, topdown=True): + for dirname in dirnames: + archive.write(dirpath + os.sep + dirname, basename + dirname) + for filename in filenames: + fullpath = dirpath + os.sep + filename + + if not filecmp.cmp(fullpath, creates): + archive.write(fullpath, basename + filename) + successes.append(path) except OSError: @@ -228,8 +257,10 @@ def main(): if archive: archive.close() + state = 'archive' + - if state != 'archive' and remove: + if state == 'archive' and remove: for path in successes: try: if os.path.isdir(path): @@ -275,7 +306,7 @@ def main(): try: if compression == 'zip': - archive = zipfile.ZipFile(creates, 'wb') + archive = zipfile.ZipFile(creates, 'w', zipfile.ZIP_DEFLATED) archive.write(path, path[len(arcroot):]) archive.close() state = 'archive' # because all zip files are archives From d3e041d1a23c6dfbd8722212e565190382cce4e7 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Thu, 26 May 2016 23:42:03 -0400 Subject: [PATCH 08/21] Accept 'path' as a list argument, expose path and expanded_path, Use correct variable in expanduser --- files/archive.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/files/archive.py b/files/archive.py index 2dba54a300b..87840ec07cd 100644 --- a/files/archive.py +++ b/files/archive.py @@ -110,7 +110,7 @@ import tarfile def main(): module = AnsibleModule( argument_spec = dict( - path = dict(required=True), + path = dict(type='list', required=True), compression = dict(choices=['gz', 'bz2', 'zip'], default='gz', required=False), creates = dict(required=False), remove = dict(required=False, default=True, type='bool'), @@ -133,11 +133,8 @@ def main(): archive = False successes = [] - if isinstance(paths, basestring): - paths = [paths] - for i, path in enumerate(paths): - path = os.path.expanduser(params['path']) + path = os.path.expanduser(path) # Detect glob-like characters if any((c in set('*?')) for c in path): @@ -146,7 +143,7 @@ def main(): expanded_paths.append(path) if len(expanded_paths) == 0: - module.fail_json(path, msg='Error, no source paths were found') + module.fail_json(path=', '.join(paths), expanded_paths=', '.join(expanded_paths), msg='Error, no source paths were found') # If we actually matched multiple files or TRIED to, then # treat this as a multi-file archive @@ -170,7 +167,7 @@ def main(): # Use the longest common directory name among all the files # as the archive root path if arcroot == '': - arcroot = os.path.dirname(path) + arcroot = os.path.dirname(path) + os.sep else: for i in xrange(len(arcroot)): if path[i] != arcroot[i]: @@ -259,8 +256,7 @@ def main(): archive.close() state = 'archive' - - if state == 'archive' and remove: + if state in ['archive', 'incomplete'] and remove: for path in successes: try: if os.path.isdir(path): From 6db9cafdec666b288cf3554be8fa81d3e5405900 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Thu, 26 May 2016 23:49:32 -0400 Subject: [PATCH 09/21] Don't use if else syntax --- files/archive.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/files/archive.py b/files/archive.py index 87840ec07cd..3b4a512ba4f 100644 --- a/files/archive.py +++ b/files/archive.py @@ -223,7 +223,11 @@ def main(): if os.path.isdir(path) and not path.endswith(os.sep + '.'): basename = os.path.basename(path) + os.sep - archive.add(path, path[len(arcroot):], filter=lambda f: f if f.name != creates else None) + filter_create = lambda f: + if filecmp.cmp(f.name, creates): + return f + + archive.add(path, path[len(arcroot):], filter=filter_create) successes.append(path) # Slightly more difficult (and less efficient!) compression using zipfile module From ae35ce5641cef696573529761d0e90fc66912b82 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Thu, 26 May 2016 23:58:17 -0400 Subject: [PATCH 10/21] Make remove default to false. It's less frightening. --- files/archive.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/files/archive.py b/files/archive.py index 3b4a512ba4f..e4931b9877f 100644 --- a/files/archive.py +++ b/files/archive.py @@ -28,7 +28,7 @@ version_added: 2.2 short_description: Creates a compressed archive of one or more files or trees. extends_documentation_fragment: files description: - - The M(archive) module packs an archive. It is the opposite of the unarchive module. By default, it assumes the compression source exists on the target. It will not copy the source file from the local system to the target before archiving - set copy=yes to pack an archive which does not already exist on the target. The source files are deleted after archiving. + - The M(archive) module packs an archive. It is the opposite of the unarchive module. By default, it assumes the compression source exists on the target. It will not copy the source file from the local system to the target before archiving. Source files can be deleted after archival by specifying remove=True. options: path: description: @@ -41,22 +41,28 @@ options: choices: [ 'gz', 'bz2', 'zip' ] creates: description: - - The file name of the destination archive. When it already exists, this step will B(not) be run. This is required when 'path' refers to multiple files by either specifying a glob, a directory or multiple paths in a list. - required: false + - The file name of the destination archive. This is required when 'path' refers to multiple files by either specifying a glob, a directory or multiple paths in a list. + required: false, unless multiple source paths or globs are specified default: null + remove: + description: + - Remove any added source files and trees after adding to archive. + type: bool + required: false + default: false + author: "Ben Doherty (@bendoh)" notes: - requires tarfile, zipfile, gzip, and bzip2 packages on target host - - can product I(gzip), I(bzip2) and I(zip) compressed files or archives - - removes source files by default + - can produce I(gzip), I(bzip2) and I(zip) compressed files or archives ''' EXAMPLES = ''' # Compress directory /path/to/foo/ into /path/to/foo.tgz - archive: path=/path/to/foo creates=/path/to/foo.tgz -# Compress regular file /path/to/foo into /path/to/foo.gz -- archive: path=/path/to/foo +# Compress regular file /path/to/foo into /path/to/foo.gz and remove it +- archive: path=/path/to/foo remove=True # Create a zip archive of /path/to/foo - archive: path=/path/to/foo compression=zip @@ -113,7 +119,7 @@ def main(): path = dict(type='list', required=True), compression = dict(choices=['gz', 'bz2', 'zip'], default='gz', required=False), creates = dict(required=False), - remove = dict(required=False, default=True, type='bool'), + remove = dict(required=False, default=False, type='bool'), ), add_file_common_args=True, supports_check_mode=True, From 20bfb1339d06b6b576e1d7b15c21834d51f015b2 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Fri, 27 May 2016 00:00:59 -0400 Subject: [PATCH 11/21] Use different syntax in lambda --- files/archive.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/files/archive.py b/files/archive.py index e4931b9877f..811a9aaa000 100644 --- a/files/archive.py +++ b/files/archive.py @@ -229,11 +229,7 @@ def main(): if os.path.isdir(path) and not path.endswith(os.sep + '.'): basename = os.path.basename(path) + os.sep - filter_create = lambda f: - if filecmp.cmp(f.name, creates): - return f - - archive.add(path, path[len(arcroot):], filter=filter_create) + archive.add(path, path[len(arcroot):], filter=lambda f: not filecmp.cmp(f.name, creates) and f) successes.append(path) # Slightly more difficult (and less efficient!) compression using zipfile module From 6e0aac888b736b4edd13d130cb0e24af01da842e Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Fri, 27 May 2016 00:07:15 -0400 Subject: [PATCH 12/21] Documentation updates --- files/archive.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/files/archive.py b/files/archive.py index 811a9aaa000..8a9a082f2a0 100644 --- a/files/archive.py +++ b/files/archive.py @@ -28,21 +28,20 @@ version_added: 2.2 short_description: Creates a compressed archive of one or more files or trees. extends_documentation_fragment: files description: - - The M(archive) module packs an archive. It is the opposite of the unarchive module. By default, it assumes the compression source exists on the target. It will not copy the source file from the local system to the target before archiving. Source files can be deleted after archival by specifying remove=True. + - The M(archive) module packs an archive. It is the opposite of the unarchive module. By default, it assumes the compression source exists on the target. It will not copy the source file from the local system to the target before archiving. Source files can be deleted after archival by specifying C(remove)=I(True). options: path: description: - Remote absolute path, glob, or list of paths or globs for the file or files to archive or compress. - required: false - default: null + required: true compression: description: - The type of compression to use. Can be 'gz', 'bz2', or 'zip'. choices: [ 'gz', 'bz2', 'zip' ] creates: description: - - The file name of the destination archive. This is required when 'path' refers to multiple files by either specifying a glob, a directory or multiple paths in a list. - required: false, unless multiple source paths or globs are specified + - The file name of the destination archive. This is required when C(path) refers to multiple files by either specifying a glob, a directory or multiple paths in a list. + required: false default: null remove: description: From ef620c7de3d59a905f980049593518a9d65e137e Mon Sep 17 00:00:00 2001 From: Benjamin Doherty Date: Sat, 28 May 2016 09:02:43 -0400 Subject: [PATCH 13/21] Add 'default' to docs for 'compression' option --- files/archive.py | 1 + 1 file changed, 1 insertion(+) diff --git a/files/archive.py b/files/archive.py index 8a9a082f2a0..6eee99bc590 100644 --- a/files/archive.py +++ b/files/archive.py @@ -38,6 +38,7 @@ options: description: - The type of compression to use. Can be 'gz', 'bz2', or 'zip'. choices: [ 'gz', 'bz2', 'zip' ] + default: 'gz' creates: description: - The file name of the destination archive. This is required when C(path) refers to multiple files by either specifying a glob, a directory or multiple paths in a list. From d5e861b3529d1e06281f0df755e55f6183d55c29 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Tue, 31 May 2016 18:30:47 -0400 Subject: [PATCH 14/21] Reword comments slightly --- files/archive.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/files/archive.py b/files/archive.py index 8a9a082f2a0..53df2d109d4 100644 --- a/files/archive.py +++ b/files/archive.py @@ -32,7 +32,7 @@ description: options: path: description: - - Remote absolute path, glob, or list of paths or globs for the file or files to archive or compress. + - Remote absolute path, glob, or list of paths or globs for the file or files to compress or archive. required: true compression: description: @@ -134,7 +134,7 @@ def main(): changed = False state = 'absent' - # Simple or archive file compression (inapplicable with 'zip') + # Simple or archive file compression (inapplicable with 'zip' since it's always an archive) archive = False successes = [] From 726c4d9ba7a91c0938e94dacd18cd1102f056156 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Tue, 31 May 2016 18:31:07 -0400 Subject: [PATCH 15/21] Some refactoring: * rename archive -> arcfile (where it's a file descriptor) * additional return * simplify logic around 'archive?' flag * maintain os separator after arcroot * use function instead of lambda for filter, ensure file exists before file.cmp'ing it * track errored files and fail if there are any --- files/archive.py | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/files/archive.py b/files/archive.py index 53df2d109d4..3ef0dcb20cc 100644 --- a/files/archive.py +++ b/files/archive.py @@ -148,11 +148,11 @@ def main(): expanded_paths.append(path) if len(expanded_paths) == 0: - module.fail_json(path=', '.join(paths), expanded_paths=', '.join(expanded_paths), msg='Error, no source paths were found') + return module.fail_json(path=', '.join(paths), expanded_paths=', '.join(expanded_paths), msg='Error, no source paths were found') # If we actually matched multiple files or TRIED to, then # treat this as a multi-file archive - archive = globby or len(expanded_paths) > 1 or any(os.path.isdir(path) for path in expanded_paths) + archive = globby or os.path.isdir(expanded_paths[0]) or len(expanded_paths) > 1 # Default created file name (for single-file archives) to # . @@ -181,6 +181,8 @@ def main(): if i < len(arcroot): arcroot = os.path.dirname(arcroot[0:i+1]) + arcroot += os.sep + # Don't allow archives to be created anywhere within paths to be removed if remove and os.path.isdir(path) and creates.startswith(path): module.fail_json(path=', '.join(paths), msg='Error, created archive can not be contained in source paths when remove=True') @@ -219,7 +221,9 @@ def main(): try: # Easier compression using tarfile module if compression == 'gz' or compression == 'bz2': - archive = tarfile.open(creates, 'w|' + compression) + arcfile = tarfile.open(creates, 'w|' + compression) + + arcfile.add(arcroot, os.path.basename(arcroot), recursive=False) for path in archive_paths: basename = '' @@ -228,12 +232,23 @@ def main(): if os.path.isdir(path) and not path.endswith(os.sep + '.'): basename = os.path.basename(path) + os.sep - archive.add(path, path[len(arcroot):], filter=lambda f: not filecmp.cmp(f.name, creates) and f) - successes.append(path) + try: + def exclude_creates(f): + if os.path.exists(f.name) and not filecmp.cmp(f.name, creates): + return f + + return None + + arcfile.add(path, basename + path[len(arcroot):], filter=exclude_creates) + successes.append(path) + + except: + e = get_exception() + errors.append('error adding %s: %s' % (path, str(e))) # Slightly more difficult (and less efficient!) compression using zipfile module elif compression == 'zip': - archive = zipfile.ZipFile(creates, 'w', zipfile.ZIP_DEFLATED) + arcfile = zipfile.ZipFile(creates, 'w', zipfile.ZIP_DEFLATED) for path in archive_paths: basename = '' @@ -244,23 +259,26 @@ def main(): for dirpath, dirnames, filenames in os.walk(path, topdown=True): for dirname in dirnames: - archive.write(dirpath + os.sep + dirname, basename + dirname) + arcfile.write(dirpath + os.sep + dirname, basename + dirname) for filename in filenames: fullpath = dirpath + os.sep + filename if not filecmp.cmp(fullpath, creates): - archive.write(fullpath, basename + filename) + arcfile.write(fullpath, basename + filename) successes.append(path) except OSError: e = get_exception() - module.fail_json(msg='Error when writing zip archive at %s: %s' % (creates, str(e))) + module.fail_json(msg='Error when writing %s archive at %s: %s' % (compression == 'zip' and 'zip' or ('tar.' + compression), creates, str(e))) - if archive: - archive.close() + if arcfile: + arcfile.close() state = 'archive' + if len(errors) > 0: + module.fail_json(msg='Errors when writing archive at %s: %s' % (creates, '; '.join(errors))) + if state in ['archive', 'incomplete'] and remove: for path in successes: try: From 0a056eccbf84ac2886432e86d04833a56ad62d7b Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Tue, 31 May 2016 23:42:37 -0400 Subject: [PATCH 16/21] Refactor zip and tarfile loops together, branch where calls are different This fixed a few bugs and simplified the code --- files/archive.py | 76 +++++++++++++++++++++++------------------------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/files/archive.py b/files/archive.py index 04f9e1f5922..21b3022f69b 100644 --- a/files/archive.py +++ b/files/archive.py @@ -220,58 +220,54 @@ def main(): if state != 'archive': try: - # Easier compression using tarfile module - if compression == 'gz' or compression == 'bz2': - arcfile = tarfile.open(creates, 'w|' + compression) - - arcfile.add(arcroot, os.path.basename(arcroot), recursive=False) - for path in archive_paths: - basename = '' - - # Prefix trees in the archive with their basename, unless specifically prevented with '.' - if os.path.isdir(path) and not path.endswith(os.sep + '.'): - basename = os.path.basename(path) + os.sep - - try: - def exclude_creates(f): - if os.path.exists(f.name) and not filecmp.cmp(f.name, creates): - return f + # Slightly more difficult (and less efficient!) compression using zipfile module + if compression == 'zip': + arcfile = zipfile.ZipFile(creates, 'w', zipfile.ZIP_DEFLATED) - return None + # Easier compression using tarfile module + elif compression == 'gz' or compression == 'bz2': + arcfile = tarfile.open(creates, 'w|' + compression) - arcfile.add(path, basename + path[len(arcroot):], filter=exclude_creates) - successes.append(path) + for path in archive_paths: + basename = '' - except: - e = get_exception() - errors.append('error adding %s: %s' % (path, str(e))) + # Prefix trees in the archive with their basename, unless specifically prevented with '.' + if os.path.isdir(path) and not path.endswith(os.sep + '.'): + basename = os.path.basename(path) + os.sep - # Slightly more difficult (and less efficient!) compression using zipfile module - elif compression == 'zip': - arcfile = zipfile.ZipFile(creates, 'w', zipfile.ZIP_DEFLATED) + for dirpath, dirnames, filenames in os.walk(path, topdown=True): + for dirname in dirnames: + fullpath = dirpath + os.sep + dirname - for path in archive_paths: - basename = '' + try: + if compression == 'zip': + arcfile.write(fullpath, basename + dirname) + else: + arcfile.add(fullpath, basename + dirname, recursive=False) - # Prefix trees in the archive with their basename, unless specifically prevented with '.' - if os.path.isdir(path) and not path.endswith(os.sep + '.'): - basename = os.path.basename(path) + os.sep + except Exception: + e = get_exception() + errors.append('%s: %s' % (fullpath, str(e))) - for dirpath, dirnames, filenames in os.walk(path, topdown=True): - for dirname in dirnames: - arcfile.write(dirpath + os.sep + dirname, basename + dirname) - for filename in filenames: - fullpath = dirpath + os.sep + filename + for filename in filenames: + fullpath = dirpath + os.sep + filename - if not filecmp.cmp(fullpath, creates): - arcfile.write(fullpath, basename + filename) + if not filecmp.cmp(fullpath, creates): + try: + if compression == 'zip': + arcfile.write(fullpath, basename + filename) + else: + arcfile.add(fullpath, basename + filename, recursive=False) - successes.append(path) + successes.append(fullpath) + except Exception: + e = get_exception() + errors.append('Adding %s: %s' % (path, str(e))) - except OSError: + except Exception: e = get_exception() - module.fail_json(msg='Error when writing %s archive at %s: %s' % (compression == 'zip' and 'zip' or ('tar.' + compression), creates, str(e))) + return module.fail_json(msg='Error when writing %s archive at %s: %s' % (compression == 'zip' and 'zip' or ('tar.' + compression), creates, str(e))) if arcfile: arcfile.close() From b57b0473cf55dc9c23bb691b09306dd282a428da Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Wed, 1 Jun 2016 08:16:31 -0400 Subject: [PATCH 17/21] Change 'creates' parameter to 'dest' --- files/archive.py | 68 ++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/files/archive.py b/files/archive.py index 21b3022f69b..11e9d155f76 100644 --- a/files/archive.py +++ b/files/archive.py @@ -39,7 +39,7 @@ options: - The type of compression to use. Can be 'gz', 'bz2', or 'zip'. choices: [ 'gz', 'bz2', 'zip' ] default: 'gz' - creates: + dest: description: - The file name of the destination archive. This is required when C(path) refers to multiple files by either specifying a glob, a directory or multiple paths in a list. required: false @@ -59,7 +59,7 @@ notes: EXAMPLES = ''' # Compress directory /path/to/foo/ into /path/to/foo.tgz -- archive: path=/path/to/foo creates=/path/to/foo.tgz +- archive: path=/path/to/foo dest=/path/to/foo.tgz # Compress regular file /path/to/foo into /path/to/foo.gz and remove it - archive: path=/path/to/foo remove=True @@ -72,7 +72,7 @@ EXAMPLES = ''' path: - /path/to/foo - /path/wong/foo - creates: /path/file.tar.bz2 + dest: /path/file.tar.bz2 compression: bz2 ''' @@ -118,7 +118,7 @@ def main(): argument_spec = dict( path = dict(type='list', required=True), compression = dict(choices=['gz', 'bz2', 'zip'], default='gz', required=False), - creates = dict(required=False), + dest = dict(required=False), remove = dict(required=False, default=False, type='bool'), ), add_file_common_args=True, @@ -127,7 +127,7 @@ def main(): params = module.params paths = params['path'] - creates = params['creates'] + dest = params['dest'] remove = params['remove'] expanded_paths = [] compression = params['compression'] @@ -157,12 +157,12 @@ def main(): # Default created file name (for single-file archives) to # . - if not archive and not creates: - creates = '%s.%s' % (expanded_paths[0], compression) + if not archive and not dest: + dest = '%s.%s' % (expanded_paths[0], compression) - # Force archives to specify 'creates' - if archive and not creates: - module.fail_json(creates=creates, path=', '.join(paths), msg='Error, must specify "creates" when archiving multiple files or trees') + # Force archives to specify 'dest' + if archive and not dest: + module.fail_json(dest=dest, path=', '.join(paths), msg='Error, must specify "dest" when archiving multiple files or trees') archive_paths = [] missing = [] @@ -185,7 +185,7 @@ def main(): arcroot += os.sep # Don't allow archives to be created anywhere within paths to be removed - if remove and os.path.isdir(path) and creates.startswith(path): + if remove and os.path.isdir(path) and dest.startswith(path): module.fail_json(path=', '.join(paths), msg='Error, created archive can not be contained in source paths when remove=True') if os.path.lexists(path): @@ -194,9 +194,9 @@ def main(): missing.append(path) # No source files were found but the named archive exists: are we 'compress' or 'archive' now? - if len(missing) == len(expanded_paths) and creates and os.path.exists(creates): + if len(missing) == len(expanded_paths) and dest and os.path.exists(dest): # Just check the filename to know if it's an archive or simple compressed file - if re.search(r'(\.tar\.gz|\.tgz|.tbz2|\.tar\.bz2|\.zip)$', os.path.basename(creates), re.IGNORECASE): + if re.search(r'(\.tar\.gz|\.tgz|.tbz2|\.tar\.bz2|\.zip)$', os.path.basename(dest), re.IGNORECASE): state = 'archive' else: state = 'compress' @@ -205,7 +205,7 @@ def main(): elif archive: if len(archive_paths) == 0: # No source files were found, but the archive is there. - if os.path.lexists(creates): + if os.path.lexists(dest): state = 'archive' elif len(missing) > 0: # SOME source files were found, but not all of them @@ -215,19 +215,19 @@ def main(): size = 0 errors = [] - if os.path.lexists(creates): - size = os.path.getsize(creates) + if os.path.lexists(dest): + size = os.path.getsize(dest) if state != 'archive': try: # Slightly more difficult (and less efficient!) compression using zipfile module if compression == 'zip': - arcfile = zipfile.ZipFile(creates, 'w', zipfile.ZIP_DEFLATED) + arcfile = zipfile.ZipFile(dest, 'w', zipfile.ZIP_DEFLATED) # Easier compression using tarfile module elif compression == 'gz' or compression == 'bz2': - arcfile = tarfile.open(creates, 'w|' + compression) + arcfile = tarfile.open(dest, 'w|' + compression) for path in archive_paths: basename = '' @@ -253,7 +253,7 @@ def main(): for filename in filenames: fullpath = dirpath + os.sep + filename - if not filecmp.cmp(fullpath, creates): + if not filecmp.cmp(fullpath, dest): try: if compression == 'zip': arcfile.write(fullpath, basename + filename) @@ -267,14 +267,14 @@ def main(): except Exception: e = get_exception() - return module.fail_json(msg='Error when writing %s archive at %s: %s' % (compression == 'zip' and 'zip' or ('tar.' + compression), creates, str(e))) + return module.fail_json(msg='Error when writing %s archive at %s: %s' % (compression == 'zip' and 'zip' or ('tar.' + compression), dest, str(e))) if arcfile: arcfile.close() state = 'archive' if len(errors) > 0: - module.fail_json(msg='Errors when writing archive at %s: %s' % (creates, '; '.join(errors))) + module.fail_json(msg='Errors when writing archive at %s: %s' % (dest, '; '.join(errors))) if state in ['archive', 'incomplete'] and remove: for path in successes: @@ -288,10 +288,10 @@ def main(): errors.append(path) if len(errors) > 0: - module.fail_json(creates=creates, msg='Error deleting some source files: ' + str(e), files=errors) + module.fail_json(dest=dest, msg='Error deleting some source files: ' + str(e), files=errors) # Rudimentary check: If size changed then file changed. Not perfect, but easy. - if os.path.getsize(creates) != size: + if os.path.getsize(dest) != size: changed = True if len(successes) and state != 'incomplete': @@ -302,27 +302,27 @@ def main(): path = expanded_paths[0] # No source or compressed file - if not (os.path.exists(path) or os.path.lexists(creates)): + if not (os.path.exists(path) or os.path.lexists(dest)): state = 'absent' # if it already exists and the source file isn't there, consider this done - elif not os.path.lexists(path) and os.path.lexists(creates): + elif not os.path.lexists(path) and os.path.lexists(dest): state = 'compress' else: if module.check_mode: - if not os.path.exists(creates): + if not os.path.exists(dest): changed = True else: size = 0 f_in = f_out = archive = None - if os.path.lexists(creates): - size = os.path.getsize(creates) + if os.path.lexists(dest): + size = os.path.getsize(dest) try: if compression == 'zip': - archive = zipfile.ZipFile(creates, 'w', zipfile.ZIP_DEFLATED) + archive = zipfile.ZipFile(dest, 'w', zipfile.ZIP_DEFLATED) archive.write(path, path[len(arcroot):]) archive.close() state = 'archive' # because all zip files are archives @@ -331,9 +331,9 @@ def main(): f_in = open(path, 'rb') if compression == 'gz': - f_out = gzip.open(creates, 'wb') + f_out = gzip.open(dest, 'wb') elif compression == 'bz2': - f_out = bz2.BZ2File(creates, 'wb') + f_out = bz2.BZ2File(dest, 'wb') else: raise OSError("Invalid compression") @@ -344,7 +344,7 @@ def main(): except OSError: e = get_exception() - module.fail_json(path=path, creates=creates, msg='Unable to write to compressed file: %s' % str(e)) + module.fail_json(path=path, dest=dest, msg='Unable to write to compressed file: %s' % str(e)) if archive: archive.close() @@ -354,7 +354,7 @@ def main(): f_out.close() # Rudimentary check: If size changed then file changed. Not perfect, but easy. - if os.path.getsize(creates) != size: + if os.path.getsize(dest) != size: changed = True state = 'compress' @@ -367,7 +367,7 @@ def main(): e = get_exception() module.fail_json(path=path, msg='Unable to remove source file: %s' % str(e)) - module.exit_json(archived=successes, creates=creates, changed=changed, state=state, arcroot=arcroot, missing=missing, expanded_paths=expanded_paths) + module.exit_json(archived=successes, dest=dest, changed=changed, state=state, arcroot=arcroot, missing=missing, expanded_paths=expanded_paths) # import module snippets from ansible.module_utils.basic import * From b9971f131a3ab6dae5d860d2e99e7b7336b8c077 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Wed, 1 Jun 2016 08:29:14 -0400 Subject: [PATCH 18/21] Rename 'archive' -> 'arcfile' in compress branch --- files/archive.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/files/archive.py b/files/archive.py index 11e9d155f76..a80fdf2a732 100644 --- a/files/archive.py +++ b/files/archive.py @@ -315,16 +315,16 @@ def main(): changed = True else: size = 0 - f_in = f_out = archive = None + f_in = f_out = arcfile = None if os.path.lexists(dest): size = os.path.getsize(dest) try: if compression == 'zip': - archive = zipfile.ZipFile(dest, 'w', zipfile.ZIP_DEFLATED) - archive.write(path, path[len(arcroot):]) - archive.close() + arcfile = zipfile.ZipFile(dest, 'w', zipfile.ZIP_DEFLATED) + arcfile.write(path, path[len(arcroot):]) + arcfile.close() state = 'archive' # because all zip files are archives else: @@ -346,8 +346,8 @@ def main(): module.fail_json(path=path, dest=dest, msg='Unable to write to compressed file: %s' % str(e)) - if archive: - archive.close() + if arcfile: + arcfile.close() if f_in: f_in.close() if f_out: From ddfd32774bf54500317a7ac86ecc867390b9f84e Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Wed, 1 Jun 2016 08:29:28 -0400 Subject: [PATCH 19/21] Don't try to walk over files when building archive --- files/archive.py | 59 ++++++++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/files/archive.py b/files/archive.py index a80fdf2a732..756fea4a3e1 100644 --- a/files/archive.py +++ b/files/archive.py @@ -230,40 +230,49 @@ def main(): arcfile = tarfile.open(dest, 'w|' + compression) for path in archive_paths: - basename = '' - - # Prefix trees in the archive with their basename, unless specifically prevented with '.' - if os.path.isdir(path) and not path.endswith(os.sep + '.'): - basename = os.path.basename(path) + os.sep - - for dirpath, dirnames, filenames in os.walk(path, topdown=True): - for dirname in dirnames: - fullpath = dirpath + os.sep + dirname - - try: - if compression == 'zip': - arcfile.write(fullpath, basename + dirname) - else: - arcfile.add(fullpath, basename + dirname, recursive=False) + if os.path.isdir(path): + basename = '' - except Exception: - e = get_exception() - errors.append('%s: %s' % (fullpath, str(e))) + # Prefix trees in the archive with their basename, unless specifically prevented with '.' + if not path.endswith(os.sep + '.'): + basename = os.path.basename(path) + os.sep - for filename in filenames: - fullpath = dirpath + os.sep + filename + # Recurse into directories + for dirpath, dirnames, filenames in os.walk(path, topdown=True): + for dirname in dirnames: + fullpath = dirpath + os.sep + dirname - if not filecmp.cmp(fullpath, dest): try: if compression == 'zip': - arcfile.write(fullpath, basename + filename) + arcfile.write(fullpath, basename + dirname) else: - arcfile.add(fullpath, basename + filename, recursive=False) + arcfile.add(fullpath, basename + dirname, recursive=False) - successes.append(fullpath) except Exception: e = get_exception() - errors.append('Adding %s: %s' % (path, str(e))) + errors.append('%s: %s' % (fullpath, str(e))) + + for filename in filenames: + fullpath = dirpath + os.sep + filename + + if not filecmp.cmp(fullpath, dest): + try: + if compression == 'zip': + arcfile.write(fullpath, basename + filename) + else: + arcfile.add(fullpath, basename + filename, recursive=False) + + successes.append(fullpath) + except Exception: + e = get_exception() + errors.append('Adding %s: %s' % (path, str(e))) + else: + if compression == 'zip': + arcfile.write(path, path[len(arcroot):]) + else: + arcfile.add(path, path[len(arcroot):], recursive=False) + + successes.append(path) except Exception: e = get_exception() From a38b510aa80a856d6d59986372150a210504b0b1 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Wed, 1 Jun 2016 08:59:28 -0400 Subject: [PATCH 20/21] Refactor computation of archive filenames, clearer archive filename --- files/archive.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/files/archive.py b/files/archive.py index 756fea4a3e1..26394b03ad2 100644 --- a/files/archive.py +++ b/files/archive.py @@ -231,36 +231,35 @@ def main(): for path in archive_paths: if os.path.isdir(path): - basename = '' - - # Prefix trees in the archive with their basename, unless specifically prevented with '.' - if not path.endswith(os.sep + '.'): - basename = os.path.basename(path) + os.sep - # Recurse into directories for dirpath, dirnames, filenames in os.walk(path, topdown=True): + if not dirpath.endswith(os.sep): + dirpath += os.sep + for dirname in dirnames: - fullpath = dirpath + os.sep + dirname + fullpath = dirpath + dirname + arcname = fullpath[len(arcroot):] try: if compression == 'zip': - arcfile.write(fullpath, basename + dirname) + arcfile.write(fullpath, arcname) else: - arcfile.add(fullpath, basename + dirname, recursive=False) + arcfile.add(fullpath, arcname, recursive=False) except Exception: e = get_exception() errors.append('%s: %s' % (fullpath, str(e))) for filename in filenames: - fullpath = dirpath + os.sep + filename + fullpath = dirpath + filename + arcname = fullpath[len(arcroot):] if not filecmp.cmp(fullpath, dest): try: if compression == 'zip': - arcfile.write(fullpath, basename + filename) + arcfile.write(fullpath, arcname) else: - arcfile.add(fullpath, basename + filename, recursive=False) + arcfile.add(fullpath, arcname, recursive=False) successes.append(fullpath) except Exception: From 07ca593c80d4b996cca7e4e2fe0b4f45a4745644 Mon Sep 17 00:00:00 2001 From: Ben Doherty Date: Wed, 1 Jun 2016 09:07:18 -0400 Subject: [PATCH 21/21] expanduser() on dest --- files/archive.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/files/archive.py b/files/archive.py index 26394b03ad2..96658461e9a 100644 --- a/files/archive.py +++ b/files/archive.py @@ -157,7 +157,9 @@ def main(): # Default created file name (for single-file archives) to # . - if not archive and not dest: + if dest: + dest = os.path.expanduser(dest) + elif not archive: dest = '%s.%s' % (expanded_paths[0], compression) # Force archives to specify 'dest'