diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 52b784b..ebfc5ca 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -38,4 +38,4 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | - pytest scripts/tests.py + pytest tests/ diff --git a/install.py b/install.py index 1c2f84c..5d3b6c0 100644 --- a/install.py +++ b/install.py @@ -1,6 +1,6 @@ # This file installs Marble shell theme for GNOME DE # Copyright (C) 2023-2025 Vladyslav Hroshev - +import os # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or @@ -22,6 +22,7 @@ from scripts.install.colors_definer import ColorsDefiner from scripts.install.global_theme_installer import GlobalThemeInstaller from scripts.install.local_theme_installer import LocalThemeInstaller from scripts.utils.gnome import apply_gnome_theme +from scripts.utils.logger.console import Console def main(): @@ -31,6 +32,12 @@ def main(): installer_class = GlobalThemeInstaller if args.gdm else LocalThemeInstaller installer = installer_class(args, colors_definer) + if args.gdm: + if os.getuid() != 0: + Console().Line().error( + "Global installation requires root privileges. Please run the script as root.") + return + if args.remove or args.reinstall: installer.remove() diff --git a/scripts/config.py b/scripts/config.py index 8a0e6f1..c9e3c71 100644 --- a/scripts/config.py +++ b/scripts/config.py @@ -1,15 +1,14 @@ -import os.path +import os from tempfile import gettempdir # folder definitions -temp_folder = f"{gettempdir()}/marble" +marble_folder = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +temp_folder = os.path.join(gettempdir(), 'marble') +temp_tests_folder = os.path.join(temp_folder, 'tests') gdm_folder = "gdm" -gnome_folder = "gnome-shell" -temp_gnome_folder = f"{temp_folder}/{gnome_folder}" tweaks_folder = "tweaks" -themes_folder = "~/.themes" +themes_folder = os.path.expanduser("~/.themes") raw_theme_folder = "theme" -scripts_folder = "scripts" # GDM definitions global_gnome_shell_theme = "/usr/share/gnome-shell" @@ -18,8 +17,7 @@ ubuntu_gresource_link = "gtk-theme.gresource" extracted_gdm_folder = "theme" # files definitions -gnome_shell_css = f"{temp_gnome_folder}/gnome-shell.css" tweak_file = f"./{tweaks_folder}/*/tweak.py" -colors_json = "colors.json" +colors_json = os.path.join(marble_folder, "colors.json") user_themes_extension = "/org/gnome/shell/extensions/user-theme/name" diff --git a/scripts/gdm.py b/scripts/gdm.py deleted file mode 100644 index 5a573a4..0000000 --- a/scripts/gdm.py +++ /dev/null @@ -1,149 +0,0 @@ -import os -from typing import Optional - -from .install.colors_definer import ColorsDefiner -from .theme import Theme -from .utils import remove_properties, remove_keywords -from . import config -from .utils.alternatives_updater import AlternativesUpdater -from .utils.console import Console, Color, Format -from .utils.files_labeler import FilesLabeler -from .utils.gresource import Gresource, GresourceBackupNotFoundError - - -class GlobalTheme: - """Class to install global theme for GDM""" - def __init__(self, - colors_json: ColorsDefiner, theme_folder: str, - destination_folder: str, destination_file: str, temp_folder: str, - mode: Optional[str] = None, is_filled = False - ): - """ - :param colors_json: location of a JSON file with color values - :param theme_folder: raw theme location - :param destination_folder: folder where themes will be installed - :param temp_folder: folder where files will be collected - :param mode: theme mode (light or dark) - :param is_filled: if True, the theme will be filled - """ - self.colors_json = colors_json - self.theme_folder = theme_folder - self.destination_folder = destination_folder - self.destination_file = destination_file - self.temp_folder = temp_folder - self.is_filled = is_filled - self.mode = mode - - self.themes: list[ThemePrepare] = [] - - self.__is_installed_trigger = "\n/* Marble theme */\n" - self.__gresource_file = os.path.join(self.destination_folder, self.destination_file) - - self.__gresource_temp_folder = os.path.join(self.temp_folder, config.extracted_gdm_folder) - self.__gresource = Gresource(self.destination_file, self.__gresource_temp_folder, self.destination_folder) - - def prepare(self): - if self.__is_installed(): - Console.Line().info("Theme is installed. Reinstalling...") - self.__gresource.use_backup_gresource() - - self.__gresource.extract() - self.__find_themes() - - def __is_installed(self) -> bool: - if not hasattr(self, '__is_installed_cached'): - with open(self.__gresource_file, "rb") as f: - self.__is_installed_cached = self.__is_installed_trigger.encode() in f.read() - - return self.__is_installed_cached - - def __find_themes(self): - extracted_theme_files = os.listdir(self.__gresource_temp_folder) - - allowed_modes = ("dark", "light") - allowed_css = ("gnome-shell-dark", "gnome-shell-light", "gnome-shell") - - for style_name in allowed_css: - style_file = style_name + ".css" - if style_file in extracted_theme_files: - last_mode = style_name.split("-")[-1] - mode = last_mode if last_mode in allowed_modes else None - self.__append_theme(style_name, mode=mode or self.mode, label=mode) - - def __append_theme(self, theme_type: str, mode = None, label: Optional[str] = None): - """Helper to create theme objects""" - theme = Theme(theme_type, self.colors_json, self.theme_folder, - self.__gresource_temp_folder, self.temp_folder, - mode=mode, is_filled=self.is_filled) - theme.prepare() - theme_file = os.path.join(self.__gresource_temp_folder, f"{theme_type}.css") - self.themes.append(ThemePrepare(theme=theme, theme_file=theme_file, label=label)) - - def install(self, hue, sat=None): - """Install theme globally""" - if os.geteuid() != 0: - raise Exception("Root privileges required to install GDM theme") - - self.__generate_themes(hue, 'Marble', sat) - - self.__gresource.compile() - if not self.__is_installed(): - self.__gresource.backup() - self.__gresource.move() - - self.__update_alternatives() - - def __generate_themes(self, hue: int, color: str, sat: Optional[int] = None): - """Generate theme files for gnome-shell-theme.gresource.xml""" - for theme_prepare in self.themes: - if theme_prepare.label is not None: - temp_folder = theme_prepare.theme.temp_folder - main_styles = theme_prepare.theme.main_styles - FilesLabeler(temp_folder, main_styles).append_label(theme_prepare.label) - - remove_keywords(theme_prepare.theme_file, "!important") - remove_properties(theme_prepare.theme_file, "background-color", "color", "box-shadow", "border-radius") - - self.__add_gnome_styles(theme_prepare.theme) - - theme_prepare.theme.install(hue, color, sat, destination=self.__gresource_temp_folder) - - def __add_gnome_styles(self, theme: Theme): - """Add gnome styles to the start of the file""" - with open(f"{theme.destination_folder}/{theme.theme_type}.css", 'r') as gnome_theme: - gnome_styles = gnome_theme.read() + self.__is_installed_trigger - theme.add_to_start(gnome_styles) - - def __update_alternatives(self): - link = os.path.join(self.destination_folder, config.ubuntu_gresource_link) - name = config.ubuntu_gresource_link - path = os.path.join(self.destination_folder, self.destination_file) - AlternativesUpdater.install_and_set(link, name, path) - - def remove(self): - if self.__is_installed(): - removing_line = Console.Line() - removing_line.update("Theme is installed. Removing...") - - try: - self.__gresource.restore() - removing_line.success("Global theme removed successfully. Restart GDM to apply changes.") - except GresourceBackupNotFoundError: - formatted_shell = Console.format("gnome-shell", color=Color.BLUE, format_type=Format.BOLD) - removing_line.error(f"Backup file not found. Try reinstalling {formatted_shell} package.") - else: - Console.Line().error("Theme is not installed. Nothing to remove.") - Console.Line().warn("If theme is still installed globally, try reinstalling gnome-shell package.") - - def __remove_alternatives(self): - name = config.ubuntu_gresource_link - path = os.path.join(self.destination_folder, self.destination_file) - AlternativesUpdater.remove(name, path) - - -class ThemePrepare: - """Theme data class prepared for installation""" - def __init__(self, theme: Theme, theme_file, label: Optional[str] = None): - self.theme = theme - self.theme_file = theme_file - self.label = label \ No newline at end of file diff --git a/scripts/install/global_theme_installer.py b/scripts/install/global_theme_installer.py index a45a341..77e3470 100644 --- a/scripts/install/global_theme_installer.py +++ b/scripts/install/global_theme_installer.py @@ -1,13 +1,11 @@ -import os - -from scripts import config -from scripts.gdm import GlobalTheme from scripts.install.theme_installer import ThemeInstaller -from scripts.utils.console import Console, Color, Format +from scripts.utils.global_theme.gdm import GDMTheme +from scripts.utils.global_theme.gdm_builder import GDMThemeBuilder +from scripts.utils.logger.console import Console, Color, Format class GlobalThemeInstaller(ThemeInstaller): - theme: GlobalTheme + theme: GDMTheme def remove(self): gdm_rm_status = self.theme.remove() @@ -15,13 +13,10 @@ class GlobalThemeInstaller(ThemeInstaller): print("GDM theme removed successfully.") def _define_theme(self): - gdm_temp = os.path.join(config.temp_folder, config.gdm_folder) - self.theme = GlobalTheme(self.colors, f"{config.raw_theme_folder}/{config.gnome_folder}", - config.global_gnome_shell_theme, config.gnome_shell_gresource, - gdm_temp, mode=self.args.mode, is_filled=self.args.filled) - - def _install_theme(self, hue, theme_name, sat): - self.theme.install(hue, sat) + gdm_builder = GDMThemeBuilder(self.colors) + gdm_builder.with_mode(self.args.mode) + gdm_builder.with_filled(self.args.filled) + self.theme = gdm_builder.build() def _apply_tweaks_to_theme(self): for theme in self.theme.themes: diff --git a/scripts/install/local_theme_installer.py b/scripts/install/local_theme_installer.py index 7648003..b181843 100644 --- a/scripts/install/local_theme_installer.py +++ b/scripts/install/local_theme_installer.py @@ -1,10 +1,8 @@ -import os.path - -from scripts import config from scripts.install.theme_installer import ThemeInstaller -from scripts.theme import Theme +from scripts.utils.theme.gnome_shell_theme_builder import GnomeShellThemeBuilder +from scripts.utils.theme.theme import Theme from scripts.utils import remove_files -from scripts.utils.console import Console, Color, Format +from scripts.utils.logger.console import Console, Color, Format class LocalThemeInstaller(ThemeInstaller): @@ -15,13 +13,10 @@ class LocalThemeInstaller(ThemeInstaller): remove_files(self.args, colors) def _define_theme(self): - theme_folder = os.path.join(config.raw_theme_folder, config.gnome_folder) - self.theme = Theme("gnome-shell", self.colors, theme_folder, - config.themes_folder, config.temp_folder, - mode=self.args.mode, is_filled=self.args.filled) - - def _install_theme(self, hue, theme_name, sat): - self.theme.install(hue, theme_name, sat) + theme_builder = GnomeShellThemeBuilder(self.colors) + theme_builder.with_mode(self.args.mode) + theme_builder.filled(self.args.filled) + self.theme = theme_builder.build() def _apply_tweaks_to_theme(self): self._apply_tweaks(self.theme) diff --git a/scripts/install/theme_installer.py b/scripts/install/theme_installer.py index 4f53669..5ad3c06 100644 --- a/scripts/install/theme_installer.py +++ b/scripts/install/theme_installer.py @@ -3,13 +3,13 @@ import concurrent.futures from abc import ABC, abstractmethod from scripts.install.colors_definer import ColorsDefiner -from scripts.theme import Theme +from scripts.types.theme_base import ThemeBase from scripts.tweaks_manager import TweaksManager class ThemeInstaller(ABC): """Base class for theme installers""" - theme: Theme + theme: ThemeBase def __init__(self, args: argparse.Namespace, colors: ColorsDefiner): self.args = args @@ -37,11 +37,6 @@ class ThemeInstaller(ABC): """Should apply the tweaks for prepared theme""" pass - @abstractmethod - def _install_theme(self, hue, theme_name, sat): - """Should say how to install the defined theme""" - pass - @abstractmethod def _after_install(self): """Method to be called after the theme is installed. Can be used for logging or other actions""" @@ -53,16 +48,14 @@ class ThemeInstaller(ABC): tweaks_manager.apply_tweaks(self.args, theme, self.colors) def _apply_colors(self): - installed_any = False - if self.args.hue: - installed_any = True self._apply_custom_color() - else: - installed_any = self._apply_default_color() + return - if not installed_any: - raise Exception('No color arguments specified. Use -h or --help to see the available options.') + if self._apply_default_color(): + return + + raise Exception('No color arguments specified. Use -h or --help to see the available options.') def _apply_custom_color(self): name = self.args.name @@ -70,7 +63,7 @@ class ThemeInstaller(ABC): sat = self.args.sat theme_name = name if name else f'hue{hue}' - self._install_theme(hue, theme_name, sat) + self.theme.install(hue, theme_name, sat) def _apply_default_color(self) -> bool: colors = self.colors.colors @@ -90,7 +83,7 @@ class ThemeInstaller(ABC): def _run_concurrent_installation(self, colors_to_install): with concurrent.futures.ThreadPoolExecutor() as executor: - futures = [executor.submit(self._install_theme, hue, color, sat) + futures = [executor.submit(self.theme.install, hue, color, sat) for hue, color, sat in colors_to_install] for future in concurrent.futures.as_completed(futures): diff --git a/scripts/tests.py b/scripts/tests.py deleted file mode 100644 index 9130560..0000000 --- a/scripts/tests.py +++ /dev/null @@ -1,70 +0,0 @@ -# TODO: Add more tests - -import unittest -import os -import json -import shutil -from unittest.mock import patch - -from . import config -from .theme import Theme - -# folders -tests_folder = '.tests' -project_folder = '.' - - -class TestInstall(unittest.TestCase): - def setUp(self): - # Create necessary directories - os.makedirs(f"{project_folder}/{config.raw_theme_folder}/{config.gnome_folder}", exist_ok=True) - os.makedirs(f"{tests_folder}/.themes", exist_ok=True) - os.makedirs(f"{tests_folder}/.temp", exist_ok=True) - - def tearDown(self): - # Clean up after tests - shutil.rmtree(tests_folder, ignore_errors=True) - - @patch('scripts.utils.gnome.subprocess.check_output') - def test_install_theme(self, mock_check_output): - """ - Test if theme is installed correctly (colors are replaced) - """ - mock_check_output.return_value = 'GNOME Shell 47.0\n' - - # folders - themes_folder = f"{tests_folder}/.themes" - temp_folder = f"{tests_folder}/.temp" - - # colors from colors.json - colors_json = open(f"{project_folder}/{config.colors_json}") - colors = json.load(colors_json) - colors_json.close() - - # create test theme - test_theme = Theme("gnome-shell", colors, - f"{project_folder}/{config.raw_theme_folder}/{config.gnome_folder}", - themes_folder, temp_folder, - mode='light', is_filled=True) - - # install test theme - test_theme.install(120, 'test', 70) - - # folder with installed theme (.tests/.themes/Marble-test-light/gnome-shell) - installed_theme = f"{themes_folder}/{os.listdir(themes_folder)[0]}/{config.gnome_folder}" - - # check if files are installed - for file in os.listdir(installed_theme): - with open(f"{installed_theme}/{file}") as f: - read_file = f.read() - - for color in colors["elements"]: - self.assertNotIn(color, read_file, msg=f"Color {color} is not replaced in {file}") - - # delete test theme - del test_theme - shutil.rmtree(tests_folder) - - -if __name__ == '__main__': - unittest.main() diff --git a/scripts/theme.py b/scripts/theme.py deleted file mode 100644 index 05573de..0000000 --- a/scripts/theme.py +++ /dev/null @@ -1,172 +0,0 @@ -import os -import shutil -import colorsys # colorsys.hls_to_rgb(h, l, s) - -from .install.colors_definer import ColorsDefiner -from .utils import ( - replace_keywords, # replace keywords in file - copy_files, # copy files from source to destination - destination_return, # copied/modified theme location - generate_file) # combine files from folder to one file -from .utils.console import Console, Color, Format - - -class Theme: - def __init__(self, theme_type, colors_json, theme_folder, destination_folder, temp_folder, - mode=None, is_filled=False): - """ - Initialize Theme class - :param colors_json: location of a json file with colors - :param theme_type: theme type (gnome-shell, gtk, etc.) - :param theme_folder: raw theme location - :param destination_folder: folder where themes will be installed - :param temp_folder: folder where files will be collected - :param mode: theme mode (light or dark) - :param is_filled: if True, theme will be filled - """ - - self.colors: ColorsDefiner = colors_json - self.temp_folder = f"{temp_folder}/{theme_type}" - self.theme_folder = theme_folder - self.theme_type = theme_type - self.modes = [mode] if mode else ['light', 'dark'] - self.destination_folder = destination_folder - self.main_styles = f"{self.temp_folder}/{theme_type}.css" - self.is_filled = is_filled - - def __add__(self, other): - """ - Add to main styles another styles - :param other: styles to add - :return: new Theme object - """ - - with open(self.main_styles, 'a') as main_styles: - main_styles.write('\n' + other) - return self - - def __mul__(self, other): - """ - Copy files to temp folder - :param other: file or folder - :return: new Theme object - """ - - if os.path.isfile(other): - shutil.copy(other, self.temp_folder) - else: - shutil.copytree(other, self.temp_folder) - - return self - - def prepare(self): - # move files to temp folder - copy_files(self.theme_folder, self.temp_folder) - generate_file(f"{self.theme_folder}", self.temp_folder, self.main_styles) - # after generating main styles, remove .css and .versions folders - shutil.rmtree(f"{self.temp_folder}/.css/", ignore_errors=True) - shutil.rmtree(f"{self.temp_folder}/.versions/", ignore_errors=True) - - # if theme is filled - if self.is_filled: - for apply_file in os.listdir(f"{self.temp_folder}/"): - replace_keywords(f"{self.temp_folder}/{apply_file}", - ("BUTTON-COLOR", "ACCENT-FILLED-COLOR"), - ("BUTTON_HOVER", "ACCENT-FILLED_HOVER"), - ("BUTTON_ACTIVE", "ACCENT-FILLED_ACTIVE"), - ("BUTTON_INSENSITIVE", "ACCENT-FILLED_INSENSITIVE"), - ("BUTTON-TEXT-COLOR", "TEXT-BLACK-COLOR"), - ("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY")) - - def add_to_start(self, content): - """ - Add content to the start of main styles - :param content: content to add - """ - - with open(self.main_styles, 'r') as main_styles: - main_content = main_styles.read() - - with open(self.main_styles, 'w') as main_styles: - main_styles.write(content + '\n' + main_content) - - def install(self, hue, name: str, sat=None, destination=None): - """ - Copy files and generate theme with specified accent color - :param hue - :param name: theme name - :param sat - :param destination: folder where theme will be installed - """ - - joint_modes = f"({', '.join(self.modes)})" - - line = Console.Line(name) - formatted_name = Console.format(name.capitalize(), color=Color.get(name), format_type=Format.BOLD) - formatted_mode = Console.format(joint_modes, color=Color.GRAY) - line.update(f"Creating {formatted_name} {formatted_mode} theme...") - - try: - self._install_and_apply_theme(hue, name, sat=sat, destination=destination) - line.success(f"{formatted_name} {formatted_mode} theme created successfully.") - - except Exception as err: - line.error(f"Error installing {formatted_name} theme: {str(err)}") - - def _install_and_apply_theme(self, hue, name, sat=None, destination=None): - is_dest = bool(destination) - for mode in self.modes: - if not is_dest: - destination = destination_return(self.destination_folder, name, mode, self.theme_type) - - copy_files(self.temp_folder + '/', destination) - self.__apply_theme(hue, self.temp_folder, destination, mode, sat=sat) - - def __apply_theme(self, hue, source, destination, theme_mode, sat=None): - """ - Apply theme to all files in directory - :param hue - :param source - :param destination: file directory - :param theme_mode: theme name (light or dark) - :param sat: color saturation (optional) - """ - - for apply_file in os.listdir(f"{source}/"): - self.__apply_colors(hue, destination, theme_mode, apply_file, sat=sat) - - def __apply_colors(self, hue, destination, theme_mode, apply_file, sat=None): - """ - Install accent colors from colors.json to different file - :param hue - :param destination: file directory - :param theme_mode: theme name (light or dark) - :param apply_file: file name - :param sat: color saturation (optional) - """ - - # list of (keyword, replaced value) - replaced_colors = list() - - # colorsys works in range(0, 1) - h = hue / 360 - for element in self.colors.replacers: - # if color has default color and hasn't been replaced - if theme_mode not in self.colors.replacers[element] and self.colors.replacers[element]["default"]: - default_element = self.colors.replacers[element]["default"] - default_color = self.colors.replacers[default_element][theme_mode] - self.colors.replacers[element][theme_mode] = default_color - - # convert sla to range(0, 1) - lightness = int(self.colors.replacers[element][theme_mode]["l"]) / 100 - saturation = int(self.colors.replacers[element][theme_mode]["s"]) / 100 if sat is None else \ - int(self.colors.replacers[element][theme_mode]["s"]) * (sat / 100) / 100 - alpha = self.colors.replacers[element][theme_mode]["a"] - - # convert hsl to rgb and multiply every item - red, green, blue = [int(item * 255) for item in colorsys.hls_to_rgb(h, lightness, saturation)] - - replaced_colors.append((element, f"rgba({red}, {green}, {blue}, {alpha})")) - - # replace colors - replace_keywords(os.path.expanduser(f"{destination}/{apply_file}"), *replaced_colors) \ No newline at end of file diff --git a/scripts/types/__init__.py b/scripts/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/types/installation_color.py b/scripts/types/installation_color.py new file mode 100644 index 0000000..ded5b40 --- /dev/null +++ b/scripts/types/installation_color.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import Literal + +InstallationMode = Literal["light", "dark"] + + +@dataclass +class InstallationColor: + hue: int + saturation: int | None + modes: list[InstallationMode] \ No newline at end of file diff --git a/scripts/types/theme_base.py b/scripts/types/theme_base.py new file mode 100644 index 0000000..a9c18c9 --- /dev/null +++ b/scripts/types/theme_base.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod + + +class ThemeBase(ABC): + """Base class for theme installation and preparation.""" + @abstractmethod + def prepare(self): + pass + + @abstractmethod + def install(self, hue: int, name: str, sat: float | None = None): + pass \ No newline at end of file diff --git a/scripts/utils/__init__.py b/scripts/utils/__init__.py index 786b174..2b91836 100644 --- a/scripts/utils/__init__.py +++ b/scripts/utils/__init__.py @@ -1,8 +1,5 @@ -from .concatenate_files import concatenate_files from .copy_files import copy_files -from .destinaiton_return import destination_return from .generate_file import generate_file -from .hex_to_rgba import hex_to_rgba from .remove_files import remove_files from .remove_keywords import remove_keywords from .remove_properties import remove_properties diff --git a/scripts/utils/alternatives_updater.py b/scripts/utils/alternatives_updater.py index f5e4ff8..1761dce 100644 --- a/scripts/utils/alternatives_updater.py +++ b/scripts/utils/alternatives_updater.py @@ -2,7 +2,7 @@ import functools import subprocess from typing import TypeAlias -from scripts.utils.console import Console +from scripts.utils.logger.console import Console PathString: TypeAlias = str | bytes @@ -28,7 +28,7 @@ class AlternativesUpdater: @staticmethod @ubuntu_specific - def install_and_set(link: str, name: str, path: PathString, priority: int = 0): + def install_and_set(link: PathString, name: str, path: PathString, priority: int = 0): AlternativesUpdater.install(link, name, path, priority) AlternativesUpdater.set(name, path) diff --git a/scripts/utils/color_converter/__init__.py b/scripts/utils/color_converter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/utils/color_converter/color_converter.py b/scripts/utils/color_converter/color_converter.py new file mode 100644 index 0000000..6683659 --- /dev/null +++ b/scripts/utils/color_converter/color_converter.py @@ -0,0 +1,26 @@ +from abc import ABC, abstractmethod + + +class ColorConverter(ABC): + @staticmethod + @abstractmethod + def hex_to_rgba(hex_color): + """ + Converts a HEX color code to RGBA format. + :param hex_color: HEX color code (e.g., '#ff5733' or 'ff5733'). + :return: Tuple of RGBA values (red, green, blue, alpha). + :raises ValueError: If the HEX color code is invalid. + """ + pass + + @staticmethod + @abstractmethod + def hsl_to_rgb(hue, saturation, lightness): + """ + Converts HSL color values to RGB format. + :param hue: Hue value (0-360). + :param saturation: Saturation value (0-1). + :param lightness: Lightness value (0-1). + :return: Tuple of RGB values (red, green, blue) in range(0-255). + """ + pass diff --git a/scripts/utils/color_converter/color_converter_impl.py b/scripts/utils/color_converter/color_converter_impl.py new file mode 100644 index 0000000..aff09ad --- /dev/null +++ b/scripts/utils/color_converter/color_converter_impl.py @@ -0,0 +1,43 @@ +import colorsys + +from scripts.utils.color_converter.color_converter import ColorConverter + + +class ColorConverterImpl(ColorConverter): + @staticmethod + def hex_to_rgba(hex_color): + try: + hex_color = hex_color.lstrip('#') + + # Handle shorthand hex colors (e.g., #fff) + if len(hex_color) == 3: + hex_color = ''.join([char * 2 for char in hex_color]) + + # Add alpha channel if missing + if len(hex_color) == 6: + hex_color += "ff" + + # Validate the hex color + int(hex_color, 16) + + except ValueError: + raise ValueError(f'Error: Invalid HEX color code: #{hex_color}') + + else: + return int(hex_color[0:2], 16), \ + int(hex_color[2:4], 16), \ + int(hex_color[4:6], 16), \ + int(hex_color[6:8], 16) / 255 + + @staticmethod + def hsl_to_rgb(hue, saturation, lightness): + if hue > 360 or hue < 0: + raise ValueError(f'Hue must be between 0 and 360, not {hue}') + if saturation > 1 or saturation < 0: + raise ValueError(f'Saturation must be between 0 and 1, not {saturation}') + if lightness > 1 or lightness < 0: + raise ValueError(f'Lightness must be between 0 and 1, not {lightness}') + + h = hue / 360 + red, green, blue = [round(item * 255) for item in colorsys.hls_to_rgb(h, lightness, saturation)] + return red, green, blue diff --git a/scripts/utils/command_runner/__init__.py b/scripts/utils/command_runner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/utils/command_runner/command_runner.py b/scripts/utils/command_runner/command_runner.py new file mode 100644 index 0000000..abbbca0 --- /dev/null +++ b/scripts/utils/command_runner/command_runner.py @@ -0,0 +1,14 @@ +import subprocess +from abc import ABC, abstractmethod + + +class CommandRunner(ABC): + @abstractmethod + def run(self, command: list[str], **kwargs) -> subprocess.CompletedProcess: + """ + Run a command in the shell and return the output. + :param command: Command to run. + :param kwargs: Additional arguments for the command. + :return: Output of the command. + """ + pass \ No newline at end of file diff --git a/scripts/utils/command_runner/subprocess_command_runner.py b/scripts/utils/command_runner/subprocess_command_runner.py new file mode 100644 index 0000000..0f9732e --- /dev/null +++ b/scripts/utils/command_runner/subprocess_command_runner.py @@ -0,0 +1,8 @@ +import subprocess + +from scripts.utils.command_runner.command_runner import CommandRunner + + +class SubprocessCommandRunner(CommandRunner): + def run(self, command: list[str], **kwargs) -> subprocess.CompletedProcess: + return subprocess.run(command, **kwargs) \ No newline at end of file diff --git a/scripts/utils/concatenate_files.py b/scripts/utils/concatenate_files.py deleted file mode 100644 index 6ede247..0000000 --- a/scripts/utils/concatenate_files.py +++ /dev/null @@ -1,12 +0,0 @@ -def concatenate_files(edit_file, file): - """ - Merge two files - :param edit_file: where it will be appended - :param file: file you want to append - """ - - with open(file, 'r') as read_file: - file_content = read_file.read() - - with open(edit_file, 'a') as write_file: - write_file.write('\n' + file_content) \ No newline at end of file diff --git a/scripts/utils/destinaiton_return.py b/scripts/utils/destinaiton_return.py deleted file mode 100644 index c43cf75..0000000 --- a/scripts/utils/destinaiton_return.py +++ /dev/null @@ -1,11 +0,0 @@ -def destination_return(themes_folder, path_name, theme_mode, theme_type): - """ - Copied/modified theme location - :param themes_folder: themes folder location - :param path_name: color name - :param theme_mode: theme name (light or dark) - :param theme_type: theme type (gnome-shell, gtk-4.0, ...) - :return: copied files' folder location - """ - - return f"{themes_folder}/Marble-{path_name}-{theme_mode}/{theme_type}/" diff --git a/scripts/utils/files_labeler.py b/scripts/utils/files_labeler.py index 6243f7a..1107f45 100644 --- a/scripts/utils/files_labeler.py +++ b/scripts/utils/files_labeler.py @@ -1,15 +1,28 @@ import os +from abc import ABC, abstractmethod from typing import Tuple, TypeAlias LabeledFileGroup: TypeAlias = Tuple[str, str] + +class FilesLabelerFactory(ABC): + @abstractmethod + def create(self, temp_folder: str, *files_to_update_references: str) -> 'FilesLabeler': + pass + + +class FilesLabelerFactoryImpl(FilesLabelerFactory): + def create(self, temp_folder: str, *files_to_update_references: str) -> 'FilesLabeler': + return FilesLabeler(temp_folder, *files_to_update_references) + + class FilesLabeler: - def __init__(self, directory: str, *args: str): + def __init__(self, directory: str, *files_to_update_references: str): """ Initialize the working directory and files to change """ self.directory = directory - self.files = args + self.files = files_to_update_references def append_label(self, label: str): """ diff --git a/scripts/utils/get_version_folder.py b/scripts/utils/get_version_folder.py index 92848fc..4ba2418 100644 --- a/scripts/utils/get_version_folder.py +++ b/scripts/utils/get_version_folder.py @@ -7,6 +7,9 @@ def get_version_folders(version, base_path): :param base_path: base path to version folders :return: list of matching version folders """ + if not os.path.exists(base_path): + return [] + version_folders = os.listdir(base_path) version = int(version.split('.')[0]) # Use only the major version for comparison matching_folders = [] diff --git a/scripts/utils/global_theme/__init__.py b/scripts/utils/global_theme/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/utils/global_theme/gdm.py b/scripts/utils/global_theme/gdm.py new file mode 100644 index 0000000..6af2608 --- /dev/null +++ b/scripts/utils/global_theme/gdm.py @@ -0,0 +1,88 @@ +from scripts.types.theme_base import ThemeBase +from scripts.utils.global_theme.gdm_installer import GDMThemeInstaller +from scripts.utils.global_theme.gdm_preparer import GDMThemePreparer +from scripts.utils.global_theme.gdm_remover import GDMThemeRemover +from scripts.utils.global_theme.gdm_theme_prepare import GDMThemePrepare + + +class GDMTheme(ThemeBase): + """ + GDM theming module. + + This module provides functionality to prepare, install, and remove GNOME Display Manager themes. + It follows a workflow of: + 1. Preparing themes from existing GDM resources + 2. Installing themes with custom colors/styles + 3. Providing ability to restore original GDM themes + + The main entry point is the GDMTheme class, which orchestrates the entire theme management process. + """ + def __init__(self, preparer: GDMThemePreparer, installer: GDMThemeInstaller, remover: GDMThemeRemover): + """ + :param preparer: GDMThemePreparer instance for preparing themes + :param installer: GDMThemeInstaller instance for installing themes + :param remover: GDMThemeRemover instance for removing themes + """ + self.preparer = preparer + self.installer = installer + self.remover = remover + + self.themes: list[GDMThemePrepare] = [] + + def prepare(self): + """ + Prepare the theme for installation. + + This method: + 1. Checks if a theme is already installed and uses backup if needed + 2. Extracts relevant theme files + 3. Processes them into ready-to-compile GDMThemePrepare objects + + The processed themes are stored in the themes attribute for later use. + """ + if self._is_installed(): + self.preparer.use_backup_as_source() + self.themes = self.preparer.prepare() + + def _is_installed(self) -> bool: + """ + Check if a GDM theme is currently installed. + + This looks for specific markers in the system gresource files + that indicate our theme has been installed. + + :return: True if a custom theme is installed, False otherwise + """ + return self.installer.is_installed() + + def install(self, hue: int, name: str, sat: float | None = None): + """ + Install the prepared theme with specified color adjustments. + + This method: + 1. Compiles theme files with the specified hue and saturation + 2. Creates a backup of the original GDM theme if one doesn't exist + 3. Installs the compiled theme to the system + + :param hue: The hue adjustment (0-360) to apply to the theme + :param name: The name of the theme to be installed. In GDM will only be shown in logger + :param sat: Optional saturation adjustment (0-100) to apply + """ + self.installer.compile(self.themes, hue, name, sat) + + if not self._is_installed(): + self.installer.backup() + + self.installer.install() + + def remove(self): + """ + Remove the installed theme and restore the original GDM theme. + + If no theme is installed, displays a warning message to the user. + This will restore from backup and update GDM alternatives if needed. + """ + if self._is_installed(): + self.remover.remove() + else: + self.remover.warn_not_installed() diff --git a/scripts/utils/global_theme/gdm_builder.py b/scripts/utils/global_theme/gdm_builder.py new file mode 100644 index 0000000..31ea366 --- /dev/null +++ b/scripts/utils/global_theme/gdm_builder.py @@ -0,0 +1,171 @@ +import os.path +from typing import Optional + +from scripts import config +from scripts.install.colors_definer import ColorsDefiner +from scripts.types.installation_color import InstallationMode +from scripts.utils.alternatives_updater import AlternativesUpdater, PathString +from scripts.utils.command_runner.subprocess_command_runner import SubprocessCommandRunner +from scripts.utils.files_labeler import FilesLabelerFactoryImpl +from scripts.utils.global_theme.gdm import GDMTheme +from scripts.utils.global_theme.gdm_installer import GDMThemeInstaller +from scripts.utils.global_theme.gdm_preparer import GDMThemePreparer +from scripts.utils.global_theme.gdm_remover import GDMThemeRemover +from scripts.utils.global_theme.ubuntu_alternatives_updater import UbuntuGDMAlternativesUpdater +from scripts.utils.gresource.gresource import Gresource +from scripts.utils.logger.console import Console +from scripts.utils.logger.logger import LoggerFactory +from scripts.utils.theme.gnome_shell_theme_builder import GnomeShellThemeBuilder + + +class GDMThemeBuilder: + """ + Builder class for creating GDMTheme instances with configurable components. + + This class follows the Builder pattern to create a GDMTheme with all necessary + dependencies. Dependencies can be injected via the with_* methods or will be + automatically resolved during build() if not provided. + + Example usage: + builder = GMDThemeBuilder(colors_provider) + theme = builder.with_mode("dark").with_filled(True).build() + """ + def __init__(self, colors_provider: ColorsDefiner): + """ + :param colors_provider: A provider for color definitions. + """ + self.colors_provider: ColorsDefiner = colors_provider + self._temp_folder: PathString = os.path.join(config.temp_folder, config.gdm_folder) + self._mode: Optional[InstallationMode] = None + self._is_filled: bool = False + + self._logger_factory: Optional[LoggerFactory] = None + self._gresource: Optional[Gresource] = None + self._ubuntu_gdm_alternatives_updater: Optional[UbuntuGDMAlternativesUpdater] = None + + self._preparer: Optional[GDMThemePreparer] = None + self._installer: Optional[GDMThemeInstaller] = None + self._remover: Optional[GDMThemeRemover] = None + + def with_mode(self, mode: InstallationMode | None) -> 'GDMThemeBuilder': + """Set the mode for the theme.""" + self._mode = mode + return self + + def with_filled(self, is_filled=True) -> 'GDMThemeBuilder': + """Set the filled state for the theme.""" + self._is_filled = is_filled + return self + + def with_logger_factory(self, logger_factory: LoggerFactory) -> 'GDMThemeBuilder': + """Inject a logger factory for logging purposes.""" + self._logger_factory = logger_factory + return self + + def with_gresource(self, gresource: Gresource) -> 'GDMThemeBuilder': + """Inject a gresource instance for managing gresource files.""" + self._gresource = gresource + return self + + def with_ubuntu_gdm_alternatives_updater(self, alternatives_updater: UbuntuGDMAlternativesUpdater) -> 'GDMThemeBuilder': + """Inject an alternatives updater for managing GDM alternatives.""" + self._ubuntu_gdm_alternatives_updater = alternatives_updater + return self + + def with_preparer(self, preparer: GDMThemePreparer) -> 'GDMThemeBuilder': + """Inject a preparer for preparing the theme.""" + self._preparer = preparer + return self + + def with_installer(self, installer: GDMThemeInstaller) -> 'GDMThemeBuilder': + """Inject an installer for installing the theme.""" + self._installer = installer + return self + + def with_remover(self, remover: GDMThemeRemover) -> 'GDMThemeBuilder': + """Inject a remover for removing the theme.""" + self._remover = remover + return self + + def build(self) -> GDMTheme: + """ + Build the GDMTheme object with the configured components. + + Automatically resolves any dependencies that haven't been explicitly + provided through with_* methods. The order of resolution ensures + that dependencies are created before they're needed. + + :return: A fully configured GDMTheme instance ready for use + """ + self._resolve_logger_factory() + self._resolve_gresource() + self._resolve_ubuntu_gdm_alternatives_updater() + + self._resolve_preparer() + self._resolve_installer() + self._resolve_remover() + + return GDMTheme(self._preparer, self._installer, self._remover) + + def _resolve_logger_factory(self): + """Instantiate a default Console logger if not explicitly provided.""" + if self._logger_factory: return + self._logger_factory = Console() + + def _resolve_gresource(self): + """ + Create a Gresource handler if not explicitly provided. + Uses configuration values for file paths and destinations. + """ + if self._gresource: return + + gresource_file = config.gnome_shell_gresource + temp_folder = os.path.join(self._temp_folder, config.extracted_gdm_folder) + destination = config.global_gnome_shell_theme + runner = SubprocessCommandRunner() + + self._gresource = Gresource( + gresource_file=gresource_file, + temp_folder=temp_folder, + destination=destination, + logger_factory=self._logger_factory, + runner=runner + ) + + def _resolve_ubuntu_gdm_alternatives_updater(self): + """Create an UbuntuGDMAlternativesUpdater if not explicitly provided.""" + if self._ubuntu_gdm_alternatives_updater: return + alternatives_updater = AlternativesUpdater() + self._ubuntu_gdm_alternatives_updater = UbuntuGDMAlternativesUpdater(alternatives_updater) + + def _resolve_preparer(self): + """Create a GDMThemePreparer if not explicitly provided.""" + if self._preparer: return + theme_builder = GnomeShellThemeBuilder(self.colors_provider) + files_labeler_factory = FilesLabelerFactoryImpl() + self._preparer = GDMThemePreparer( + temp_folder=self._temp_folder, + default_mode=self._mode, + is_filled=self._is_filled, + gresource=self._gresource, + theme_builder=theme_builder, + logger_factory=self._logger_factory, + files_labeler_factory=files_labeler_factory, + ) + + def _resolve_installer(self): + """Create a GDMThemeInstaller if not explicitly provided.""" + if self._installer: return + self._installer = GDMThemeInstaller( + gresource=self._gresource, + alternatives_updater=self._ubuntu_gdm_alternatives_updater, + ) + + def _resolve_remover(self): + """Create a GDMThemeRemover if not explicitly provided.""" + if self._remover: return + self._remover = GDMThemeRemover( + gresource=self._gresource, + alternatives_updater=self._ubuntu_gdm_alternatives_updater, + logger_factory=self._logger_factory + ) \ No newline at end of file diff --git a/scripts/utils/global_theme/gdm_installer.py b/scripts/utils/global_theme/gdm_installer.py new file mode 100644 index 0000000..29f5350 --- /dev/null +++ b/scripts/utils/global_theme/gdm_installer.py @@ -0,0 +1,66 @@ +from scripts.utils.global_theme.gdm_theme_prepare import GDMThemePrepare +from scripts.utils.global_theme.ubuntu_alternatives_updater import UbuntuGDMAlternativesUpdater +from scripts.utils.gresource.gresource import Gresource + + +class GDMThemeInstaller: + """ + Handles the installation of GDM themes system-wide. + + This class manages: + - Compiling prepared theme resources into a gresource file + - Creating backups of original system files + - Installing compiled themes via the alternatives system + - Detecting if a theme is already installed + """ + def __init__(self, gresource: Gresource, alternatives_updater: UbuntuGDMAlternativesUpdater): + """ + :param gresource: Handler for gresource operations + :param alternatives_updater: Handler for update-alternatives operations + """ + self.gresource = gresource + self.alternatives_updater = alternatives_updater + + self._is_installed_trigger = "\n/* Marble theme */\n" + + def is_installed(self) -> bool: + """ + Check if the theme is installed + by looking for the trigger in the gresource file. + """ + return self.gresource.has_trigger(self._is_installed_trigger) + + def compile(self, themes: list[GDMThemePrepare], hue: int, color: str, sat: int = None): + """ + Prepares themes for gresource and compiles them. + :param themes: themes to be compiled + :param hue: hue value for the theme + :param color: the color name. in GDM will only be shown in logger + :param sat: saturation value for the theme + """ + self._generate_themes(themes, hue, color, sat) + self.gresource.compile() + + def _generate_themes(self, themes: list[GDMThemePrepare], hue: int, color: str, sat: int = None): + """Generate theme files for further compiling by gresource""" + for theme_prepare in themes: + if theme_prepare.label is not None: + theme_prepare.label_theme() + + theme_prepare.remove_keywords("!important") + theme_prepare.remove_properties("background-color", "color", "box-shadow", "border-radius") + theme_prepare.prepend_source_styles(self._is_installed_trigger) + + theme_prepare.install(hue, color, sat, destination=self.gresource.temp_folder) + + def backup(self): + """Backup the current gresource file.""" + self.gresource.backup() + + def install(self): + """ + Install the theme globally by moving the compiled gresource file to the destination. + Also updates the alternatives for the gdm theme. + """ + self.gresource.move() + self.alternatives_updater.install_and_set() \ No newline at end of file diff --git a/scripts/utils/global_theme/gdm_preparer.py b/scripts/utils/global_theme/gdm_preparer.py new file mode 100644 index 0000000..3af77e6 --- /dev/null +++ b/scripts/utils/global_theme/gdm_preparer.py @@ -0,0 +1,94 @@ +import os + +from scripts.utils.files_labeler import FilesLabelerFactory +from scripts.utils.global_theme.gdm_theme_prepare import GDMThemePrepare +from scripts.utils.gresource.gresource import Gresource +from scripts.utils.logger.logger import LoggerFactory +from scripts.utils.theme.gnome_shell_theme_builder import GnomeShellThemeBuilder + + +class GDMThemePreparer: + """ + GDM theme preparation module. + + This module contains classes responsible for extracting and processing + GDM theme resources for later compilation and installation. + + The main class, GDMThemePreparer, orchestrates the extraction process + and creates GDMThemePrepare objects representing processable themes. + """ + def __init__(self, temp_folder: str, default_mode: str | None, is_filled: bool, + gresource: Gresource, + theme_builder: GnomeShellThemeBuilder, + logger_factory: LoggerFactory, + files_labeler_factory: FilesLabelerFactory): + """ + :param temp_folder: Temporary folder for extracted theme files + :param default_mode: Default theme mode to use if not specified in CSS filename + :param is_filled: Whether to generate filled (True) or dimmed (False) styles + :param gresource: Gresource instance for managing gresource files + :param theme_builder: Theme builder instance for creating themes + :param logger_factory: Logger factory for logging messages + :param files_labeler_factory: Factory for creating FilesLabeler instances + """ + self.temp_folder = temp_folder + self.gresource_temp_folder = gresource.temp_folder + + self.default_mode = default_mode + self.is_filled = is_filled + + self.gresource = gresource + self.theme_builder = theme_builder + self.logger_factory = logger_factory + self.files_labeler_factory = files_labeler_factory + + def use_backup_as_source(self): + """Use backup gresource file for extraction""" + self.gresource.use_backup_gresource() + self.logger_factory.create_logger().info("Using backup gresource file for extraction...") + + def prepare(self) -> list[GDMThemePrepare]: + """ + Extract and prepare GDM themes for processing. + :return: List of prepared theme objects ready for compilation + """ + self.gresource.extract() + return self._find_themes() + + def _find_themes(self) -> list[GDMThemePrepare]: + extracted_files = os.listdir(self.gresource_temp_folder) + allowed_css = {"gnome-shell-dark.css", "gnome-shell-light.css", "gnome-shell.css"} + + themes = [ + self._create_theme(file_name) + for file_name in extracted_files + if file_name in allowed_css + ] + return themes + + def _create_theme(self, file_name: str) -> GDMThemePrepare: + """Helper to create and prepare a theme""" + mode = file_name.split("-")[-1].replace(".css", "") + mode = mode if mode in {"dark", "light"} else self.default_mode + + self._setup_theme_builder(file_name, mode) + + theme = self.theme_builder.build() + theme.prepare() + + theme_file = os.path.join(self.gresource_temp_folder, file_name) + files_labeler = self.files_labeler_factory.create( + theme.temp_folder, theme.main_styles) + return GDMThemePrepare( + theme=theme, theme_file=theme_file, label=mode, files_labeler=files_labeler) + + def _setup_theme_builder(self, file_name: str, mode: str): + theme_name = file_name.replace(".css", "") + + (self.theme_builder + .with_temp_folder(self.temp_folder) + .with_theme_name(theme_name) + .with_mode(mode) + .filled(self.is_filled) + .with_logger_factory(self.logger_factory) + .with_reset_dependencies()) \ No newline at end of file diff --git a/scripts/utils/global_theme/gdm_remover.py b/scripts/utils/global_theme/gdm_remover.py new file mode 100644 index 0000000..75f4c62 --- /dev/null +++ b/scripts/utils/global_theme/gdm_remover.py @@ -0,0 +1,65 @@ +from scripts.utils.global_theme.ubuntu_alternatives_updater import UbuntuGDMAlternativesUpdater +from scripts.utils.gresource import GresourceBackupNotFoundError +from scripts.utils.gresource.gresource import Gresource +from scripts.utils.logger.console import Console, Color, Format +from scripts.utils.logger.logger import LoggerFactory + + +class GDMThemeRemover: + """ + Responsible for safely removing installed GDM themes. + + This class handles: + - Restoring original gresource files from backups + - Removing theme alternatives from the system + - Providing feedback about removal status + """ + def __init__(self, + gresource: Gresource, + alternatives_updater: UbuntuGDMAlternativesUpdater, + logger_factory: LoggerFactory): + """ + :param gresource: Handler for gresource operations + :param alternatives_updater: Handler for update-alternatives operations + :param logger_factory: Factory for creating loggers + """ + self.gresource = gresource + self.alternatives_updater = alternatives_updater + self.remover_logger = GDMRemoverLogger(logger_factory) + + def remove(self): + """Restores the gresource backup and removes the alternatives.""" + self.remover_logger.start_removing() + + try: + self.gresource.restore() + self.alternatives_updater.remove() + self.remover_logger.success_removing() + except GresourceBackupNotFoundError: + self.remover_logger.error_removing() + + def warn_not_installed(self): + self.remover_logger.not_installed_warning() + + +class GDMRemoverLogger: + def __init__(self, logger_factory: LoggerFactory): + self.logger_factory = logger_factory + self.removing_line = None + + def start_removing(self): + self.removing_line = self.logger_factory.create_logger() + self.removing_line.update("Theme is installed. Removing...") + + def success_removing(self): + self.removing_line.success("Global theme removed successfully. Restart GDM to apply changes.") + + def error_removing(self): + formatted_shell = Console.format("gnome-shell", color=Color.BLUE, format_type=Format.BOLD) + self.removing_line.error(f"Backup file not found. Try reinstalling {formatted_shell} package.") + + def not_installed_warning(self): + self.logger_factory.create_logger().error( + "Theme is not installed. Nothing to remove.") + self.logger_factory.create_logger().warn( + "If theme is still installed globally, try reinstalling gnome-shell package.") \ No newline at end of file diff --git a/scripts/utils/global_theme/gdm_theme_prepare.py b/scripts/utils/global_theme/gdm_theme_prepare.py new file mode 100644 index 0000000..66db0d7 --- /dev/null +++ b/scripts/utils/global_theme/gdm_theme_prepare.py @@ -0,0 +1,68 @@ +from scripts.utils import remove_keywords, remove_properties +from scripts.utils.files_labeler import FilesLabeler +from scripts.utils.theme.theme import Theme + + +class GDMThemePrepare: + """ + Prepares theme files for installation into the GDM system. + + This class handles: + - Theme file labeling for dark/light variants + - CSS property and keyword removal for customization + - Theme installation with color adjustments + """ + def __init__(self, theme: Theme, theme_file: str, label: str | None, + files_labeler: FilesLabeler): + """ + :param theme: The theme object to prepare + :param theme_file: Path to the original decompiled CSS file + :param label: Optional label for the theme (e.g. "dark", "light") + :param files_labeler: FilesLabeler instance for labeling files + """ + self.theme = theme + self.theme_file = theme_file + self.label = label + self.files_labeler = files_labeler + + def label_theme(self): + """ + Label the theme files if the label is set. + Also updates references in the theme files. + :raises ValueError: if the label is not set + """ + if self.label is None: + raise ValueError("Label is not set for the theme.") + + self.files_labeler.append_label(self.label) + + def remove_keywords(self, *args: str): + """Remove specific keywords from the theme file""" + remove_keywords(self.theme_file, *args) + + def remove_properties(self, *args: str): + """Remove specific properties from the theme file""" + remove_properties(self.theme_file, *args) + + def prepend_source_styles(self, trigger: str): + """ + Add source styles and installation trigger to the theme file. + + This adds original theme styles and a marker that identifies + the theme as installed by this application. + + :param trigger: String marker used to identify installed themes + """ + with open(self.theme_file, 'r') as gnome_theme: + gnome_styles = gnome_theme.read() + '\n' + trigger + '\n' + self.theme.add_to_start(gnome_styles) + + def install(self, hue: int, color: str, sat: int | None, destination: str): + """ + Install the theme to the specified destination + :param hue: Hue value for the theme + :param color: Color name for the theme + :param sat: Saturation value for the theme + :param destination: Destination folder for the theme + """ + self.theme.install(hue, color, sat, destination=destination) \ No newline at end of file diff --git a/scripts/utils/global_theme/ubuntu_alternatives_updater.py b/scripts/utils/global_theme/ubuntu_alternatives_updater.py new file mode 100644 index 0000000..351cb5d --- /dev/null +++ b/scripts/utils/global_theme/ubuntu_alternatives_updater.py @@ -0,0 +1,65 @@ +import os.path + +from scripts import config +from scripts.utils.alternatives_updater import AlternativesUpdater + + +class UbuntuGDMAlternativesUpdater: + """ + Manages update-alternatives for Ubuntu GDM themes. + + This class handles: + - Creating alternatives for GDM theme files + - Setting installed theme as the active alternative + - Removing theme alternatives during uninstallation + """ + def __init__(self, alternatives_updater: AlternativesUpdater): + """ + :param alternatives_updater: Handler for update-alternatives operations + """ + self.ubuntu_gresource_link_name = config.ubuntu_gresource_link + self.destination_dir = config.global_gnome_shell_theme + self.destination_file = config.gnome_shell_gresource + + self.alternatives_updater = alternatives_updater + + self._update_gresource_paths() + + def _update_gresource_paths(self): + self.ubuntu_gresource_path = os.path.join(self.destination_dir, self.ubuntu_gresource_link_name) + self.gnome_gresource_path = os.path.join(self.destination_dir, self.destination_file) + + def with_custom_destination(self, destination_dir: str, destination_file: str): + """Set custom destination directory and file for the theme.""" + self.destination_dir = destination_dir + self.destination_file = destination_file + self._update_gresource_paths() + return self + + def install_and_set(self, priority: int = 0): + """ + Add theme as an alternative and set it as active. + + This creates a system alternative for the GDM theme and + makes it the active selection with the specified priority. + + :param priority: Priority level for the alternative (higher wins in conflicts) + """ + self.alternatives_updater.install_and_set( + link=self.ubuntu_gresource_path, + name=self.ubuntu_gresource_link_name, + path=self.gnome_gresource_path, + priority=priority + ) + + def remove(self): + """ + Remove the theme alternative from the system. + + This removes the previously installed alternative, allowing + the system to fall back to the default GDM theme. + """ + self.alternatives_updater.remove( + name=self.ubuntu_gresource_link_name, + path=self.gnome_gresource_path + ) diff --git a/scripts/utils/gnome.py b/scripts/utils/gnome.py index 9448766..83e65cc 100644 --- a/scripts/utils/gnome.py +++ b/scripts/utils/gnome.py @@ -2,7 +2,7 @@ import subprocess import time from scripts import config -from scripts.utils.console import Console, Format, Color +from scripts.utils.logger.console import Console, Format, Color from scripts.utils.parse_folder import parse_folder diff --git a/scripts/utils/gresource.py b/scripts/utils/gresource.py deleted file mode 100644 index 8d15657..0000000 --- a/scripts/utils/gresource.py +++ /dev/null @@ -1,164 +0,0 @@ -import os -import subprocess -import textwrap -from pathlib import Path - -from scripts.utils.console import Console - - -class GresourceBackupNotFoundError(FileNotFoundError): - def __init__(self, location: str = None): - if location: - super().__init__(f"Gresource backup file not found: {location}") - else: - super().__init__("Gresource backup file not found.") - - -class Gresource: - """Handles the extraction and compilation of gresource files for GNOME Shell themes.""" - - def __init__(self, gresource_file: str, temp_folder: str, destination: str): - """ - :param gresource_file: The name of the gresource file to be processed. - :param temp_folder: The temporary folder where resources will be extracted. - :param destination: The destination folder where the compiled gresource file will be saved. - """ - self.gresource_file = gresource_file - self.temp_folder = temp_folder - self.destination = destination - - self.__temp_gresource = os.path.join(temp_folder, gresource_file) - self.__destination_gresource = os.path.join(destination, gresource_file) - self.__active_source_gresource = self.__destination_gresource - self.__backup_gresource = os.path.join(destination, f"{gresource_file}.backup") - self.__gresource_xml = os.path.join(temp_folder, f"{gresource_file}.xml") - - def use_backup_gresource(self): - if not os.path.exists(self.__backup_gresource): - raise GresourceBackupNotFoundError(self.__backup_gresource) - self.__active_source_gresource = self.__backup_gresource - - def extract(self): - extract_line = Console.Line() - extract_line.update("Extracting gresource files...") - - resources = self.__get_resources_list() - self.__extract_resources(resources) - - extract_line.success("Extracted gresource files.") - - def __get_resources_list(self): - resources_list_response = subprocess.run( - ["gresource", "list", self.__active_source_gresource], - capture_output=True, text=True, check=False - ) - - if resources_list_response.stderr: - raise Exception(f"gresource could not process the theme file: {self.__active_source_gresource}") - - return resources_list_response.stdout.strip().split("\n") - - def __extract_resources(self, resources: list[str]): - prefix = "/org/gnome/shell/theme/" - try: - for resource in resources: - resource_path = resource.replace(prefix, "") - output_path = os.path.join(self.temp_folder, resource_path) - os.makedirs(os.path.dirname(output_path), exist_ok=True) - - with open(output_path, 'wb') as f: - subprocess.run( - ["gresource", "extract", self.__active_source_gresource, resource], - stdout=f, check=True - ) - except FileNotFoundError as e: - if "gresource" in str(e): - self.__raise_gresource_error(e) - raise - - @staticmethod - def __raise_gresource_error(e: Exception): - print("Error: 'gresource' command not found.") - print("Please install the glib2-devel package:") - print(" - For Fedora/RHEL: sudo dnf install glib2-devel") - print(" - For Ubuntu/Debian: sudo apt install libglib2.0-dev") - print(" - For Arch: sudo pacman -S glib2-devel") - raise Exception("Missing required dependency: glib2-devel") from e - - def compile(self): - compile_line = Console.Line() - compile_line.update("Compiling gnome-shell theme...") - - self.__create_gresource_xml() - self.__compile_resources() - - compile_line.success("Theme compiled.") - - def __create_gresource_xml(self): - with open(self.__gresource_xml, 'w') as gresource_xml: - gresource_xml.write(self.__generate_gresource_xml()) - - def __generate_gresource_xml(self): - files_to_include = self.__get_files_to_include() - nl = "\n" # fstring doesn't support newline character - return textwrap.dedent(f""" - - - - {nl.join(files_to_include)} - - - """) - - def __get_files_to_include(self): - temp_path = Path(self.temp_folder) - return [ - f"{file.relative_to(temp_path)}" - for file in temp_path.glob('**/*') - if file.is_file() - ] - - def __compile_resources(self): - try: - subprocess.run(["glib-compile-resources", - "--sourcedir", self.temp_folder, - "--target", self.__temp_gresource, - self.__gresource_xml - ], - cwd=self.temp_folder, check=True) - except FileNotFoundError as e: - if "glib-compile-resources" in str(e): - self.__raise_gresource_error(e) - raise - - def backup(self): - backup_line = Console.Line() - backup_line.update("Backing up gresource files...") - - subprocess.run(["cp", "-aT", - self.__destination_gresource, - self.__backup_gresource], - check=True) - - backup_line.success("Backed up gresource files.") - - def restore(self): - if not os.path.exists(self.__backup_gresource): - raise GresourceBackupNotFoundError(self.__backup_gresource) - - subprocess.run(["sudo", "mv", "-f", - self.__backup_gresource, - self.__destination_gresource], - check=True) - - - def move(self): - move_line = Console.Line() - move_line.update("Moving gresource files...") - - subprocess.run(["sudo", "cp", "-f", - self.__temp_gresource, - self.__destination_gresource], - check=True) - - move_line.success("Moved gresource files.") \ No newline at end of file diff --git a/scripts/utils/gresource/__init__.py b/scripts/utils/gresource/__init__.py new file mode 100644 index 0000000..b69e6ab --- /dev/null +++ b/scripts/utils/gresource/__init__.py @@ -0,0 +1,21 @@ +class GresourceBackupNotFoundError(FileNotFoundError): + def __init__(self, location: str = None): + if location: + super().__init__(f"Gresource backup file not found: {location}") + else: + super().__init__("Gresource backup file not found.") + + +class MissingDependencyError(Exception): + def __init__(self, dependency: str): + super().__init__(f"Missing required dependency: {dependency}") + self.dependency = dependency + + +def raise_gresource_error(tool: str, e: Exception): + print(f"Error: '{tool}' command not found.") + print("Please install the glib2-devel package:") + print(" - For Fedora/RHEL: sudo dnf install glib2-devel") + print(" - For Ubuntu/Debian: sudo apt install libglib2.0-dev") + print(" - For Arch: sudo pacman -S glib2-devel") + raise MissingDependencyError("glib2-devel") from e diff --git a/scripts/utils/gresource/gresource.py b/scripts/utils/gresource/gresource.py new file mode 100644 index 0000000..c9963c4 --- /dev/null +++ b/scripts/utils/gresource/gresource.py @@ -0,0 +1,71 @@ +import os + +from scripts.utils.alternatives_updater import PathString +from scripts.utils.command_runner.command_runner import CommandRunner +from scripts.utils.gresource.gresource_backuper import GresourceBackuperManager +from scripts.utils.gresource.gresource_compiler import GresourceCompiler +from scripts.utils.gresource.gresource_extractor import GresourceExtractor +from scripts.utils.gresource.gresource_mover import GresourceMover +from scripts.utils.logger.logger import LoggerFactory + + +class Gresource: + """Orchestrator for gresource files.""" + + def __init__( + self, gresource_file: str, temp_folder: PathString, destination: PathString, + logger_factory: LoggerFactory, runner: CommandRunner + ): + """ + :param gresource_file: The name of the gresource file to be processed. + :param temp_folder: The temporary folder where resources will be extracted. + :param destination: The destination folder where the compiled gresource file will be saved. + """ + self.gresource_file = gresource_file + self.temp_folder = temp_folder + self.destination = destination + + self.logger_factory = logger_factory + self.runner = runner + + self._temp_gresource = os.path.join(temp_folder, gresource_file) + self._destination_gresource = os.path.join(destination, gresource_file) + self._active_source_gresource = self._destination_gresource + + self._backuper = GresourceBackuperManager(self._destination_gresource, + logger_factory=self.logger_factory) + + def has_trigger(self, trigger: str) -> bool: + """ + Check if the trigger is present in the gresource file. + Used to detect if the theme is already installed. + :param trigger: The trigger to check for. + :return: True if the trigger is found, False otherwise. + """ + return self._backuper.has_trigger(trigger) + + def use_backup_gresource(self): + self._active_source_gresource = self._backuper.get_backup() + return self._active_source_gresource + + def extract(self): + extractor = GresourceExtractor(self._active_source_gresource, self.temp_folder, + logger_factory=self.logger_factory, runner=self.runner) + extractor.extract() + + def compile(self): + compiler = GresourceCompiler(self.temp_folder, self._temp_gresource, + logger_factory=self.logger_factory, runner=self.runner) + compiler.compile() + + def backup(self): + self._backuper.backup() + + def restore(self): + self._backuper.restore() + self._active_source_gresource = self._destination_gresource + + def move(self): + mover = GresourceMover(self._temp_gresource, self._destination_gresource, + logger_factory=self.logger_factory) + mover.move() diff --git a/scripts/utils/gresource/gresource_backuper.py b/scripts/utils/gresource/gresource_backuper.py new file mode 100644 index 0000000..04245ec --- /dev/null +++ b/scripts/utils/gresource/gresource_backuper.py @@ -0,0 +1,59 @@ +import os +import shutil + +from scripts.utils.gresource import GresourceBackupNotFoundError +from scripts.utils.logger.logger import LoggerFactory + + +class GresourceBackuperManager: + def __init__(self, destination_file: str, logger_factory: LoggerFactory): + self.destination_file = destination_file + self._backup_file = f"{destination_file}.backup" + self._backuper = GresourceBackuper(destination_file, self._backup_file, logger_factory) + + def has_trigger(self, trigger: str) -> bool: + return self._backuper.has_trigger(trigger) + + def backup(self): + self._backuper.backup() + + def restore(self): + self._backuper.restore() + + def get_backup(self) -> str: + return self._backuper.get_backup() + + +class GresourceBackuper: + def __init__(self, destination_file: str, backup_file: str, logger_factory: LoggerFactory): + self.destination_file = destination_file + self.backup_file = backup_file + self.logger_factory = logger_factory + + def has_trigger(self, trigger: str) -> bool: + with open(self.destination_file, "rb") as f: + return trigger.encode() in f.read() + + def get_backup(self) -> str: + if not os.path.exists(self.backup_file): + raise GresourceBackupNotFoundError(self.backup_file) + return self.backup_file + + def backup(self): + backup_line = self.logger_factory.create_logger() + backup_line.update("Backing up gresource files...") + + if os.path.exists(self.backup_file): + os.remove(self.backup_file) + + shutil.copy2(self.destination_file, self.backup_file) + + backup_line.success("Backed up gresource files.") + + def restore(self): + if not os.path.exists(self.backup_file): + raise GresourceBackupNotFoundError(self.backup_file) + + shutil.move(self.backup_file, self.destination_file) + + self.logger_factory.create_logger().success("Restored gresource files.") diff --git a/scripts/utils/gresource/gresource_compiler.py b/scripts/utils/gresource/gresource_compiler.py new file mode 100644 index 0000000..350afde --- /dev/null +++ b/scripts/utils/gresource/gresource_compiler.py @@ -0,0 +1,68 @@ +import textwrap +from pathlib import Path + +from scripts.utils.command_runner.command_runner import CommandRunner +from scripts.utils.gresource import raise_gresource_error +from scripts.utils.logger.logger import LoggerFactory + + +class GresourceCompiler: + def __init__( + self, source_folder: str, target_file: str, + logger_factory: LoggerFactory, runner: CommandRunner + ): + self.source_folder = source_folder + self.target_file = target_file + self.gresource_xml = target_file + ".xml" + + self.logger_factory = logger_factory + self.runner = runner + + def compile(self): + compile_line = self.logger_factory.create_logger() + compile_line.update("Compiling gnome-shell theme...") + + self._create_gresource_xml() + self._compile_resources() + + compile_line.success("Compiled gnome-shell theme.") + + def _create_gresource_xml(self): + with open(self.gresource_xml, 'w') as gresource_xml: + gresource_xml.write(self._generate_gresource_xml()) + + def _generate_gresource_xml(self): + files_to_include = self._get_files_to_include() + nl = "\n" # fstring doesn't support newline character + return textwrap.dedent(f""" + + + + {nl.join(files_to_include)} + + + """) + + def _get_files_to_include(self): + source_path = Path(self.source_folder) + return [ + f"{file.relative_to(source_path)}" + for file in source_path.glob('**/*') + if file.is_file() + ] + + def _compile_resources(self): + try: + self._try_compile_resources() + except FileNotFoundError as e: + if "glib-compile-resources" in str(e): + raise_gresource_error("glib-compile-resources", e) + raise + + def _try_compile_resources(self): + self.runner.run(["glib-compile-resources", + "--sourcedir", self.source_folder, + "--target", self.target_file, + self.gresource_xml + ], + cwd=self.source_folder, check=True) diff --git a/scripts/utils/gresource/gresource_extractor.py b/scripts/utils/gresource/gresource_extractor.py new file mode 100644 index 0000000..136c109 --- /dev/null +++ b/scripts/utils/gresource/gresource_extractor.py @@ -0,0 +1,56 @@ +import os + +from scripts.utils.command_runner.command_runner import CommandRunner +from scripts.utils.gresource import raise_gresource_error +from scripts.utils.logger.logger import LoggerFactory + + +class GresourceExtractor: + def __init__( + self, gresource_path: str, extract_folder: str, + logger_factory: LoggerFactory, runner: CommandRunner + ): + self.gresource_path = gresource_path + self.extract_folder = extract_folder + self.logger_factory = logger_factory + self.runner = runner + + def extract(self): + extract_line = self.logger_factory.create_logger() + extract_line.update("Extracting gresource files...") + + self._try_extract_resources() + + extract_line.success("Extracted gresource files.") + + def _try_extract_resources(self): + try: + resources = self._get_resources_list() + self._extract_resources(resources) + except FileNotFoundError as e: + print(e) + if "gresource" in str(e): + raise_gresource_error("gresource", e) + raise + except Exception as e: + raise Exception(f"gresource could not process the theme file: {self.gresource_path}") from e + + def _get_resources_list(self): + resources_list_response = self.runner.run( + ["gresource", "list", self.gresource_path], + capture_output=True, text=True, check=True + ) + return resources_list_response.stdout.strip().split("\n") + + def _extract_resources(self, resources: list[str]): + prefix = "/org/gnome/shell/theme/" + for resource in resources: + resource_path = resource.replace(prefix, "") + output_path = os.path.join(self.extract_folder, resource_path) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + with open(output_path, 'wb') as f: + self.runner.run( + ["gresource", "extract", self.gresource_path, resource], + stdout=f, check=True + ) diff --git a/scripts/utils/gresource/gresource_mover.py b/scripts/utils/gresource/gresource_mover.py new file mode 100644 index 0000000..314c057 --- /dev/null +++ b/scripts/utils/gresource/gresource_mover.py @@ -0,0 +1,21 @@ +import os +import shutil + +from scripts.utils.logger.logger import LoggerFactory + + +class GresourceMover: + def __init__(self, source_file: str, destination_file: str, logger_factory: LoggerFactory): + self.source_file = source_file + self.destination_file = destination_file + self.logger_factory = logger_factory + + def move(self): + move_line = self.logger_factory.create_logger() + move_line.update("Moving gresource files...") + + os.makedirs(os.path.dirname(self.destination_file), exist_ok=True) + shutil.copyfile(self.source_file, self.destination_file) + os.chmod(self.destination_file, 0o644) + + move_line.success("Moved gresource files.") diff --git a/scripts/utils/hex_to_rgba.py b/scripts/utils/hex_to_rgba.py deleted file mode 100644 index eff3daf..0000000 --- a/scripts/utils/hex_to_rgba.py +++ /dev/null @@ -1,22 +0,0 @@ -def hex_to_rgba(hex_color): - """ - Convert hex(a) to rgba - :param hex_color: input value - """ - - try: - if len(hex_color) in range(6, 10): - hex_color = hex_color.lstrip('#') + "ff" - # if is convertable - int(hex_color[:], 16) - else: - raise ValueError - - except ValueError: - raise ValueError(f'Error: Invalid HEX color code: {hex_color}') - - else: - return int(hex_color[0:2], 16), \ - int(hex_color[2:4], 16), \ - int(hex_color[4:6], 16), \ - int(hex_color[6:8], 16) / 255 \ No newline at end of file diff --git a/scripts/utils/logger/__init__.py b/scripts/utils/logger/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/utils/console.py b/scripts/utils/logger/console.py similarity index 88% rename from scripts/utils/console.py rename to scripts/utils/logger/console.py index 53d2738..b04552f 100644 --- a/scripts/utils/console.py +++ b/scripts/utils/logger/console.py @@ -3,14 +3,24 @@ import threading from enum import Enum from typing import Optional +from scripts.utils.logger.logger import LoggerFactory, Logger -class Console: + +class Console(LoggerFactory): """Manages console output for concurrent processes with line tracking""" _print_lock = threading.Lock() _line_mapping = {} _next_line = 0 - class Line: + def create_logger(self, name: Optional[str]=None) -> 'Console.Line': + """ + Create a logger instance with the given name. + :param name: Name of the logger. + :return: Logger instance. + """ + return Console.Line(name) + + class Line(Logger): def __init__(self, name: Optional[str]=None): """Initialize a new managed line""" self.name = name or f"line_{Console._next_line}" diff --git a/scripts/utils/logger/logger.py b/scripts/utils/logger/logger.py new file mode 100644 index 0000000..e3a00e3 --- /dev/null +++ b/scripts/utils/logger/logger.py @@ -0,0 +1,36 @@ +from abc import ABC, abstractmethod +from typing import Optional + + +class LoggerFactory(ABC): + @staticmethod + @abstractmethod + def create_logger(name: Optional[str] = None) -> 'Logger': + """ + Create a logger instance with the given name. + :param name: Name of the logger. + :return: Logger instance. + """ + pass + + +class Logger(ABC): + @abstractmethod + def update(self, message: str): + pass + + @abstractmethod + def success(self, message): + pass + + @abstractmethod + def error(self, message): + pass + + @abstractmethod + def warn(self, message): + pass + + @abstractmethod + def info(self, message): + pass \ No newline at end of file diff --git a/scripts/utils/remove_files.py b/scripts/utils/remove_files.py index ffb9173..7a1d9af 100644 --- a/scripts/utils/remove_files.py +++ b/scripts/utils/remove_files.py @@ -7,7 +7,7 @@ import shutil from collections import defaultdict from typing import Any -from .console import Console, Color, Format +from scripts.utils.logger.console import Console, Color, Format from .parse_folder import parse_folder from .. import config import os diff --git a/scripts/utils/remove_properties.py b/scripts/utils/remove_properties.py index eed79ce..f20ef5e 100644 --- a/scripts/utils/remove_properties.py +++ b/scripts/utils/remove_properties.py @@ -10,10 +10,10 @@ def remove_properties(file, *args): with open(file, "r") as read_file: content = read_file.read() - for line in content.splitlines(): + for i, line in enumerate(content.splitlines()): if not any(prop in line for prop in args): new_content += line + "\n" - elif "}" in line: + elif "}" in line and not "{" in line: new_content += "}\n" with open(file, "w") as write_file: diff --git a/scripts/utils/replace_keywords.py b/scripts/utils/replace_keywords.py index b90445e..f280602 100644 --- a/scripts/utils/replace_keywords.py +++ b/scripts/utils/replace_keywords.py @@ -1,4 +1,4 @@ -def replace_keywords(file, *args): +def replace_keywords(file, *args: tuple[str, str]): """ Replace file with several keywords :param file: file name where keywords must be replaced diff --git a/scripts/utils/style_manager.py b/scripts/utils/style_manager.py new file mode 100644 index 0000000..13e978c --- /dev/null +++ b/scripts/utils/style_manager.py @@ -0,0 +1,40 @@ +import os + +from scripts.utils import generate_file + + +class StyleManager: + """Manages the style files for the theme.""" + + def __init__(self, output_file: str): + """ + :param output_file: The path to the output file where styles will be combined. + """ + self.output_file = output_file + + def append_content(self, content: str): + """ + Append content to the output file. + :raises FileNotFoundError: if the file does not exist. + """ + if not os.path.exists(self.output_file): + raise FileNotFoundError(f"The file {self.output_file} does not exist.") + with open(self.output_file, 'a') as output: + output.write('\n' + content) + + def prepend_content(self, content: str): + """ + Prepend content to the output file. + :raises FileNotFoundError: if the file does not exist. + """ + with open(self.output_file, 'r') as output: + main_content = output.read() + with open(self.output_file, 'w') as output: + output.write(content + '\n' + main_content) + + def generate_combined_styles(self, sources_location: str, temp_folder: str): + """ + Generate the combined styles file + by merging all styles from the source location. + """ + generate_file(sources_location, temp_folder, self.output_file) \ No newline at end of file diff --git a/scripts/utils/theme/__init__.py b/scripts/utils/theme/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/utils/theme/color_replacement_generator.py b/scripts/utils/theme/color_replacement_generator.py new file mode 100644 index 0000000..904467b --- /dev/null +++ b/scripts/utils/theme/color_replacement_generator.py @@ -0,0 +1,44 @@ +import copy + +from scripts.install.colors_definer import ColorsDefiner +from scripts.types.installation_color import InstallationMode, InstallationColor +from scripts.utils.color_converter.color_converter import ColorConverter + + +class ColorReplacementGenerator: + def __init__(self, colors_provider: ColorsDefiner, color_converter: ColorConverter): + self.colors = copy.deepcopy(colors_provider) + self.color_converter = color_converter + + def convert(self, mode: InstallationMode, theme_color: InstallationColor) -> list[tuple[str, str]]: + """Generate a list of color replacements for the given theme color and mode""" + return [ + (element, self._create_rgba_value(element, mode, theme_color)) + for element in self.colors.replacers + ] + + def _create_rgba_value(self, element: str, mode: str, theme_color: InstallationColor) -> str: + """Create RGBA value for the specified element""" + color_def = self._get_color_definition(element, mode) + + lightness = int(color_def["l"]) / 100 + saturation = int(color_def["s"]) / 100 + if theme_color.saturation is not None: + saturation *= theme_color.saturation / 100 + alpha = color_def["a"] + + red, green, blue = self.color_converter.hsl_to_rgb( + theme_color.hue, saturation, lightness + ) + + return f"rgba({red}, {green}, {blue}, {alpha})" + + def _get_color_definition(self, element: str, mode: str) -> dict: + """Get color definition for element, handling defaults if needed""" + replacer = self.colors.replacers[element] + + if mode not in replacer and replacer["default"]: + default_element = replacer["default"] + return self.colors.replacers[default_element][mode] + + return replacer[mode] diff --git a/scripts/utils/theme/gnome_shell_theme_builder.py b/scripts/utils/theme/gnome_shell_theme_builder.py new file mode 100644 index 0000000..25bfbac --- /dev/null +++ b/scripts/utils/theme/gnome_shell_theme_builder.py @@ -0,0 +1,128 @@ +import os.path + +from scripts import config +from scripts.install.colors_definer import ColorsDefiner +from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl +from scripts.utils.logger.console import Console +from scripts.utils.logger.logger import LoggerFactory +from scripts.utils.style_manager import StyleManager +from scripts.utils.theme.theme import Theme +from scripts.utils.theme.theme_color_applier import ThemeColorApplier +from scripts.utils.theme.color_replacement_generator import ColorReplacementGenerator +from scripts.utils.theme.theme_installer import ThemeInstaller +from scripts.utils.theme.theme_path_provider import ThemePathProvider +from scripts.utils.theme.theme_preparation import ThemePreparation +from scripts.utils.theme.theme_temp_manager import ThemeTempManager +from theme import SourceFolder + + +class GnomeShellThemeBuilder: + """ + Builder for creating a Gnome Shell theme. + + Example: + builder = GnomeShellThemeBuilder(colors_provider) + theme = builder.with_mode("dark").filled().build() + theme.prepare() + theme.install(hue=200, name="MyTheme") + """ + + def __init__(self, colors_provider: ColorsDefiner): + self.theme_name = "gnome-shell" + self.colors_provider = colors_provider + self.source_folder = SourceFolder().gnome_shell + self._base_temp_folder = config.temp_folder + self.destination_folder = config.themes_folder + self.mode = None + self.is_filled = False + + self.temp_folder = os.path.join(self._base_temp_folder, self.theme_name) + self.main_styles = os.path.join(self.temp_folder, f"{self.theme_name}.css") + + self.logger_factory: LoggerFactory | None = None + self.preparation: ThemePreparation | None = None + self.installer: ThemeInstaller | None = None + + def __update_paths(self): + """Update derived paths when base folder or theme name changes""" + self.temp_folder = os.path.join(self._base_temp_folder, self.theme_name) + self.main_styles = os.path.join(self.temp_folder, f"{self.theme_name}.css") + + def with_temp_folder(self, temp_folder: str): + """Set the base temporary folder""" + self._base_temp_folder = temp_folder + self.__update_paths() + return self + + def with_theme_name(self, theme_name: str): + """Set the theme name""" + self.theme_name = theme_name + self.__update_paths() + return self + + def with_mode(self, mode): + self.mode = mode + return self + + def filled(self, filled = True): + self.is_filled = filled + return self + + def with_logger_factory(self, logger_factory: LoggerFactory | None): + """Inject a logger factory for logging purposes.""" + self.logger_factory = logger_factory + return self + + def with_preparation(self, preparation: ThemePreparation | None): + """Inject a preparation instance for preparing the theme.""" + self.preparation = preparation + return self + + def with_installer(self, installer: ThemeInstaller | None): + """Inject an installer for installing the theme.""" + self.installer = installer + return self + + + def with_reset_dependencies(self): + """Reset the dependencies for the theme preparation and installation.""" + self.preparation = None + self.installer = None + return self + + def build(self) -> "Theme": + """ + Constructs and returns a Theme instance using the configured properties. + + This method resolves all necessary dependencies for the theme's preparation + and installation. The returned Theme will have the mode and filled options set + according to the builder's configuration. + + :return: Theme instance ready for preparation and installation + """ + self._resolve_preparation() + self._resolve_installer() + return Theme(self.preparation, self.installer, self.mode, self.is_filled) + + def _resolve_preparation(self): + if self.preparation: return + + file_manager = ThemeTempManager(self.temp_folder) + style_manager = StyleManager(self.main_styles) + self.preparation = ThemePreparation(self.source_folder, + file_manager=file_manager, style_manager=style_manager) + + def _resolve_installer(self): + if self.installer: return + + color_converter = ColorConverterImpl() + color_replacement_generator = ColorReplacementGenerator( + colors_provider=self.colors_provider, + color_converter=color_converter) + color_applier = ThemeColorApplier(color_replacement_generator=color_replacement_generator) + logger_factory = self.logger_factory or Console() + path_provider = ThemePathProvider() + self.installer = ThemeInstaller(self.theme_name, self.temp_folder, self.destination_folder, + logger_factory=logger_factory, + color_applier=color_applier, + path_provider=path_provider) \ No newline at end of file diff --git a/scripts/utils/theme/theme.py b/scripts/utils/theme/theme.py new file mode 100644 index 0000000..8cac48a --- /dev/null +++ b/scripts/utils/theme/theme.py @@ -0,0 +1,93 @@ +from scripts.types.installation_color import InstallationColor +from scripts.types.theme_base import ThemeBase +from scripts.utils.theme.theme_installer import ThemeInstaller +from scripts.utils.theme.theme_preparation import ThemePreparation + + +class Theme(ThemeBase): + """ + Manages theme preparation and installation. + + The Theme class orchestrates the process of preparing a theme by combining files, + applying color schemes, and installing the final theme into a destination folder. + """ + + def __init__(self, preparation: ThemePreparation, installer: ThemeInstaller, mode=None, is_filled=False): + """ + :param preparation: Object responsible for theme extraction and preparation. + :param installer: Object responsible for installing the theme. + :param mode: Theme mode (e.g., 'light' or 'dark'). If not provided, both modes are used. + :param is_filled: if True, theme will be filled + """ + self.modes = [mode] if mode else ['light', 'dark'] + self.is_filled = is_filled + + self._preparation = preparation + self._installer = installer + + @property + def temp_folder(self): + """The temporary folder path where the theme is prepared.""" + return self._preparation.temp_folder + + @property + def destination_folder(self): + """The destination folder path where the theme will be installed.""" + return self._installer.destination_folder + + @property + def main_styles(self): + """The path to the combined styles file generated during preparation.""" + return self._preparation.combined_styles_location + + @property + def theme_name(self): + return self._installer.theme_type + + def __add__(self, other: str) -> "Theme": + """ + Appends additional styles to the main styles file. + :param other: The additional styles to append. + """ + self._preparation += other + return self + + def __mul__(self, other: str) -> "Theme": + """ + Adds a file to the theme, copying it to the temporary folder. + :param other: The path of the file or folder to add. + """ + self._preparation *= other + return self + + def add_to_start(self, content) -> "Theme": + """ + Inserts content at the beginning of the main styles file. + :param content: The content to insert. + """ + self._preparation.add_to_start(content) + return self + + def prepare(self): + """Extract theme from source folder and prepare it for installation.""" + self._preparation.prepare() + if self.is_filled: + self._preparation.replace_filled_keywords() + + def install(self, hue, name: str, sat: float | None = None, destination: str | None = None): + """ + Installs the theme by applying the specified accent color and copying the finalized files + to the designated destination. + + Args: + hue: The hue value for the accent color (0-360 degrees). + name: The name of the theme. + sat: The saturation value for the accent color. + destination: The custom folder where the theme will be installed. + """ + theme_color = InstallationColor( + hue=hue, + saturation=sat, + modes=self.modes + ) + self._installer.install(theme_color, name, destination) \ No newline at end of file diff --git a/scripts/utils/theme/theme_color_applier.py b/scripts/utils/theme/theme_color_applier.py new file mode 100644 index 0000000..fc88aef --- /dev/null +++ b/scripts/utils/theme/theme_color_applier.py @@ -0,0 +1,20 @@ +import os + +from scripts.types.installation_color import InstallationColor, InstallationMode +from scripts.utils import replace_keywords +from scripts.utils.theme.color_replacement_generator import ColorReplacementGenerator + + +class ThemeColorApplier: + """Class to apply theme colors to files in a directory.""" + + def __init__(self, color_replacement_generator: ColorReplacementGenerator): + self.color_replacement_generator = color_replacement_generator + + def apply(self, theme_color: InstallationColor, destination: str, mode: InstallationMode): + """Apply theme colors to all files in the directory""" + replacements = self.color_replacement_generator.convert(mode, theme_color) + + for filename in os.listdir(destination): + file_path = os.path.join(destination, filename) + replace_keywords(file_path, *replacements) diff --git a/scripts/utils/theme/theme_installer.py b/scripts/utils/theme/theme_installer.py new file mode 100644 index 0000000..b9873eb --- /dev/null +++ b/scripts/utils/theme/theme_installer.py @@ -0,0 +1,75 @@ +from scripts.types.installation_color import InstallationColor, InstallationMode +from scripts.utils import copy_files +from scripts.utils.logger.console import Console, Color, Format +from scripts.utils.logger.logger import LoggerFactory +from scripts.utils.theme.theme_color_applier import ThemeColorApplier +from scripts.utils.theme.theme_path_provider import ThemePathProvider + + +class ThemeInstaller: + """ + Handles the installation of themes by copying files and applying color schemes. + """ + + def __init__(self, theme_type: str, source_folder: str, destination_folder: str, + logger_factory: LoggerFactory, color_applier: ThemeColorApplier, path_provider: ThemePathProvider): + """ + :param theme_type: type of the theme (e.g., gnome-shell, gtk) + :param source_folder: folder containing the theme files (e.g. temp folder) + :param destination_folder: folder where the theme will be installed + """ + self.theme_type = theme_type + self.source_folder = source_folder + self.destination_folder = destination_folder + + self.logger_factory = logger_factory + self.color_applier = color_applier + self.path_provider = path_provider + + def install(self, theme_color: InstallationColor, name: str, custom_destination: str = None): + """ + Install theme and generate theme with specified accent color + :param theme_color: object containing color and modes + :param name: theme name + :param custom_destination: optional custom destination folder + """ + logger = InstallationLogger(name, theme_color.modes, self.logger_factory) + + try: + self._perform_installation(theme_color, name, custom_destination=custom_destination) + logger.success() + except Exception as err: + logger.error(str(err)) + raise + + def _perform_installation(self, theme_color, name, custom_destination=None): + for mode in theme_color.modes: + destination = (custom_destination or + self.path_provider.get_theme_path( + self.destination_folder, name, mode, self.theme_type)) + + copy_files(self.source_folder, destination) + self.color_applier.apply(theme_color, destination, mode) + + +class InstallationLogger: + def __init__(self, name: str, modes: list[InstallationMode], logger_factory: LoggerFactory): + self.name = name + self.modes = modes + + self.logger = logger_factory.create_logger(self.name) + self._setup_logger() + + def _setup_logger(self): + self.formatted_name = Console.format(self.name.capitalize(), + color=Color.get(self.name), + format_type=Format.BOLD) + joint_modes = f"({', '.join(self.modes)})" + self.formatted_modes = Console.format(joint_modes, color=Color.GRAY) + self.logger.update(f"Creating {self.formatted_name} {self.formatted_modes} theme...") + + def success(self): + self.logger.success(f"{self.formatted_name} {self.formatted_modes} theme created successfully.") + + def error(self, error_message: str): + self.logger.error(f"Error installing {self.formatted_name} theme: {error_message}") \ No newline at end of file diff --git a/scripts/utils/theme/theme_path_provider.py b/scripts/utils/theme/theme_path_provider.py new file mode 100644 index 0000000..bfa1c3e --- /dev/null +++ b/scripts/utils/theme/theme_path_provider.py @@ -0,0 +1,20 @@ +import os + + +class ThemePathProvider: + @staticmethod + def get_theme_path(themes_folder: str, color_name: str, theme_mode: str, theme_type: str) -> str: + """ + Generates the path for the theme based on the provided parameters. + :param themes_folder: The base folder where themes are stored. + :param color_name: The name of the color scheme. + :param theme_mode: The mode of the theme (e.g., 'light' or 'dark'). + :param theme_type: The type of the theme (e.g., 'gnome-shell', 'gtk'). + """ + if not themes_folder or not color_name or not theme_mode or not theme_type: + raise ValueError("All parameters must be non-empty strings.") + + marble_name = '-'.join(["Marble", color_name, theme_mode]) + final_path = os.path.join(themes_folder, marble_name, theme_type, "") + + return final_path diff --git a/scripts/utils/theme/theme_preparation.py b/scripts/utils/theme/theme_preparation.py new file mode 100644 index 0000000..c36a12a --- /dev/null +++ b/scripts/utils/theme/theme_preparation.py @@ -0,0 +1,63 @@ +import os + +from scripts.utils import replace_keywords +from scripts.utils.theme.theme_temp_manager import ThemeTempManager +from scripts.utils.style_manager import StyleManager + + +class ThemePreparation: + """ + Class for extracting themes from the source folder + and preparing them for installation. + """ + + def __init__(self, sources_location: str, file_manager: ThemeTempManager, style_manager: StyleManager): + self.sources_location = sources_location + + self.file_manager = file_manager + self.style_manager = style_manager + + @property + def temp_folder(self): + return self.file_manager.temp_folder + + @property + def combined_styles_location(self): + return self.style_manager.output_file + + def __add__(self, content: str) -> "ThemePreparation": + """Append additional styles to the main styles file.""" + self.style_manager.append_content(content) + return self + + def __mul__(self, content: str) -> "ThemePreparation": + """Adds a file to the theme, copying it to the temporary folder.""" + self.file_manager.copy_to_temp(content) + return self + + def add_to_start(self, content) -> "ThemePreparation": + """Inserts content at the beginning of the main styles file.""" + self.style_manager.prepend_content(content) + return self + + def prepare(self): + """ + Extract theme from source folder and prepare it for installation. + """ + self.file_manager.copy_to_temp(self.sources_location) + self.style_manager.generate_combined_styles(self.sources_location, self.temp_folder) + self.file_manager.cleanup() + + def replace_filled_keywords(self): + """ + Replace keywords in the theme files for filled mode. + This method is deprecated and will be removed in future versions. + """ + for apply_file in os.listdir(f"{self.temp_folder}/"): + replace_keywords(f"{self.temp_folder}/{apply_file}", + ("BUTTON-COLOR", "ACCENT-FILLED-COLOR"), + ("BUTTON_HOVER", "ACCENT-FILLED_HOVER"), + ("BUTTON_ACTIVE", "ACCENT-FILLED_ACTIVE"), + ("BUTTON_INSENSITIVE", "ACCENT-FILLED_INSENSITIVE"), + ("BUTTON-TEXT-COLOR", "TEXT-BLACK-COLOR"), + ("BUTTON-TEXT_SECONDARY", "TEXT-BLACK_SECONDARY")) diff --git a/scripts/utils/theme/theme_temp_manager.py b/scripts/utils/theme/theme_temp_manager.py new file mode 100644 index 0000000..2294a57 --- /dev/null +++ b/scripts/utils/theme/theme_temp_manager.py @@ -0,0 +1,29 @@ +import os +import shutil + + +class ThemeTempManager: + """ + Manages operations with temp folder for Theme class + """ + def __init__(self, temp_folder: str): + self.temp_folder = temp_folder + os.makedirs(self.temp_folder, exist_ok=True) + + def copy_to_temp(self, content: str): + """ + Copy a file or directory to the temporary folder. + If the content is a file, it will be copied directly. + If the content is a directory, all its contents will be copied to the temp folder. + """ + if os.path.isfile(content): + final_path = os.path.join(self.temp_folder, os.path.basename(content)) + shutil.copy(content, final_path) + else: + shutil.copytree(content, self.temp_folder, dirs_exist_ok=True) + return self + + def cleanup(self): + """Remove temporary folders""" + shutil.rmtree(f"{self.temp_folder}/.css/", ignore_errors=True) + shutil.rmtree(f"{self.temp_folder}/.versions/", ignore_errors=True) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_helpers/__init__.py b/tests/_helpers/__init__.py new file mode 100644 index 0000000..ea9510b --- /dev/null +++ b/tests/_helpers/__init__.py @@ -0,0 +1,14 @@ +import os + + +def create_dummy_file(file_path: str, content: str = "dummy content"): + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'w') as f: + f.write(content) + + +def try_remove_file(file_path: str): + try: + os.remove(file_path) + except FileNotFoundError: + pass \ No newline at end of file diff --git a/tests/_helpers/dummy_logger_factory.py b/tests/_helpers/dummy_logger_factory.py new file mode 100644 index 0000000..1b2f373 --- /dev/null +++ b/tests/_helpers/dummy_logger_factory.py @@ -0,0 +1,25 @@ +from typing import Optional + +from scripts.utils.logger.logger import LoggerFactory, Logger + + +class DummyLoggerFactory(LoggerFactory): + def create_logger(self, name: Optional[str] = None) -> 'DummyLogger': + return DummyLogger() + + +class DummyLogger(Logger): + def update(self, msg): + pass + + def success(self, msg): + pass + + def error(self, msg): + pass + + def warn(self, msg): + pass + + def info(self, msg): + pass \ No newline at end of file diff --git a/tests/_helpers/dummy_runner.py b/tests/_helpers/dummy_runner.py new file mode 100644 index 0000000..fcff239 --- /dev/null +++ b/tests/_helpers/dummy_runner.py @@ -0,0 +1,6 @@ +from scripts.utils.command_runner.command_runner import CommandRunner + + +class DummyRunner(CommandRunner): + def run(self, command: list[str], **kwargs) -> str: + return "Dummy output" \ No newline at end of file diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/color_converter/__init__.py b/tests/utils/color_converter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/color_converter/test_color_converter_impl.py b/tests/utils/color_converter/test_color_converter_impl.py new file mode 100644 index 0000000..e90a8de --- /dev/null +++ b/tests/utils/color_converter/test_color_converter_impl.py @@ -0,0 +1,64 @@ +import unittest + +from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl + + +class ColorConverterImplTestCase(unittest.TestCase): + def setUp(self): + self.converter = ColorConverterImpl() + + def test_hex_to_rgba_is_valid(self): + hex_color = "#ff5733" + expected_rgba = (255, 87, 51, 1.0) + + result = self.converter.hex_to_rgba(hex_color) + + self.assertEqual(result, expected_rgba) + + def test_hex_to_rgba_is_invalid(self): + hex_color = "#invalid" + + with self.assertRaises(ValueError): + self.converter.hex_to_rgba(hex_color) + + def test_hex_to_rgba_with_alpha_is_valid(self): + hex_color = "#ff5733ff" + expected_rgba = (255, 87, 51, 1.0) + + result = self.converter.hex_to_rgba(hex_color) + + self.assertEqual(result, expected_rgba) + + def test_hex_to_rgba_with_shorthand_is_valid(self): + hex_color = "#fff" + expected_rgba = (255, 255, 255, 1.0) + + result = self.converter.hex_to_rgba(hex_color) + + self.assertEqual(result, expected_rgba) + + def test_hsl_to_rgb_is_valid(self): + hue = 360 + saturation = 1 + lightness = 0.5 + expected_rgb = (255, 0, 0) + + result = self.converter.hsl_to_rgb(hue, saturation, lightness) + + self.assertEqual(result, expected_rgb) + + def test_hsl_to_rgb_with_overflow_hue_is_invalid(self): + hue = 400 + saturation = 1 + lightness = 0.5 + + with self.assertRaises(ValueError): + self.converter.hsl_to_rgb(hue, saturation, lightness) + + def test_hsl_to_rgb_with_invalid_saturation_and_lightness_is_invalid(self): + hue = 360 + saturation = 1.5 + lightness = -2 + + with self.assertRaises(ValueError): + self.converter.hsl_to_rgb(hue, saturation, lightness) \ No newline at end of file diff --git a/tests/utils/global_theme/__init__.py b/tests/utils/global_theme/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/global_theme/test_gdm.py b/tests/utils/global_theme/test_gdm.py new file mode 100644 index 0000000..4906086 --- /dev/null +++ b/tests/utils/global_theme/test_gdm.py @@ -0,0 +1,81 @@ +from unittest import TestCase +from unittest.mock import Mock + +from scripts.utils.global_theme.gdm import GDMTheme +from scripts.utils.global_theme.gdm_installer import GDMThemeInstaller +from scripts.utils.global_theme.gdm_preparer import GDMThemePreparer +from scripts.utils.global_theme.gdm_remover import GDMThemeRemover + + +class GDMTestCase(TestCase): + def setUp(self): + self.preparer = Mock(spec=GDMThemePreparer) + self.installer = Mock(spec=GDMThemeInstaller) + self.remover = Mock(spec=GDMThemeRemover) + + self.gdm = GDMTheme(self.preparer, self.installer, self.remover) + + def test_prepare_uses_backup_if_installed(self): + self.installer.is_installed.return_value = True + + self.gdm.prepare() + + self.preparer.use_backup_as_source.assert_called_once() + + def test_prepare_does_not_use_backup_if_not_installed(self): + self.installer.is_installed.return_value = False + + self.gdm.prepare() + + self.preparer.use_backup_as_source.assert_not_called() + + def test_prepare_calls_preparer_prepare_and_sets_themes(self): + mock_theme = Mock() + self.preparer.prepare.return_value = [mock_theme] + + self.gdm.prepare() + + self.preparer.prepare.assert_called_once() + self.assertEqual(self.gdm.themes, [mock_theme]) + + def test_install_correctly_passes_arguments_to_installer_compile(self): + hue = 100 + name = "test_theme" + sat = 0.5 + + self.gdm.install(hue, name, sat) + + self.installer.compile.assert_called_once_with(self.gdm.themes, hue, name, sat) + + def test_install_calls_installer_backup_if_not_installed(self): + self.installer.is_installed.return_value = False + + self.gdm.install(100, "test_theme") + + self.installer.backup.assert_called_once() + + def test_install_does_not_call_installer_backup_if_installed(self): + self.installer.is_installed.return_value = True + + self.gdm.install(100, "test_theme") + + self.installer.backup.assert_not_called() + + def test_install_calls_installer_install(self): + self.gdm.install(100, "test_theme") + + self.installer.install.assert_called_once() + + def test_remove_calls_installer_remove_if_installed(self): + self.installer.is_installed.return_value = True + + self.gdm.remove() + + self.remover.remove.assert_called_once() + + def test_remove_calls_installer_warn_if_not_installed(self): + self.installer.is_installed.return_value = False + + self.gdm.remove() + + self.remover.remove.assert_not_called() \ No newline at end of file diff --git a/tests/utils/global_theme/test_gdm_builder.py b/tests/utils/global_theme/test_gdm_builder.py new file mode 100644 index 0000000..e01012a --- /dev/null +++ b/tests/utils/global_theme/test_gdm_builder.py @@ -0,0 +1,155 @@ +from unittest import TestCase +from unittest.mock import Mock + +from scripts.types.installation_color import InstallationMode +from scripts.utils.global_theme.gdm_builder import GDMThemeBuilder + + +class GDMBuilderTestCase(TestCase): + def setUp(self): + self.colors_provider = Mock() + self.builder = GDMThemeBuilder(colors_provider=self.colors_provider) + + def test_with_mode_sets_correct_mode(self): + self.builder._mode = None + mode: InstallationMode = "dark" + + builder = self.builder.with_mode(mode) + + self.assertEqual(builder._mode, mode) + + def test_with_filled_sets_correct_filled_state(self): + self.builder._is_filled = False + is_filled = True + + builder = self.builder.with_filled(is_filled) + + self.assertEqual(builder._is_filled, is_filled) + + def test_with_logger_factory_sets_specified_logger_factory(self): + logger_factory = Mock() + builder = self.builder.with_logger_factory(logger_factory) + self.assertEqual(builder._logger_factory, logger_factory) + + def test_with_gresource_sets_specified_gresource(self): + gresource = Mock() + builder = self.builder.with_gresource(gresource) + self.assertEqual(builder._gresource, gresource) + + def test_with_ubuntu_gdm_alternatives_updater_sets_specified_updater(self): + alternatives_updater = Mock() + builder = self.builder.with_ubuntu_gdm_alternatives_updater(alternatives_updater) + self.assertEqual(builder._ubuntu_gdm_alternatives_updater, alternatives_updater) + + def test_with_preparer_sets_specified_preparer(self): + preparer = Mock() + builder = self.builder.with_preparer(preparer) + self.assertEqual(builder._preparer, preparer) + + def test_with_installer_sets_specified_installer(self): + installer = Mock() + builder = self.builder.with_installer(installer) + self.assertEqual(builder._installer, installer) + + def test_with_remover_sets_specified_remover(self): + remover = Mock() + builder = self.builder.with_remover(remover) + self.assertEqual(builder._remover, remover) + + def test_resolve_logger_factory_initializes_logger_factory(self): + self.builder._logger_factory = None + + self.builder._resolve_logger_factory() + + self.assertIsNotNone(self.builder._logger_factory) + + def test_resolve_gresource_initializes_gresource(self): + self.builder._logger_factory = Mock() + self.builder._gresource = None + + self.builder._resolve_gresource() + + self.assertIsNotNone(self.builder._gresource) + + def test_builder_supports_chaining(self): + theme = self.builder.with_mode("dark").with_filled(True).build() + + self.assertIsNotNone(theme) + + def test_resolve_ubuntu_gdm_alternatives_updater_initializes_gresource(self): + self.builder._logger_factory = Mock() + self.builder._gresource = Mock() + self.builder._ubuntu_gdm_alternatives_updater = None + self.builder._resolve_ubuntu_gdm_alternatives_updater() + self.assertIsNotNone(self.builder._ubuntu_gdm_alternatives_updater) + + def test_resolve_preparer_initializes_preparer(self): + self.builder._logger_factory = Mock() + self.builder._gresource = Mock() + self.builder._ubuntu_gdm_alternatives_updater = Mock() + self.builder._preparer = None + + self.builder._resolve_preparer() + + self.assertIsNotNone(self.builder._preparer) + + def test_resolve_installer_initializes_installer(self): + self.builder._gresource = Mock() + self.builder._ubuntu_gdm_alternatives_updater = Mock() + self.builder._installer = None + + self.builder._resolve_installer() + + self.assertIsNotNone(self.builder._installer) + + def test_resolve_remover_initializes_remover(self): + self.builder._gresource = Mock() + self.builder._ubuntu_gdm_alternatives_updater = Mock() + self.builder._remover = None + + self.builder._resolve_remover() + + self.assertIsNotNone(self.builder._remover) + + def test_build_resolves_dependencies(self): + self.builder._resolve_logger_factory = Mock() + self.builder._resolve_gresource = Mock() + self.builder._resolve_ubuntu_gdm_alternatives_updater = Mock() + self.builder._resolve_preparer = Mock() + self.builder._resolve_installer = Mock() + self.builder._resolve_remover = Mock() + + self.builder.build() + + self.builder._resolve_logger_factory.assert_called_once() + self.builder._resolve_gresource.assert_called_once() + self.builder._resolve_ubuntu_gdm_alternatives_updater.assert_called_once() + self.builder._resolve_preparer.assert_called_once() + self.builder._resolve_installer.assert_called_once() + self.builder._resolve_remover.assert_called_once() + + def test_build_correctly_builds_gdm_theme(self): + self.builder._preparer = Mock() + self.builder._installer = Mock() + self.builder._remover = Mock() + + result = self.builder.build() + + self.assertEqual(result.preparer, self.builder._preparer) + self.assertEqual(result.installer, self.builder._installer) + self.assertEqual(result.remover, self.builder._remover) + + def test_build_with_explicit_dependencies_works_correctly(self): + preparer = Mock() + installer = Mock() + remover = Mock() + builder = (self.builder + .with_preparer(preparer) + .with_installer(installer) + .with_remover(remover)) + + result = builder.build() + + self.assertEqual(result.preparer, preparer) + self.assertEqual(result.installer, installer) + self.assertEqual(result.remover, remover) \ No newline at end of file diff --git a/tests/utils/global_theme/test_gdm_installer.py b/tests/utils/global_theme/test_gdm_installer.py new file mode 100644 index 0000000..79bc558 --- /dev/null +++ b/tests/utils/global_theme/test_gdm_installer.py @@ -0,0 +1,87 @@ +import os.path +from unittest import TestCase +from unittest.mock import MagicMock + +from scripts import config +from scripts.utils.global_theme.gdm_installer import GDMThemeInstaller + + +class GDMInstallerTestCase(TestCase): + def setUp(self): + self.temp_folder = os.path.join(config.temp_tests_folder, "gdm_installer") + self.gresource = MagicMock() + self.gresource.temp_folder = self.temp_folder + + self.alternatives_updater = MagicMock() + + self.gdm_installer = GDMThemeInstaller( + gresource=self.gresource, + alternatives_updater=self.alternatives_updater + ) + + def test_is_installed_return_the_same_value_as_gresource(self): + self.gresource.has_trigger.return_value = True + + result = self.gdm_installer.is_installed() + + self.assertTrue(result) + self.gresource.has_trigger.assert_called_once() + + def test_compile_does_not_call_label_theme_if_label_is_none(self): + theme_prepare = MagicMock() + theme_prepare.label = None + theme_prepare.label_theme = MagicMock() + + self.gdm_installer.compile(themes=[theme_prepare], hue=0, color="red", sat=None) + + theme_prepare.label_theme.assert_not_called() + + def test_compile_calls_label_theme_if_label_is_set(self): + theme_prepare = MagicMock() + theme_prepare.label = "dark" + theme_prepare.label_theme = MagicMock() + + self.gdm_installer.compile(themes=[theme_prepare], hue=0, color="red", sat=None) + + theme_prepare.label_theme.assert_called_once() + + def test_compile_calls_removes_keywords_and_properties_and_prepends_source_styles(self): + theme_prepare = MagicMock() + theme_prepare.remove_keywords = MagicMock() + theme_prepare.remove_properties = MagicMock() + theme_prepare.prepend_source_styles = MagicMock() + + self.gdm_installer.compile(themes=[theme_prepare], hue=0, color="red", sat=None) + + theme_prepare.remove_keywords.assert_called_once() + theme_prepare.remove_properties.assert_called_once() + theme_prepare.prepend_source_styles.assert_called_once() + + def test_compile_installs_themes_with_correct_parameters(self): + theme_prepare = MagicMock() + theme_prepare.install = MagicMock() + themes = [theme_prepare] + hue = 0 + color = "red" + sat = None + + self.gdm_installer.compile(themes, hue, color, sat) + + theme_prepare.install.assert_called_once() + theme_prepare.install.assert_called_with(hue, color, sat, destination=self.temp_folder) + + def test_compile_calls_gresource_compile(self): + self.gdm_installer.compile([], 0, "red", None) + + self.gresource.compile.assert_called_once() + + def test_backup_calls_gresource_backup(self): + self.gdm_installer.backup() + + self.gresource.backup.assert_called_once() + + def test_install_calls_gresource_move_and_alternatives_updater_install_and_set(self): + self.gdm_installer.install() + + self.gresource.move.assert_called_once() + self.alternatives_updater.install_and_set.assert_called_once() \ No newline at end of file diff --git a/tests/utils/global_theme/test_gdm_preparer.py b/tests/utils/global_theme/test_gdm_preparer.py new file mode 100644 index 0000000..99748ce --- /dev/null +++ b/tests/utils/global_theme/test_gdm_preparer.py @@ -0,0 +1,124 @@ +import os +import shutil +import unittest +from unittest.mock import MagicMock, patch + +from scripts import config +from scripts.types.theme_base import ThemeBase +from scripts.utils.global_theme.gdm_preparer import GDMThemePreparer + + +class DummyTheme(ThemeBase): + def __init__(self): + super().__init__() + self.temp_folder = None + self.main_styles = None + + def prepare(self): + pass + + def install(self, hue: int, name: str, sat: float | None = None): + pass + + +class TestGDMThemePreparer(unittest.TestCase): + def setUp(self): + self.temp_folder = os.path.join(config.temp_tests_folder, "gdm_preparer") + + self.gresource = self._mock_gresource(self.temp_folder) + self.theme_builder = self._mock_builder() + + self.mock_logger = MagicMock() + self.logger_factory = MagicMock() + self.logger_factory.create_logger.return_value = self.mock_logger + + self.preparer = GDMThemePreparer( + temp_folder=self.temp_folder, + default_mode="light", + is_filled=True, + gresource=self.gresource, + theme_builder=self.theme_builder, + logger_factory=self.logger_factory, + files_labeler_factory=MagicMock(), + ) + + @staticmethod + def _mock_gresource(temp_folder): + gresource = MagicMock() + gresource.temp_folder = temp_folder + gresource.extract = MagicMock() + gresource.use_backup_gresource = MagicMock() + return gresource + + @staticmethod + def _mock_builder(): + theme_builder = MagicMock() + theme_builder.with_temp_folder.return_value = theme_builder + theme_builder.with_theme_name.return_value = theme_builder + theme_builder.with_mode.return_value = theme_builder + theme_builder.filled.return_value = theme_builder + theme_builder.with_logger_factory.return_value = theme_builder + theme_builder.with_reset_dependencies.return_value = theme_builder + theme_builder.build.return_value = DummyTheme() + return theme_builder + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + + def test_use_backup_as_source(self): + self.preparer.use_backup_as_source() + + self.gresource.use_backup_gresource.assert_called_once() + + @patch("os.listdir") + def test_preparer_extracts_gresource(self, mock_listdir): + mock_listdir.return_value = ["gnome-shell.css"] + + self.preparer.prepare() + + self.gresource.extract.assert_called_once() + + @patch("os.listdir") + def test_preparer_scans_correct_directory(self, mock_listdir): + mock_listdir.return_value = ["gnome-shell.css"] + + self.preparer.prepare() + + mock_listdir.assert_called_once_with(self.gresource.temp_folder) + + @patch("os.listdir") + def test_preparer_filters_valid_css_files(self, mock_listdir): + valid_files = ["gnome-shell-dark.css", "gnome-shell-light.css", "gnome-shell.css"] + invalid_files = ["other.css", "readme.txt"] + mock_listdir.return_value = valid_files + invalid_files + + themes = self.preparer.prepare() + + self.assertEqual(len(themes), len(valid_files)) + + @patch("os.listdir") + def test_preparer_assigns_correct_labels(self, mock_listdir): + test_files = ["gnome-shell-dark.css", "gnome-shell-light.css", "gnome-shell.css"] + mock_listdir.return_value = test_files + + themes = self.preparer.prepare() + + expected_labels = { + "gnome-shell-dark.css": "dark", + "gnome-shell-light.css": "light", + "gnome-shell.css": "light" # Uses default_mode + } + + for theme_obj in themes: + file_name = os.path.basename(theme_obj.theme_file) + self.assertEqual(theme_obj.label, expected_labels[file_name]) + + @patch("os.listdir") + def test_preparer_configures_theme_builder_correctly(self, mock_listdir): + mock_listdir.return_value = ["gnome-shell-dark.css", "gnome-shell.css"] + + self.preparer.prepare() + + self.theme_builder.with_theme_name.assert_any_call("gnome-shell-dark") + self.theme_builder.with_theme_name.assert_any_call("gnome-shell") + diff --git a/tests/utils/global_theme/test_gdm_remover.py b/tests/utils/global_theme/test_gdm_remover.py new file mode 100644 index 0000000..04b2d07 --- /dev/null +++ b/tests/utils/global_theme/test_gdm_remover.py @@ -0,0 +1,44 @@ +from unittest import TestCase +from unittest.mock import MagicMock + +from scripts.utils.global_theme.gdm_remover import GDMThemeRemover +from scripts.utils.gresource import GresourceBackupNotFoundError + + +class GDMRemoverTestCase(TestCase): + def setUp(self): + self.gresource = MagicMock() + self.alternatives_updater = MagicMock() + self.logger = MagicMock() + self.logger_factory = MagicMock(return_value=self.logger) + + self.remover = GDMThemeRemover( + gresource=self.gresource, + alternatives_updater=self.alternatives_updater, + logger_factory=self.logger_factory + ) + + self.remover.remover_logger = MagicMock() + + def test_remove_logs_start_message(self): + self.remover.remove() + + self.remover.remover_logger.start_removing.assert_called_once() + + def test_remove_calls_gresource_restore_and_alternatives_remove(self): + self.remover.remove() + + self.gresource.restore.assert_called_once() + self.alternatives_updater.remove.assert_called_once() + + def test_remove_logs_success_message(self): + self.remover.remove() + + self.remover.remover_logger.success_removing.assert_called_once() + + def test_remove_logs_error_message_when_backup_not_found(self): + self.gresource.restore.side_effect = GresourceBackupNotFoundError() + + self.remover.remove() + + self.remover.remover_logger.error_removing.assert_called_once() \ No newline at end of file diff --git a/tests/utils/global_theme/test_gdm_theme_prepare.py b/tests/utils/global_theme/test_gdm_theme_prepare.py new file mode 100644 index 0000000..cc0fac1 --- /dev/null +++ b/tests/utils/global_theme/test_gdm_theme_prepare.py @@ -0,0 +1,118 @@ +import os.path +import shutil +from unittest import TestCase +from unittest.mock import MagicMock + +from scripts import config +from scripts.utils.global_theme.gdm_theme_prepare import GDMThemePrepare +from ..._helpers import create_dummy_file, try_remove_file + + +class GDMThemePrepareTestCase(TestCase): + def setUp(self): + self.temp_folder = os.path.join(config.temp_tests_folder, "gdm_theme_prepare") + self.main_styles = os.path.join(self.temp_folder, "gnome-shell.css") + self.theme = MagicMock() + self.theme.add_to_start.return_value = None + self.theme.temp_folder = self.temp_folder + self.theme.main_styles = self.main_styles + + self.main_styles_destination = os.path.join(self.temp_folder, "gnome-shell-result.css") + create_dummy_file(self.main_styles_destination, "body { background-color: #000; }") + + self.files_labeler = MagicMock() + + self.theme_prepare = GDMThemePrepare( + theme=self.theme, + theme_file=self.main_styles_destination, + label=None, + files_labeler=self.files_labeler, + ) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + + def test_label_files_calls_labeler(self): + self.theme_prepare.label = "dark" + + self.theme_prepare.label_theme() + + self.files_labeler.append_label.assert_called_once_with("dark") + + def test_label_files_raises_value_error_if_label_none(self): + self.theme_prepare.label = None + + with self.assertRaises(ValueError): + self.theme_prepare.label_theme() + + def test_remove_keywords_removes_destination_keywords(self): + try_remove_file(self.main_styles_destination) + expected_content = "body { background-color: #000; }" + create_dummy_file(self.main_styles_destination, "body {keyword1 background-color: #000 !important; }") + keywords = ["keyword1", " !important"] + + self.theme_prepare.remove_keywords(*keywords) + + with open(self.main_styles_destination, 'r') as file: + content = file.read() + self.assertEqual(content, expected_content) + try_remove_file(self.main_styles_destination) + + def test_remove_properties_removes_destination_properties(self): + try_remove_file(self.main_styles_destination) + expected_content = "body {\n}\n" + create_dummy_file(self.main_styles_destination, "body {\nbackground-color: #000;\n}") + properties = ["background-color"] + + self.theme_prepare.remove_properties(*properties) + + with open(self.main_styles_destination, 'r') as file: + actual_content = file.read() + self.assertEqual(expected_content, actual_content) + try_remove_file(self.main_styles_destination) + + def test_remove_properties_removes_one_line_properties(self): + try_remove_file(self.main_styles_destination) + expected_content = "" + create_dummy_file(self.main_styles_destination, "body { background-color: #000; }") + properties = ["background-color"] + + self.theme_prepare.remove_properties(*properties) + + with open(self.main_styles_destination, 'r') as file: + actual_content = file.read() + self.assertEqual(expected_content, actual_content) + try_remove_file(self.main_styles_destination) + + def test_prepend_source_styles_prepends_destination_styles(self): + try_remove_file(self.main_styles_destination) + expected_content = "body { background-color: #000; }\n" + create_dummy_file(self.main_styles_destination, "body { background-color: #000; }") + + self.theme_prepare.prepend_source_styles("") + + called_content: str = self.theme.add_to_start.call_args[0][0] + self.assertTrue(called_content.startswith(expected_content)) + try_remove_file(self.main_styles_destination) + + def test_prepend_source_styles_adds_trigger(self): + try_remove_file(self.main_styles_destination) + expected_content = "\ntrigger\n" + create_dummy_file(self.main_styles_destination) + trigger = "trigger" + + self.theme_prepare.prepend_source_styles(trigger) + + called_content: str = self.theme.add_to_start.call_args[0][0] + self.assertTrue(expected_content in called_content) + try_remove_file(self.main_styles_destination) + + def test_install_passes_arguments_to_theme(self): + hue = 0 + color = "#000000" + sat = 100 + destination = os.path.join(self.temp_folder, "destination") + + self.theme_prepare.install(hue, color, sat, destination) + + self.theme.install.assert_called_once_with(hue, color, sat, destination=destination) \ No newline at end of file diff --git a/tests/utils/global_theme/test_ubuntu_gdm_update_alternatives.py b/tests/utils/global_theme/test_ubuntu_gdm_update_alternatives.py new file mode 100644 index 0000000..98b5f3e --- /dev/null +++ b/tests/utils/global_theme/test_ubuntu_gdm_update_alternatives.py @@ -0,0 +1,47 @@ +from unittest import TestCase +from unittest.mock import MagicMock + +from scripts.utils.alternatives_updater import AlternativesUpdater +from scripts.utils.global_theme.ubuntu_alternatives_updater import UbuntuGDMAlternativesUpdater + + +class UbuntuGDMUpdateAlternativesTestCase(TestCase): + def setUp(self): + self.updater = MagicMock(spec=AlternativesUpdater) + self.ubuntu_updater = UbuntuGDMAlternativesUpdater( + alternatives_updater=self.updater + ) + + def test_custom_destination_updates_correctly(self): + custom_destination_dir = "/custom/path" + custom_destination_file = "custom_file.gresource" + + self.ubuntu_updater.with_custom_destination( + custom_destination_dir, custom_destination_file + ) + + self.assertEqual( + self.ubuntu_updater.destination_dir, custom_destination_dir + ) + self.assertEqual( + self.ubuntu_updater.destination_file, custom_destination_file + ) + + def test_install_and_set_calls_updater_correctly(self): + priority = 100 + self.ubuntu_updater.install_and_set(priority) + + self.updater.install_and_set.assert_called_once_with( + link=self.ubuntu_updater.ubuntu_gresource_path, + name=self.ubuntu_updater.ubuntu_gresource_link_name, + path=self.ubuntu_updater.gnome_gresource_path, + priority=priority + ) + + def test_remove_calls_updater_correctly(self): + self.ubuntu_updater.remove() + + self.updater.remove.assert_called_once_with( + name=self.ubuntu_updater.ubuntu_gresource_link_name, + path=self.ubuntu_updater.gnome_gresource_path + ) \ No newline at end of file diff --git a/tests/utils/gresource/__init__.py b/tests/utils/gresource/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/gresource/test_gresource.py b/tests/utils/gresource/test_gresource.py new file mode 100644 index 0000000..08ba95b --- /dev/null +++ b/tests/utils/gresource/test_gresource.py @@ -0,0 +1,130 @@ +import os.path +import shutil +import unittest +from unittest.mock import patch + +from scripts import config +from scripts.utils.gresource.gresource import Gresource +from ..._helpers import create_dummy_file, try_remove_file +from ..._helpers.dummy_logger_factory import DummyLoggerFactory +from ..._helpers.dummy_runner import DummyRunner + + +class GresourceTestCase(unittest.TestCase): + def setUp(self): + self.gresource_file = "test.gresource" + self.temp_folder = os.path.join(config.temp_tests_folder, "gresource_temp") + self.destination = os.path.join(config.temp_tests_folder, "gresource_dest") + + self.temp_file = os.path.join(self.temp_folder, self.gresource_file) + self.destination_file = os.path.join(self.destination, self.gresource_file) + + self.logger = DummyLoggerFactory() + self.runner = DummyRunner() + + self.gresource = Gresource( + self.gresource_file, self.temp_folder, self.destination, + logger_factory=self.logger, runner=self.runner + ) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + shutil.rmtree(self.destination, ignore_errors=True) + + def test_use_backup_gresource(self): + destination_file = os.path.join(self.destination, self.gresource_file) + create_dummy_file(destination_file) + self.gresource.backup() + + self.gresource.use_backup_gresource() + + assert self.gresource._active_source_gresource != self.gresource._destination_gresource + assert os.path.exists(self.gresource._active_source_gresource) + + try_remove_file(self.gresource._active_source_gresource) + try_remove_file(destination_file) + + def test_use_backup_gresource_not_found(self): + destination_file = os.path.join(self.destination, self.gresource_file) + try_remove_file(destination_file) + + with self.assertRaises(FileNotFoundError): + self.gresource.use_backup_gresource() + + def test_extract(self): + """Test that extract creates and calls GresourceExtractor correctly.""" + with patch('scripts.utils.gresource.gresource.GresourceExtractor') as mock_extractor_class: + mock_extractor_instance = mock_extractor_class.return_value + + self.gresource.extract() + + mock_extractor_class.assert_called_once_with( + self.gresource._active_source_gresource, + self.temp_folder, + logger_factory=self.logger, + runner=self.runner + ) + mock_extractor_instance.extract.assert_called_once() + + def test_compile(self): + """Test that compile creates and calls GresourceCompiler correctly.""" + with (patch('scripts.utils.gresource.gresource.GresourceCompiler') as mock_compiler_class): + mock_compiler_instance = mock_compiler_class.return_value + + self.gresource.compile() + + mock_compiler_class.assert_called_once_with( + self.temp_folder, + self.gresource._temp_gresource, + logger_factory=self.logger, + runner=self.runner + ) + mock_compiler_instance.compile.assert_called_once() + + def test_backup(self): + create_dummy_file(self.destination_file) + + self.gresource.backup() + backup = self.gresource.use_backup_gresource() + + assert os.path.exists(backup) + self.gresource.restore() + + def test_backup_not_found(self): + try_remove_file(self.destination_file) + + with self.assertRaises(FileNotFoundError): + self.gresource.backup() + + def test_restore(self): + destination_file = os.path.join(self.destination, self.gresource_file) + create_dummy_file(destination_file, content="dummy content") + self.gresource.backup() + create_dummy_file(destination_file, content="new content") + + self.gresource.restore() + + assert os.path.exists(destination_file) + with open(destination_file) as f: + content = f.read() + assert content == "dummy content" + + def test_restore_not_found(self): + destination_file = os.path.join(self.destination, self.gresource_file) + try_remove_file(destination_file) + + with self.assertRaises(FileNotFoundError): + self.gresource.restore() + + def test_move(self): + create_dummy_file(self.temp_file) + + self.gresource.move() + + assert os.path.exists(self.destination_file) + + def test_move_not_found(self): + try_remove_file(self.temp_file) + + with self.assertRaises(FileNotFoundError): + self.gresource.move() \ No newline at end of file diff --git a/tests/utils/gresource/test_gresource_backuper.py b/tests/utils/gresource/test_gresource_backuper.py new file mode 100644 index 0000000..69b0145 --- /dev/null +++ b/tests/utils/gresource/test_gresource_backuper.py @@ -0,0 +1,100 @@ +import os +import shutil +import unittest + +import pytest + +from scripts import config +from scripts.utils.gresource import GresourceBackupNotFoundError +from scripts.utils.gresource.gresource_backuper import GresourceBackuperManager, GresourceBackuper +from ..._helpers import create_dummy_file, try_remove_file +from ..._helpers.dummy_logger_factory import DummyLoggerFactory + + +class GresourceBackuperManagerTestCase(unittest.TestCase): + def setUp(self): + self.gresource_file = "test.gresource" + self.temp_folder = os.path.join(config.temp_tests_folder, "backup_temp") + self.destination = os.path.join(config.temp_tests_folder, "backup_dest") + self.destination_file = os.path.join(self.temp_folder, self.gresource_file) + + self.logger = DummyLoggerFactory() + + self.backuper_manager = GresourceBackuperManager(self.destination_file, + logger_factory=self.logger) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + shutil.rmtree(self.destination, ignore_errors=True) + + def test_get_backup(self): + create_dummy_file(self.destination_file) + + self.backuper_manager.backup() + backup = self.backuper_manager.get_backup() + + assert os.path.exists(backup) + + def test_backup_overwrites_existing_backup(self): + """Test that backup properly overwrites an existing backup file.""" + create_dummy_file(self.destination_file, content="original") + create_dummy_file(self.backuper_manager._backup_file, content="old backup") + + self.backuper_manager.backup() + + with open(self.backuper_manager._backup_file, 'r') as f: + content = f.read() + assert content == "original" + + +class GresourceBackuperTestCase(unittest.TestCase): + def setUp(self): + self.temp_folder = os.path.join(config.temp_tests_folder, "backup_temp") + self.destination_file = os.path.join(self.temp_folder, "test.gresource") + self.backup_file = f"{self.destination_file}.backup" + + self.logger = DummyLoggerFactory() + + self.backuper = GresourceBackuper(self.destination_file, self.backup_file, + logger_factory=self.logger) + + os.makedirs(self.temp_folder, exist_ok=True) + + def test_get_backup(self): + create_dummy_file(self.backup_file) + + backup = self.backuper.get_backup() + + assert os.path.exists(backup) + assert backup == self.backup_file + + def test_use_backup_gresource_not_found(self): + try_remove_file(self.backup_file) + + with pytest.raises(GresourceBackupNotFoundError): + self.backuper.get_backup() + + def test_backup_creates_backup_file(self): + """Test direct backup functionality.""" + create_dummy_file(self.destination_file) + + self.backuper.backup() + + assert os.path.exists(self.backup_file) + + def test_backup_handles_missing_destination(self): + """Test backup behavior when destination file doesn't exist.""" + try_remove_file(self.destination_file) + + with pytest.raises(FileNotFoundError): + self.backuper.backup() + + def test_restore_implementation(self): + """Test direct restore implementation.""" + create_dummy_file(self.backup_file) + try_remove_file(self.destination_file) + + self.backuper.restore() + + assert os.path.exists(self.destination_file) + assert not os.path.exists(self.backup_file) \ No newline at end of file diff --git a/tests/utils/gresource/test_gresource_compiler.py b/tests/utils/gresource/test_gresource_compiler.py new file mode 100644 index 0000000..2973867 --- /dev/null +++ b/tests/utils/gresource/test_gresource_compiler.py @@ -0,0 +1,125 @@ +import os +import shutil +import subprocess +import unittest +from unittest.mock import patch, MagicMock + +import pytest + +from scripts import config +from scripts.utils.gresource import MissingDependencyError +from scripts.utils.gresource.gresource_compiler import GresourceCompiler +from ..._helpers.dummy_logger_factory import DummyLoggerFactory +from ..._helpers.dummy_runner import DummyRunner + + +class GresourceCompilerTestCase(unittest.TestCase): + def setUp(self): + self.temp_folder = os.path.join(config.temp_tests_folder, "gresource_compiler_temp") + self.target_file = os.path.join(self.temp_folder, "test.gresource") + + self.logger = DummyLoggerFactory() + self.runner = DummyRunner() + + self.compiler = GresourceCompiler(self.temp_folder, self.target_file, + logger_factory=self.logger, runner=self.runner) + + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + + def test_compile_calls_correct_methods(self): + """Test that compile calls the right methods in sequence.""" + with ( + patch.object(self.compiler, '_create_gresource_xml') as mock_create_xml, + patch.object(self.compiler, '_compile_resources') as mock_compile + ): + self.compiler.compile() + + # Verify methods were called correctly in order + mock_create_xml.assert_called_once() + mock_compile.assert_called_once() + + def test_create_gresource_xml(self): + """Test that _create_gresource_xml creates the XML file with correct content.""" + with ( + patch("builtins.open", create=True) as mock_open, + patch.object(self.compiler, '_generate_gresource_xml') as mock_generate + ): + mock_generate.return_value = "test content" + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + + self.compiler._create_gresource_xml() + + mock_open.assert_called_once_with(self.compiler.gresource_xml, 'w') + mock_file.write.assert_called_once_with("test content") + + def test_generate_gresource_xml(self): + """Test that _generate_gresource_xml creates correct XML structure.""" + with patch.object(self.compiler, '_get_files_to_include') as mock_get_files: + mock_get_files.return_value = ["file1.css", "subdir/file2.css"] + + result = self.compiler._generate_gresource_xml() + + assert "" in result + assert "" in result + assert "file1.css" in result + assert "subdir/file2.css" in result + + def test_get_files_to_include(self): + """Test that _get_files_to_include finds and formats files correctly.""" + self.__create_dummy_files_in_temp() + + result = self.compiler._get_files_to_include() + + assert len(result) == 2 + assert "file1.css" in result + assert "subdir/file2.css" in result + + def __create_dummy_files_in_temp(self): + os.makedirs(self.temp_folder, exist_ok=True) + test_file1 = os.path.join(self.temp_folder, "file1.css") + test_subdir = os.path.join(self.temp_folder, "subdir") + os.makedirs(test_subdir, exist_ok=True) + test_file2 = os.path.join(test_subdir, "file2.css") + + with open(test_file1, 'w') as f: + f.write("test content") + with open(test_file2, 'w') as f: + f.write("test content") + + def test_compile_resources(self): + """Test that _compile_resources runs the correct subprocess command.""" + with patch.object(self.runner, "run") as mock_run: + self.compiler._compile_resources() + + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert args[0] == "glib-compile-resources" + assert args[2] == self.temp_folder + assert args[4] == self.compiler.target_file + assert args[5] == self.compiler.gresource_xml + + def test_compile_resources_file_not_found(self): + """Test that _compile_resources raises appropriate error when command not found.""" + with ( + patch.object(self.runner, "run", side_effect=FileNotFoundError("glib-compile-resources not found")), + patch("builtins.print") + ): + with pytest.raises(MissingDependencyError): + self.compiler._compile_resources() + + def test_try_compile_resources_called_process_error(self): + """Test handling of subprocess execution failures.""" + process_error = subprocess.CalledProcessError(1, "glib-compile-resources", output="Failed to compile") + with patch.object(self.runner, "run", side_effect=process_error): + with pytest.raises(subprocess.CalledProcessError): + self.compiler._try_compile_resources() + + def test_compile_resources_other_file_not_found_error(self): + """Test that other FileNotFoundError exceptions are propagated.""" + with patch.object(self.runner, "run", side_effect=FileNotFoundError("Some other file not found")): + with pytest.raises(FileNotFoundError): + self.compiler._compile_resources() \ No newline at end of file diff --git a/tests/utils/gresource/test_gresource_extractor.py b/tests/utils/gresource/test_gresource_extractor.py new file mode 100644 index 0000000..88cd613 --- /dev/null +++ b/tests/utils/gresource/test_gresource_extractor.py @@ -0,0 +1,123 @@ +import os +import shutil +import unittest +from unittest.mock import patch, MagicMock + +import pytest + +from scripts import config +from scripts.utils.gresource import MissingDependencyError +from scripts.utils.gresource.gresource_extractor import GresourceExtractor +from ..._helpers.dummy_logger_factory import DummyLoggerFactory +from ..._helpers.dummy_runner import DummyRunner + + +class GresourceExtractorTestCase(unittest.TestCase): + def setUp(self): + self.gresource_file = "test.gresource" + self.temp_folder = os.path.join(config.temp_tests_folder, "gresource_extractor_temp") + self.destination = os.path.join(config.temp_tests_folder, "gresource_extractor_dest") + + self.logger = DummyLoggerFactory() + self.runner = DummyRunner() + + self.extractor = GresourceExtractor( + self.gresource_file, self.temp_folder, + logger_factory=self.logger, runner=self.runner + ) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + shutil.rmtree(self.destination, ignore_errors=True) + + def test_extract_calls_correct_methods(self): + with ( + patch.object(self.extractor, '_get_resources_list') as mock_get_list, + patch.object(self.extractor, '_extract_resources') as mock_extract + ): + resources = ["resource1", "resource2"] + mock_get_list.return_value = resources + + self.extractor.extract() + + mock_get_list.assert_called_once() + mock_extract.assert_called_once_with(resources) + + def test_get_resources_list(self): + """Test that resources are correctly listed from the gresource file.""" + test_resources = ["/org/gnome/shell/theme/file1.css", "/org/gnome/shell/theme/file2.css"] + + with patch.object(self.runner, "run") as mock_run: + mock_run.return_value = self.__mock_gresources_list( + stdout="\n".join(test_resources), + stderr="" + ) + + result = self.extractor._get_resources_list() + + assert result == test_resources + mock_run.assert_called_once() + assert mock_run.call_args[0][0][1] == "list" + + @staticmethod + def __mock_gresources_list(stdout: str, stderr: str): + mock_result = MagicMock() + mock_result.stdout = stdout + mock_result.stderr = stderr + return mock_result + + def test_get_resources_list_error(self): + """Test that an exception is raised when gresource fails to list resources.""" + with patch.object(self.runner, "run", + side_effect=Exception("Error: gresource failed")): + + with pytest.raises(Exception): + self.extractor._get_resources_list() + + def test_extract_resources(self): + """Test that resources are correctly extracted.""" + test_resources = [ + "/org/gnome/shell/theme/file1.css", + "/org/gnome/shell/theme/subdir/file2.css" + ] + + with ( + patch.object(self.runner, "run") as mock_run, + patch("os.makedirs") as mock_makedirs, + patch("builtins.open", create=True) + ): + self.extractor._extract_resources(test_resources) + + assert mock_makedirs.call_count == 2 + assert mock_run.call_count == 2 + for i, resource in enumerate(test_resources): + args_list = mock_run.call_args_list[i][0][0] + assert args_list[1] == "extract" + assert args_list[3] == resource + + def test_extract_resources_file_not_found(self): + with ( + patch.object(self.runner, "run", + side_effect=FileNotFoundError("gresource not found")), + patch("builtins.print") + ): + with pytest.raises(MissingDependencyError): + self.extractor.extract() + + def test_try_extract_resources(self): + resources = ["/org/gnome/shell/theme/file.css"] + + with ( + patch("os.makedirs"), + patch("builtins.open", create=True) as mock_open + ): + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + + self.extractor._extract_resources(resources) + + expected_path = os.path.join(self.temp_folder, "file.css") + mock_open.assert_called_once_with(expected_path, 'wb') + + def test_empty_resource_list(self): + self.extractor._extract_resources([]) \ No newline at end of file diff --git a/tests/utils/gresource/test_gresource_mover.py b/tests/utils/gresource/test_gresource_mover.py new file mode 100644 index 0000000..9e95f8e --- /dev/null +++ b/tests/utils/gresource/test_gresource_mover.py @@ -0,0 +1,51 @@ +import os.path +import unittest +from unittest.mock import patch + +from scripts import config +from scripts.utils.gresource.gresource_mover import GresourceMover +from ..._helpers import create_dummy_file, try_remove_file +from ..._helpers.dummy_logger_factory import DummyLoggerFactory +from ..._helpers.dummy_runner import DummyRunner + + +class GresourceMoverTestCase(unittest.TestCase): + def setUp(self): + self.gresource_file = "test.gresource" + self.source_file = os.path.join(config.temp_tests_folder, "gresource_mover_source", self.gresource_file) + self.destination_file = os.path.join(config.temp_tests_folder, "gresource_mover_destination", self.gresource_file) + + self.logger = DummyLoggerFactory() + self.runner = DummyRunner() + + self.mover = GresourceMover(self.source_file, self.destination_file, + logger_factory=self.logger) + + + def tearDown(self): + try_remove_file(self.source_file) + try_remove_file(self.destination_file) + + def test_move_with_correct_permissions(self): + """Test that move changes permissions correctly.""" + create_dummy_file(self.source_file) + + self.mover.move() + + assert os.path.exists(self.mover.destination_file) + permissions = oct(os.stat(self.mover.destination_file).st_mode)[-3:] + assert permissions == "644" + + def test_move_handles_cp_error(self): + """Test that errors during copy are properly handled.""" + with patch('shutil.copyfile', side_effect=OSError): + with self.assertRaises(OSError): + self.mover.move() + + def test_move_handles_chmod_error(self): + """Test that errors during chmod are properly handled.""" + create_dummy_file(self.source_file) + + with patch('os.chmod', side_effect=PermissionError): + with self.assertRaises(PermissionError): + self.mover.move() \ No newline at end of file diff --git a/tests/utils/test_files_labeler.py b/tests/utils/test_files_labeler.py new file mode 100644 index 0000000..8c024a1 --- /dev/null +++ b/tests/utils/test_files_labeler.py @@ -0,0 +1,57 @@ +import os.path +import shutil +from unittest import TestCase + +from scripts import config +from scripts.utils.files_labeler import FilesLabelerFactoryImpl +from .._helpers import create_dummy_file + + +class FilesLabelerTestCase(TestCase): + def setUp(self): + self.temp_folder = os.path.join(config.temp_tests_folder, "labeler") + + self.files = ["file1.svg", "file2.png", "file3.svg"] + self.styles_file = os.path.join(self.temp_folder, "styles-test.css") # styles files are already labeled + self.original_styles_content = f"body {{ background: url('./{self.files[0]}'); }}" + + self.factory = FilesLabelerFactoryImpl() + + def _generate_test_files(self): + self.tearDown() + + for filename in self.files: + create_dummy_file(os.path.join(self.temp_folder, filename)) + + create_dummy_file(self.styles_file, self.original_styles_content) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + + def test_append_label_correctly_labels_files(self): + self._generate_test_files() + label = "test" + labeled_files = [(f, f.replace(".", f"-{label}.")) for f in self.files] + labeler = self.factory.create(self.temp_folder) + + labeler.append_label(label) + + for original, labeled in labeled_files: + labeled_path = os.path.join(self.temp_folder, labeled) + original_path = os.path.join(self.temp_folder, original) + self.assertTrue(os.path.exists(labeled_path)) + self.assertFalse(os.path.exists(original_path)) + + def test_append_label_correctly_updates_references(self): + self._generate_test_files() + label = "test" + replaced_file = self.files[0].replace('.', f'-{label}.') + expected_content = f"body {{ background: url('./{replaced_file}'); }}" + labeler = self.factory.create(self.temp_folder, self.styles_file) + + labeler.append_label(label) + + with open(self.styles_file, 'r') as file: + actual_content = file.read() + self.assertNotEqual(actual_content, self.original_styles_content) + self.assertEqual(actual_content, expected_content) \ No newline at end of file diff --git a/tests/utils/test_style_manager.py b/tests/utils/test_style_manager.py new file mode 100644 index 0000000..ab07f06 --- /dev/null +++ b/tests/utils/test_style_manager.py @@ -0,0 +1,80 @@ +import os +import shutil +import unittest +from unittest.mock import patch + +from scripts import config +from scripts.utils.style_manager import StyleManager +from .._helpers import create_dummy_file + + +class StyleManagerTestCase(unittest.TestCase): + def setUp(self): + self.temp_folder = os.path.join(config.temp_tests_folder, "style_manager") + os.makedirs(self.temp_folder, exist_ok=True) + self.output_file = os.path.join(self.temp_folder, "output.css") + self.manager = StyleManager(output_file=self.output_file) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + + def test_append_content(self): + start_css = "body { background-color: blue; }" + create_dummy_file(self.output_file, start_css) + end_css = "h1 { color: red; }" + + self.manager.append_content(end_css) + + with open(self.output_file, 'r') as f: + content = f.read() + split_content = content.splitlines() + assert split_content[0] == start_css + assert split_content[1] == end_css + os.remove(self.output_file) + + def test_append_does_not_create_file(self): + end_css = "h1 { color: red; }" + + with self.assertRaises(FileNotFoundError): + self.manager.append_content(end_css) + + def test_prepend_content(self): + start_css = "body { background-color: blue; }" + create_dummy_file(self.output_file, start_css) + prepend_css = "h1 { color: red; }" + + self.manager.prepend_content(prepend_css) + + with open(self.output_file, 'r') as f: + content = f.read() + split_content = content.splitlines() + assert split_content[0] == prepend_css + assert split_content[1] == start_css + os.remove(self.output_file) + + def test_prepend_does_not_create_file(self): + prepend_css = "h1 { color: red; }" + + with self.assertRaises(FileNotFoundError): + self.manager.prepend_content(prepend_css) + + def test_generate_combined_styles(self): + source_folder = os.path.join(config.temp_tests_folder, "style_manager_source") + source_css_folder = os.path.join(source_folder, ".css") + first_file = os.path.join(source_css_folder, "file1.css") + second_file = os.path.join(source_css_folder, "file2.css") + first_css = "body { background-color: blue; }" + second_css = "h1 { color: red; }" + create_dummy_file(first_file, first_css) + create_dummy_file(second_file, second_css) + + with patch("subprocess.check_output", return_value="GNOME Shell 47.0"): + self.manager.generate_combined_styles(source_folder, self.temp_folder) + + with open(self.output_file, 'r') as f: + content = f.read() + split_content = content.splitlines() + assert first_css in split_content + assert second_css in split_content + os.remove(self.output_file) + shutil.rmtree(source_folder, ignore_errors=True) \ No newline at end of file diff --git a/tests/utils/theme/__init__.py b/tests/utils/theme/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/theme/assets/colors.json b/tests/utils/theme/assets/colors.json new file mode 100644 index 0000000..42fbe85 --- /dev/null +++ b/tests/utils/theme/assets/colors.json @@ -0,0 +1,72 @@ +{ + "elements": { + "BUTTON-COLOR": { + "default": "ACCENT-COLOR" + }, + "ACCENT-COLOR": { + "light": { + "s": 52, + "l": 67, + "a": 1 + }, + "dark": { + "s": 42, + "l": 26, + "a": 1 + } + }, + "ACCENT_HOVER": { + "light": { + "s": 50, + "l": 60, + "a": 0.8 + }, + "dark": { + "s": 66, + "l": 22, + "a": 0.4 + } + } + }, + "colors": { + "red": { + "h": 0 + }, + "gray": { + "h": 0, + "s": 0 + }, + "expected": { + "BUTTON-COLOR": { + "0,100": { + "light": "rgba(215, 127, 127, 1)", + "dark": "rgba(94, 38, 38, 1)" + }, + "0,0": { + "light": "rgba(171, 171, 171, 1)", + "dark": "rgba(66, 66, 66, 1)" + } + }, + "ACCENT-COLOR": { + "0,100": { + "light": "rgba(215, 127, 127, 1)", + "dark": "rgba(94, 38, 38, 1)" + }, + "0,0": { + "light": "rgba(171, 171, 171, 1)", + "dark": "rgba(66, 66, 66, 1)" + } + }, + "ACCENT_HOVER": { + "0,100": { + "light": "rgba(204, 102, 102, 0.8)", + "dark": "rgba(93, 19, 19, 0.4)" + }, + "0,0": { + "light": "rgba(153, 153, 153, 0.8)", + "dark": "rgba(56, 56, 56, 0.4)" + } + } + } + } +} \ No newline at end of file diff --git a/tests/utils/theme/test_color_replacement_generator.py b/tests/utils/theme/test_color_replacement_generator.py new file mode 100644 index 0000000..96c0764 --- /dev/null +++ b/tests/utils/theme/test_color_replacement_generator.py @@ -0,0 +1,64 @@ +import os.path +import unittest + +from scripts.install.colors_definer import ColorsDefiner +from scripts.types.installation_color import InstallationColor, InstallationMode +from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl +from scripts.utils.theme.color_replacement_generator import ColorReplacementGenerator + +class ColorReplacementGeneratorTestCase(unittest.TestCase): + def setUp(self): + colors_location = os.path.join(os.path.dirname(__file__), "assets", "colors.json") + self.colors_provider = ColorsDefiner(colors_location) + self.color_converter = ColorConverterImpl() + self.generator = ColorReplacementGenerator(self.colors_provider, self.color_converter) + + def test_convert_red_color_in_dark_mode_generates_correct_rgba(self): + theme_color = InstallationColor(hue=0, saturation=None, modes=[]) + mode: InstallationMode = "dark" + expected_output = self._get_expected_output(theme_color, mode) + + actual_output = self.generator.convert(mode, theme_color) + + self._assert_expected_and_actual_replacers_match(expected_output, actual_output) + + def test_convert_gray_color_in_light_mode_generates_correct_rgba(self): + theme_color = InstallationColor(hue=0, saturation=0, modes=[]) + mode: InstallationMode = "light" + expected_output = self._get_expected_output(theme_color, mode) + + actual_output = self.generator.convert(mode, theme_color) + + self._assert_expected_and_actual_replacers_match(expected_output, actual_output) + + def test_convert_not_existent_mode_raises_key_error(self): + theme_color = InstallationColor(hue=0, saturation=0, modes=[]) + mode = "not_existent_mode" + + with self.assertRaises(KeyError): + # noinspection PyTypeChecker + self.generator.convert(mode, theme_color) + + def _get_expected_output(self, theme_color: InstallationColor, mode: str): + return [ + ("ACCENT-COLOR", self._get_rgba("ACCENT-COLOR", theme_color, mode)), + ("ACCENT_HOVER", self._get_rgba("ACCENT_HOVER", theme_color, mode)), + ("BUTTON-COLOR", self._get_rgba("BUTTON-COLOR", theme_color, mode)), + ] + + def _get_rgba(self, replacer_name: str, theme_color: InstallationColor, mode: str): + expected_colors: dict = self.colors_provider.colors.get("expected") + replacer_colors = expected_colors.get(replacer_name) + saturation = theme_color.saturation if theme_color.saturation is not None else 100 + variant_colors = replacer_colors.get(f"{theme_color.hue},{saturation}") + expected_rgba = variant_colors.get(mode) + return expected_rgba + + @staticmethod + def _assert_expected_and_actual_replacers_match(expected: list, actual: list): + for expected_element, expected_rgba in expected: + actual_rgba = next( + (rgba for element, rgba in actual if element == expected_element), None + ) + assert actual_rgba is not None + assert expected_rgba == actual_rgba \ No newline at end of file diff --git a/tests/utils/theme/test_gnome_shell_theme_builder.py b/tests/utils/theme/test_gnome_shell_theme_builder.py new file mode 100644 index 0000000..a6c6a37 --- /dev/null +++ b/tests/utils/theme/test_gnome_shell_theme_builder.py @@ -0,0 +1,76 @@ +import unittest +from unittest.mock import Mock +import os + +from scripts.utils.theme.gnome_shell_theme_builder import GnomeShellThemeBuilder + + +class GnomeShellThemeBuilderTestCase(unittest.TestCase): + def setUp(self): + self.colors_provider = Mock() + self.builder = GnomeShellThemeBuilder(self.colors_provider) + + def test_builder_method_chaining_works_correctly(self): + result = (self.builder.with_theme_name("test-theme") + .with_mode("dark") + .filled() + .with_temp_folder("/tmp/test")) + + self.assertIs(result, self.builder) + self.assertEqual("test-theme", self.builder.theme_name) + self.assertEqual("dark", self.builder.mode) + self.assertTrue(self.builder.is_filled) + self.assertEqual("/tmp/test", self.builder._base_temp_folder) + + def test_paths_update_when_base_folder_changes(self): + self.builder.with_temp_folder("/custom/temp") + + expected_temp_folder = os.path.join("/custom/temp", self.builder.theme_name) + expected_main_styles = os.path.join(expected_temp_folder, f"{self.builder.theme_name}.css") + + self.assertEqual(expected_temp_folder, self.builder.temp_folder) + self.assertEqual(expected_main_styles, self.builder.main_styles) + + def test_paths_update_when_theme_name_changes(self): + original_temp_folder = self.builder.temp_folder + original_main_styles = self.builder.main_styles + + self.builder.with_theme_name("custom-theme") + + self.assertNotEqual(original_temp_folder, self.builder.temp_folder) + self.assertNotEqual(original_main_styles, self.builder.main_styles) + self.assertEqual(os.path.join(self.builder._base_temp_folder, "custom-theme"), self.builder.temp_folder) + self.assertEqual(os.path.join(self.builder.temp_folder, "custom-theme.css"), self.builder.main_styles) + + def test_default_values_are_set_properly(self): + builder = GnomeShellThemeBuilder(self.colors_provider) + + self.assertEqual("gnome-shell", builder.theme_name) + self.assertIsNone(builder.mode) + self.assertFalse(builder.is_filled) + self.assertIsNone(builder.preparation) + self.assertIsNone(builder.installer) + + def test_build_correctly_resolves_dependencies(self): + self.builder.build() + + self.assertIsNotNone(self.builder.preparation) + self.assertIsNotNone(self.builder.installer) + + def test_build_correctly_creates_theme(self): + self.builder.with_mode("light").filled() + + theme = self.builder.build() + + self.assertEqual(theme._preparation, self.builder.preparation) + self.assertEqual(theme._installer, self.builder.installer) + self.assertTrue(theme.is_filled) + self.assertTrue(len(theme.modes) == 1) + self.assertEqual(theme.modes[0], "light") + + def test_filled_method_with_parameter(self): + self.builder.filled(False) + self.assertFalse(self.builder.is_filled) + + self.builder.filled(True) + self.assertTrue(self.builder.is_filled) \ No newline at end of file diff --git a/tests/utils/theme/test_theme.py b/tests/utils/theme/test_theme.py new file mode 100644 index 0000000..5ed64fd --- /dev/null +++ b/tests/utils/theme/test_theme.py @@ -0,0 +1,90 @@ +import os.path +import unittest +from unittest.mock import MagicMock + +from scripts import config +from scripts.types.installation_color import InstallationColor +from scripts.utils.theme.theme import Theme + + +class ThemeTestCase(unittest.TestCase): + def setUp(self): + self.mock_preparation = MagicMock() + self.mock_installer = MagicMock() + + temp_folder = os.path.join(config.temp_tests_folder, "theme_temp") + destination_folder = os.path.join(temp_folder, "theme_destination") + + self.mock_preparation.temp_folder = temp_folder + self.mock_preparation.combined_styles_location = os.path.join(temp_folder, "styles.css") + self.mock_installer.destination_folder = destination_folder + self.mock_installer.theme_type = "gtk" + + self.theme = Theme(self.mock_preparation, self.mock_installer) + + def test_default_initialization_works_correctly(self): + self.assertEqual(self.theme.modes, ['light', 'dark']) + self.assertFalse(self.theme.is_filled) + + def test_initialization_with_specific_mode_works_correctly(self): + theme_light = Theme(self.mock_preparation, self.mock_installer, mode='light') + self.assertEqual(theme_light.modes, ['light']) + + def test_initialization_with_is_filled_works_correctly(self): + theme_filled = Theme(self.mock_preparation, self.mock_installer, is_filled=True) + self.assertTrue(theme_filled.is_filled) + + def test_properties_fetch_values_correctly(self): + temp_folder = os.path.join(config.temp_tests_folder, "theme_temp") + destination_folder = os.path.join(temp_folder, "theme_destination") + + self.assertEqual(self.theme.temp_folder, temp_folder) + self.assertEqual(self.theme.destination_folder, destination_folder) + self.assertEqual(self.theme.main_styles, os.path.join(temp_folder, "styles.css")) + self.assertEqual(self.theme.theme_name, "gtk") + + def test_add_operator_called_once_and_return_value(self): + result = self.theme + "additional styles" + + self.mock_preparation.__iadd__.assert_called_once_with("additional styles") + self.assertEqual(result, self.theme) + + def test_mul_operator_called_once_and_return_value(self): + result = self.theme * "/path/to/file" + + self.mock_preparation.__imul__.assert_called_once_with("/path/to/file") + self.assertEqual(result, self.theme) + + def test_add_to_start_called_once_and_return_value(self): + result = self.theme.add_to_start("starting content") + + self.mock_preparation.add_to_start.assert_called_once_with("starting content") + self.assertEqual(result, self.theme) + + def test_prepare_called_once(self): + self.theme.prepare() + + self.mock_preparation.prepare.assert_called_once() + + def test_install_without_optional_params_called_correctly(self): + self.theme.install(200, "Green") + + args = self.mock_installer.install.call_args[0] + self.assertEqual(args[0].hue, 200) + self.assertIsNone(args[0].saturation) + self.assertEqual(args[1], "Green") + self.assertIsNone(args[2]) + + def test_install_with_optional_params_called_correctly(self): + self.theme.install(hue=180, name="Blue", sat=0.5, destination="/custom/dest") + + self.mock_installer.install.assert_called_once() + args = self.mock_installer.install.call_args[0] + + theme_color = args[0] + self.assertIsInstance(theme_color, InstallationColor) + self.assertEqual(theme_color.hue, 180) + self.assertEqual(theme_color.saturation, 0.5) + self.assertEqual(theme_color.modes, ['light', 'dark']) + self.assertEqual(args[1], "Blue") + self.assertEqual(args[2], "/custom/dest") diff --git a/tests/utils/theme/test_theme_color_applier.py b/tests/utils/theme/test_theme_color_applier.py new file mode 100644 index 0000000..5c442da --- /dev/null +++ b/tests/utils/theme/test_theme_color_applier.py @@ -0,0 +1,52 @@ +import os.path +import shutil +import unittest +from unittest.mock import Mock + +from scripts import config +from scripts.utils.theme.theme_color_applier import ThemeColorApplier +from ..._helpers import create_dummy_file + + +class ThemeColorApplierTestCase(unittest.TestCase): + def setUp(self): + color_replacement_generator = Mock() + color_replacement_generator.convert.return_value = [ + ("ACCENT-COLOR", "rgba(255, 0, 0, 1)"), + ("ACCENT_HOVER", "rgba(255, 0, 0, 0.8)"), + ("BACKGROUND-COLOR", "rgba(0, 0, 0, 1)") + ] + + self.temp_folder = os.path.join(config.temp_tests_folder, "color_applier") + self._setup_temp_folder() + + self.color_applier = ThemeColorApplier(color_replacement_generator) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + + def _setup_temp_folder(self): + self.first_file = os.path.join(self.temp_folder, "file1.css") + self.second_file = os.path.join(self.temp_folder, "file2.css") + self.first_css = "body { background-color: ACCENT-COLOR; color: ACCENT_HOVER; }" + self.second_css = "body { background-color: BACKGROUND-COLOR; }" + create_dummy_file(self.first_file, self.first_css) + create_dummy_file(self.second_file, self.second_css) + + def test_colors_in_files_are_replaced_correctly(self): + theme_color = Mock() + + self.color_applier.apply(theme_color, self.temp_folder, "dark") + + with open(self.first_file, "r") as file: + content = file.read() + replaced = self.first_css + replaced = replaced.replace("ACCENT-COLOR", "rgba(255, 0, 0, 1)") + replaced = replaced.replace("ACCENT_HOVER", "rgba(255, 0, 0, 0.8)") + assert content == replaced + + with open(self.second_file, "r") as file: + content = file.read() + replaced = self.second_css + replaced = replaced.replace("BACKGROUND-COLOR", "rgba(0, 0, 0, 1)") + assert content == replaced \ No newline at end of file diff --git a/tests/utils/theme/test_theme_installer.py b/tests/utils/theme/test_theme_installer.py new file mode 100644 index 0000000..f6f623a --- /dev/null +++ b/tests/utils/theme/test_theme_installer.py @@ -0,0 +1,109 @@ +import os +import shutil +import unittest +from unittest.mock import Mock + +from scripts import config +from scripts.utils.theme.theme_installer import ThemeInstaller +from scripts.utils.theme.theme_path_provider import ThemePathProvider +from ..._helpers import create_dummy_file + + +class ThemeInstallerTestCase(unittest.TestCase): + def setUp(self): + self.theme_type = "gnome-shell" + self.source_folder = os.path.join(config.temp_tests_folder, "theme_installer_source") + self.destination_folder = os.path.join(config.temp_tests_folder, "theme_installer_destination") + self.custom_destination_folder = os.path.join(config.temp_tests_folder, "theme_installer_custom_destination") + + self.logger_factory = Mock() + self.color_applier = Mock() + self.path_provider = ThemePathProvider() + self.path_provider.get_theme_path = Mock(return_value=self.destination_folder) + + self.theme_installer = ThemeInstaller( + theme_type=self.theme_type, + source_folder=self.source_folder, + destination_folder=self.destination_folder, + logger_factory=self.logger_factory, + color_applier=self.color_applier, + path_provider=self.path_provider, + ) + + self._setup_source_folder() + + def tearDown(self): + shutil.rmtree(self.source_folder, ignore_errors=True) + shutil.rmtree(self.destination_folder, ignore_errors=True) + shutil.rmtree(self.custom_destination_folder, ignore_errors=True) + + def _setup_source_folder(self): + os.makedirs(self.source_folder, exist_ok=True) + first_file = os.path.join(self.source_folder, "file1.css") + second_file = os.path.join(self.source_folder, "file2.css") + first_css = "body { background-color: ACCENT-COLOR; color: ACCENT_HOVER; }" + second_css = "body { background-color: BACKGROUND-COLOR; }" + create_dummy_file(first_file, first_css) + create_dummy_file(second_file, second_css) + + def test_install_calls_get_theme_path_and_apply_methods_with_correct_parameters(self): + theme_color = Mock() + theme_color.modes = ["light"] + name = "test-theme" + + self.theme_installer.install(theme_color, name) + + # noinspection PyUnresolvedReferences + self.path_provider.get_theme_path.assert_called_once_with( + self.destination_folder, name, "light", self.theme_type + ) + self.color_applier.apply.assert_called_once_with(theme_color, self.destination_folder, "light") + + def test_install_with_custom_destination_calls_get_theme_path_and_apply_methods_with_correct_parameters(self): + theme_color = Mock() + theme_color.modes = ["light"] + name = "test-theme" + os.makedirs(self.custom_destination_folder, exist_ok=True) + + self.theme_installer.install(theme_color, name, self.custom_destination_folder) + + # noinspection PyUnresolvedReferences + self.path_provider.get_theme_path.assert_not_called() + self.color_applier.apply.assert_called_once_with(theme_color, self.custom_destination_folder, "light") + + def test_install_with_multiple_modes_calls_get_theme_path_and_apply_methods_for_each_mode(self): + theme_color = Mock() + theme_color.modes = ["light", "dark"] + name = "test-theme" + + self.theme_installer.install(theme_color, name) + + # noinspection PyUnresolvedReferences + self.assertEqual(self.path_provider.get_theme_path.call_count, 2) + self.assertEqual(self.color_applier.apply.call_count, 2) + + def test_install_raises_exception_and_logs_error(self): + theme_color = Mock() + theme_color.modes = ["light"] + name = "test-theme" + self.color_applier.apply.side_effect = Exception("Test error") + + with self.assertRaises(Exception): + self.theme_installer.install(theme_color, name) + + logger_mock = self.logger_factory.create_logger.return_value + self.assertTrue(logger_mock.error.called) + + def test_install_copies_files_to_destination(self): + theme_color = Mock() + theme_color.modes = ["light"] + name = "test-theme" + destination = os.path.join(self.destination_folder, "actual_destination") + self.path_provider.get_theme_path.return_value = destination + + self.theme_installer.install(theme_color, name) + + first_file_exists = os.path.exists(os.path.join(destination, "file1.css")) + second_file_exists = os.path.exists(os.path.join(destination, "file2.css")) + self.assertTrue(first_file_exists) + self.assertTrue(second_file_exists) \ No newline at end of file diff --git a/tests/utils/theme/test_theme_path_provider.py b/tests/utils/theme/test_theme_path_provider.py new file mode 100644 index 0000000..b1e7a1e --- /dev/null +++ b/tests/utils/theme/test_theme_path_provider.py @@ -0,0 +1,28 @@ +import unittest + +from scripts.utils.theme.theme_path_provider import ThemePathProvider + + +class ThemePathProviderTestCase(unittest.TestCase): + def setUp(self): + self.theme_path_provider = ThemePathProvider() + + def test_get_theme_path_with_valid_values_returns_correct_path(self): + themes_folder = "/usr/share/themes" + color_name = "Marble" + theme_mode = "dark" + theme_type = "gnome-shell" + + expected_path = f"{themes_folder}/Marble-{color_name}-{theme_mode}/{theme_type}/" + actual_path = self.theme_path_provider.get_theme_path(themes_folder, color_name, theme_mode, theme_type) + + assert expected_path == actual_path + + def test_get_theme_path_with_empty_values_raises_exception(self): + themes_folder = "" + color_name = "" + theme_mode = "" + theme_type = "" + + with self.assertRaises(ValueError): + self.theme_path_provider.get_theme_path(themes_folder, color_name, theme_mode, theme_type) \ No newline at end of file diff --git a/tests/utils/theme/test_theme_temp_manager.py b/tests/utils/theme/test_theme_temp_manager.py new file mode 100644 index 0000000..3e9a084 --- /dev/null +++ b/tests/utils/theme/test_theme_temp_manager.py @@ -0,0 +1,71 @@ +import os.path +import shutil +import unittest + +from scripts import config +from scripts.utils.theme.theme_temp_manager import ThemeTempManager +from ..._helpers import create_dummy_file + + +class ThemeTempManagerTestCase(unittest.TestCase): + def setUp(self): + self.source_folder = os.path.join(config.temp_tests_folder, "theme_temp_manager_source") + self.temp_folder = os.path.join(config.temp_tests_folder, "theme_temp_manager") + self.manager = ThemeTempManager(temp_folder=self.temp_folder) + + def tearDown(self): + shutil.rmtree(self.temp_folder, ignore_errors=True) + shutil.rmtree(self.source_folder, ignore_errors=True) + + @staticmethod + def _verify_file_copied(source, destination): + assert os.path.exists(destination) + assert os.path.getsize(destination) == os.path.getsize(source) + assert open(destination).read() == open(source).read() + + def test_file_copies_correctly_to_temp(self): + test_file_name = "test_file.txt" + test_file_location = os.path.join(self.source_folder, test_file_name) + create_dummy_file(test_file_location) + + self.manager.copy_to_temp(test_file_location) + + final_file_location = os.path.join(self.temp_folder, test_file_name) + self._verify_file_copied(test_file_location, final_file_location) + os.remove(final_file_location) + + def test_directory_content_copies_correctly_to_temp(self): + test_dir_name = "test_dir" + test_dir_location = os.path.join(self.source_folder, test_dir_name) + os.makedirs(test_dir_location, exist_ok=True) + + test_file_name = "test_file.txt" + test_file_location = os.path.join(test_dir_location, test_file_name) + create_dummy_file(test_file_location) + + self.manager.copy_to_temp(test_dir_location) + + final_file_location = os.path.join(self.temp_folder, test_file_name) + self._verify_file_copied(test_file_location, final_file_location) + os.remove(final_file_location) + + def test_cleanup_removes_temp_folders(self): + css_folder = os.path.join(self.temp_folder, ".css") + versions_folder = os.path.join(self.temp_folder, ".versions") + os.makedirs(css_folder, exist_ok=True) + os.makedirs(versions_folder, exist_ok=True) + + self.manager.cleanup() + + assert not os.path.exists(css_folder) + assert not os.path.exists(versions_folder) + + def test_cleanup_non_existent_folders_do_not_raise_error(self): + css_folder = os.path.join(self.temp_folder, ".css") + versions_folder = os.path.join(self.temp_folder, ".versions") + + self.manager.cleanup() + + # Check that no error is raised and the method completes successfully + assert not os.path.exists(css_folder) + assert not os.path.exists(versions_folder) \ No newline at end of file diff --git a/theme/__init__.py b/theme/__init__.py new file mode 100644 index 0000000..8bb3151 --- /dev/null +++ b/theme/__init__.py @@ -0,0 +1,9 @@ +import os + + +class SourceFolder: + themes_folder = os.path.dirname(__file__) + + @property + def gnome_shell(self): + return os.path.join(self.themes_folder, "gnome-shell") \ No newline at end of file diff --git a/tweaks/panel/tweak.py b/tweaks/panel/tweak.py index 0defa53..e58e89f 100755 --- a/tweaks/panel/tweak.py +++ b/tweaks/panel/tweak.py @@ -1,5 +1,6 @@ from scripts import config -from scripts.utils import hex_to_rgba +from scripts.utils.color_converter.color_converter_impl import ColorConverterImpl + panel_folder = f"{config.tweaks_folder}/panel" @@ -28,5 +29,5 @@ def apply_tweak(args, theme, colors): theme += ".panel-button,\ .clock,\ .clock-display StIcon {\ - color: rgba(" + ', '.join(map(str, hex_to_rgba(args.panel_text_color))) + ");\ + color: rgba(" + ', '.join(map(str, ColorConverterImpl.hex_to_rgba(args.panel_text_color))) + ");\ }"