11 Commits

11 changed files with 215 additions and 81 deletions
Unified View
  1. +0
    -40
      CHANGELOG.md
  2. +3
    -0
      README.md
  3. +1
    -1
      pyproject.toml
  4. +26
    -0
      scripts/upgrade-2.2.py
  5. +0
    -4
      src/transpose/__init__.py
  6. +45
    -17
      src/transpose/console.py
  7. +0
    -6
      src/transpose/logger.py
  8. +57
    -8
      src/transpose/transpose.py
  9. +23
    -3
      tests/test_console.py
  10. +58
    -2
      tests/test_transpose.py
  11. +2
    -0
      tests/utils.py

+ 0
- 40
CHANGELOG.md View File

@ -1,40 +0,0 @@
# Change Log
## 1.1.0 (2022-07-26)
### Breaking Changes
* Moving store_path to only the store action
Note: I know I said this shouldn't happen again but I couldn't help myself. This time....for sure.
### Features
* Allow for short -s for --store-path
### Documentation
* Add Quick Reference section
### Tests
* Updating tests to support moving store_path to store action
## 1.0.2 (2022-07-17)
### Added
* The `name` argument is now optional when using the `transpose store`
### Changed
* Moved `name` argument to end of `transpose store`
Note: I'm abusing the versioning a bit as I'm the only user of this tool. Normally, this would be considered a breaking change due to the change in argument order. Shouldn't happen again.
## 1.0.1 (2022-07-16)
### Fixed
* Utilize `expanduser` and `~` in cache files to allow for more portable restorations
## 1.0.0 (2022-07-12)
Initial release

+ 3
- 0
README.md View File

@ -114,8 +114,11 @@ It's possible to modify the transpose configuration file, `STORE_PATH/transpose.
``` ```
transpose config add "NewEntry" "/path/to/location" transpose config add "NewEntry" "/path/to/location"
transpose config get "NewEntry" transpose config get "NewEntry"
transpose config disable "NewEntry"
transpose config enable "NewEntry"
transpose config list transpose config list
transpose config remove "NewEntry" transpose config remove "NewEntry"
transpose config update "NewEntry" "path" "/path/to/new/location"
``` ```


+ 1
- 1
pyproject.toml View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "transpose" name = "transpose"
version = "2.1.0"
version = "2.2.2"
description = "Move and symlink a path to a central location" description = "Move and symlink a path to a central location"
authors = ["Ryan Reed"] authors = ["Ryan Reed"]
license = "GPLv3" license = "GPLv3"


+ 26
- 0
scripts/upgrade-2.2.py View File

@ -0,0 +1,26 @@
"""
Loop through entries and ensure using the latest 2.2 entities
This means adding the following new fields to each entry:
* created (2.1)
* enabled (2.2)
"""
import json
from transpose import DEFAULT_STORE_PATH, TransposeConfig
def main() -> None:
config_file = f"{DEFAULT_STORE_PATH}/transpose.json"
with open(config_file, "r") as f:
d = json.load(f)
config = TransposeConfig()
for entry_name in d["entries"]:
config.add(entry_name, d["entries"][entry_name]["path"])
config.save(config_file)
if __name__ == "__main__":
main()

+ 0
- 4
src/transpose/__init__.py View File

@ -2,14 +2,10 @@ import os
from importlib.metadata import version from importlib.metadata import version
from .logger import create_logger
DEFAULT_XDG_PATH = os.environ.get("XDG_DATA_HOME", f"{os.environ['HOME']}/.local/share") DEFAULT_XDG_PATH = os.environ.get("XDG_DATA_HOME", f"{os.environ['HOME']}/.local/share")
STORE_PATH = f"{DEFAULT_XDG_PATH}/transpose" STORE_PATH = f"{DEFAULT_XDG_PATH}/transpose"
DEFAULT_STORE_PATH = os.environ.get("TRANSPOSE_STORE_PATH", STORE_PATH) DEFAULT_STORE_PATH = os.environ.get("TRANSPOSE_STORE_PATH", STORE_PATH)
version = version("transpose") version = version("transpose")
logger = create_logger(__package__)
from .transpose import Transpose, TransposeConfig, TransposeEntry # noqa: E402 from .transpose import Transpose, TransposeConfig, TransposeEntry # noqa: E402

