11 Commits

11 changed files with 215 additions and 81 deletions
Split 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 get "NewEntry"
transpose config disable "NewEntry"
transpose config enable "NewEntry"
transpose config list
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]
name = "transpose"
version = "2.1.0"
version = "2.2.2"
description = "Move and symlink a path to a central location"
authors = ["Ryan Reed"]
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 .logger import create_logger
DEFAULT_XDG_PATH = os.environ.get("XDG_DATA_HOME", f"{os.environ['HOME']}/.local/share")
STORE_PATH = f"{DEFAULT_XDG_PATH}/transpose"
DEFAULT_STORE_PATH = os.environ.get("TRANSPOSE_STORE_PATH", STORE_PATH)
version = version("transpose")
logger = create_logger(__package__)
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":
t.apply(args.name, force=args.force)
if args.action == "apply-all":
elif args.action == "apply-all":
run_apply_all(t, force=args.force)
elif args.action == "restore":
t.restore(args.name, force=args.force)
@ -34,6 +34,12 @@ def run(args, config_path) -> None:
if args.config_action == "add":
t.config.add(args.name, args.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":
print(t.config.get(args.name))
elif args.config_action == "list":
@ -43,7 +49,7 @@ def run(args, config_path) -> None:
t.config.remove(args.name)
t.config.save(config_path)
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)
@ -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
Useful after restoring the machine
Useful after restoring a machine
Args:
t: An instance of Transpose
@ -60,19 +66,12 @@ def run_apply_all(t: Transpose, force: bool = False) -> None:
Returns:
None
"""
results = {}
for entry_name in t.config.entries:
for entry_name in sorted(t.config.entries):
try:
t.apply(entry_name, force)
results[entry_name] = {"status": "success", "error": ""}
print(f"\t{entry_name:<30}: success")
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):
@ -122,7 +121,7 @@ def parse_arguments(args=None):
apply_all_parser.add_argument(
"--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",
)
@ -135,7 +134,12 @@ def parse_arguments(args=None):
"name",
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",
@ -176,6 +180,26 @@ def parse_arguments(args=None):
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(
"get",
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",
)
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)


+ 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 pathlib import Path
from typing import Any
# from typing import Self
@ -16,6 +17,7 @@ class TransposeEntry:
name: str
path: str
created: str # Should be datetime.datetime but not really necessary here
enabled: bool = True
@dataclass
@ -47,6 +49,36 @@ class TransposeConfig:
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:
"""
Get an entry by the name
@ -77,37 +109,46 @@ class TransposeConfig:
except KeyError:
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:
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:
None
"""
try:
self.entries[name].path = path
if not hasattr(self.entries[name], field_key):
raise TransposeError(f"Unknown TransposeEntry field: {field_key}")
except KeyError:
raise TransposeError(f"'{name}' does not exist in Transpose config entries")
setattr(self.entries[name], field_key, field_value)
@staticmethod
def load(config_path: str): # -> Self:
try:
in_config = json.load(open(config_path, "r"))
except json.decoder.JSONDecodeError as e:
raise TransposeError(f"Invalid JSON format for '{config_path}': {e}")
except FileNotFoundError:
in_config = {"entries": {}}
config = TransposeConfig()
try:
for name in in_config["entries"]:
entry = in_config["entries"][name]
config.add(
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:
raise TransposeError(f"Unrecognized Transpose config file format: {e}")
@ -160,7 +201,11 @@ class Transpose:
if not self.config.entries.get(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 force: # Backup the existing path
move(entry_path, entry_path.with_suffix(".backup"))
@ -188,7 +233,11 @@ class Transpose:
if not self.config.entries.get(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 force: # Backup the existing path
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{SECOND_ENTRY_NAME:<30}: success" in captured.out
assert SECOND_TARGET_PATH.is_symlink()
assert SECOND_TARGET_PATH.with_suffix(".backup").is_dir()
@ -185,6 +184,26 @@ def test_run_config_add():
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()
def test_run_config_get(capsys):
args = RunConfigArgs("get")
@ -218,9 +237,10 @@ def test_run_config_remove():
@setup_restore()
def test_run_config_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)
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 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()
def test_restore():
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
t.restore(ENTRY_NAME)
assert TARGET_PATH.is_dir()
@ -125,6 +140,28 @@ def test_config_add():
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()
def test_config_get():
config = TransposeConfig.load(TRANSPOSE_CONFIG_PATH)
@ -157,9 +194,12 @@ def test_config_update():
with pytest.raises(
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"
@ -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()
def test_config_load():
config = TransposeConfig.load(TRANSPOSE_CONFIG_PATH)


+ 2
- 0
tests/utils.py View File

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


Loading…
Cancel
Save