# -*- coding: utf-8 -*- import gzip import json import logging import os import shutil import tempfile from os import scandir import filelock import magic import pathspec from s4.clients import SyncClient, SyncObject logger = logging.getLogger(__name__) def get_local_client(target): return LocalSyncClient(target) def traverse(path, ignore_files=None): if not os.path.exists(path): return if ignore_files is None: ignore_files = [] for item in scandir(path): full_path = os.path.join(path, item.name) spec = pathspec.PathSpec.from_lines( pathspec.patterns.GitWildMatchPattern, ignore_files ) if spec.match_file(full_path): logger.debug("Ignoring %s", item) continue if item.is_dir(): for result in traverse(item.path, ignore_files): yield os.path.join(item.name, result) else: yield item.name class LocalSyncClient(SyncClient): DEFAULT_IGNORE_FILES = [".index", ".s4lock"] LOCK_FILE_NAME = ".s4lock" def __init__(self, path): self.path = path self.reload_index() self.reload_ignore_files() self._lock = filelock.FileLock(self.lock_file) @property def lock_file(self): return self.get_uri(self.LOCK_FILE_NAME) def ensure_path(self, path): parent = os.path.dirname(path) if not os.path.exists(parent): os.makedirs(parent) def lock(self, timeout=10): """ Advisory lock. Use to ensure that only one LocalSyncClient is working on the Target at the same time. """ logger.debug("Locking %s", self.lock_file) if not os.path.exists(self.lock_file): self.ensure_path(self.lock_file) with open(self.lock_file, "w"): os.utime(self.lock_file) self._lock.acquire(timeout=timeout) def unlock(self): """ Unlock the active advisory lock. """ logger.debug("Releasing lock %s", self.lock_file) self._lock.release() try: os.unlink(self.lock_file) except FileNotFoundError: pass def get_client_name(self): return "local" def __repr__(self): return "LocalSyncClient<{}>".format(self.path) def get_uri(self, key=""): return os.path.join(self.path, key) def index_path(self): return os.path.join(self.path, ".index") def put(self, key, sync_object, callback=None): path = os.path.join(self.path, key) self.ensure_path(path) BUFFER_SIZE = 4096 fd, temp_path = tempfile.mkstemp() try: with open(temp_path, "wb") as fp_1: while True: data = sync_object.fp.read(BUFFER_SIZE) fp_1.write(data) if callback is not None: callback(len(data)) if len(data) < BUFFER_SIZE: break shutil.move(temp_path, path) except Exception: os.remove(temp_path) raise finally: os.close(fd) self.set_remote_timestamp(key, sync_object.timestamp) def get(self, key): path = os.path.join(self.path, key) if os.path.exists(path): fp = open(path, "rb") stat = os.stat(path) return SyncObject(fp, stat.st_size, stat.st_mtime) else: return None def delete(self, key): path = os.path.join(self.path, key) if os.path.exists(path): os.remove(path) return True else: return False def reload_index(self): self.index = self._load_index() def _load_index(self): index_path = self.index_path() if not os.path.exists(index_path): return {} content_type = magic.from_file(index_path, mime=True) if content_type in ("application/json", "text/plain"): logger.debug("Detected %s encoding for reading index", content_type) method = open elif content_type in ("application/gzip", "application/x-gzip"): logger.debug("Detected gzip encoding for reading index") method = gzip.open else: raise ValueError("Index is of unknown type", content_type) with method(index_path, "rt") as fp: data = json.load(fp) return data def flush_index(self, compressed=True): if compressed: logger.debug("Using gzip encoding for writing index") method = gzip.open else: logger.debug("Using plaintext encoding for writing index") method = open fd, temp_path = tempfile.mkstemp() with method(temp_path, "wt") as fp: json.dump(self.index, fp) os.close(fd) shutil.move(temp_path, self.index_path()) def get_local_keys(self): return list(traverse(self.path, ignore_files=self.ignore_files)) def get_real_local_timestamp(self, key): full_path = os.path.join(self.path, key) if os.path.exists(full_path): return os.path.getmtime(full_path) else: return None def get_index_keys(self): return self.index.keys() def get_index_local_timestamp(self, key): return self.index.get(key, {}).get("local_timestamp") def get_all_real_local_timestamps(self): result = {} for key in self.get_local_keys(): result[key] = self.get_real_local_timestamp(key) return result def get_all_remote_timestamps(self): return {key: value.get("remote_timestamp") for key, value in self.index.items()} def get_all_index_local_timestamps(self): return {key: value.get("local_timestamp") for key, value in self.index.items()} def set_index_local_timestamp(self, key, timestamp): if key not in self.index: self.index[key] = {} self.index[key]["local_timestamp"] = timestamp def get_size(self, key): path = self.get_uri(key) if os.path.exists(path): return os.stat(path).st_size else: return 0 def get_remote_timestamp(self, key): return self.index.get(key, {}).get("remote_timestamp") def set_remote_timestamp(self, key, timestamp): if key not in self.index: self.index[key] = {} self.index[key]["remote_timestamp"] = timestamp def reload_ignore_files(self): ignore_path = os.path.join(self.path, ".syncignore") if os.path.exists(ignore_path): with open(ignore_path, "r") as fp: ignore_list = fp.read().split("\n") else: ignore_list = [] self.ignore_files = self.DEFAULT_IGNORE_FILES + ignore_list