+ 45
- 17
src/transpose/console.py View File

@ -21,7 +21,7 @@ def run(args, config_path) -> None:
if args.action == "apply": if args.action == "apply":
t.apply(args.name, force=args.force) t.apply(args.name, force=args.force)
if args.action == "apply-all":
elif args.action == "apply-all":
run_apply_all(t, force=args.force) run_apply_all(t, force=args.force)
elif args.action == "restore": elif args.action == "restore":
t.restore(args.name, force=args.force) t.restore(args.name, force=args.force)
@ -34,6 +34,12 @@ def run(args, config_path) -> None:
if args.config_action == "add": if args.config_action == "add":
t.config.add(args.name, args.path) t.config.add(args.name, args.path)
t.config.save(config_path) t.config.save(config_path)
elif args.config_action == "disable":
t.config.disable(args.name)
t.config.save(config_path)
elif args.config_action == "enable":
t.config.enable(args.name)
t.config.save(config_path)
elif args.config_action == "get": elif args.config_action == "get":
print(t.config.get(args.name)) print(t.config.get(args.name))
elif args.config_action == "list": elif args.config_action == "list":
@ -43,7 +49,7 @@ def run(args, config_path) -> None:
t.config.remove(args.name) t.config.remove(args.name)
t.config.save(config_path) t.config.save(config_path)
elif args.config_action == "update": elif args.config_action == "update":
t.config.update(args.name, args.path)
t.config.update(args.name, args.field_key, args.field_value)
t.config.save(config_path) t.config.save(config_path)
@ -51,7 +57,7 @@ def run_apply_all(t: Transpose, force: bool = False) -> None:
""" """
Loop over the entries and recreate the symlinks to the store location Loop over the entries and recreate the symlinks to the store location
Useful after restoring the machine
Useful after restoring a machine
Args: Args:
t: An instance of Transpose t: An instance of Transpose
@ -60,19 +66,12 @@ def run_apply_all(t: Transpose, force: bool = False) -> None:
Returns: Returns:
None None
""" """
results = {}
for entry_name in t.config.entries:
for entry_name in sorted(t.config.entries):
try: try:
t.apply(entry_name, force) t.apply(entry_name, force)
results[entry_name] = {"status": "success", "error": ""}
print(f"\t{entry_name:<30}: success")
except TransposeError as e: except TransposeError as e:
results[entry_name] = {"status": "failure", "error": str(e)}
for name in sorted(results):
if results[name]["status"] != "success":
print(f"\t{name:<30}: {results[name]['error']}")
else:
print(f"\t{name:<30}: success")
print(f"\t{entry_name:<30}: {e}")
def parse_arguments(args=None): def parse_arguments(args=None):
@ -122,7 +121,7 @@ def parse_arguments(args=None):
apply_all_parser.add_argument( apply_all_parser.add_argument(
"--force", "--force",
dest="force", dest="force",
help="If original path already exists, existing path to <path>.backup and continue",
help="Continue with apply even if original path already exists or entry is disabled in config",
action="store_true", action="store_true",
) )
@ -135,7 +134,12 @@ def parse_arguments(args=None):
"name", "name",
help="The name of the stored entity to restore", help="The name of the stored entity to restore",
) )
restore_parser.add_argument("--force", dest="force", action="store_true")
restore_parser.add_argument(
"--force",
dest="force",
help="Continue with restore even if original path already exists or entry is disabled in config",
action="store_true",
)
store_parser = subparsers.add_parser( store_parser = subparsers.add_parser(
"store", "store",
@ -176,6 +180,26 @@ def parse_arguments(args=None):
help="The path of the directory that should be symlinked to the store", help="The path of the directory that should be symlinked to the store",
) )
config_disable_parser = config_subparsers.add_parser(
"disable",
help="Disable an entry within the config",
parents=[base_parser],
)
config_disable_parser.add_argument(
"name",
help="The name of the entry the config",
)
config_enable_parser = config_subparsers.add_parser(
"enable",
help="enable an entry within the config",
parents=[base_parser],
)
config_enable_parser.add_argument(
"name",
help="The name of the entry the config",
)
config_get_parser = config_subparsers.add_parser( config_get_parser = config_subparsers.add_parser(
"get", "get",
help="Retrieve the settings of a specific entity, such as the path", help="Retrieve the settings of a specific entity, such as the path",
@ -212,8 +236,12 @@ def parse_arguments(args=None):
help="The name of the entry in the store path", help="The name of the entry in the store path",
) )
config_update_parser.add_argument( config_update_parser.add_argument(
"path",
help="The path of the directory that should be symlinked to the store",
"field_key",
help="The config key to be updated",
)
config_update_parser.add_argument(
"field_value",
help="The value to updated in the config",
) )
return parser.parse_args(args) return parser.parse_args(args)


