From cd82704b9819174fa2b7a08fe6edbdd674e0d573 Mon Sep 17 00:00:00 2001 From: Seth Mackert Date: Tue, 14 Jan 2025 05:42:40 +0000 Subject: [PATCH] cursorthemes + theme attr rewrite Theme attributes are now abstracted to their own class, which includes validation, initalization of available options, fetching the current option, and application of the attribute With this rewrite, cursorthemes have been added --- README.md | 8 +- pyproject.toml | 4 +- src/kshift/conf.py | 164 ++++--------------- src/kshift/main.py | 93 +++++++---- src/kshift/theme.py | 381 ++++++++++++++++++++++++++++++++++++++++++++ src/kshift/utils.py | 206 ------------------------ tests/test_api.py | 7 +- 7 files changed, 486 insertions(+), 377 deletions(-) create mode 100644 src/kshift/theme.py delete mode 100644 src/kshift/utils.py diff --git a/README.md b/README.md index c0dea20..f7694a7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ https://github.com/user-attachments/assets/c18332df-b3b7-4bda-9254-e061a7fa0367 ## Why Use kshift? -**Effortless and Dynamic Theme Automation**: Save time by automating theme changes for morning, evening, or specific events without manual adjustments. Coordinate your system's appearance with wallpapers, icon themes, and desktop themes that match the time of day or occasion. +**Effortless and Dynamic Theme Automation**: Save time by automating theme changes for morning, evening, or specific events without manual adjustments. Coordinate your system's appearance with colorschemes, wallpapers, cursorthemes, icon themes, and desktop themes that match the time of day or occasion. **Customizable and Reliable Schedules**: Define unique schedules for themes using flexible time settings or solar events like sunrise and sunset. With systemd integration, theme changes are guaranteed to occur on time, even after system reboots. @@ -94,9 +94,10 @@ All parameters are optional, but to enable automatic theme switching, a `time` v | Parameter | Description | Example Value | | -------------- | --------------------------------------------------- | --------------------------------- | | `colorscheme` | Name of the Plasma color scheme | `BreezeLight` | +| `cursortheme` | Name of the Plasma cursor theme | `HighContrast` | +| `desktoptheme` | Name of the Plasma desktop theme | `Breeze` | | `icontheme` | Name of the icon theme | `Papirus-Dark` | | `wallpaper` | Path to the wallpaper image | `~/Pictures/morning.jpg` | -| `desktoptheme` | Name of the Plasma desktop theme | `Breeze` | | `command` | Custom command to execute when the theme is applied | `echo 'Theme applied'` | | `time` | Schedule for theme activation | `sunset`, `HH:MM`, `weekly` | @@ -115,9 +116,10 @@ Run `kshift` with various options: - `theme [THEME_NAME]`: Apply a specific theme by name. If no name is provided, kshift determines the appropriate theme to apply based on time and configuration. - Positional argument `[THEME_NAME]`: Optional. Specify the theme to apply. - `-c, --colorscheme `: Apply a specific colorscheme (overrides the theme configuration). + - `-csr, --cursortheme `: Apply a specific cursor theme (overrides the theme configuration). + - `-dk, --desktop_theme `: Apply a specific desktop theme (overrides the theme configuration). - `-i, --icontheme `: Apply a specific icon theme (overrides the theme configuration). - `-w, --wallpaper `: Apply a specific wallpaper (overrides the theme configuration). - - `-dk, --desktop_theme `: Apply a specific desktop theme (overrides the theme configuration). - `install`: Install systemd services and timers for kshift. - `remove`: Remove systemd services and timers for kshift. - `status`: Display the current status of kshift and active timers. diff --git a/pyproject.toml b/pyproject.toml index e50e77a..da0ae2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "kshift" -version = "1.0.1" +version = "1.2.0" authors = [ { name="Seth Mackert", email="seth.mackert@fastsycamore.com" }, ] @@ -15,7 +15,7 @@ classifiers = [ "Programming Language :: Python", # Development Status - "Development Status :: 4 - Beta", # If your project is stable, switch to "5 - Production/Stable" + "Development Status :: 5 - Production/Stable", # Environment "Environment :: X11 Applications :: KDE", diff --git a/src/kshift/conf.py b/src/kshift/conf.py index 4a27fe4..47ca015 100644 --- a/src/kshift/conf.py +++ b/src/kshift/conf.py @@ -7,136 +7,12 @@ import requests import json -from kshift import utils +from kshift.theme import Theme from pathlib import Path from pydantic import BaseModel, Field, field_validator, model_validator -from typing import Dict, Optional, Union, List - - -class ThemeConfig(BaseModel): - # name: str = Field(description="Name for kshift theme") - colorscheme: Optional[str] = Field( - None, description="The name of the color scheme.") - icontheme: Optional[str] = Field(None, - description="The name of the icon theme.") - wallpaper: Optional[str] = Field( - None, description="Path to the wallpaper image.") - desktoptheme: Optional[str] = Field(None, - description="The desktop theme name.") - command: Optional[str] = Field( - None, description="Command to execute when the theme is applied.") - time: List[Union[str, datetime]] = Field( - [], description="The time when this theme is applied.") - enabled: bool = Field(True, description="Whether the theme is enabled.") - - def kshift(self) -> None: - - if self.wallpaper: - os.system(f"plasma-apply-wallpaperimage {self.wallpaper}") - - if self.colorscheme and self.colorscheme != utils.curr_colorscheme(): - os.system(f"plasma-apply-colorscheme {self.colorscheme}") - - # if self.icontheme and self.icontheme != utils.curr_icontheme(): - if self.icontheme: - plasma_changeicons = utils.find_plasma_changeicons() - if (plasma_changeicons is not None): - os.system(f"{plasma_changeicons} {self.icontheme}") - - if self.desktoptheme and self.desktoptheme != utils.curr_desktoptheme( - ): - os.system(f"plasma-apply-desktoptheme {self.desktoptheme}") - - if self.command: - os.system(self.command) - - @field_validator("colorscheme") - def validate_colorscheme(cls, value): - colorschemes = utils.get_colorschemes() - if value and value not in colorschemes: - raise ValueError( - f"Unknown colorscheme: {value}.\nValid options are {colorschemes}" - ) - else: - return value - - @field_validator("icontheme") - def validate_icontheme(cls, value): - iconthemes = utils.get_iconthemes() - if value and value not in iconthemes: - raise ValueError( - f"Unknown icontheme: {value}.\nValid options are {iconthemes}") - else: - return value - - @field_validator("wallpaper") - def validate_wallpaper(cls, value): - if value: - value = Path(value).expanduser() - if value.exists(): - return value - else: - raise ValueError(f"Wallpaper does not exist: {value}") - - @field_validator("desktoptheme") - def validate_desktoptheme(cls, value): - desktopthemes = utils.get_desktopthemes() - if value and value not in desktopthemes: - raise ValueError( - f"Unknown desktoptheme: {value}.\nValid options are {desktopthemes}" - ) - else: - return value - - @field_validator("time", mode="before") - def parse_time(cls, value): - if isinstance(value, str): - return [value] # Convert single string to a list - elif isinstance(value, list): - if not all(isinstance(item, str) for item in value): - raise ValueError( - "All elements in the 'time' list must be strings.") - return value - else: - raise TypeError("'time' must be a string or a list of strings.") - - @model_validator(mode="after") - def if_disabled(self): - if not self.enabled: - self.time = [] - - return self - - @field_validator("time", mode="after") - def convert_time_strings(cls, times): - new_times = [] - for item in times: - if isinstance(item, str): - if re.match(r'^\d{2}:\d{2}$', item): - # Parse "HH:MM" into a datetime object with today's date - now = datetime.now() - hour, minute = map(int, item.split(':')) - dt = now.replace(hour=hour, - minute=minute, - second=0, - microsecond=0) - # If the time has already passed today, schedule for tomorrow - if dt < now: - dt += timedelta(days=1) - item = dt - - new_times.append(item) - elif isinstance(item, datetime): - new_times.append(item) - else: - raise TypeError( - "Each time entry must be a string in systemd OnCalendar format." - ) - - return new_times - +from typing import Dict defaults = { "latitude": 39, @@ -147,8 +23,8 @@ def convert_time_strings(cls, times): "set_delay": 0, "net_timeout": 10, "themes": { - 'day': ThemeConfig(colorscheme="BreezeLight", time=["sunrise"]), - 'night': ThemeConfig(colorscheme="BreezeDark", time=["sunset"]), + 'day': Theme(colorscheme="BreezeLight", time=["sunrise"]), + 'night': Theme(colorscheme="BreezeDark", time=["sunset"]), } } @@ -172,6 +48,7 @@ class Config(BaseModel): sunset: datetime = Field( datetime.strptime(defaults["sunset"], "%H:%M"), description="Default sunset time in HH:MM format.") + rise_delay: int = Field( defaults["rise_delay"], ge=-23, @@ -190,8 +67,8 @@ class Config(BaseModel): ge=0, le=60, description="Network timeout in seconds, between 0 and 60.") - themes: Dict[str, ThemeConfig] = Field(defaults["themes"], - description="Dictionary of themes.") + themes: Dict[str, Theme] = Field(defaults["themes"], + description="Dictionary of themes.") # Environment-based paths home_directory: Path = Field(default=Path.home(), @@ -229,7 +106,7 @@ class Config(BaseModel): description="Path to the cache file for sunrise and sunset data.") @model_validator(mode="after") - def set_dependant(self): + def set_dependant_paths(self): # Compute dependent paths self.systemd_loc = self.xdg_data / "systemd/user" self.config_loc_base = self.xdg_config / "kshift" @@ -266,7 +143,30 @@ def apply_delay(time_obj: datetime, delay_hours: int) -> datetime: elif t == "sunset": t = apply_delay(self.get_sundata(t), self.set_delay) else: - t = utils.time_to_systemd(t) + + # Determine if theme time is a valid calendar time + if t: + process = subprocess.run( + ["systemd-analyze", "calendar", t], + stdout=subprocess.PIPE) + stat_code = process.returncode + + if stat_code == 0: + calendar_time = "" + output = process.stdout.decode('utf-8').strip() + + for line in output.splitlines(): + r = re.search("Normalized form: (.*)", + line) + if r: + calendar_time = r.group(1) + break + + t = calendar_time + + else: + raise ValueError( + "Invalid systemd calendar time!: " + t) updated_times.append(t) elif isinstance(t, datetime): diff --git a/src/kshift/main.py b/src/kshift/main.py index 578161d..dbc4d15 100755 --- a/src/kshift/main.py +++ b/src/kshift/main.py @@ -14,11 +14,11 @@ from importlib.resources import files -from kshift.conf import ThemeConfig, load_config +from kshift.conf import load_config from string import Template -import kshift.utils +from kshift.theme import * c = load_config() @@ -54,7 +54,7 @@ def log_theme_change(theme_name): logging.info(json.dumps(log_data)) # Use JSON for structured logs -def log_element_change(theme: ThemeConfig): +def log_element_change(theme: Theme): log_data = {"event": "specific_change", "theme": str(theme)} logging.info(json.dumps(log_data)) @@ -277,7 +277,17 @@ def status(): @cli.command(help="Edit the kshift configuration file") def config(): """Edit the configuration file.""" - kshift.utils.open_in_default_editor(c.config_loc) + + filepath = c.config_loc + if filepath.exists(): + try: + # Use xdg-open to open the file in the default editor + subprocess.run(["xdg-open", filepath], check=True) + print(f"Opened {filepath} in the default editor.") + except subprocess.CalledProcessError as e: + print(f"Failed to open the file: {e}") + else: + print(f"Config does not exist @ {filepath}") @cli.command(help="Tail on kshift logs") @@ -301,26 +311,39 @@ def logs(all): @cli.command(help="List possible themes or attributes") @click.argument("attribute", - type=click.Choice( - ["themes", "colorschemes", "iconthemes", "desktopthemes"], - case_sensitive=False)) + type=click.Choice([ + "themes", "colorschemes", "cursorthemes", "desktopthemes", + "iconthemes", "wallpapers" + ], + case_sensitive=False)) def list(attribute): + + def print_available(attr, items): + print(f"Available {attr}:") + for a in items: + print(f"- {a}") + if attribute == "themes": - print("Available themes:") - for name, theme_config in c.themes.items(): - print(f"- {name}") - elif attribute == "colorschemes": - print("Available colorschemes:") - for colorscheme in kshift.utils.get_colorschemes(): - print(f"- {colorscheme}") - elif attribute == "iconthemes": - print("Available icon themes:") - for icontheme in kshift.utils.get_iconthemes(): - print(f"- {icontheme}") - elif attribute == "desktopthemes": - print("Available desktop themes:") - for desktop_theme in kshift.utils.get_desktopthemes(): - print(f"- {desktop_theme}") + for name, conf in c.themes.items(): + print(f"theme: {name}\n {conf}\n") + pass + + items = [] + match attribute: + # items are the class.available + case "colorschemes": + items = Colorscheme.fetch_colorschemes()[0] + case "cursorthemes": + items = CursorTheme.fetch_cursorthemes()[0] + case "desktopthemes": + items = DesktopTheme.fetch_desktopthemes()[0] + case "iconthemes": + items = IconTheme.fetch_iconthemes()[0] + case "wallpapers": + items = Wallpaper.fetch_wallpapers()[0] + + if items: + print_available(attribute, items) @cli.command(help="Change themes or apply specific theme elements") @@ -330,12 +353,24 @@ def list(attribute): default=None, nargs=1, metavar="[THEME_NAME]") +@click.option( + "-csr", + "--cursortheme", + type=str, + help="Set a specific cursor theme (overrides theme)", +) @click.option( "-c", "--colorscheme", type=str, help="Set a specific colorscheme (overrides theme)", ) +@click.option( + "-dk", + "--desktop_theme", + type=str, + help="Set a specific desktop theme (overrides theme)", +) @click.option( "-i", "--icontheme", @@ -348,13 +383,8 @@ def list(attribute): type=str, help="Set a specific wallpaper (overrides theme)", ) -@click.option( - "-dk", - "--desktop_theme", - type=str, - help="Set a specific desktop theme (overrides theme)", -) -def theme(theme, colorscheme, icontheme, wallpaper, desktop_theme): +def theme(theme, colorscheme, cursortheme, desktop_theme, icontheme, + wallpaper): kshift_status = subprocess.run( "systemctl --user is-enabled kshift-startup.timer".split(), @@ -368,10 +398,11 @@ def theme(theme, colorscheme, icontheme, wallpaper, desktop_theme): else: print(f"Error: Theme '{theme}' not found in configuration.") - if any([colorscheme, icontheme, wallpaper, desktop_theme]): + if any([colorscheme, cursortheme, desktop_theme, icontheme, wallpaper]): # Apply individual theme elements dynamically - custom_theme = ThemeConfig( + custom_theme = Theme( colorscheme=colorscheme, + cursortheme=cursortheme, icontheme=icontheme, wallpaper=wallpaper, desktoptheme=desktop_theme, diff --git a/src/kshift/theme.py b/src/kshift/theme.py new file mode 100644 index 0000000..a887958 --- /dev/null +++ b/src/kshift/theme.py @@ -0,0 +1,381 @@ +from datetime import datetime, timedelta +import os +import re +import subprocess +import configparser + +from pathlib import Path + +from pydantic import BaseModel, field_validator, model_validator +from typing import Optional, Union, List, Tuple, ClassVar + + +class BaseAttribute(BaseModel): + """Abstract base class for attribute configurations.""" + val: str + + command: ClassVar[str] = "" + + available: ClassVar[List[str]] = [] + current: ClassVar[Optional[str]] = None + + def apply(self): + if self.val and self.val != self.current: + os.system(f"{self.command} {self.val}") + + @classmethod + def fetch_themes(cls, cmd: str, + regex: str) -> Tuple[List[str], Optional[str]]: + """Fetch available and the current theme.""" + if cls.available and cls.current: + return cls.available, cls.current + + try: + output = subprocess.run(cmd.split(), + capture_output=True, + text=True, + check=True).stdout.strip() + + for line in output.splitlines(): + match = re.search(regex, line) + if match: + cls.available.append(match.group(1)) + if "current" in line.lower(): + cls.current = match.group(1) + + return cls.available, cls.current + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to fetch themes: {e}") + + @classmethod + def init_themes(cls, fetch_function): + """Generic initialization for themes.""" + cls.available, cls.current = fetch_function() + + def validate_theme(self): + if self.val and self.val not in self.available: + raise ValueError( + f"Invalid attribute: {self.val}. Available options are {self.available}." + ) + + return self + + def __str__(self) -> str: + return self.val + + +class Colorscheme(BaseAttribute): + available: ClassVar[List[str]] = [] + current: ClassVar[Optional[str]] = None + + command = "plasma-apply-colorscheme" + + @classmethod + def fetch_colorschemes(cls) -> Tuple[List[str], Optional[str]]: + """Fetch available colorschemes and the current colorscheme.""" + return cls.fetch_themes(f"{cls.command} -l", r" \* ([A-Za-z]*)") + + @model_validator(mode="after") + def init_colorschemes(self): + """Initialization of colorscheme variables.""" + + self.init_themes(self.fetch_colorschemes) + return self + + @model_validator(mode="after") + def validate_colorscheme(self): + self.validate_theme() + return self + + +class CursorTheme(BaseAttribute): + available: ClassVar[List[str]] = [] + current: ClassVar[Optional[str]] = None + + command = "plasma-apply-cursortheme" + + @classmethod + def fetch_cursorthemes(cls) -> Tuple[List[str], Optional[str]]: + """Fetch available cursorthemes and the current cursortheme.""" + return cls.fetch_themes(f"{cls.command} --list-themes", + r"\* .* \[(.*?)\]") + + @model_validator(mode="after") + def init_cursorthemes(self): + self.init_themes(self.fetch_cursorthemes) + return self + + @model_validator(mode="after") + def validate_colorscheme(self): + self.validate_theme() + return self + + +class DesktopTheme(BaseAttribute): + available: ClassVar[List[str]] = [] + current: ClassVar[Optional[str]] = None + + command = "plasma-apply-desktoptheme" + + @classmethod + def fetch_desktopthemes(cls) -> Tuple[List[str], Optional[str]]: + """Fetch available desktopthemes and the current desktoptheme.""" + return cls.fetch_themes(f"{cls.command} --list-themes", + r" \* ([\w-]+)") + + @model_validator(mode="after") + def init_desktopthemes(self): + self.init_themes(self.fetch_desktopthemes) + return self + + @model_validator(mode="after") + def validate_colorscheme(self): + self.validate_theme() + return self + + +class IconTheme(BaseAttribute): + available: ClassVar[List[str]] = [] + current: ClassVar[Optional[str]] = None + + @classmethod + def fetch_iconthemes(cls) -> Tuple[List[str], Optional[str]]: + if cls.available and cls.current: + return cls.available, cls.current + + home_dir = Path.home() + old_icon_dir = home_dir / ".icons" + icon_dir = home_dir / ".local/share/icons" + system_icon_dir = Path("/usr/share/icons") + + for path in [old_icon_dir, icon_dir, system_icon_dir]: + if path.exists(): + cls.available += [ + item.name for item in path.iterdir() if item.is_dir() + ] + + kdeconfig_path = Path.home() / ".config/kdeglobals" + + if kdeconfig_path.exists(): + config = configparser.ConfigParser() + config.read(kdeconfig_path) + cls.current = config.get("Icons", "Theme") + + return cls.available, cls.current + + @model_validator(mode="after") + def init_iconthemes(self): + if not self.command: + + search_paths = [ + "/usr/local/libexec", "/usr/local/lib", "/usr/libexec", + "/usr/lib", "/usr/lib/x86_64-linux-gnu/libexec", + "/usr/lib/aarch64-linux-gnu/libexec" + ] + + for path in search_paths: + executable_path = Path(path) / "plasma-changeicons" + if executable_path.is_file(): + IconTheme.command = str(executable_path) + + self.fetch_iconthemes() + return self + + @model_validator(mode="after") + def validate_colorscheme(self): + self.validate_theme() + return self + + +class Wallpaper(BaseAttribute): + path: Optional[Path] = None + command = "plasma-apply-wallpaperimage" + available: ClassVar[List[str]] = [] + current: ClassVar[Optional[str]] = None + + @classmethod + def fetch_wallpapers(cls) -> Tuple[List[str], Optional[str]]: + if cls.available and cls.current: + return cls.available, cls.current + + config_file = Path( + '~/.config/plasma-org.kde.plasma.desktop-appletsrc').expanduser() + cls.current = "" + + # Open and search for the Image variable + with open(config_file, 'r') as file: + section_found = False + for line in file: + # Look for the specific section + if '[Wallpaper][org.kde.image]' in line: + section_found = True + + # Look for the Image variable after finding the section + elif section_found and line.strip().startswith('Image='): + cls.current = line.strip().split('=', 1)[1] + cls.current = cls.current.replace('file://', '') + break + + cls.available = [] + valid_extensions = { + '.jpg', '.jpeg', '.jxl', '.png', '.bmp', '.webp', '.tiff' + } + + for d in ["~/.local/share/wallpapers/", "/usr/share/wallpapers"]: + path = Path(d).expanduser() + if not path.exists(): + continue + + for entry in path.iterdir(): + + if entry.is_dir(): # Check for metadata.json in directories + metadata_path = entry / "metadata.json" + if metadata_path.is_file(): + cls.available.append(str(entry)) + + elif entry.is_file(): # Check for valid extensions in files + if entry.suffix.lower() in valid_extensions: + cls.available.append(str(entry)) + + return cls.available, cls.current + + @model_validator(mode="after") + def init_wallpaper(self): + self.init_themes(self.fetch_wallpapers) + + name_to_path = {Path(p).name: Path(p) for p in self.available} + + if self.val: + if self.val in name_to_path: + self.path = name_to_path[self.val] + else: + self.path = Path(self.val).expanduser() + + if self.path and self.path.exists(): + self.val = str(self.path) + + if self.val not in self.available: + self.available.append(self.val) + + return self + + @model_validator(mode="after") + def validate_colorscheme(self): + self.validate_theme() + return self + + +class Theme(BaseModel): + colorscheme: Optional[Colorscheme] = None + cursortheme: Optional[CursorTheme] = None + desktoptheme: Optional[DesktopTheme] = None + icontheme: Optional[IconTheme] = None + wallpaper: Optional[Wallpaper] = None + + command: Optional[str] = None + time: List[Union[str, datetime]] = [] + enabled: bool = True + + def __str__(self) -> str: + components = {} + + for attr in [ + "colorscheme", "cursortheme", "desktoptheme", "icontheme", + "wallpaper" + ]: + if eval(f"self.{attr}"): + components[attr] = eval(f"self.{attr}.val") + + if self.time: + time_str = [ + t.strftime("%H:%M") if isinstance(t, datetime) else t + for t in self.time + ] + components["time"] = time_str + + components["enabled"] = self.enabled + + if self.command: + components["command"] = self.command + + # Format the output like YAML + result = "\n ".join(f"{key}: {value}" + for key, value in components.items()) + return result + + def kshift(self) -> None: + + for attr in [ + self.colorscheme, self.cursortheme, self.desktoptheme, + self.icontheme, self.wallpaper + ]: + if attr: + attr.apply() + + if self.command: + os.system(self.command) + + @model_validator(mode="before") + def parse_attributes(cls, values): + mtch = { + "colorscheme": Colorscheme, + "cursortheme": CursorTheme, + "desktoptheme": DesktopTheme, + "icontheme": IconTheme, + "wallpaper": Wallpaper + } + + for attr in mtch.keys(): + if attr in values and isinstance(values[attr], str): + attr_cls = mtch[attr] + values[attr] = attr_cls(val=values[attr]) + + return values + + @field_validator("time", mode="before") + def parse_time(cls, value): + if isinstance(value, str): + return [value] # Convert single string to a list + elif isinstance(value, list): + if not all(isinstance(item, str) for item in value): + raise ValueError( + "All elements in the 'time' list must be strings.") + return value + else: + raise TypeError("'time' must be a string or a list of strings.") + + @model_validator(mode="after") + def if_disabled(self): + if not self.enabled: + self.time = [] + + return self + + @field_validator("time", mode="after") + def convert_time_strings(cls, times): + """HH:MM times are converted to datetime, other strings are to be checked if OnCalendar""" + new_times = [] + for item in times: + if isinstance(item, str): + if re.match(r'^\d{2}:\d{2}$', item): + # Parse "HH:MM" into a datetime object with today's date + now = datetime.now() + hour, minute = map(int, item.split(':')) + dt = now.replace(hour=hour, + minute=minute, + second=0, + microsecond=0) + # If the time has already passed today, schedule for tomorrow + if dt < now: + dt += timedelta(days=1) + item = dt + + new_times.append(item) + elif isinstance(item, datetime): + new_times.append(item) + else: + raise TypeError( + "Each time entry must be a string in HH:MM or systemd OnCalendar format." + ) + + return new_times diff --git a/src/kshift/utils.py b/src/kshift/utils.py deleted file mode 100644 index fa825b8..0000000 --- a/src/kshift/utils.py +++ /dev/null @@ -1,206 +0,0 @@ -from datetime import datetime -from pathlib import Path -import subprocess -import re -import os -import configparser - - -# Gets the names of all available colorschemes -def get_colorschemes(): - arr = [] - - colorscheme_cmd = "plasma-apply-colorscheme -l" - output = subprocess.run(colorscheme_cmd.split(), - capture_output=True, - text=True).stdout.strip() - - for line in output.splitlines(): - r = re.search(" \\* ([A-Za-z]*)", line) - if r: - arr.append(r.group(1)) - - return arr - - -# Gets the current colorscheme -def curr_colorscheme(): - - curr = "" - - colorscheme_cmd = "plasma-apply-colorscheme -l" - output = subprocess.run( - colorscheme_cmd.split(), - stdout=subprocess.PIPE).stdout.decode('utf-8').strip() - - for line in output.splitlines(): - r = re.search(" \\* ([A-Za-z]*) \\(current color scheme\\)", line) - if r: - curr = r.group(1) - break - - return curr - - -def get_iconthemes(): - - arr = [] - - home_dir = Path.home() - old_icon_dir = home_dir / ".icons" - icon_dir = home_dir / ".local/share/icons" - system_icon_dir = Path("/usr/share/icons") - - for path in [old_icon_dir, icon_dir, system_icon_dir]: - if path.exists(): - arr += [item.name for item in path.iterdir() if item.is_dir()] - - return arr - - -# Gets the current icon theme -# TODO FIX, currently broken -def curr_icontheme(): - - curr = "" - kdeconfig_path = f"{os.path.expanduser('~')}/.config/kdeglobals" - - if os.path.exists(kdeconfig_path): - config = configparser.ConfigParser() - config.read(kdeconfig_path) - curr = config["Icons"]["Theme"] - - return curr - - -# Gets the path to plasma-changeicon -def find_plasma_changeicons(): - search_paths = [ - "/usr/local/libexec", "/usr/local/lib", "/usr/libexec", "/usr/lib", - "/usr/lib/x86_64-linux-gnu/libexec", - "/usr/lib/aarch64-linux-gnu/libexec" - ] - - for path in search_paths: - executable_path = os.path.join(path, "plasma-changeicons") - if os.path.isfile(executable_path): - return executable_path - - return None - - -def time_to_systemd(time): - - if time is None: - return time - - process = subprocess.run(["systemd-analyze", "calendar", time], - stdout=subprocess.PIPE) - stat_code = process.returncode - - if stat_code == 0: - calendar_time = "" - output = process.stdout.decode('utf-8').strip() - - for line in output.splitlines(): - r = re.search("Normalized form: (.*)", line) - if r: - calendar_time = r.group(1) - break - - return calendar_time - - else: - raise ValueError("Invalid systemd calendar time!: " + time) - - -# Converting to today's version of datetime for theme comparison -def systemd_to_datetime(time, today: datetime) -> datetime: - - if time is None or type(time).__name__ == "datetime": - return time - - parts = time.split() - - # Y-M-D HH:MM:SS - # OR - # DOW Y-M-D HH:MM:SS - - if len(parts) == 3: - time = " ".join(parts[1:]) - - r = re.search("(.*)-(.*)-(.*) (.*):(.*):(.*)", time) - if r is None: - raise Exception("Bad systemd time format") - - today_broken = [ - today.year, today.month, today.day, today.hour, today.minute, - today.second - ] - final = [] - - for i in range(6): - final.append( - r.group(i + 1) if r.group(i + 1) != '*' else today_broken[i]) - - if len(parts) == 3: - dow = parts[0] - new_time = "{} {}-{}-{} {}:{}:{}".format(dow, *final) - - date = datetime.strptime(new_time, "%a %Y-%m-%d %H:%M:%S") - else: - new_time = "{}-{}-{} {}:{}:{}".format(*final) - - date = datetime.strptime(new_time, "%Y-%m-%d %H:%M:%S") - - return date - - -# Gets the names of all available desktop themes -def get_desktopthemes(): - - arr = [] - - desktopthemes_cmd = "plasma-apply-desktoptheme --list-themes" - output = subprocess.run( - desktopthemes_cmd.split(), - stdout=subprocess.PIPE).stdout.decode('utf-8').strip() - - for line in output.splitlines(): - r = re.search(" \\* ([A-Za-z]*(-[A-Za-z]*)?)", line) - if r: - arr.append(r.group(1)) - - return arr - - -# Gets the current desktoptheme -def curr_desktoptheme(): - curr = "" - - desktoptheme_cmd = "plasma-apply-desktoptheme --list-themes" - output = subprocess.run( - desktoptheme_cmd.split(), - stdout=subprocess.PIPE).stdout.decode('utf-8').strip() - - for line in output.splitlines(): - r = re.search( - " \\* ([A-Za-z]*(-[A-Za-z]*)?) \\(current theme for the Plasma session\\)", - line) - if r: - curr = r.group(1) - break - - return curr - - -def open_in_default_editor(filepath: Path): - if filepath.exists(): - try: - # Use xdg-open to open the file in the default editor - subprocess.run(["xdg-open", filepath], check=True) - print(f"Opened {filepath} in the default editor.") - except subprocess.CalledProcessError as e: - print(f"Failed to open the file: {e}") - else: - print(f"File does not exist: {filepath}") diff --git a/tests/test_api.py b/tests/test_api.py index c72b361..9f9450f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,10 +2,11 @@ def test_get_sundata_real_api(mocker): + from kshift.theme import Colorscheme - # Apply the mock_run behavior to the mock - mock_subprocess_run = mocker.patch("kshift.utils.subprocess.run") - mock_subprocess_run.return_value.stdout = "You have the following color schemes on your system:\n * BreezeClassic\n * BreezeDark (current color scheme)\n * BreezeLight" + mocker.patch.object(Colorscheme, 'available', + ["BreezeClassic", "BreezeDark", "BreezeLight"]) + mocker.patch.object(Colorscheme, 'current', "BreezeDark") from kshift.conf import Config, defaults