Merge pull request #54 from imarkoff/unstable

SOLID refactoring, covered the program with tests
This commit is contained in:
Vladyslav Hroshev
2025-04-13 23:53:10 +03:00
committed by GitHub
91 changed files with 3656 additions and 667 deletions

View File

@@ -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/

View File

@@ -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()

View File

@@ -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"

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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)

View File

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}/"

View File

@@ -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):
"""

View File

@@ -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 = []

View File

View File

@@ -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()

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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())

View File

@@ -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.")

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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"""
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/shell/theme">
{nl.join(files_to_include)}
</gresource>
</gresources>
""")
def __get_files_to_include(self):
temp_path = Path(self.temp_folder)
return [
f"<file>{file.relative_to(temp_path)}</file>"
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.")

View File

@@ -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

View File

@@ -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()

View File

@@ -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.")

View File

@@ -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"""
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/shell/theme">
{nl.join(files_to_include)}
</gresource>
</gresources>
""")
def _get_files_to_include(self):
source_path = Path(self.source_folder)
return [
f"<file>{file.relative_to(source_path)}</file>"
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)

View File

@@ -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
)

View File

@@ -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.")

View File

@@ -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

View File

View File

@@ -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}"

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

View File

@@ -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]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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

View File

@@ -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"))

View File

@@ -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)

0
tests/__init__.py Normal file
View File

View File

@@ -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

View File

@@ -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

View File

@@ -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"

0
tests/utils/__init__.py Normal file
View File

View File

View File

@@ -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)

View File

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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")

View File

@@ -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()

View File

@@ -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)

View File

@@ -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
)

View File

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 = "<xml>test content</xml>"
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("<xml>test content</xml>")
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 = ["<file>file1.css</file>", "<file>subdir/file2.css</file>"]
result = self.compiler._generate_gresource_xml()
assert "<?xml version=" in result
assert "<gresources>" in result
assert "<gresource prefix=\"/org/gnome/shell/theme\">" in result
assert "<file>file1.css</file>" in result
assert "<file>subdir/file2.css</file>" 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 "<file>file1.css</file>" in result
assert "<file>subdir/file2.css</file>" 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()

View File

@@ -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([])

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

View File

@@ -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)"
}
}
}
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

9
theme/__init__.py Normal file
View File

@@ -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")

View File

@@ -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))) + ");\
}"