| @ -0,0 +1,5 @@ | |||
| [flake8] | |||
| ignore = E203, E266, E501, W503, F403, F401 | |||
| max-line-length = 120 | |||
| max-complexity = 18 | |||
| select = B,C,E,F,W,T4,B9 | |||
| @ -0,0 +1,120 @@ | |||
| # Swap files | |||
| *.sw[a-p] | |||
| # Byte-compiled / optimized / DLL files | |||
| __pycache__/ | |||
| *.py[cod] | |||
| *$py.class | |||
| # C extensions | |||
| *.so | |||
| # Distribution / packaging | |||
| .Python | |||
| build/ | |||
| develop-eggs/ | |||
| dist/ | |||
| downloads/ | |||
| eggs/ | |||
| .eggs/ | |||
| lib/ | |||
| lib64/ | |||
| parts/ | |||
| sdist/ | |||
| var/ | |||
| wheels/ | |||
| *.egg-info/ | |||
| .installed.cfg | |||
| *.egg | |||
| MANIFEST | |||
| poetry.lock | |||
| # PyInstaller | |||
| # Usually these files are written by a python script from a template | |||
| # before PyInstaller builds the exe, so as to inject date/other infos into it. | |||
| *.manifest | |||
| *.spec | |||
| # Installer logs | |||
| pip-log.txt | |||
| pip-delete-this-directory.txt | |||
| # Unit test / coverage reports | |||
| htmlcov/ | |||
| .tox/ | |||
| .nox/ | |||
| .coverage | |||
| .coverage.* | |||
| .cache | |||
| nosetests.xml | |||
| coverage.xml | |||
| *.cover | |||
| .hypothesis/ | |||
| .pytest_cache/ | |||
| .mypy_cache/ | |||
| # Translations | |||
| *.mo | |||
| *.pot | |||
| # Django stuff: | |||
| *.log | |||
| local_settings.py | |||
| db.sqlite3 | |||
| # Flask stuff: | |||
| instance/ | |||
| .webassets-cache | |||
| # Scrapy stuff: | |||
| .scrapy | |||
| # Sphinx documentation | |||
| docs/_build/ | |||
| # PyBuilder | |||
| target/ | |||
| # Jupyter Notebook | |||
| .ipynb_checkpoints | |||
| # IPython | |||
| profile_default/ | |||
| ipython_config.py | |||
| # pyenv | |||
| .python-version | |||
| # celery beat schedule file | |||
| celerybeat-schedule | |||
| # SageMath parsed files | |||
| *.sage.py | |||
| # Environments | |||
| .env | |||
| .secrets | |||
| .venv | |||
| env/ | |||
| venv/ | |||
| ENV/ | |||
| env.bak/ | |||
| venv.bak/ | |||
| # Spyder project settings | |||
| .spyderproject | |||
| .spyproject | |||
| # Rope project settings | |||
| .ropeproject | |||
| # mkdocs documentation | |||
| /site | |||
| # mypy | |||
| .mypy_cache/ | |||
| .dmypy.json | |||
| dmypy.json | |||
| # Pyre type checker | |||
| .pyre/ | |||
| @ -0,0 +1,9 @@ | |||
| repos: | |||
| - repo: https://github.com/psf/black | |||
| rev: "22.6.0" | |||
| hooks: | |||
| - id: black | |||
| - repo: https://gitlab.com/pycqa/flake8 | |||
| rev: "3.8.4" | |||
| hooks: | |||
| - id: flake8 | |||
| @ -0,0 +1,70 @@ | |||
| # Transpose | |||
| A tool for moving and symlinking directories to a central location | |||
| ## Inspiration | |||
| I've been using linux as my main machine for a while and wanted a central directory to backup as backing up my entire `HOME` directory was a mess. I found moving directories and symlinking worked great. I created a simple project when learning python (I called symlinker) and used it for a while but found it annoying to configure and work with. | |||
| I recently found I could use a tool like this for a SteamDeck and decided to start from scratch with better code and easier to use. | |||
| This is the current result, although it still needs a lot of work as I'm sure I'm not doing things particularly well. | |||
| ## Installation | |||
| TODO | |||
| ## Configuration | |||
| There are a few environment variables that can be defined to override defaults | |||
| ``` | |||
| TRANSPOSE_STORE_PATH="$XDG_DATA_HOME/transpose" | |||
| TRANSPOSE_CACHE_FILENAME=".transpose.json" | |||
| ``` | |||
| ## Usage | |||
| ### Storing a Directory | |||
| Storing a directory will: | |||
| 1. Move a `target` to `$STORE_PATH/{name}` | |||
| 2. Symlink `target` to `$STORE_PATH/{name}` | |||
| 3. Create a cache file at `$STORE_PATH/{name}/.transpose.json` to store the original target path | |||
| ``` | |||
| transpose store "My Documents" /home/user/Documents | |||
| ``` | |||
| The above will (assuming using all the defaults): | |||
| 1. Move `/home/user/Documents` to `$XDG_DATA_HOME/transpose/My Documents` | |||
| 2. Symlink `/home/user/Documents` to `$XDG_DATA_HOME/transpose/My Documents` | |||
| ### Restoring a Store Directory | |||
| Restoring a directory will: | |||
| 1. Remove the old symlink in the `original_path` of the cache file, `$STORE_PATH/{name}/.transpose.json` | |||
| 2. Move the stored directory to the `original_path` | |||
| ``` | |||
| transpose restore "$XDG_DATA_HOME/transpose/My Documents" | |||
| ``` | |||
| The above will (assuming all the defaults): | |||
| 1. Remove the symlink at `/home/user/Documents` (from cache file) | |||
| 2. Move `$XDG_DATA_HOME/transpose/My Documents` to `/home/user/Documents` | |||
| ## License | |||
| TODO | |||
| @ -0,0 +1,15 @@ | |||
| from os import environ | |||
| from pydantic import BaseSettings | |||
| default_xdg_path = environ.get("XDG_DATA_HOME", f"{environ['HOME']}/.local/share") | |||
| class Config(BaseSettings): | |||
| store_path: str = f"{default_xdg_path}/transpose" | |||
| cache_filename: str = ".transpose.json" | |||
| class Config: | |||
| env_file = ".env" | |||
| env_file_encoding = "utf-8" | |||
| env_nested_delimiter = "__" | |||
| env_prefix = "TRANSPOSE_" | |||
| @ -0,0 +1,63 @@ | |||
| import argparse | |||
| import os | |||
| from config import Config | |||
| from transpose import Transpose, version | |||
| config = Config() | |||
| def main() -> None: | |||
| args = parse_arguments() | |||
| t = Transpose( | |||
| target_path=args.target_path, | |||
| store_path=config.store_path, | |||
| cache_filename=config.cache_filename, | |||
| ) | |||
| if args.action == "restore": | |||
| t.restore() | |||
| elif args.action == "store": | |||
| t.store(name=args.name) | |||
| def parse_arguments(): | |||
| base_parser = argparse.ArgumentParser(add_help=False) | |||
| parser = argparse.ArgumentParser( | |||
| parents=[base_parser], | |||
| description=""" | |||
| Move and symlink a path for easier management | |||
| """, | |||
| ) | |||
| parser.add_argument("--version", action="version", version=f"Transpose {version}") | |||
| subparsers = parser.add_subparsers(help="Transpose Action", dest="action") | |||
| restore_parser = subparsers.add_parser( | |||
| "restore", | |||
| help="Move a transposed directory back to it's original location", | |||
| parents=[base_parser], | |||
| ) | |||
| restore_parser.add_argument( | |||
| "target_path", | |||
| help="The path to the directory to restore", | |||
| ) | |||
| store_parser = subparsers.add_parser( | |||
| "store", help="Move target and create symlink in place", parents=[base_parser] | |||
| ) | |||
| store_parser.add_argument( | |||
| "name", | |||
| help="The name of the directory that will be created in the store path", | |||
| ) | |||
| store_parser.add_argument( | |||
| "target_path", | |||
| help="The path to the directory to be stored", | |||
| ) | |||
| return parser.parse_args() | |||
| if __name__ == "__main__": | |||
| main() | |||
| @ -0,0 +1,24 @@ | |||
| [tool.poetry] | |||
| name = "transpose" | |||
| version = "0.9.0" | |||
| description = "Move and symlink a path" | |||
| authors = ["Ryan Reed"] | |||
| [tool.poetry.dependencies] | |||
| python = "^3.7" | |||
| rich = "*" | |||
| click = "^8.1.3" | |||
| [tool.poetry.dev-dependencies] | |||
| black = "==22.6" | |||
| flake8 = "==3.8.4" | |||
| pre-commit = "*" | |||
| pydantic = "*" | |||
| pytest = "*" | |||
| pytest-sugar = "*" | |||
| python-dotenv = "*" | |||
| [build-system] | |||
| requires = ["poetry>=0.12"] | |||
| build-backend = "poetry.masonry.api" | |||
| @ -0,0 +1,2 @@ | |||
| [pytest] | |||
| testpaths = tests | |||
| @ -0,0 +1,116 @@ | |||
| import json | |||
| import os | |||
| from pathlib import Path, PurePath | |||
| from contextlib import contextmanager | |||
| from tempfile import TemporaryDirectory | |||
| from config import Config | |||
| from transpose import version | |||
| from transpose.utils import check_path, create_cache, get_cache, move, remove, symlink | |||
| TARGET_DIR = "source" | |||
| STORE_DIR = "destination" | |||
| SYMLINK_DIR = "symlink_test" | |||
| CACHE_FILE_CONTENTS = {"version": version, "original_path": TARGET_DIR} | |||
| config = Config() | |||
| @contextmanager | |||
| def setup(): | |||
| old_dir = os.getcwd() | |||
| with TemporaryDirectory("tests-temp") as td: | |||
| try: | |||
| os.chdir(td) | |||
| os.mkdir(TARGET_DIR) | |||
| os.mkdir(STORE_DIR) | |||
| os.symlink(TARGET_DIR, SYMLINK_DIR) | |||
| cache_path = Path(PurePath(TARGET_DIR, config.cache_filename)) | |||
| with open(str(cache_path), "w") as f: | |||
| json.dump(CACHE_FILE_CONTENTS, f) | |||
| yield | |||
| finally: | |||
| os.chdir(old_dir) | |||
| @setup() | |||
| def test_check_path(): | |||
| existing_dir = Path(TARGET_DIR) | |||
| nonexisting_dir = Path("nonexistent") | |||
| symlink_dir = Path(SYMLINK_DIR) | |||
| cache_path = Path(PurePath(TARGET_DIR, config.cache_filename)) | |||
| assert check_path(existing_dir) is True | |||
| assert check_path(nonexisting_dir) is False | |||
| assert check_path(symlink_dir, is_symlink=True) is True | |||
| assert check_path(cache_path) is False | |||
| @setup() | |||
| def test_cache_create(): | |||
| cache_file = "test_cache_file.json" | |||
| cache_path = Path(PurePath(TARGET_DIR, cache_file)) | |||
| original_path = Path("/tmp/some/random/path") | |||
| create_cache(cache_path=cache_path, original_path=original_path) | |||
| cache = json.load(open(cache_path, "r")) | |||
| assert cache_path.exists() | |||
| assert cache["original_path"] == str(original_path) | |||
| assert cache["version"] == version | |||
| @setup() | |||
| def test_cache_get(): | |||
| cache_path = Path(PurePath(TARGET_DIR, config.cache_filename)) | |||
| cache = get_cache(cache_path) | |||
| assert cache["version"] == CACHE_FILE_CONTENTS["version"] | |||
| assert cache["original_path"] == CACHE_FILE_CONTENTS["original_path"] | |||
| @setup() | |||
| def test_file_move(): | |||
| source_path = Path(TARGET_DIR) | |||
| destination_path = Path(STORE_DIR) | |||
| move(source=source_path.absolute(), destination=destination_path.absolute()) | |||
| assert not source_path.exists() | |||
| assert destination_path.exists() | |||
| @setup() | |||
| def test_file_remove(): | |||
| cache_path = Path(PurePath(TARGET_DIR, config.cache_filename)) | |||
| symlink_filepath = Path(PurePath(TARGET_DIR, SYMLINK_DIR)) | |||
| target_filepath = Path(TARGET_DIR) | |||
| remove(path=cache_path) | |||
| remove(path=symlink_filepath) | |||
| remove(path=target_filepath) | |||
| assert not cache_path.exists() # Should be able to remove files | |||
| assert not symlink_filepath.exists() # Should be able to remove symlinks | |||
| assert target_filepath.exists() # Should not be able to remove directories | |||
| @setup() | |||
| def test_file_symlink(): | |||
| symlink_name = "test_link" | |||
| symlink_filepath = Path(symlink_name) | |||
| target_filepath = Path(TARGET_DIR) | |||
| symlink(target_path=target_filepath, symlink_path=symlink_filepath) | |||
| assert target_filepath.exists() | |||
| assert symlink_filepath.is_symlink() | |||
| assert symlink_filepath.readlink() == target_filepath | |||
| @ -0,0 +1,8 @@ | |||
| from .logger import create_logger | |||
| version_info = (0, 9, 0) | |||
| version = ".".join(str(c) for c in version_info) | |||
| logger = create_logger(__package__) | |||
| from .transpose import Transpose # noqa: E402 | |||
| @ -0,0 +1,6 @@ | |||
| import logging | |||
| def create_logger(logger_name: str) -> logging: | |||
| logger = logging.getLogger(logger_name) | |||
| return logger | |||
| @ -0,0 +1,129 @@ | |||
| from pathlib import Path, PurePath | |||
| from .utils import check_path, create_cache, get_cache, move, remove, symlink | |||
| class Transpose: | |||
| def __init__( | |||
| self, target_path: str, store_path: str, cache_filename: str = None | |||
| ) -> None: | |||
| self.target_path = Path(target_path) | |||
| self.store_path = Path(store_path) | |||
| if not cache_filename: | |||
| cache_filename = ".transpose.json" | |||
| self.cache_filename = cache_filename | |||
| self.cache_path = Path(PurePath(self.target_path, cache_filename)) | |||
| def restore(self) -> None: | |||
| """ | |||
| Restores a previously Transpose managed directory to it's previous location. | |||
| Performs: | |||
| 1. Verify `cache_file` exists | |||
| 2. Verify `target_path` exists | |||
| 3. Verify if `original_path` in cache is a symlink | |||
| a. Remove if true | |||
| 4. Verify `original_path` doesn't exist | |||
| 5. Move `target_path` to `original_path` based on cache file settings | |||
| Args: | |||
| None | |||
| Returns: | |||
| None | |||
| Raises: | |||
| ValueError: Any paths not existing | |||
| RuntimeError: Any error during the actual path changes | |||
| """ | |||
| if not self.cache_path.exists(): | |||
| raise ValueError( | |||
| f"Cache file does not exist indicating target is not managed by Transpose: {self.cache_path}" | |||
| ) | |||
| if not self.target_path.exists(): | |||
| raise ValueError(f"Target path does not exist: {self.target_path}") | |||
| cache = get_cache(self.cache_path) | |||
| original_path = Path(cache["original_path"]) | |||
| if original_path.is_symlink(): | |||
| try: | |||
| remove(original_path) | |||
| except: # noqa: E722 # TODO | |||
| raise RuntimeError( | |||
| f"Failed to remove symlink in original path: {original_path}" | |||
| ) | |||
| elif original_path.exists(): | |||
| raise ValueError( | |||
| f"Original path in cache file already exists: {original_path}" | |||
| ) | |||
| try: | |||
| move(source=self.target_path, destination=original_path) | |||
| except: # noqa: E722 # TODO | |||
| raise RuntimeError( | |||
| f"Failed to move target to original location: {self.target_path} -> {original_path}" | |||
| ) | |||
| new_cache_path = Path(PurePath(original_path, self.cache_filename)) | |||
| try: | |||
| remove(new_cache_path) | |||
| except: # noqa: E722 # TODO | |||
| raise RuntimeError( | |||
| f"Failed to remove previous cache file: {new_cache_path}" | |||
| ) | |||
| def store(self, name: str) -> None: | |||
| """ | |||
| Moves a directory to a central location and creates a symlink to the old path. | |||
| Performs: | |||
| 1. Verify `target_path` exists | |||
| 2. Verify `store_path` exists | |||
| 3. Create the cache file | |||
| 4. Move the `target_path` to `store_path/name` | |||
| 5. Create symlink `target_path` -> `store_path/name` | |||
| Args: | |||
| name: The directory name to give the new location | |||
| Returns: | |||
| None | |||
| Raises | |||
| ValueError: Any paths not existing | |||
| RuntimeError: Any error during the actual path changes | |||
| """ | |||
| new_location = Path(PurePath(self.store_path, name)) | |||
| if not check_path(path=self.target_path): | |||
| raise ValueError( | |||
| f"Target path, {self.target_path}, does not exist. Cannot continue." | |||
| ) | |||
| if check_path(path=new_location): | |||
| raise ValueError( | |||
| f"Store path, {new_location}, already exists. Cannot continue." | |||
| ) | |||
| try: | |||
| create_cache( | |||
| cache_path=self.cache_path, | |||
| original_path=self.target_path, | |||
| ) | |||
| except: # noqa: E722 # TODO | |||
| raise RuntimeError("Failed to create cache file: {self.cache_path}") | |||
| try: | |||
| move(source=self.target_path, destination=new_location) | |||
| except: # noqa: E722 # TODO | |||
| raise RuntimeError( | |||
| f"Failed to move target to store path: {self.target_path} -> {self.new_location}" | |||
| ) | |||
| try: | |||
| symlink(target_path=new_location, symlink_path=self.target_path) | |||
| except: # noqa: E722 # TODO | |||
| raise RuntimeError( | |||
| f"Failed to symlink store path to target: {new_location} -> {self.target_path}" | |||
| ) | |||
| @ -0,0 +1,102 @@ | |||
| from pathlib import Path | |||
| from typing import Dict | |||
| import json | |||
| from . import version | |||
| def check_path(path: Path, is_symlink: bool = False) -> bool: | |||
| """ | |||
| Checks whether a path exists and is a directory (doesn't support single files) | |||
| Args: | |||
| path: The location to the path being verified | |||
| is_symlink: Should this path be a symlink? | |||
| Returns: | |||
| bool | |||
| """ | |||
| if is_symlink and not path.is_symlink(): | |||
| return False | |||
| if not is_symlink and path.is_symlink(): | |||
| return False | |||
| if not path.exists(): | |||
| return False | |||
| if not path.is_dir(): | |||
| return False | |||
| return True | |||
| def create_cache(cache_path: Path, original_path: Path) -> None: | |||
| """ | |||
| Create a cache file for transpose settings in the stored directory | |||
| Args: | |||
| cache_path: Path to store the cache file | |||
| original_path: Path where the stored directory originated | |||
| Returns: | |||
| None | |||
| """ | |||
| template = {"version": version, "original_path": str(original_path)} | |||
| with open(str(cache_path), "w") as f: | |||
| json.dump(template, f) | |||
| def get_cache(cache_path: Path) -> Dict: | |||
| """ | |||
| Read a JSON cache file | |||
| Args: | |||
| cache_path: Path to the Transpose cache file | |||
| Returns: | |||
| dict: Cache file contents | |||
| """ | |||
| return json.load(open(cache_path, "r")) | |||
| def move(source: Path, destination: Path) -> None: | |||
| """ | |||
| Move a file using pathlib | |||
| Args: | |||
| source: Path to original source of the file/directory | |||
| destination: Path to new destination | |||
| Returns: | |||
| None | |||
| """ | |||
| source.rename(destination) | |||
| def remove(path: Path) -> None: | |||
| """ | |||
| Remove a file or symlink | |||
| Does not support directories as a precaution and lack of need | |||
| Args: | |||
| path: Path to the file or symlink | |||
| Returns: | |||
| None | |||
| """ | |||
| if path.is_symlink() or path.is_file(): | |||
| path.unlink() | |||
| def symlink(target_path: Path, symlink_path: Path) -> None: | |||
| """ | |||
| Symlinks a file or directory | |||
| Args: | |||
| target_path: Path to the target that is being symlinked to | |||
| symlink_path: Path to the symlink | |||
| Returns: | |||
| None | |||
| """ | |||
| symlink_path.symlink_to(target_path) | |||