Browse Source

Initial commit - v0.9.0

pull/2/head
Ryan Reed 2 years ago
commit
f92bf87fc7
14 changed files with 669 additions and 0 deletions
  1. +5
    -0
      .flake8
  2. +120
    -0
      .gitignore
  3. +9
    -0
      .pre-commit-config.yaml
  4. +70
    -0
      README.md
  5. +15
    -0
      config.py
  6. +63
    -0
      main.py
  7. +24
    -0
      pyproject.toml
  8. +2
    -0
      pytest.ini
  9. +0
    -0
      tests/__init__.py
  10. +116
    -0
      tests/test_project.py
  11. +8
    -0
      transpose/__init__.py
  12. +6
    -0
      transpose/logger.py
  13. +129
    -0
      transpose/transpose.py
  14. +102
    -0
      transpose/utils.py

+ 5
- 0
.flake8 View File

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

+ 120
- 0
.gitignore View File

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

+ 9
- 0
.pre-commit-config.yaml View File

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

+ 70
- 0
README.md View File

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

+ 15
- 0
config.py View File

@ -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_"

+ 63
- 0
main.py View File

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

+ 24
- 0
pyproject.toml View File

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

+ 2
- 0
pytest.ini View File

@ -0,0 +1,2 @@
[pytest]
testpaths = tests

+ 0
- 0
tests/__init__.py View File


+ 116
- 0
tests/test_project.py View File

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

+ 8
- 0
transpose/__init__.py View File

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

+ 6
- 0
transpose/logger.py View File

@ -0,0 +1,6 @@
import logging
def create_logger(logger_name: str) -> logging:
logger = logging.getLogger(logger_name)
return logger

+ 129
- 0
transpose/transpose.py View File

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

+ 102
- 0
transpose/utils.py View File

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

Loading…
Cancel
Save