from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# from typing import Self
|
|
|
|
import datetime
|
|
import json
|
|
|
|
from . import version as transpose_version
|
|
from .exceptions import TransposeError
|
|
from .utils import move, symlink
|
|
|
|
|
|
@dataclass
|
|
class TransposeEntry:
|
|
name: str
|
|
path: str
|
|
created: str # Should be datetime.datetime but not really necessary here
|
|
enabled: bool = True
|
|
|
|
|
|
@dataclass
|
|
class TransposeConfig:
|
|
entries: dict = field(default_factory=dict)
|
|
version: str = field(default=transpose_version)
|
|
|
|
def add(self, name: str, path: str, created: str = None) -> None:
|
|
"""
|
|
Add a new entry to the entries
|
|
|
|
Args:
|
|
name: The name of the entry (must not exist)
|
|
path: The path where the entry originally exists
|
|
created: The date in datetime.now().__str__() format
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.entries.get(name):
|
|
raise TransposeError(f"'{name}' already exists")
|
|
|
|
if not created:
|
|
created = str(datetime.datetime.now())
|
|
|
|
self.entries[name] = TransposeEntry(
|
|
name=name,
|
|
path=str(path),
|
|
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
|
|
|
|
Args:
|
|
name: The name of the entry (must exist)
|
|
|
|
Returns:
|
|
TransposeEntry
|
|
"""
|
|
try:
|
|
return self.entries[name]
|
|
except KeyError:
|
|
raise TransposeError(f"'{name}' does not exist in Transpose config entries")
|
|
|
|
def remove(self, name: str) -> None:
|
|
"""
|
|
Remove an entry by name
|
|
|
|
Args:
|
|
name: The name of the entry (must exist)
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
try:
|
|
del self.entries[name]
|
|
except KeyError:
|
|
raise TransposeError(f"'{name}' does not exist in Transpose config entries")
|
|
|
|
def update(self, name: str, field_key: str, field_value: Any) -> None:
|
|
"""
|
|
Update an entry by name
|
|
|
|
Args:
|
|
name: The name of the entry (must exist)
|
|
field_key: The key to update
|
|
field_value: The value to update
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
try:
|
|
setattr(self.entries[name], field_key, field_value)
|
|
except KeyError:
|
|
raise TransposeError(f"'{name}' does not exist in Transpose config entries")
|
|
|
|
@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}")
|
|
|
|
config = TransposeConfig()
|
|
try:
|
|
for name in in_config["entries"]:
|
|
entry = in_config["entries"][name]
|
|
config.add(
|
|
name,
|
|
entry["path"],
|
|
created=entry.get("created"),
|
|
)
|
|
if "enabled" in entry and not entry["enabled"]:
|
|
config.disable(name)
|
|
except (KeyError, TypeError) as e:
|
|
raise TransposeError(f"Unrecognized Transpose config file format: {e}")
|
|
|
|
return config
|
|
|
|
def save(self, config_path: str) -> None:
|
|
"""
|
|
Save the Config to a location in JSON format
|
|
|
|
Args:
|
|
path: The path to save the json file
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
config_path = Path(config_path)
|
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(str(config_path), "w") as f:
|
|
json.dump(self.to_dict(), f, default=str)
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
|
|
class Transpose:
|
|
config: TransposeConfig
|
|
config_path: Path
|
|
store_path: Path
|
|
|
|
def __init__(self, config_path: str) -> None:
|
|
self.config = TransposeConfig.load(config_path)
|
|
self.config_path = Path(config_path)
|
|
self.store_path = self.config_path.parent
|
|
|
|
if not self.store_path.exists():
|
|
self.store_path.mkdir(parents=True)
|
|
|
|
def apply(self, name: str, force: bool = False) -> None:
|
|
"""
|
|
Create/recreate the symlink to an existing entry
|
|
|
|
Args:
|
|
name: The name of the entry (must exist)
|
|
force: If enabled and path already exists, move the path to '{path}.backup' first
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.config.entries.get(name):
|
|
raise TransposeError(f"Entry does not exist: '{name}'")
|
|
|
|
entry = self.config.entries[name]
|
|
if hasattr(entry, "enabled") and 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"))
|
|
else:
|
|
raise TransposeError(
|
|
f"Entry path already exists, cannot apply (force required): '{entry_path}'"
|
|
)
|
|
|
|
symlink(
|
|
target_path=self.store_path.joinpath(name),
|
|
symlink_path=entry_path,
|
|
)
|
|
|
|
def restore(self, name: str, force: bool = False) -> None:
|
|
"""
|
|
Remove the symlink and move the stored entry back to it's original path
|
|
|
|
Args:
|
|
name: The name of the entry (must exist)
|
|
force: If enabled and path already exists, move the path to '{path}.backup' first
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.config.entries.get(name):
|
|
raise TransposeError(f"Could not locate entry by name: '{name}'")
|
|
|
|
entry = self.config.entries[name]
|
|
if hasattr(entry, "enabled") and 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"))
|
|
else:
|
|
raise TransposeError(
|
|
f"Entry path already exists, cannot restore (force required): '{entry_path}'"
|
|
)
|
|
|
|
move(self.store_path.joinpath(name), entry_path)
|
|
|
|
self.config.remove(name)
|
|
self.config.save(self.config_path)
|
|
|
|
def store(self, name: str, source_path: str) -> None:
|
|
"""
|
|
Move the source path to the store path, create a symlink, and update the config
|
|
|
|
Args:
|
|
name: The name of the entry
|
|
source_path: The directory or file to be stored
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.config.entries.get(name):
|
|
raise TransposeError(
|
|
f"Entry already exists: {name} -> {self.config.entries[name].path}"
|
|
)
|
|
|
|
storage_path = self.store_path.joinpath(name)
|
|
if storage_path.exists():
|
|
raise TransposeError(f"Store path already exists: '{storage_path}'")
|
|
|
|
source_path = Path(source_path)
|
|
if not source_path.exists():
|
|
raise TransposeError(f"Source path does not exist: '{source_path}'")
|
|
|
|
move(source=source_path, destination=storage_path)
|
|
symlink(target_path=storage_path, symlink_path=source_path)
|
|
|
|
self.config.add(name, source_path)
|
|
self.config.save(self.config_path)
|