/ bin / sff
sff
  1  #!/usr/bin/env python3
  2  
  3  import argparse
  4  import os
  5  import re
  6  import shutil
  7  from argparse import FileType
  8  from pathlib import Path
  9  
 10  
 11  def target_dir(dir_):
 12      dir_ = Path(dir_)
 13      if not dir_.is_dir():
 14          raise argparse.ArgumentTypeError("Please pass a valid path to a directory")
 15      return dir_
 16  
 17  
 18  def list_dir(dir_, ignore_regex=None):
 19      tree_items = []
 20      for (dirpath, dirnames, filenames) in os.walk(dir_):
 21          dirpath = Path(dirpath)
 22          rel_dirpath = dirpath.relative_to(dir_)
 23          for item in filenames:
 24              rel_path = str(rel_dirpath / item)
 25              if ignore_regex and ignore_regex.search(rel_path):
 26                  continue
 27              tree_items.append(rel_path)
 28      return tree_items
 29  
 30  
 31  def get_abs_and_rel(parent, path):
 32      if path.is_absolute():
 33          if str(path).startswith(str(parent)):
 34              rel_path = path.relative_to(parent)
 35          else:
 36              rel_path = path
 37          return path, rel_path
 38      else:
 39          abs_path = parent / path
 40          return abs_path, path
 41  
 42  
 43  def main(args):
 44      parent = Path(args.parent or os.curdir).resolve()
 45  
 46      dry_run = args.dry_run
 47  
 48      def p(string):
 49          if dry_run:
 50              string = "would %s" % string
 51          print(string)
 52  
 53      relative_sources = set()
 54      ignored_sources = {}
 55      readlines = args.file.readlines()
 56      print("lines read: %s" % len(readlines))
 57      target_dir = args.target_dir
 58      for path_str in readlines:
 59          path_str = path_str.strip()
 60          # Ignore comments
 61          if path_str.strip().startswith("#"):
 62              continue
 63          abs_path, rel_path = get_abs_and_rel(parent, Path(path_str))
 64          if not abs_path.exists():
 65              print("'%s' doesn't exist" % abs_path)
 66              ignored_sources[str(abs_path)] = "Nonexistent"
 67              continue
 68  
 69          if args.recreate_dirs:
 70              target_path = target_dir / rel_path
 71          else:
 72              target_path = target_dir / rel_path.name
 73  
 74          if target_path.exists():
 75              source_stat = abs_path.stat()
 76              target_stat = target_path.stat()
 77              # Don't touch unchanged files
 78              if source_stat.st_size == target_stat.st_size:
 79                  ignored_sources[str(rel_path)] = "Same size"
 80                  continue
 81          elif not dry_run:
 82              target_path.parent.mkdir(parents=True, exist_ok=True)
 83  
 84          p("Copy %s" % rel_path)
 85          if not dry_run:
 86              shutil.copy(abs_path, target_path)
 87  
 88          relative_sources.add(str(target_path.relative_to(target_dir)))
 89  
 90      print("%s ignored" % len(ignored_sources))
 91      print("%s to copy" % len(relative_sources))
 92  
 93      # Remove files only on destination
 94      destination_items = set(list_dir(target_dir, args.dest_exclude))
 95      destination_items_len = len(destination_items)
 96      print("%s files in destination" % destination_items_len)
 97  
 98      ignored_set = set(ignored_sources.keys())
 99      files_to_delete = (destination_items - relative_sources) - ignored_set
100      to_delete_len = len(files_to_delete)
101      print("expected count: %s" % (destination_items_len - to_delete_len,))
102  
103      if to_delete_len:
104          print("%s to delete" % to_delete_len)
105          for to_delete in files_to_delete:
106              to_delete = target_dir / to_delete
107              p("delete %s" % to_delete)
108              if not dry_run:
109                  to_delete.unlink()
110      else:
111          print("Nothing to delete")
112      print("Done!")
113  
114  
115  if __name__ == '__main__':
116      parser = argparse.ArgumentParser(
117          description=
118          "Sync from file."
119          "Reads a list of files and directories to hard-sync to a directory. "
120          "Any file or directory not in the target dir will be deleted!")
121  
122      # parser.add_argument("--ignore-mod-date",
123      #                     help="Ignores modification date and "
124      #                          "only checks if target exists")
125      parser.add_argument("-p", "--parent", help="Parent of the paths in the directory."
126                                                 "If the paths are relative, this will serve as their parent"
127                                                 "If the paths are absolute, this will strip off the parent")
128      parser.add_argument("-c", "--recreate-dirs",
129                          help="Recreate parent directories of paths in given file "
130                               "otherwise the files will be directly copied into the target directory",
131                          action="store_true")
132      # TODO: Exclude pattern for source files
133      # parser.add_argument("-e", "--exclude",
134      #                     help="Exclude regex on source", type=re.compile)
135      parser.add_argument("-E", "--dest-exclude",
136                          help="Exclude regex on destination", type=re.compile)
137  
138      parser.add_argument("-n", "--dry-run", action="store_true")
139      parser.add_argument("file", type=FileType())
140      parser.add_argument("target_dir", type=target_dir)
141  
142      main(parser.parse_args())