+ 0
- 6
src/transpose/logger.py View File

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

+ 57
- 8
src/transpose/transpose.py View File

@ -1,5 +1,6 @@
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any
# from typing import Self # from typing import Self
@ -16,6 +17,7 @@ class TransposeEntry:
name: str name: str
path: str path: str
created: str # Should be datetime.datetime but not really necessary here created: str # Should be datetime.datetime but not really necessary here
enabled: bool = True
@dataclass @dataclass
@ -47,6 +49,36 @@ class TransposeConfig:
created=created, created=created,
) )
def disable(self, name: str) -> None:
"""
Disable an entry by name. This ensures actions are not run against this entry, such as apply and restore
Args:
name: The name of the entry (must exist)
Returns:
None
"""
try:
self.entries[name].enabled = False
except KeyError:
raise TransposeError(f"'{name}' does not exist in Transpose config entries")
def enable(self, name: str) -> None:
"""
Enable an entry by name
Args:
name: The name of the entry (must exist)
Returns:
None
"""
try:
self.entries[name].enabled = True
except KeyError:
raise TransposeError(f"'{name}' does not exist in Transpose config entries")
def get(self, name: str) -> TransposeEntry: def get(self, name: str) -> TransposeEntry:
""" """
Get an entry by the name Get an entry by the name
@ -77,37 +109,46 @@ class TransposeConfig:
except KeyError: except KeyError:
raise TransposeError(f"'{name}' does not exist in Transpose config entries") raise TransposeError(f"'{name}' does not exist in Transpose config entries")
def update(self, name: str, path: str) -> None:
def update(self, name: str, field_key: str, field_value: Any) -> None:
""" """
Update an entry by name
Update an entry's field (attribute) value
Args: Args:
name: The name of the entry (must exist) name: The name of the entry (must exist)
path: The path where the entry originally exists
field_key: The key to update
field_value: The value to update
Returns: Returns:
None None
""" """
try: try:
self.entries[name].path = path
if not hasattr(self.entries[name], field_key):
raise TransposeError(f"Unknown TransposeEntry field: {field_key}")
except KeyError: except KeyError:
raise TransposeError(f"'{name}' does not exist in Transpose config entries") raise TransposeError(f"'{name}' does not exist in Transpose config entries")
setattr(self.entries[name], field_key, field_value)
@staticmethod @staticmethod
def load(config_path: str): # -> Self: def load(config_path: str): # -> Self:
try: try:
in_config = json.load(open(config_path, "r")) in_config = json.load(open(config_path, "r"))
except json.decoder.JSONDecodeError as e: except json.decoder.JSONDecodeError as e:
raise TransposeError(f"Invalid JSON format for '{config_path}': {e}") raise TransposeError(f"Invalid JSON format for '{config_path}': {e}")
except FileNotFoundError:
in_config = {"entries": {}}
config = TransposeConfig() config = TransposeConfig()
try: try:
for name in in_config["entries"]: for name in in_config["entries"]:
entry = in_config["entries"][name]
config.add( config.add(
name, name,
in_config["entries"][name]["path"],
created=in_config["entries"].get("created"),
entry["path"],
created=entry["created"],
) )
if not entry["enabled"]:
config.disable(name)
except (KeyError, TypeError) as e: except (KeyError, TypeError) as e:
raise TransposeError(f"Unrecognized Transpose config file format: {e}") raise TransposeError(f"Unrecognized Transpose config file format: {e}")
@ -160,7 +201,11 @@ class Transpose:
if not self.config.entries.get(name): if not self.config.entries.get(name):
raise TransposeError(f"Entry does not exist: '{name}'") raise TransposeError(f"Entry does not exist: '{name}'")
entry_path = Path(self.config.entries[name].path)
entry = self.config.entries[name]
if not entry.enabled and not force:
raise TransposeError(f"Entry '{name}' is not enabled in the config")
entry_path = Path(entry.path)
if entry_path.exists(): if entry_path.exists():
if force: # Backup the existing path if force: # Backup the existing path
move(entry_path, entry_path.with_suffix(".backup")) move(entry_path, entry_path.with_suffix(".backup"))
@ -188,7 +233,11 @@ class Transpose:
if not self.config.entries.get(name): if not self.config.entries.get(name):
raise TransposeError(f"Could not locate entry by name: '{name}'") raise TransposeError(f"Could not locate entry by name: '{name}'")
entry_path = Path(self.config.entries[name].path)
entry = self.config.entries[name]
if not entry.enabled and not force:
raise TransposeError(f"Entry '{name}' is not enabled in the config")
entry_path = Path(entry.path)
if entry_path.exists(): if entry_path.exists():
if force: # Backup the existing path if force: # Backup the existing path
move(entry_path, entry_path.with_suffix(".backup")) move(entry_path, entry_path.with_suffix(".backup"))


