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)