commit 40df43929971857260d36c82ec0c700856e5ea33 Author: Kat Inskip Date: Fri Sep 8 17:07:20 2023 -0700 Initial commit, win32 and darwin support only diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6769e21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f627709 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# konawall-py + +A rewrite of konawall-rs in Python so that image manipulation is easier and cross-platform support is sort-of easier. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_errors.py b/custom_errors.py new file mode 100644 index 0000000..e61fc04 --- /dev/null +++ b/custom_errors.py @@ -0,0 +1,14 @@ +class RequestFailed(Exception): + "Raised when a request fails." + + def __init__(self, status_code: int): + self.status_code = status_code + self.message = f"Request failed with status code {self.status_code}" + super().__init__(self.message) + +class UnsupportedPlatform(Exception): + "Raised when the platform is not supported." + + def __init__(self, message: str): + self.message = message + super().__init__(self.message) \ No newline at end of file diff --git a/custom_print.py b/custom_print.py new file mode 100644 index 0000000..e5c9975 --- /dev/null +++ b/custom_print.py @@ -0,0 +1,12 @@ +import termcolor + +""" +Print a key-value pair with a key and value coloured differently. + +:param key: The key to print +:param value: The value to print +:param newline: Whether to print a newline after the value +:returns: None +""" +def kv_print(key: str, value: str, newline: bool = False) -> None: + print(termcolor.colored(key, "cyan") + ": " + termcolor.colored(value, "white"), end="\n" if newline else " ") \ No newline at end of file diff --git a/downloader.py b/downloader.py new file mode 100644 index 0000000..36f656b --- /dev/null +++ b/downloader.py @@ -0,0 +1,31 @@ +import logging +import tempfile +import requests +from custom_print import kv_print + +""" +Download files given a list of URLs + +:param files: A list of URLs to download from +""" +def download_files(files: list) -> list: + logging.debug(f"download_posts() called with files=[{', '.join(files)}]") + # Keep a list of downloaded files + downloaded_files: list = [] + # Download the images + for i, url in enumerate(files): + logging.debug(f"Downloading {url}") + # Get the image data + image = requests.get(url) + # Create a temporary file to store the image + image_file = tempfile.NamedTemporaryFile(delete=False) + logging.debug(f"Created temporary file {image_file.name}") + # Write the image data to the file + image_file.write(image.content) + # Close the file + image_file.close() + # Give the user data about the downloaded image + kv_print(f"Image {str(i+1)}", image_file.name, newline=True) + # Add the file to the list of downloaded files + downloaded_files.append(image_file.name) + return downloaded_files \ No newline at end of file diff --git a/environment.py b/environment.py new file mode 100644 index 0000000..64182e3 --- /dev/null +++ b/environment.py @@ -0,0 +1,60 @@ +import sys +import os +import logging +from custom_errors import UnsupportedPlatform +from module_loader import environment_handlers + +""" +This detects the DE/WM from the Linux environment because it's not provided by the platform +""" +def detect_linux_environment(): + if os.environ.get("SWAYSOCK"): + return "sway" + return_unmodified_if_these = [ + # TODO: implement + "gnome", # dconf + "cinnamon", # dconf + "mate", # dconf + "deepin", # dconf + "xfce4", # xfconf + "lxde", # pcmanfm + "kde", # qdbus + ] + modified_mapping = { + "fluxbox": "feh", + "blackbox": "feh", + "openbox": "feh", + "i3": "feh", + "ubuntustudio": "kde", + "ubuntu": "gnome", + "lubuntu": "lxde", + "xubuntu": "xfce4", + "kubuntu": "kde", + "ubuntugnome": "gnome", + } + desktop_session = os.environ.get("DESKTOP_SESSION") + if desktop_session in return_unmodified_if_these: + return desktop_session + elif desktop_session in modified_mapping: + return modified_mapping[desktop_session] + else: + UnsupportedPlatform(f"Desktop session {desktop_session} is not supported, sorry!") + +def detect_environment(): + if sys.platform == "linux": + environment = detect_linux_environment() + logging.info(f"Detected environment is {environment} running on Linux") + else: + environment = sys.platform + logging.info(f"Detected environment is {environment}") + return environment + +""" + This sets wallpapers on any platform, as long as it is supported. +""" +def set_environment_wallpapers(environment: str, files: list): + if environment in environment_handlers: + environment_handlers[f"{environment}_setter"](files) + logging.info("Wallpapers set!") + else: + UnsupportedPlatform(f"Environment {environment} is not supported, sorry!") \ No newline at end of file diff --git a/environments/darwin.py b/environments/darwin.py new file mode 100644 index 0000000..4481723 --- /dev/null +++ b/environments/darwin.py @@ -0,0 +1,13 @@ +import subprocess +from module_loader import add_environment + +""" +This sets wallpapers on Darwin. + +:param files: A list of files to set as wallpapers +""" +@add_environment("darwin_setter") +def set_wallpapers(files: list, displays: list): + for i, file in enumerate(files): + # Run osascript to set the wallpaper for each monitor + subprocess.run(["osascript", "-e", f'tell application "System Events" to set picture of desktop {i} file "{file}"']) \ No newline at end of file diff --git a/environments/win32.py b/environments/win32.py new file mode 100644 index 0000000..c4fe0f8 --- /dev/null +++ b/environments/win32.py @@ -0,0 +1,28 @@ +import os +import ctypes +import logging +from imager import combine_to_viewport +from module_loader import add_environment + +""" +Pre-setting on Windows +""" +@add_environment("win32_init") +def init(): + os.system("color") + logging.info("Initialized for a Windows environment") + +""" +This sets wallpapers on Windows. + +:param files: A list of files to set as wallpapers +""" +@add_environment("win32_setter") +def set_wallpapers(files: list, displays: list): + if len(files) > 1: + logging.info("Several monitors detected, going the hard route") + file = combine_to_viewport(displays, files) + ctypes.windll.user32.SystemParametersInfoW(20, 0, file, 0) + else: + logging.info("Detected only one monitor, setting wallpaper simply") + ctypes.windll.user32.SystemParametersInfoW(20, 0, files[0] , 0) \ No newline at end of file diff --git a/imager.py b/imager.py new file mode 100644 index 0000000..16d0a13 --- /dev/null +++ b/imager.py @@ -0,0 +1,17 @@ +import tempfile +import logging +from PIL import Image + +def combine_to_viewport(displays: list, files: list): + # Create an image that is the size of the combined viewport, with offsets for each display + max_width = max([display.x + display.width for display in displays]) + max_height = max([display.y + display.height for display in displays]) + combined = Image.new("RGB", (max_width, max_height)) + for i, file in enumerate(files): + open_image = Image.open(file, "r") + resized_image = open_image.resize((displays[i].width, displays[i].height)) + combined.paste(resized_image, (displays[i].x, displays[i].y)) + file = tempfile.NamedTemporaryFile(delete=False) + logging.info(f"Created temporary file {file.name} to save combined viewport image into") + combined.save(file.name, format="PNG") + return file diff --git a/main.py b/main.py new file mode 100644 index 0000000..b8d3fdf --- /dev/null +++ b/main.py @@ -0,0 +1,55 @@ +import os +import logging +import argparse +import screeninfo +from environment import set_environment_wallpapers, detect_environment +from module_loader import import_dir, environment_handlers, source_handlers +from imager import combine_to_viewport + +def main(): + parser = argparse.ArgumentParser( + prog="konawall", + description="Set wallpapers from various sources on various platforms", + epilog="If you need help with this, I'm @floofywitch on Discord and Telegram. ^^;" + ) + + parser.add_argument("-v", "--verbose", help="increase output verbosity", action="store_true") + parser.add_argument("-e", "--environment", help="override the environment detection", type=str) + parser.add_argument("-s", "--source", help="override the source provider", type=str, default="konachan") + parser.add_argument("-c", "--count", help="override the number of wallpapers to fetch", type=int) + parser.add_argument("tags", nargs="+") + args = parser.parse_args() + + + if args.verbose: + logging.basicConfig(level=logging.DEBUG) + else: + logging.basicConfig(level=logging.WARNING) + + logging.debug(f"Called with args={args}") + + import_dir(os.path.join(os.path.dirname(os.path.abspath( __file__ )), "sources")) + logging.info(f"Loaded source handlers: {', '.join(source_handlers)}") + import_dir(os.path.join(os.path.dirname(os.path.abspath( __file__ )), "environments")) + logging.info(f"Loaded environment handlers: {', '.join(environment_handlers)}") + + environment = detect_environment() + environment_handlers[environment + "_init"]() + + displays = screeninfo.get_monitors() + if not args.count: + count = len(displays) + else: + count = args.count + + files = source_handlers[args.source](count, args.tags) + + if not args.environment: + set_environment_wallpapers(environment, files, displays) + else: + environment_handlers[args.environment](files, displays) + logging.info("Wallpapers set!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/module_loader.py b/module_loader.py new file mode 100644 index 0000000..9a5ad76 --- /dev/null +++ b/module_loader.py @@ -0,0 +1,69 @@ +import imp +import os +import re +import inspect +import logging + +global environment_handlers +global source_handlers +environment_handlers = {} +source_handlers = {} + +""" +This finds all modules in a directory + +:param path: The path to the directory +:returns: A set of modules in the directory +""" +def modules_in_dir(path: str) -> set: + result = set() + for entry in os.listdir(path): + if os.path.isfile(os.path.join(path, entry)): + matches = re.search("(.+\.py)$", entry) + if matches: + result.add(matches.group(0)) + return result + +""" +This automatically loads all modules in a directory + +:param path: The path to the directory +""" +def import_dir(path: str): + for filename in sorted(modules_in_dir(path)): + search_path = os.path.join(os.getcwd(), path) + module_name, _ = os.path.splitext(filename) + fp, path_name, description = imp.find_module(module_name, [search_path,]) + imp.load_module(module_name, fp, path_name, description) + +""" +This provides a dynamic way to load environment handlers through a decorator + +:param environment: The name of the environment +:returns: A function for decoration +""" +def add_environment(environment: str) -> callable: + # Get the current frame + frame = inspect.stack()[1] + # From the current frame, extract the relative path to the file + path = frame[0].f_code.co_filename + def wrapper(function): + environment_handlers[environment] = function + logging.info(f"Loaded environment handler {environment} from {path}") + return wrapper + +""" +This provides a dynamic way to load wallpaper sources through a decorator + +:param source: The name of the source +:returns: A function for decoration +""" +def add_source(source: str) -> callable: + # Get the current frame + frame = inspect.stack()[1] + # From the current frame, extract the relative path to the file + path = frame[0].f_code.co_filename + def wrapper(function): + source_handlers[source] = function + logging.info(f"Loaded wallpaper source {source} from {path}") + return wrapper \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bebf66b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Pillow +screeninfo +requests +termcolor \ No newline at end of file diff --git a/sources/konachan.py b/sources/konachan.py new file mode 100644 index 0000000..dd0ed54 --- /dev/null +++ b/sources/konachan.py @@ -0,0 +1,61 @@ +import requests +import logging +from custom_print import kv_print +from custom_errors import RequestFailed +from module_loader import add_source +from downloader import download_files + +""" +Turn a list of tags and a count into a list of URLs to download from + +:param count: The number of images to provide download URLs for +:param user_tags: A list of tags to search for +:returns: A list of URLs to download from +""" +def request_posts(count: int, tags: list) -> list: + logging.debug(f"request_posts() called with count={count}, tags=[{', '.join(tags)}]") + # Make sure we get a different result every time by using "order:random" as a tag + tags.append("order:random") + # Tags are separated by a plus sign for this API + tag_string: str = "+".join(tags) + # Request URL for getting posts from the API + url: str = f"https://konachan.com/post.json?limit={str(count)}&tags={tag_string}" + logging.debug(f"Request URL: {url}") + response = requests.get(url, headers={"User-Agent": "konachan-py/alpha"}) + # Check if the request was successful + logging.debug("Status code: " + str(response.status_code)) + # List of URLs to download + post_urls: list = [] + if response.status_code == 200: + # Get the JSON data from the response + json = response.json() + for post in json: + # Give the user data about the post retrieved + kv_print("Post ID", post["id"]) + kv_print("Author", post["author"]) + kv_print("Rating", post["rating"]) + kv_print("Resolution", f"{post['width']}x{post['height']}", newline=True) + kv_print("Tags", post["tags"], newline=True) + kv_print("URL", post["file_url"], newline=True) + # Append the URL to the list + post_urls.append(post["file_url"]) + else: + # Raise an exception if the request failed + RequestFailed(response.status_code) + return post_urls + +""" +Download a number of images from Konachan given a list of tags and a count + +:param count: The number of images to download +:param tags: A list of tags to search for +""" +@add_source("konachan") +def handle(count: int, tags: list) -> list: + logging.debug(f"handle_konachan() called with count={count}, tags=[{', '.join(tags)}]") + # Get a list of URLs to download + post_urls: list = request_posts(count, tags) + # Download the images + files = download_files(post_urls) + # Return the downloaded files + return files \ No newline at end of file