+ 23
- 3
tests/test_console.py View File

@ -161,7 +161,6 @@ def test_run_apply_all(capsys):
assert f"\t{ENTRY_NAME:<30}: success" in captured.out assert f"\t{ENTRY_NAME:<30}: success" in captured.out
assert f"\t{SECOND_ENTRY_NAME:<30}: success" in captured.out assert f"\t{SECOND_ENTRY_NAME:<30}: success" in captured.out
assert SECOND_TARGET_PATH.is_symlink() assert SECOND_TARGET_PATH.is_symlink()
assert SECOND_TARGET_PATH.with_suffix(".backup").is_dir() assert SECOND_TARGET_PATH.with_suffix(".backup").is_dir()
@ -185,6 +184,26 @@ def test_run_config_add():
assert config.entries.get(args.name) assert config.entries.get(args.name)
@setup_restore()
def test_run_config_disable():
args = RunConfigArgs("disable")
run_console(args, TRANSPOSE_CONFIG_PATH)
config = TransposeConfig().load(TRANSPOSE_CONFIG_PATH)
assert config.entries[args.name].enabled is False
@setup_restore()
def test_run_config_enable():
args = RunConfigArgs("enable")
run_console(args, TRANSPOSE_CONFIG_PATH)
config = TransposeConfig().load(TRANSPOSE_CONFIG_PATH)
assert config.entries[args.name].enabled is True
@setup_restore() @setup_restore()
def test_run_config_get(capsys): def test_run_config_get(capsys):
args = RunConfigArgs("get") args = RunConfigArgs("get")
@ -218,9 +237,10 @@ def test_run_config_remove():
@setup_restore() @setup_restore()
def test_run_config_update(): def test_run_config_update():
args = RunConfigArgs("update") args = RunConfigArgs("update")
args.path = "/var/tmp/something"
args.field_key = "path"
args.field_value = "/var/tmp/something"
run_console(args, TRANSPOSE_CONFIG_PATH) run_console(args, TRANSPOSE_CONFIG_PATH)
config = TransposeConfig().load(TRANSPOSE_CONFIG_PATH) config = TransposeConfig().load(TRANSPOSE_CONFIG_PATH)
assert config.entries[args.name].path == args.path
assert config.entries[args.name].path == args.field_value

+ 58
- 2
tests/test_transpose.py View File

