commit f92bf87fc768aace8d9588f58dd92f3e7adedfcd Author: Ryan Reed Date: Wed Jun 29 17:33:25 2022 -0400 Initial commit - v0.9.0 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..7bf302c --- /dev/null +++ b/.flake8 @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4779690 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d692225 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c179fdf --- /dev/null +++ b/README.md @@ -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 diff --git a/config.py b/config.py new file mode 100644 index 0000000..98b72b7 --- /dev/null +++ b/config.py @@ -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_" diff --git a/main.py b/main.py new file mode 100644 index 0000000..bff84ab --- /dev/null +++ b/main.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..93cb62b --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..f56c672 --- /dev/null +++ b/tests/test_project.py @@ -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 diff --git a/transpose/__init__.py b/transpose/__init__.py new file mode 100644 index 0000000..b06f8c3 --- /dev/null +++ b/transpose/__init__.py @@ -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 diff --git a/transpose/logger.py b/transpose/logger.py new file mode 100644 index 0000000..47bc0a7 --- /dev/null +++ b/transpose/logger.py @@ -0,0 +1,6 @@ +import logging + + +def create_logger(logger_name: str) -> logging: + logger = logging.getLogger(logger_name) + return logger diff --git a/transpose/transpose.py b/transpose/transpose.py new file mode 100644 index 0000000..11410e7 --- /dev/null +++ b/transpose/transpose.py @@ -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}" + ) diff --git a/transpose/utils.py b/transpose/utils.py new file mode 100644 index 0000000..6109512 --- /dev/null +++ b/transpose/utils.py @@ -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)