Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
119 changes: 94 additions & 25 deletions dotfile_manager/project.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -27,68 +29,135 @@ 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. """


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.
Expand All @@ -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')
Expand Down
24 changes: 14 additions & 10 deletions dotfile_manager/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,37 +18,41 @@ 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


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():
Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 8 additions & 5 deletions test/test_data/dotfiles_repo/topic_a/dotfile_manager.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
4 changes: 1 addition & 3 deletions test/test_data/dotfiles_repo/topic_b/dotfile_manager.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
install_macos:
- install.sh
install_ubuntu:
install:
- install.sh
symlinks:
symlink_original.txt: ~/symlink_replica_from_b.txt
Expand Down
5 changes: 3 additions & 2 deletions test/test_data/dotfiles_repo/topic_c/dotfile_manager.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
disable: true
install_macos:
- install.sh
install:
macos:
- install.sh
symlinks:
symlink_original.txt: ~/symlink_replica_from_c.txt
source:
Expand Down
4 changes: 1 addition & 3 deletions test/test_data/dotfiles_repo/topic_d/dotfile_manager.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
install_macos:
- install.sh
install_ubuntu:
install:
- install.sh
15 changes: 10 additions & 5 deletions test/test_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,31 +30,35 @@ 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'))
self.assertFalse(os.path.exists('topic_c_install_macos.txt'))
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'))
self.assertFalse(os.path.exists('topic_c_install_macos.txt'))
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'))
self.assertFalse(os.path.exists('topic_c_install_macos.txt'))
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'))
Expand All @@ -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'))

Expand Down