@ -52,11 +52,26 @@ def test_apply():
assert TARGET_PATH.is_symlink() assert TARGET_PATH.is_symlink()
assert ENTRY_STORE_PATH.is_dir() assert ENTRY_STORE_PATH.is_dir()
# Target is disabled in the config
t.config.entries[ENTRY_NAME].enabled = False
with pytest.raises(
TransposeError, match=f"Entry '{ENTRY_NAME}' is not enabled in the config"
):
t.apply(ENTRY_NAME)
@setup_restore() @setup_restore()
def test_restore(): def test_restore():
t = Transpose(config_path=TRANSPOSE_CONFIG_PATH) t = Transpose(config_path=TRANSPOSE_CONFIG_PATH)
# Target is disabled in the config
t.config.entries[ENTRY_NAME].enabled = False
with pytest.raises(
TransposeError, match=f"Entry '{ENTRY_NAME}' is not enabled in the config"
):
t.restore(ENTRY_NAME)
t.config.entries[ENTRY_NAME].enabled = True
# Success # Success
t.restore(ENTRY_NAME) t.restore(ENTRY_NAME)
assert TARGET_PATH.is_dir() assert TARGET_PATH.is_dir()
@ -125,6 +140,28 @@ def test_config_add():
assert config.entries["NewEntry"].path == str(TARGET_PATH) assert config.entries["NewEntry"].path == str(TARGET_PATH)
@setup_store()
def test_config_disable():
config = TransposeConfig.load(TRANSPOSE_CONFIG_PATH)
with pytest.raises(TransposeError, match="'UnknownEntry' does not exist"):
config.disable("UnknownEntry")
config.disable(ENTRY_NAME)
assert config.entries[ENTRY_NAME].enabled is False
@setup_store()
def test_config_enable():
config = TransposeConfig.load(TRANSPOSE_CONFIG_PATH)
with pytest.raises(TransposeError, match="'UnknownEntry' does not exist"):
config.enable("UnknownEntry")
config.enable(ENTRY_NAME)
assert config.entries[ENTRY_NAME].enabled is True
@setup_store() @setup_store()
def test_config_get(): def test_config_get():
config = TransposeConfig.load(TRANSPOSE_CONFIG_PATH) config = TransposeConfig.load(TRANSPOSE_CONFIG_PATH)
@ -157,9 +194,12 @@ def test_config_update():
with pytest.raises( with pytest.raises(
TransposeError, match="does not exist in Transpose config entries" TransposeError, match="does not exist in Transpose config entries"
): ):
config.update("UnknownEntry", "/some/new/path")
config.update("UnknownEntry", "path", "/some/new/path")
with pytest.raises(TransposeError, match="Unknown TransposeEntry field"):
config.update(ENTRY_NAME, "UnknownField", "Some Value")
config.update(ENTRY_NAME, "/some/new/path")
config.update(ENTRY_NAME, "path", "/some/new/path")
assert config.entries[ENTRY_NAME].path == "/some/new/path" assert config.entries[ENTRY_NAME].path == "/some/new/path"
@ -176,6 +216,22 @@ def test_config_save():
) )
@setup_store()
def test_config_save_fresh():
"""
Verify creation of transpose config when doesn't initially exist
"""
TRANSPOSE_CONFIG_PATH.unlink()
config = TransposeConfig.load(TRANSPOSE_CONFIG_PATH)
assert len(config.entries) == 0
config.add("TestEntry", TARGET_PATH)
config.save(TRANSPOSE_CONFIG_PATH)
config = TransposeConfig.load(TRANSPOSE_CONFIG_PATH)
assert config.entries.get("TestEntry")
@setup_store() @setup_store()
def test_config_load(): def test_config_load():
config = TransposeConfig.load(TRANSPOSE_CONFIG_PATH) config = TransposeConfig.load(TRANSPOSE_CONFIG_PATH)


+ 2
- 0
tests/utils.py View File

@ -26,11 +26,13 @@ TRANSPOSE_CONFIG = {
"name": ENTRY_NAME, "name": ENTRY_NAME,
"path": str(TARGET_PATH), "path": str(TARGET_PATH),
"created": "2023-01-21 01:02:03.1234567", "created": "2023-01-21 01:02:03.1234567",
"enabled": True,
}, },
SECOND_ENTRY_NAME: { SECOND_ENTRY_NAME: {
"name": SECOND_ENTRY_NAME, "name": SECOND_ENTRY_NAME,
"path": str(SECOND_TARGET_PATH), "path": str(SECOND_TARGET_PATH),
"created": "2023-02-23 01:02:03.1234567", "created": "2023-02-23 01:02:03.1234567",
"enabled": True,
}, },
}, },
} }


Loading…
Cancel
Save