From 424bae2fbc01c693d1ec26b2c8680a16d9136d7a Mon Sep 17 00:00:00 2001 From: lgulich Date: Fri, 25 Apr 2025 10:31:07 +0200 Subject: [PATCH] Generalize OS differentiation The os name can now be used in any configuration in the `dotfile_manager.yaml` file. Also symlinks can now be linked to multiple destinations. Example: ```yaml install: macos: - install_macos.sh ubuntu: - install_ubuntu.sh symlinks: macos: foo.txt: [ - ~/foo1.txt - ~/foo2.txt ``` --- .github/workflows/ci.yml | 2 +- dotfile_manager/project.py | 119 ++++++++++++++---- dotfile_manager/repo.py | 24 ++-- pyproject.toml | 5 +- .../topic_a/dotfile_manager.yaml | 13 +- .../topic_b/dotfile_manager.yaml | 4 +- .../topic_c/dotfile_manager.yaml | 5 +- .../topic_d/dotfile_manager.yaml | 4 +- test/test_repo.py | 15 ++- 9 files changed, 135 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bebd120..53701d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} diff --git a/dotfile_manager/project.py b/dotfile_manager/project.py index 6838558..e07f1bc 100644 --- a/dotfile_manager/project.py +++ b/dotfile_manager/project.py @@ -1,8 +1,10 @@ import os from pathlib import Path import subprocess +from typing import Any import yaml +import typeguard from dotfile_manager.config import PROJECT_CONFIG_NAME @@ -27,6 +29,64 @@ def force_symlink(source: Path, destination: Path): os.symlink(source, destination) +class ProjectConfig: + """ + Class used to represent a project's configuration. + + This class automatically takes care of abstracting the configuration for a + specific OS. + """ + + def __init__(self, config: dict[str, Any], os_name: str): + self._config = config + self._os_name = os_name + + def _get_config(self, key: str, expected_type: type) -> Any: + all_values = self._config.get(key, []) + + # Return the os-specific values if they exist. + if isinstance(all_values, dict) and self._os_name in all_values: + os_values = all_values[self._os_name] + typeguard.check_type(os_values, expected_type) + return os_values + + # Check if the generic values can be returned or if they are for a + # specific different os. + try: + # The type matches so we can return it. + typeguard.check_type(all_values, expected_type) + return all_values + except typeguard.TypeCheckError: + # The type does not match, so we will return the default value. + return None + + def set_os_name(self, os_name: str) -> None: + self._os_name = os_name + + def is_disabled(self) -> bool: + return self._get_config('disable', bool) or False + + def get_requires(self) -> list[str]: + return self._get_config('requires', list[str]) or [] + + def get_install(self) -> list[str]: + return self._get_config('install', list[str]) or [] + + def get_symlinks(self) -> dict[str, str | list[str]]: + return self._get_config('symlinks', dict[str, str | list[str]]) or {} + + def get_bin(self) -> list[str]: + return self._get_config('bin', list[str]) or [] + + def get_source(self) -> list[str]: + return self._get_config('source', list[str]) or [] + + @classmethod + def load_from_path(cls, path: Path, os_name: str) -> 'ProjectConfig': + config = yaml.load(path.read_text(), Loader=yaml.FullLoader) + return cls(config, os_name) + + class InvalidProjectError(Exception): """ Exception raised when a project is invalid. """ @@ -34,61 +94,70 @@ class InvalidProjectError(Exception): class Project: """ Class used to represent a dotfile project. """ - def __init__(self, path: Path) -> None: - self.path = path - self.config_path = path / PROJECT_CONFIG_NAME - self.name = self.path.name - if not self.path.is_dir(): - raise InvalidProjectError(f"Project path {self.path}' is not a directory") - if not self.config_path.exists(): - raise InvalidProjectError(f"Project config path {self.config_path}' does not exist") - self.config = yaml.load(self.config_path.read_text(), Loader=yaml.FullLoader) + def __init__(self, path: Path, os_name: str) -> None: + self._path = path + self._os_name = os_name + self._config_path = path / PROJECT_CONFIG_NAME + if not self._path.is_dir(): + raise InvalidProjectError(f"Project path {self._path}' is not a directory") + if not self._config_path.exists(): + raise InvalidProjectError(f"Project config path {self._config_path}' does not exist") + + self._config = ProjectConfig.load_from_path(self._config_path, os_name=self._os_name) + self._name = self._path.name self._is_installed = False + def set_os_name(self, os_name: str) -> None: + self._os_name = os_name + self._config.set_os_name(os_name) + def get_name(self) -> str: - return self.name + return self._name def get_requires(self) -> list[str]: - return self.config.get('requires', []) + return self._config.get_requires() def is_disabled(self) -> bool: - return self.config.get('disable', False) + return self._config.is_disabled() def is_installed(self) -> bool: return self._is_installed - def install(self, os_name: str, verbose: bool) -> None: - print(f'Installing project {self.name} for {os_name}...') - install_scripts = self.config.get(f'install_{os_name}', None) + def install(self, verbose: bool) -> None: + print(f'Installing project {self._name} for {self._os_name}...') + install_scripts = self._config.get_install() if not install_scripts: print('No configured install scripts found.') return for install_script in install_scripts: - run_script(self.path / install_script, verbose=verbose) + run_script(self._path / install_script, verbose=verbose) self._is_installed = True - print(f'Successfully installed project {self.name} for {os_name}.') + print(f'Successfully installed project {self._name} for {self._os_name}.') def create_symbolic_links(self) -> None: - symlinks = self.config.get('symlinks', None) + symlinks = self._config.get_symlinks() if not symlinks: print('No configured symlinks found.') return - for source, destination in symlinks.items(): - force_symlink(self.path / source, Path(destination)) - print(f'Created symlink from {source} to {destination}.') + for source, destinations in symlinks.items(): + if not isinstance(destinations, list): + destinations = [destinations] + for destination in destinations: + force_symlink(self._path / source, Path(destination)) + print(f'Created symlink from {source} to {destination}.') def create_bin(self, destination_folder: Path) -> None: - binaries = self.config.get('bin', None) + binaries = self._config.get_bin() if not binaries: print('No configured binaries found.') return for binary in binaries: - binary_path = self.path / binary + binary_path = self._path / binary assert binary_path.exists(), binary_path # Use only binary_path.name s.t. we ignore the path if it is in a subfolder. @@ -97,13 +166,13 @@ def create_bin(self, destination_folder: Path) -> None: print(f'Created symlink from {binary_path} to {destination}.') def add_sources(self, output_file) -> None: - source_files = self.config.get('source', None) + source_files = self._config.get_source() if not source_files: print('No configured sourcing files found.') return for source_file in source_files: - source_path = self.path / source_file + source_path = self._path / source_file assert source_path.exists(), source_path output_file.write(f'. {source_path}\n') diff --git a/dotfile_manager/repo.py b/dotfile_manager/repo.py index b2bcce7..a98a42b 100644 --- a/dotfile_manager/repo.py +++ b/dotfile_manager/repo.py @@ -18,17 +18,17 @@ def get_os_name() -> str: raise ValueError -def collect_projects(path: Path) -> dict[str, Project]: +def collect_projects(path: Path, os_name: str) -> dict[str, Project]: """ Collect all projects in the passed folder. """ projects = {} for project_path in sorted(path.iterdir()): try: - project = Project(project_path) + project = Project(project_path, os_name) except InvalidProjectError: continue if project.is_disabled(): continue - projects[project.name] = project + projects[project.get_name()] = project print(f'Found {len(projects)} projects.') return projects @@ -36,19 +36,23 @@ def collect_projects(path: Path) -> dict[str, Project]: class Repo: """ Class used to represent a dotfile repository. """ - def __init__(self, path: Path): + def __init__(self, path: Path, os_name: str = get_os_name()): self.path: Path = path - self.projects: dict[str, Project] = collect_projects(path) + self.projects: dict[str, Project] = collect_projects(path, os_name) + + def set_os_name(self, os_name: str): + for project in self.projects.values(): + project.set_os_name(os_name) def get_path(self) -> Path: return self.path - def install_all(self, verbose: bool = False, os_name: str = get_os_name()): + def install_all(self, verbose: bool = False): for name in self.projects: - self.install(name, verbose, os_name) + self.install(name, verbose) print('Successfully installed all projects.') - def install(self, project_name: str, verbose: bool = False, os_name: str = get_os_name()): + def install(self, project_name: str, verbose: bool = False): project = self.projects[project_name] if project.is_disabled(): @@ -59,9 +63,9 @@ def install(self, project_name: str, verbose: bool = False, os_name: str = get_o return for requires_name in project.get_requires(): - self.install(requires_name, verbose, os_name) + self.install(requires_name, verbose) - project.install(verbose=verbose, os_name=os_name) + project.install(verbose=verbose) def setup_all(self): # Create folder into which all binaries will be symlinked. diff --git a/pyproject.toml b/pyproject.toml index 413b547..ed561e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,19 +4,20 @@ build-backend = "setuptools.build_meta" [project] name = "dotfile_manager" -version = "0.3.6" +version = "0.4.0" authors = [ { name="Lionel Gulich", email="lgulich@ethz.ch" }, ] description = "A tool for managing dotfiles." readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent", ] dependencies = [ "PyYAML", + "typeguard", ] [project.urls] diff --git a/test/test_data/dotfiles_repo/topic_a/dotfile_manager.yaml b/test/test_data/dotfiles_repo/topic_a/dotfile_manager.yaml index eff6c17..76fb299 100644 --- a/test/test_data/dotfiles_repo/topic_a/dotfile_manager.yaml +++ b/test/test_data/dotfiles_repo/topic_a/dotfile_manager.yaml @@ -1,9 +1,12 @@ -install_macos: - - install_macos.sh -install_ubuntu: - - install_ubuntu.sh +install: + macos: + - install_macos.sh + ubuntu: + - install_ubuntu.sh symlinks: - symlink_original.txt: ~/symlink_replica_from_a.txt + symlink_original.txt: + - ~/symlink_replica_one_from_a.txt + - ~/symlink_replica_two_from_a.txt bin: - executable_from_a.sh source: diff --git a/test/test_data/dotfiles_repo/topic_b/dotfile_manager.yaml b/test/test_data/dotfiles_repo/topic_b/dotfile_manager.yaml index e68f46c..b80e1f0 100644 --- a/test/test_data/dotfiles_repo/topic_b/dotfile_manager.yaml +++ b/test/test_data/dotfiles_repo/topic_b/dotfile_manager.yaml @@ -1,6 +1,4 @@ -install_macos: - - install.sh -install_ubuntu: +install: - install.sh symlinks: symlink_original.txt: ~/symlink_replica_from_b.txt diff --git a/test/test_data/dotfiles_repo/topic_c/dotfile_manager.yaml b/test/test_data/dotfiles_repo/topic_c/dotfile_manager.yaml index 63da828..a13b52b 100644 --- a/test/test_data/dotfiles_repo/topic_c/dotfile_manager.yaml +++ b/test/test_data/dotfiles_repo/topic_c/dotfile_manager.yaml @@ -1,6 +1,7 @@ disable: true -install_macos: - - install.sh +install: + macos: + - install.sh symlinks: symlink_original.txt: ~/symlink_replica_from_c.txt source: diff --git a/test/test_data/dotfiles_repo/topic_d/dotfile_manager.yaml b/test/test_data/dotfiles_repo/topic_d/dotfile_manager.yaml index 8a9f7b9..19cf3ae 100644 --- a/test/test_data/dotfiles_repo/topic_d/dotfile_manager.yaml +++ b/test/test_data/dotfiles_repo/topic_d/dotfile_manager.yaml @@ -1,4 +1,2 @@ -install_macos: - - install.sh -install_ubuntu: +install: - install.sh diff --git a/test/test_repo.py b/test/test_repo.py index d053bac..de20037 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -30,7 +30,8 @@ def _clean(self): shutil.rmtree(self.repo.path / 'generated', ignore_errors=True) def test_install_all_macos(self): - self.repo.install_all(os_name='macos', verbose=True) + self.repo.set_os_name('macos') + self.repo.install_all(verbose=True) self.assertTrue(os.path.exists('topic_a_install_macos.txt')) self.assertTrue(os.path.exists('topic_b_install_macos.txt')) self.assertTrue(os.path.exists('topic_d_install_macos.txt')) @@ -38,7 +39,8 @@ def test_install_all_macos(self): self.assertFalse(os.path.exists('topic_c_install_ubuntu.txt')) def test_install_macos(self): - self.repo.install(project_name='topic_a', os_name='macos', verbose=True) + self.repo.set_os_name('macos') + self.repo.install(project_name='topic_a', verbose=True) self.assertTrue(os.path.exists('topic_a_install_macos.txt')) self.assertTrue(os.path.exists('topic_d_install_macos.txt')) self.assertFalse(os.path.exists('topic_b_install_macos.txt')) @@ -46,7 +48,8 @@ def test_install_macos(self): self.assertFalse(os.path.exists('topic_c_install_ubuntu.txt')) def test_install_all_ubuntu(self): - self.repo.install_all(os_name='ubuntu', verbose=True) + self.repo.set_os_name('ubuntu') + self.repo.install_all(verbose=True) self.assertTrue(os.path.exists('topic_a_install_ubuntu.txt')) self.assertTrue(os.path.exists('topic_d_install_ubuntu.txt')) self.assertTrue(os.path.exists('topic_b_install_ubuntu.txt')) @@ -54,7 +57,8 @@ def test_install_all_ubuntu(self): self.assertFalse(os.path.exists('topic_c_install_ubuntu.txt')) def test_install_ubuntu(self): - self.repo.install(project_name='topic_a', os_name='ubuntu', verbose=True) + self.repo.set_os_name('ubuntu') + self.repo.install(project_name='topic_a', verbose=True) self.assertTrue(os.path.exists('topic_a_install_ubuntu.txt')) self.assertTrue(os.path.exists('topic_d_install_ubuntu.txt')) self.assertFalse(os.path.exists('topic_b_install_ubuntu.txt')) @@ -76,7 +80,8 @@ def test_setup(self): # Test that generated symlinks to general files are available: home = pathlib.Path.home() - self.assertTrue(os.path.exists(home / 'symlink_replica_from_a.txt')) + self.assertTrue(os.path.exists(home / 'symlink_replica_one_from_a.txt')) + self.assertTrue(os.path.exists(home / 'symlink_replica_two_from_a.txt')) self.assertTrue(os.path.exists(home / 'symlink_replica_from_b.txt')) self.assertFalse(os.path.exists(home / 'symlink_replica_from_c.txt'))