Wallman/wallman/wallman_lib.py

486 lines
22 KiB
Python
Raw Normal View History

from os import chdir, getenv, system, path
2024-06-03 19:38:03 +00:00
import logging
import tomllib
from datetime import datetime, time
from apscheduler.schedulers.background import BackgroundScheduler
2024-06-03 19:38:03 +00:00
from apscheduler.triggers.cron import CronTrigger
2025-02-03 21:35:51 +00:00
from typing import Dict, List
2024-06-03 19:38:03 +00:00
from wallman.wallman_classes import ConfigError, ConfigGeneral, ConfigFile
2024-10-16 17:39:13 +00:00
# Setup Logging. NOTE: Declaration as a global variable is necessary to ensure correct functionality across multiple modules.
global logger
2024-09-01 17:29:04 +00:00
logger = logging.getLogger("wallman")
2024-06-03 19:38:03 +00:00
2025-02-27 16:28:32 +00:00
2025-02-26 14:26:24 +00:00
class _Config:
# Initializes the most important config values.
def __init__(self) -> None:
# Config file
2025-02-27 16:28:32 +00:00
self.config_file: ConfigFile = self._initialize_config() # Full config
# Config general
valid_general: bool = self._initialize_general()
if not valid_general:
logger.critical("The general dictionary was not found or contains errors")
print("CRITICAL: The general dictionary was not found or contains errors")
raise ConfigError("The general dictionary was not found or contains errors")
# Changing times
valid_changing_times: bool = self._initialize_changing_times()
if not valid_changing_times:
2025-02-27 16:28:32 +00:00
logger.critical(
"The amount of provided changing times does not match the amount of wallpapers per set, or the dictionary has not been found in the config file."
)
print(
"CRITICAL: The amount of provided changing times does not match the amount of wallpapers per set, or the dictionary has not been found in the config file."
)
2025-02-27 12:50:58 +00:00
raise ConfigError
# Wallpaper sets
valid_wallpaper_amount: bool = self._check_wallpaper_amount()
if not valid_wallpaper_amount:
2025-02-27 16:28:32 +00:00
raise ConfigError(
"The amount of wallpapers in a set does not match the amount of wallpapers_per_set provided in general."
)
2024-09-01 17:29:04 +00:00
2025-02-26 14:26:24 +00:00
# Read config
def _initialize_config(self) -> ConfigFile:
2024-10-16 17:39:13 +00:00
chdir(str(getenv("HOME")) + "/.config/wallman/")
2024-09-01 17:29:04 +00:00
try:
with open("wallman.toml", "rb") as config_file:
2025-02-27 16:28:32 +00:00
data: ConfigFile = tomllib.load(config_file) # type: ignore #pyright:ignore
return data
except FileNotFoundError:
2025-02-27 16:28:32 +00:00
raise FileNotFoundError(
"No config file could be found in ~/.config/wallman/wallman.toml"
)
except tomllib.TOMLDecodeError as e:
print("ERROR: Config could not be parsed: Invalid TOML Syntax")
raise e
def _verify_systray_deps(self):
from importlib import util
2025-02-27 16:28:32 +00:00
2025-02-04 00:05:53 +00:00
if util.find_spec("pystray") is None or util.find_spec("PIL") is None:
2025-02-27 16:28:32 +00:00
logger.error(
"systray is enabled, but dependencies for the systray couldn't be found. Are pystray and pillow installed?"
)
logger.info("Setting self.config_systray to false.")
2025-02-27 16:28:32 +00:00
print(
"ERROR: systray is enabled, but dependencies for the systray couldn't be found. Are pystray and pillow installed?"
)
2024-09-01 17:29:04 +00:00
self.config_systray = False
2025-02-27 16:28:32 +00:00
def _set_log_level(self) -> None:
global logging
global logger
2024-09-02 11:16:20 +00:00
chdir("/var/log/wallman/")
numeric_level: int = getattr(logging, self.config_log_level, logging.INFO)
logger.setLevel(numeric_level)
if not path.exists("wallman.log"):
system("touch wallman.log")
2025-02-27 16:28:32 +00:00
logging.basicConfig(
filename="wallman.log", encoding="utf-8", level=numeric_level
)
def _set_behavior(self) -> str:
try:
2025-02-27 16:28:32 +00:00
behavior: str = self.config_general["behavior"]
except KeyError:
2025-02-27 16:28:32 +00:00
logger.warning(
"There is no wallpaper behavior specified in general, defaulting to fill..."
)
print(
"WARNING: There is no wallpaper behavior specified in general, defaulting to fill..."
)
human_behaviors: List[str] = ["plain", "tile", "center", "fill", "max", "scale"]
2025-02-27 16:28:32 +00:00
machine_behaviors: List[str] = [
"--bg",
"--bg-tile",
"--bg-center",
"--bg-fill",
"--bg-max",
"--bg-scale",
]
behavior: str = self.config_general.get("behavior", "--bg-fill").lower()
if behavior not in human_behaviors and behavior not in machine_behaviors:
2025-02-27 16:28:32 +00:00
logging.error(
f"The value provided for behaviors, {behavior}, is not valid. Defaulting to fill..."
)
print(
f"ERROR: The value provided for behaviors, {behavior}, is not valid. Defaulting to --bg-fill..."
)
if behavior not in machine_behaviors:
match behavior:
case "plain":
behavior = "--bg"
case "tile":
behavior = "--bg-tile"
case "center":
behavior = "--bg-center"
case "max":
behavior = "--bg-max"
case "scale":
behavior = "--bg-scale"
case _:
behavior = "--bg-fill"
logger.info(f"The wallpaper behavior '{behavior}' has been set.")
return behavior
def _set_fallback_wallpaper(self) -> None:
if self.config_fallback_wallpaper:
2025-02-27 16:28:32 +00:00
successfully_set: int = system(
f"feh {self.config_behavior} --no-fehbg {self.config_fallback_wallpaper}"
)
if successfully_set == 0:
logger.info("The fallback Wallpaper has been set.")
else:
2025-02-27 16:28:32 +00:00
logger.critical(
"An Error occured and no fallback wallpaper was provided, exiting..."
)
raise ConfigError(
"An error occured and no fallback wallpaper has been set, exiting..."
)
2024-06-03 19:38:03 +00:00
2025-02-26 14:26:24 +00:00
def _initialize_general(self) -> bool:
# Create Config General Dict
try:
self.config_general: ConfigGeneral = self.config_file["general"]
except KeyError:
print("CRITICAL: No general dictionary found in Config file.")
2025-02-27 16:28:32 +00:00
raise ConfigError(
"The general dictionary could not be found in the config, exiting!"
)
2025-02-26 14:26:24 +00:00
# Set up logger.
self.config_log_level = self.config_general.get("log_level", "INFO").upper()
self._set_log_level()
logger.debug(f"Log level has been set to {self.config_log_level}")
logger.debug("Logger initialized successfully")
# Set up fallback wallpaper
2025-02-27 16:28:32 +00:00
self.config_fallback_wallpaper: str = self.config_general.get(
"fallback_wallpaper", "/etc/wallman/DefaultFallbackWallpaper.jpg"
)
logger.debug(f"Set fallback wallpaper: {self.config_fallback_wallpaper}")
# Wallpapers per set
try:
2025-02-27 16:28:32 +00:00
self.config_wallpapers_per_set: int = self.config_general[
"wallpapers_per_set"
]
logger.debug(
f"Set config_wallpapers_per_set to {self.config_wallpapers_per_set}"
)
except KeyError:
2025-02-27 16:28:32 +00:00
print(
"CRITICAL: No option wallpapers_per_set provided in the general dictionary. Attempting to set the fallback wallpaper"
)
logger.critical(
"No option wallpapers_per_set provided in the general dictionary. Attempting to set the fallback wallpaper"
)
self._set_fallback_wallpaper()
return False
# Are wallpaper sets enabled to begin with?
try:
2025-02-27 16:28:32 +00:00
self.config_wallpaper_sets_enabled: bool = self.config_general[
"enable_wallpaper_sets"
]
logger.debug(
f"Set config_wallpaper_sets_enabled to {self.config_wallpaper_sets_enabled}"
)
except KeyError:
2025-02-27 16:28:32 +00:00
logger.critical(
"No option enable_wallpaper_sets provided in the general dictionary. Attempting to set the fallback wallpaper"
)
print(
"CRITICAL: No option enable_wallpaper_sets provided in the general dictionary. Attempting to set the fallback wallpaper"
)
self._set_fallback_wallpaper()
return False
# Configure used sets
if self.config_wallpaper_sets_enabled:
try:
self.config_used_sets: List[str] = self.config_general["used_sets"]
2025-02-27 16:28:32 +00:00
logger.debug(
f"These wallpaper sets are in use: {self.config_used_sets}"
)
except KeyError:
2025-02-27 16:28:32 +00:00
print(
"CRITICAL: No array used_sets provided in the general dictionary. Attempting to set the fallback wallpaper."
)
logger.critical(
"No array used_sets provided in the general dictionary. Attempting to set the fallback wallpaper."
)
self._set_fallback_wallpaper()
return False
# Systray
try:
self.config_systray: bool = self.config_general["systray"]
logger.debug(f"config_systray has been set to: {self.config_systray}")
except KeyError:
self.config_systray: bool = True
logger.warning("No option systray found in general. Defaulting to true...")
if self.config_systray:
self._verify_systray_deps()
# Wallpaper behavior
self.config_behavior = self._set_behavior()
# Notifications
try:
self.config_notify: bool = self.config_general["notify"]
logger.debug(f"Set config_notify to {self.config_notify}.")
except KeyError:
self.config_notify: bool = False
2025-02-27 16:28:32 +00:00
logger.warning(
"notify is not set in dictionary general in the config file, defaulting to 'false'."
)
2025-02-26 14:26:24 +00:00
return True
def _initialize_changing_times(self) -> bool:
try:
2025-02-27 16:28:32 +00:00
self.config_changing_times: Dict[str, str] = self.config_file[
"changing_times"
]
2025-02-27 12:50:58 +00:00
self.config_total_changing_times: int = len(self.config_changing_times)
logger.debug(f"Changing times are {self.config_changing_times}")
except KeyError:
2025-02-27 16:28:32 +00:00
logger.critical(
"No dictionary called changing_times has been found in the config file."
)
print(
"CRITICAL: No dictionary called changing_times has been found in the config file."
)
return False
return self._wallpapers_per_set_and_changing_times_match()
2024-06-03 19:38:03 +00:00
def _wallpapers_per_set_and_changing_times_match(self) -> bool:
2024-06-03 19:38:03 +00:00
# Check if the amount of wallpapers_per_set and given changing times match
if self.config_total_changing_times == self.config_wallpapers_per_set:
2025-02-27 16:28:32 +00:00
logger.debug(
"The amount of changing times and wallpapers per set is set correctly"
)
return True
2024-06-03 19:38:03 +00:00
else:
try:
self._set_fallback_wallpaper()
2025-02-27 16:28:32 +00:00
logger.critical(
"The amount of changing_times and the amount of wallpapers_per_set does not match, the fallback wallpaper has been set."
)
print(
"CRITICAL: The amount of changing_times and the amount of wallpapers_per_set does not match, the fallback wallpaper has been set."
)
return False
2024-06-03 19:38:03 +00:00
except ConfigError:
2025-02-27 16:28:32 +00:00
logger.critical(
"The amount of changing times and the amount of wallpapers per set does not match, exiting..."
)
print(
"CRITICAL: The amount of changing times and the amount of wallpapers per set does not match, exiting..."
)
raise ConfigError(
"The amount of changing times and the amount of wallpapers per set does not match."
)
2024-06-03 19:38:03 +00:00
def _check_wallpaper_amount(self) -> bool:
2024-06-03 19:38:03 +00:00
# This block checks if if each wallpaper set dictionary provides enough wallpapers to satisfy wallpapers_per_set
for wallpaper_set in self.config_used_sets:
2025-02-27 16:28:32 +00:00
if len(self.config_file[wallpaper_set]) == self.config_wallpapers_per_set: # type: ignore
2024-06-03 19:38:03 +00:00
logger.debug(f"Dictionary {wallpaper_set} has sufficient values.")
return True
2024-06-03 19:38:03 +00:00
else:
try:
self._set_fallback_wallpaper()
2025-02-27 16:28:32 +00:00
logger.error(
f"The Dictionary {wallpaper_set} does not have sufficient entries, the fallback wallpaper has been set."
)
print(
f"ERROR: The Dictionaty {wallpaper_set} does not have sufficient entries, the fallback wallpaper has been set."
)
return False
2024-06-03 19:38:03 +00:00
except ConfigError:
2025-02-27 16:28:32 +00:00
logger.critical(
f"Dictionary {wallpaper_set} does not have sufficient entries, exciting..."
)
print(
f"Dictionary {wallpaper_set} does not have sufficient entries, exciting..."
)
return False
2024-06-03 19:38:03 +00:00
2024-10-16 17:39:13 +00:00
# TODO: Improve modularity. See notes inside the class for more details.
# TODO: Ensure functionality and if needed add handling for the 1 wallpaper per set case.
2025-02-26 14:26:24 +00:00
class WallpaperLogic(_Config):
def __init__(self) -> None:
2024-06-03 19:38:03 +00:00
super().__init__()
2025-02-27 16:28:32 +00:00
self.wallpaper_list: List[str] = None # type: ignore # pyright: ignore
self.chosen_wallpaper_set: str = None # type: ignore # pyright: ignore
2024-06-03 19:38:03 +00:00
2024-10-16 17:39:13 +00:00
# NOTE: This function could be in a different file because it's not needed in the case only 1 wallpaper per set is needed.
2024-06-03 19:38:03 +00:00
# Returns a list of a split string that contains a changing time from the config file
def _clean_times(self, desired_time: int) -> List[str]:
unclean_times: str = list(self.config_changing_times.values())[desired_time]
2024-06-03 19:38:03 +00:00
return unclean_times.split(":")
2024-10-16 17:39:13 +00:00
# NOTE: This could be in a different file because it's not needed in the "Only one wallpaper set" case.
2024-06-03 19:38:03 +00:00
def _choose_wallpaper_set(self) -> None:
from random import choice as choose_from
2025-02-27 16:28:32 +00:00
2024-06-03 19:38:03 +00:00
self.chosen_wallpaper_set = choose_from(self.config_used_sets)
2025-02-27 16:28:32 +00:00
self.wallpaper_list: List[str] = list(
self.config_file[self.chosen_wallpaper_set].values()
) # type: ignore
2024-06-03 19:38:03 +00:00
logger.debug(f"Chose wallpaper set {self.chosen_wallpaper_set}")
2024-10-16 17:39:13 +00:00
# NOTE: Same as _clean_times()
2024-06-03 19:38:03 +00:00
# Verify if a given time is in a given range
def _time_in_range(self, start: time, end: time, x: time) -> bool:
2024-06-03 19:38:03 +00:00
if start <= end:
return start <= x <= end
else:
return start <= x or x < end
2024-10-16 17:39:13 +00:00
# NOTE: Potentially add handling for this to be also usable for notify_user and add logging if notify_user fails. Consider adding an argument that is where it's called from and handle accordingly.
def _check_system_exitcode(self, code: int) -> bool:
if code != 0:
try:
2024-06-08 04:36:12 +00:00
self._set_fallback_wallpaper()
2025-02-27 16:28:32 +00:00
logger.error(
f"The wallpaper {self.wallpaper_list[self.current_time_range]} has not been found, the fallback wallpaper has been set. Future wallpapers will still attempted to be set."
)
print(
f"ERROR: The wallpaper {self.wallpaper_list[self.current_time_range]} has not been found, the fallback wallpaper has been set. Future wallpapers will still attempted to be set."
)
return False
except ConfigError:
2025-02-27 16:28:32 +00:00
logger.error(
f"The wallpaper {self.wallpaper_list[self.current_time_range]} has not been found and no fallback wallpaper has been set. Future wallpapers will still attempted to be set."
)
print(
f"ERROR: The wallpaper {self.wallpaper_list[self.current_time_range]} has not been found and no fallback wallpaper has been set. Future wallpapers will still attempted to be set."
)
return False
else:
2025-02-27 16:28:32 +00:00
logger.info(
f"The wallpaper {self.wallpaper_list[self.current_time_range]} has been set."
)
return True
2024-10-16 17:39:13 +00:00
# NOTE: Add error handling in case libnotify is not installed or notify-send fails for any other reason.
# TODO: Add a check whether config[notify] is true or not.
2024-06-03 19:38:03 +00:00
def _notify_user(self):
system("notify-send 'Wallman' 'A new Wallpaper has been set.'")
logger.debug("Sent desktop notification.")
2024-10-16 17:39:13 +00:00
# TODO: Clean this up. It's way too large and way too intimidating.
# NOTE: This could be in a different for the case that the user only wants 1 wallpaper per set.
# TODO: Add an way for the user to choose if the wallpaper should scale, fill or otherwise. This needs to be editable in the config file.
def set_wallpaper_by_time(self) -> bool:
2024-06-03 19:38:03 +00:00
# Ensure use of a consistent wallpaper set
if not self.chosen_wallpaper_set:
2024-06-03 19:38:03 +00:00
self._choose_wallpaper_set()
for time_range in range(self.config_total_changing_times - 1):
2025-02-27 16:28:32 +00:00
self.current_time_range = (
time_range # Store current time for better debugging output
)
clean_time: List[str] = self._clean_times(time_range)
clean_time_two: List[str] = self._clean_times(time_range + 1)
2024-10-16 17:39:13 +00:00
# HACK on this to make it more readable. This function call is way too long. Consider storing these in a bunch of temporary variables, though keep function length in mind.
# HACK on this to see if this logic can be simplified. It's very ugly to check it that way.
2024-06-03 19:38:03 +00:00
# Check if the current time is between a given and the following changing time and if so, set that wallpaper. If not, keep trying.
2025-02-27 16:28:32 +00:00
if self._time_in_range(
time(int(clean_time[0]), int(clean_time[1]), int(clean_time[2])),
time(
int(clean_time_two[0]),
int(clean_time_two[1]),
int(clean_time_two[2]),
),
datetime.now().time(),
):
exitcode: int = system(
f"feh {self.config_behavior} --no-fehbg --quiet {self.wallpaper_list[time_range]}"
)
has_wallpaper_been_set: bool = self._check_system_exitcode(exitcode)
2024-10-16 17:39:13 +00:00
# TODO: Add this check to _notify_user.
2024-06-03 19:38:03 +00:00
if self.config_notify:
self._notify_user()
return has_wallpaper_been_set
2024-06-03 19:38:03 +00:00
else:
continue
2025-02-27 16:28:32 +00:00
exitcode: int = system(
f"feh {self.config_behavior} --no-fehbg {self.wallpaper_list[-1]}"
)
has_wallpaper_been_set: bool = self._check_system_exitcode(exitcode)
2024-06-03 19:38:03 +00:00
if self.config_notify:
self._notify_user()
return has_wallpaper_been_set
2024-06-03 19:38:03 +00:00
2024-10-16 17:39:13 +00:00
# NOTE: Consider avoiding nested functions.
def schedule_wallpapers(self) -> None:
def _schedule_background_wallpapers() -> BackgroundScheduler:
2024-09-01 17:29:04 +00:00
from apscheduler.schedulers.background import BackgroundScheduler
2025-02-27 16:28:32 +00:00
2024-09-01 17:29:04 +00:00
scheduler = BackgroundScheduler()
# Create a scheduled job for every changing time
2024-10-16 17:39:13 +00:00
# NOTE: This should be a function.
2024-09-01 17:29:04 +00:00
for changing_time in range(len(self.config_changing_times)):
clean_time = self._clean_times(changing_time)
2025-02-27 16:28:32 +00:00
scheduler.add_job(
self.set_wallpaper_by_time,
trigger=CronTrigger(
hour=clean_time[0], minute=clean_time[1], second=clean_time[2]
),
)
2024-09-01 17:29:04 +00:00
scheduler.start()
logger.info("The background scheduler has been started.")
return scheduler
def _schedule_blocking_wallpapers() -> None:
2024-09-01 17:29:04 +00:00
from apscheduler.schedulers.blocking import BlockingScheduler
2025-02-27 16:28:32 +00:00
2024-09-01 17:29:04 +00:00
scheduler = BlockingScheduler()
# Create a scheduled job for every changing time
2024-10-16 17:39:13 +00:00
# NOTE: Thisshould be a function.
2024-09-01 17:29:04 +00:00
for changing_time in range(len(self.config_changing_times)):
clean_time = self._clean_times(changing_time)
2025-02-27 16:28:32 +00:00
scheduler.add_job(
self.set_wallpaper_by_time,
trigger=CronTrigger(
hour=clean_time[0], minute=clean_time[1], second=clean_time[2]
),
)
2024-09-01 17:29:04 +00:00
logger.info("The blocking scheduler has been started.")
scheduler.start()
2024-09-01 17:29:04 +00:00
if self.config_systray:
2025-02-04 00:31:35 +00:00
import wallman.wallman_systray as systray
2024-09-01 17:29:04 +00:00
from functools import partial
scheduler: BackgroundScheduler = _schedule_background_wallpapers()
2025-02-27 16:28:32 +00:00
menu: systray.Menu = systray.Menu(
systray.item(
"Re-Set Wallpaper",
partial(
systray.set_wallpaper_again,
wallpaper_setter=self.set_wallpaper_by_time,
),
),
systray.item(
"Reroll Wallpapers",
partial(
systray.reroll_wallpapers,
wallpaper_chooser=self._choose_wallpaper_set,
wallpaper_setter=self.set_wallpaper_by_time,
),
),
systray.item(
"Quit",
partial(systray.on_quit, shutdown_scheduler=scheduler.shutdown),
),
)
icon = systray.Icon(
"wallman_icon", systray.icon_image, "My Tray Icon", menu
2024-09-01 17:29:04 +00:00
)
icon.run()
else:
_schedule_blocking_wallpapers()