@ -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) |