spaceoddity.py

The main file that runs the program

This file is executable and can be called from the terminal like:

foo@bar:~$ cd [path to directory of this file] foo@bar:~[path to directory of this file] ./spaceoddity.py [cmd line]

or if installed in a global location:

foo@bar:~$ spaceoddity [cmd line]

Typical usage is show in the main() method.

Spaceoddity

Bases: SpaceoddityBase

The main class, responsible for the operation of the program

Public methods

main: The main method of the program

This class does the most of the work of a typical CLI program. It parses command line options, loads/saves config files, and performs the operations required for the program.

Source code in src/spaceoddity.py
class Spaceoddity(SpaceoddityBase):
    """
    The main class, responsible for the operation of the program

    Public methods:
        main: The main method of the program

    This class does the most of the work of a typical CLI program. It parses
    command line options, loads/saves config files, and performs the operations
    required for the program.
    """

    # --------------------------------------------------------------------------
    # Constants
    # --------------------------------------------------------------------------

    # --------------------------------------------------------------------------
    # strings

    # the url to load json from
    S_APOD_URL = (
        "https://api.nasa.gov/planetary/apod?"
        "api_key=K0sNPQo8Dn9f8kaO35hzs8kUnU9bHwhTtazybTbr"
    )

    # cmd line options

    # enable option strings
    S_ARG_ENABLE_OPTION = "--enable"
    S_ARG_ENABLE_ACTION = "store_true"
    S_ARG_ENABLE_DEST = "ENABLE_DEST"
    # I18N: enable mode help
    S_ARG_ENABLE_HELP = _("enable the program to run automatically")

    # disable option strings
    S_ARG_DISABLE_OPTION = "--disable"
    S_ARG_DISABLE_ACTION = "store_true"
    S_ARG_DISABLE_DEST = "DISABLE_DEST"
    # I18N: disable mode help
    S_ARG_DISABLE_HELP = _("disable the program from running automatically")

    # edit option strings
    S_ARG_EDIT_OPTION = "-e"
    S_ARG_EDIT_ACTION = "store_true"
    S_ARG_EDIT_DEST = "EDIT_DEST"
    # I18N: edit config file
    S_ARG_EDIT_HELP = _("edit the program's configuration file")

    # gui option strings
    S_ARG_GUI_OPTION = "-g"
    S_ARG_GUI_ACTION = "store_true"
    S_ARG_GUI_DEST = "GUI_DEST"
    # I18N: gui edit config file
    S_ARG_GUI_HELP = _("run gui to edit the program's configuration file")

    # edit option strings
    S_ARG_LOG_OPTION = "-l"
    S_ARG_LOG_ACTION = "store_true"
    S_ARG_LOG_DEST = "LOG_DEST"
    # I18N: view log file
    S_ARG_LOG_HELP = _("view the program's log file")

    # messages

    # I18N: enable cron job
    S_MSG_CRON_ADD = _("Enabling cron job... ")
    # I18N: disable cron job
    S_MSG_CRON_DEL = _("Disabling cron job... ")
    # I18N: update cron job
    S_MSG_CRON_UPD = _("Updating cron job... ")
    # I18N: get initial apod dict
    S_MSG_GET = _("Getting data from server... ")
    # I18N: check for media type
    S_MSG_MEDIA = _("Checking for media type... ")
    # I18N: new download is not image
    S_MSG_NOT_IMG = _("The new APOD is not an image")
    # I18N: check for same url
    S_MSG_URL = _("Checking for same URL... ")
    # I18N: no change, exit
    S_MSG_SAME_URL = _("The APOD picture has not changed")
    # I18N: download succeeded
    S_MSG_DL = _("Downloading image... ")
    # I18N: convert to png
    S_MSG_CONVERT = _("Converting image to png... ")
    # I18N: get screen size
    S_MSG_SCR_SIZE = _("Getting screen size... ")
    # I18N: resize and crop
    S_MSG_RESIZE = _("Resizing and cropping image... ")
    # I18N: make caption
    S_MSG_MAKE_CAP = _("Making caption... ")
    # I18N: set image as background
    S_MSG_SET = _("Setting image as background... ")

    # success/fail for any operation
    # I18N: success
    S_MSG_DONE = _("Done")
    # I18N: fail
    S_MSG_FAIL = _("Failed")

    # errors

    # I18N: env failed or returned empty value(s)
    S_ERR_CRON_VAL = _("Could not get env values")
    # I18N: failed to open log file
    S_ERR_LOG = _("Could not open log file")
    # I18N: error on initial get
    # NB: param is error msg
    S_ERR_GET = _("Could not get data from server: {}")
    # I18N: could not download new image
    # NB: param is error msg
    S_ERR_DL = _("Could not download image: {}")
    # I18N: failed to get screen size, fatal error
    S_ERR_NO_SCR = _("Could not get screen size")
    # I18N: could not set new image
    # NB: param is error msg
    S_ERR_SET = _("Could not set new image: {}")
    # I18N: could not edit config file
    S_ERR_EDIT = _("Could not edit configuration file")
    # I18N: could not load GUI
    S_ERR_GUI = _("Could not load GUI")

    # file names
    # NB: format param is current ext
    S_NAME_DL = "apod.{}"
    S_NAME_EXT_PNG = ".png"
    S_NAME_PNG = "apod.png"
    S_NAME_CAP_PNG = "apod_cap.png"

    # commands to set image
    # NB: param is file path
    S_CMD_SET_LIGHT = (
        "gsettings set org.gnome.desktop.background picture-uri file://{}"
    )
    # NB: param is file path
    S_CMD_SET_DARK = (
        "gsettings set org.gnome.desktop.background picture-uri-dark file://{}"
    )

    # commands
    S_CMD_EDIT = f"/usr/bin/editor {B.P_CFG_DEF}"
    S_CMD_LOG = f"/usr/bin/editor {B.P_LOG_DEF}"
    S_CMD_GUI = f"spaceoddity-gui {B.P_CFG_DEF}"

    # lists
    # acceptable media types
    L_MEDIA_TYPES = ["image"]

    # dicts
    # set default config dict
    D_DEFAULT = {
        K.S_KEY_CRON: {
            K.S_KEY_CRON_ENABLED: True,
            K.S_KEY_CRON_INTERVAL: 10,
        },
        K.S_KEY_CAPTION: {
            K.S_KEY_CAPTION_SHOW: True,
            K.S_KEY_CAPTION_POS: K.S_KEY_CAP_POS_BR,
            K.S_KEY_CAPTION_WRAP: 80,
        },
        K.S_KEY_INFO: {
            K.S_KEY_APOD_TITLE: True,
            K.S_KEY_APOD_DATE: True,
            K.S_KEY_APOD_COPY: True,
            K.S_KEY_APOD_EXP: True,
        },
        K.S_KEY_FONT: {
            K.S_KEY_FONT_NAME: "",
            K.S_KEY_FONT_SIZE: 20,
            K.S_KEY_FONT_COLOR: [0, 0, 0],
            K.S_KEY_FONT_TRANS: 255,
        },
        K.S_KEY_BOX: {
            K.S_KEY_BOX_COLOR: [255, 255, 255],
            K.S_KEY_BOX_TRANS: 128,
            K.S_KEY_BOX_RAD: 20,
            K.S_KEY_BOX_PAD: 10,
        },
        K.S_KEY_PAD: {
            K.S_KEY_PAD_L: 10,
            K.S_KEY_PAD_T: 45,
            K.S_KEY_PAD_R: 10,
            K.S_KEY_PAD_B: 10,
        },
        K.S_KEY_APOD: {},
        K.S_KEY_SCREEN: {
            K.S_KEY_SCREEN_WIDTH: 0,
            K.S_KEY_SCREEN_HEIGHT: 0,
        },
    }

    # --------------------------------------------------------------------------
    # Initialize the new object
    # --------------------------------------------------------------------------
    def __init__(self):
        """
        Initialize the new object

        Initializes a new instance of the class, setting the default values
        of its properties, and any other code that needs to run to create a
        new object.
        """

        # do super init
        super().__init__()

        # set default cfg dict
        self._dict_cfg = self.D_DEFAULT.copy()

        # paths to images
        self._path_img = B.P_DIR_CONF / self.S_NAME_PNG
        self._path_img_cap = B.P_DIR_CONF / self.S_NAME_CAP_PNG

    # --------------------------------------------------------------------------
    # Public methods
    # --------------------------------------------------------------------------

    # --------------------------------------------------------------------------
    # The main method of the program
    # --------------------------------------------------------------------------
    def main(self):
        """
        The main method of the program

        This method is the main entry point for the program, initializing the
        program, and performing its steps.
        """

        self._logger.info("--------------------------------------------------")

        # call boilerplate code
        self._setup()

        # NB: self._dict_args/self._dict_cfg now available

        # sanitize dict_cfg (in situ)
        sanitize.do_sanitize(self._dict_cfg)

        # ----------------------------------------------------------------------
        # check for edit option next
        # NB: this will continue when done
        arg_edit = self._dict_args[self.S_ARG_EDIT_DEST]
        if arg_edit:
            self._handle_edit()  # ignore all others and continue

        # ----------------------------------------------------------------------
        # NEXT: check for gui option next
        # NB: this will continue when done
        # arg_gui = self._dict_args[self.S_ARG_GUI_DEST]
        # if arg_gui:
        #     self._handle_gui(self._arg_debug)  # -d and cont if ok/save/apply

        # ----------------------------------------------------------------------
        # check for log option first
        # NB: this will exit when done
        arg_log = self._dict_args[self.S_ARG_LOG_DEST]
        if arg_log:
            self._handle_log()  # ignore all others and quit

        # ----------------------------------------------------------------------
        # handle enable/disable next
        # NB: whether you edit the file by hand or not, cmd line overrides it
        self._handle_cron()  # ignore all others and quit if --disable

        # ----------------------------------------------------------------------

        # get json from nasa and make a dict
        dict_new = self._get_apod_dict()

        # check if media type is good and the url has changed
        should_dl = self._check_media_type(
            dict_new
        ) and not self._check_same_url(dict_new)

        # ----------------------------------------------------------------------

        # image is valid type and url has changed
        if should_dl:

            # apply new dict to config
            self._dict_cfg[K.S_KEY_APOD] = dict_new

            # download new image
            self._get_apod_image()
            self._convert_to_png()
            self._get_screen_size()
            self._resize_and_crop()

        # ----------------------------------------------------------------------

        # make new caption
        # NB: always do this to check for cfg changes
        self._do_caption()

        # always do this
        self._set_image()

        # ----------------------------------------------------------------------
        # teardown

        # call boilerplate code
        self._teardown()

    # --------------------------------------------------------------------------
    # Private methods
    # --------------------------------------------------------------------------

    # NB: these are the main steps, called in order from main

    # --------------------------------------------------------------------------
    # Boilerplate to use at the start of main
    # --------------------------------------------------------------------------
    def _setup(self):
        """
        Boilerplate to use at the start of main

        Perform some mundane stuff like setting properties.
        If you implement this function. make sure to call super() LAST!!!
        """

        # ----------------------------------------------------------------------

        # get a mutually exclusive group
        group = self._parser.add_mutually_exclusive_group()

        # add debug option
        group.add_argument(
            self.S_ARG_ENABLE_OPTION,
            action=self.S_ARG_ENABLE_ACTION,
            dest=self.S_ARG_ENABLE_DEST,
            help=self.S_ARG_ENABLE_HELP,
        )

        # add debug option
        group.add_argument(
            self.S_ARG_DISABLE_OPTION,
            action=self.S_ARG_DISABLE_ACTION,
            dest=self.S_ARG_DISABLE_DEST,
            help=self.S_ARG_DISABLE_HELP,
        )

        # ----------------------------------------------------------------------

        # add edit option
        self._parser.add_argument(
            self.S_ARG_EDIT_OPTION,
            action=self.S_ARG_EDIT_ACTION,
            dest=self.S_ARG_EDIT_DEST,
            help=self.S_ARG_EDIT_HELP,
        )

        # NEXT: add gui option
        # add gui option
        # self._parser.add_argument(
        #     self.S_ARG_GUI_OPTION,
        #     action=self.S_ARG_GUI_ACTION,
        #     dest=self.S_ARG_GUI_DEST,
        #     help=self.S_ARG_GUI_HELP,
        # )

        # NEXT: move to base (_add_cmd_log)
        # add log option
        self._parser.add_argument(
            self.S_ARG_LOG_OPTION,
            action=self.S_ARG_LOG_ACTION,
            dest=self.S_ARG_LOG_DEST,
            help=self.S_ARG_LOG_HELP,
        )

        # NEXT: move to base (_add_cmd_debug)
        # add debug option (all this stuff is in super)
        self._parser.add_argument(
            self.S_ARG_DBG_OPTION,
            action=self.S_ARG_DBG_ACTION,
            dest=self.S_ARG_DBG_DEST,
            help=self.S_ARG_DBG_HELP,
        )

        # NEXT: _add_cmd_debug
        # NEXT: _add_cmd_log
        # NEXT: _add_cmd_uninst

        # do super setup after add custom cmd line opts (this is where parse
        # happens)
        super()._setup()
        # NB: self._dict_args/self._dict_cfg now available

        # ----------------------------------------------------------------------
        # print stuff

        print()
        print(self.S_ABOUT)
        print()
        print(self.S_ABOUT_HELP)
        print()

    # --------------------------------------------------------------------------
    # Handle the edit (-e) cmd option
    # --------------------------------------------------------------------------
    def _handle_edit(self):
        """
        Handle the edit (-e) cmd option

        This function offloads handling of the edit command. It opens the
        config file in the default CLI editor.
        """

        # load cfg file in nano
        try:
            F.run(self.S_CMD_EDIT)
        except F.CNRunError as e:

            F.printc(self.S_ERR_EDIT, fg=F.C_FG_WHITE, bg=F.C_BG_RED)
            print(e.output)

            self._logger.error(self.S_ERR_EDIT)
            self._logger.error(e.output)

            sys.exit(-1)

        # reload config file
        self._load_config()

        # (re) sanitize dict_cfg after edit
        sanitize.do_sanitize(self._dict_cfg)

    # --------------------------------------------------------------------------
    # Handle the GUI (-g) cmd option
    # --------------------------------------------------------------------------
    def _handle_gui(self):
        """
        Handle the GUI (-g) cmd option

        This function offloads handling of the open GUI command. It opens the
        GUI application to edit the configuration options.
        """

        # load cfg file in gui
        try:
            F.run(self.S_CMD_GUI)
        except F.CNRunError as e:

            F.printc(self.S_ERR_GUI, fg=F.C_FG_WHITE, bg=F.C_BG_RED)
            print(e.output)

            self._logger.error(self.S_ERR_GUI)
            self._logger.error(e.output)

            sys.exit(-1)

        # reload config file
        self._load_config()

        # (re) sanitize dict_cfg after edit
        sanitize.do_sanitize(self._dict_cfg)

    # --------------------------------------------------------------------------
    # Handle the view log (-l) cmd option
    # --------------------------------------------------------------------------
    def _handle_log(self):
        """
        Handle the view log (-l) cmd option

        This function offloads handling of the view log command. It opens the
        log file in the default CLI editor.
        """

        # load log file in nano
        try:
            F.run(self.S_CMD_LOG)
        except F.CNRunError as e:

            F.printc(self.S_ERR_LOG, fg=F.C_FG_WHITE, bg=F.C_BG_RED)
            print(e.output)

            self._logger.error(self.S_ERR_LOG)
            self._logger.error(e.output)

            sys.exit(-1)

    # --------------------------------------------------------------------------
    # Handle cron changes from command line or config file
    # --------------------------------------------------------------------------
    def _handle_cron(self):
        """
        Handle cron changes from command line or config file

        This function offloads handling of cron changes from either the command
        line or editing the config file manually.
        """

        # ----------------------------------------------------------------------
        # get cron options from cfg file
        dict_cron = self._dict_cfg.get(K.S_KEY_CRON, {})
        cfg_enabled = dict_cron.get(
            K.S_KEY_CRON_ENABLED,
            self.D_DEFAULT[K.S_KEY_CRON][K.S_KEY_CRON_ENABLED],
        )
        cfg_interval = dict_cron.get(
            K.S_KEY_CRON_INTERVAL,
            self.D_DEFAULT[K.S_KEY_CRON][K.S_KEY_CRON_INTERVAL],
        )

        # ----------------------------------------------------------------------
        # action based on cmd arg
        val_arg_no_chg = 0  # neither
        val_arg_enable = 1  # --enable
        val_arg_disable = -1  # --disable

        # check for cmd line args
        val_arg = val_arg_no_chg
        arg_enable = self._dict_args.get(self.S_ARG_ENABLE_DEST, False)
        if arg_enable:
            val_arg = val_arg_enable
        arg_disable = self._dict_args.get(self.S_ARG_DISABLE_DEST, False)
        if arg_disable:
            val_arg = val_arg_disable

        # cmd line overrides all, wants to enable
        if val_arg == val_arg_enable:

            # show some text
            print(self.S_MSG_CRON_ADD, end="", flush=True)
            log = self.S_MSG_CRON_ADD

            # try to add job
            try:
                cron.add(cfg_interval)
                dict_cron[K.S_KEY_CRON_ENABLED] = True
                self._save_config()
            except (F.CNRunError, OSError) as e:

                # show some text
                F.printc(self.S_MSG_FAIL, fg=F.C_FG_RED, bold=True)
                print(self.S_ERR_CRON_VAL)

                self._logger.error(log + self.S_MSG_FAIL)
                # NB: OSError raised if no env, returns empty string
                if isinstance(e, OSError):
                    e = self.S_ERR_CRON_VAL
                self._logger.error(e)

                # could not enable, continue
                return

            # show some text
            F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
            self._logger.info(log + self.S_MSG_DONE)

            # enabled, continue
            return

        # cmd line overrides all, wants to disable
        elif val_arg == val_arg_disable:

            # show some text
            print(self.S_MSG_CRON_DEL, end="", flush=True)
            log = self.S_MSG_CRON_DEL

            # remove
            cron.remove()
            dict_cron[K.S_KEY_CRON_ENABLED] = False
            self._save_config()

            # show some text
            F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
            self._logger.info(log + self.S_MSG_DONE)

            # don't do anything else
            sys.exit(0)

        # ----------------------------------------------------------------------
        # next is to see if file matches current cron

        # show some text
        print(self.S_MSG_CRON_UPD, end="", flush=True)
        log = self.S_MSG_CRON_UPD

        try:
            cron.update(cfg_enabled, cfg_interval)
        except (F.CNRunError, OSError) as e:

            # tell the TUI/log
            F.printc(self.S_MSG_FAIL, fg=F.C_FG_RED, bold=True)
            print(self.S_ERR_CRON_VAL)

            self._logger.error(log + self.S_MSG_FAIL)
            # NB: OSError raised if no env, returns empty string
            if isinstance(e, OSError):
                e = self.S_ERR_CRON_VAL
            self._logger.error(e)

            # could not enable, continue
            return

        # show some text
        F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
        self._logger.info(log + self.S_MSG_DONE)

    # --------------------------------------------------------------------------
    # Get json from api.nasa.gov
    # --------------------------------------------------------------------------
    def _get_apod_dict(self) -> dict[str, str]:
        """
        Get the latest NASA dict

        :return: The latest NASA dict
        :rtype: dict[str, str]
        """

        # print some info
        print(self.S_MSG_GET, end="", flush=True)
        log = self.S_MSG_GET

        # default result
        dict_new = {}

        # get the nasa json
        try:

            # get json from url
            with request.urlopen(self.S_APOD_URL) as response:
                response_text = response.read()

                # make a dict from json
                dict_new = json.loads(response_text)

        # problem
        except OSError as error:

            # prob no internet
            F.printc(self.S_MSG_FAIL, fg=F.C_FG_RED, bold=True)
            print(self.S_ERR_GET.format(error))

            self._logger.error(log + self.S_MSG_FAIL)
            self._logger.error(self.S_ERR_GET.format(error))

            self._teardown()
            sys.exit(-1)

        # show some text
        F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
        self._logger.info(log + self.S_MSG_DONE)

        # return the result
        return dict_new

    # --------------------------------------------------------------------------
    # Check media type
    # --------------------------------------------------------------------------
    def _check_media_type(self, dict_new: dict[str, str]) -> bool:
        """
        Check media type

        :param dict_new: The dictionary to test
        :type dict_new: dict
        :return: True if the media type is acceptable, else False
        :rtype: bool
        """

        # sanity check
        if len(dict_new) == 0:
            return False

        # ----------------------------------------------------------------------
        # show some text
        print(self.S_MSG_MEDIA, end="", flush=True)
        log = self.S_MSG_MEDIA

        # check if today's apod is an image (sometimes it's a video)
        media_type = dict_new[K.S_KEY_APOD_TYPE]

        # check if today's apod is an image (sometimes it's a video)
        if not media_type in self.L_MEDIA_TYPES:

            # not a fatal error
            F.printc(self.S_MSG_FAIL, fg=F.C_FG_RED, bold=True)
            print(self.S_MSG_NOT_IMG)

            self._logger.error(log + self.S_MSG_FAIL)
            self._logger.error(self.S_MSG_NOT_IMG)

            return False

        # show some text
        F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
        self._logger.info(log + self.S_MSG_DONE)

        # media type is ok
        return True

    # --------------------------------------------------------------------------
    # Check for same URL
    # --------------------------------------------------------------------------
    def _check_same_url(self, dict_new: dict[str, str]) -> bool:
        """
        Check for same URL

        :param dict_new: The dictionary to test
        :type dict_new: dict
        :return: True if the URL is the same, else False
        :rtype: bool
        """

        # sanity checks
        if len(dict_new) == 0:
            return False
        if not self._path_img.exists():
            return False

        # ----------------------------------------------------------------------
        # show some text
        print(self.S_MSG_URL, end="", flush=True)
        log = self.S_MSG_URL

        # get current dict
        dict_old = self._dict_cfg[K.S_KEY_APOD]

        # get old/new URLs
        new_hd = dict_new.get(K.S_KEY_APOD_HDURL, "")
        old_hd = dict_old.get(K.S_KEY_APOD_HDURL, "")
        new_sd = dict_new.get(K.S_KEY_APOD_URL, "")
        old_sd = dict_old.get(K.S_KEY_APOD_URL, "")

        # return true if same URL
        res = old_hd == new_hd or old_sd == new_sd

        # show some text
        F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
        self._logger.info(log + self.S_MSG_DONE)

        # return result
        return res

    # --------------------------------------------------------------------------
    # Get image from NASA
    # --------------------------------------------------------------------------
    def _get_apod_image(self):
        """
        Get image from NASA

        Sets self._path_img to:
        B.P_DIR_CONF / "tests/apod.jpg"
        or
        B.P_DIR_CONF / self.S_NAME_DL
        """

        # ----------------------------------------------------------------------
        # show some text
        print(self.S_MSG_DL, end="", flush=True)
        log = self.S_MSG_DL

        # ----------------------------------------------------------------------
        # NB: already set curr apod dict to new apod dict
        # get current apod dict
        apod_dict = self._dict_cfg[K.S_KEY_APOD]

        # sanity check
        if len(apod_dict) == 0:
            return False

        # get most appropriate URL
        src_url = ""
        if K.S_KEY_APOD_HDURL in apod_dict:
            src_url = apod_dict[K.S_KEY_APOD_HDURL]
        elif K.S_KEY_APOD_URL in apod_dict:
            src_url = apod_dict[K.S_KEY_APOD_URL]

        # get ext of download
        url_ext = src_url.split(".")[-1]

        # get new pic name
        dl_name = self.S_NAME_DL.format(url_ext)
        dl_path = B.P_DIR_CONF / dl_name

        # try to download image
        try:

            # download the image
            request.urlretrieve(src_url, dl_path)

            # set self prop (initial download)
            self._path_img = dl_path

        except OSError as error:

            # this is a fatal error
            F.printc(self.S_MSG_FAIL, fg=F.C_FG_RED, bold=True)
            print(self.S_ERR_DL.format(error))

            self._logger.error(log + self.S_MSG_FAIL)
            self._logger.error(self.S_ERR_DL.format(error))

            self._teardown()
            sys.exit(-1)

        # ----------------------------------------------------------------------

        # show some text
        F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
        self._logger.info(log + self.S_MSG_DONE)

    # --------------------------------------------------------------------------
    # Convert image to png if necessary
    # --------------------------------------------------------------------------
    def _convert_to_png(self):
        """
        Convert image to png if necessary
        """

        # ----------------------------------------------------------------------

        # convert img to png if necessary
        path_ext = self._path_img.suffix
        if path_ext != self.S_NAME_EXT_PNG:

            # show some text
            print(self.S_MSG_CONVERT, end="", flush=True)
            log = self.S_MSG_CONVERT

            # store old name
            old_path = self._path_img

            # get new pic name
            self._path_img = B.P_DIR_CONF / self.S_NAME_PNG

            # open image
            img_src = Image.open(old_path)

            # save image as png
            img_src.save(self._path_img)

            # delete old image (if it was not png)
            old_path.unlink()

            # show some text
            F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
            self._logger.info(log + self.S_MSG_DONE)

    # --------------------------------------------------------------------------
    # Get screen size (only when install/first run)
    # --------------------------------------------------------------------------
    def _get_screen_size(self):
        """
        Get screen size (only when install/first run)
        """

        # ----------------------------------------------------------------------

        # show some text
        print(self.S_MSG_SCR_SIZE, end="", flush=True)
        log = self.S_MSG_SCR_SIZE

        # check if we already have screen size
        dict_screen = self._dict_cfg[K.S_KEY_SCREEN]
        scr_w = dict_screen[K.S_KEY_SCREEN_WIDTH]
        scr_h = dict_screen[K.S_KEY_SCREEN_HEIGHT]
        if scr_w == 0 or scr_h == 0:
            try:
                image.get_screen_size(dict_screen)
            except OSError:

                # failed to get screen size, fatal error
                F.printc(self.S_MSG_FAIL, fg=F.C_FG_RED, bold=True)
                print(self.S_ERR_NO_SCR)

                self._logger.error(log + self.S_MSG_FAIL)
                self._logger.error(self.S_ERR_NO_SCR)

                self._teardown()
                sys.exit(-1)

        # show some text
        F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
        self._logger.info(log + self.S_MSG_DONE)

    # --------------------------------------------------------------------------
    # Resize and crop image
    # --------------------------------------------------------------------------
    def _resize_and_crop(self):
        """
        Resize and crop image
        """

        # ----------------------------------------------------------------------

        # show some text
        print(self.S_MSG_RESIZE, end="", flush=True)
        log = self.S_MSG_RESIZE

        # check if we already have screen size
        dict_screen = self._dict_cfg[K.S_KEY_SCREEN]

        # resize and crop png
        image.resize_and_crop(self._path_img, dict_screen)

        # show some text
        F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
        self._logger.info(log + self.S_MSG_DONE)

    # --------------------------------------------------------------------------
    # Create caption image
    # --------------------------------------------------------------------------
    def _do_caption(self):
        """
        Create caption image
        """

        # ----------------------------------------------------------------------
        # show some text
        print(self.S_MSG_MAKE_CAP, end="", flush=True)
        log = self.S_MSG_MAKE_CAP

        # ----------------------------------------------------------------------

        # do the caption
        caption.do_caption(self._path_img, self._path_img_cap, self._dict_cfg)

        # ----------------------------------------------------------------------

        # show some text
        F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
        self._logger.info(log + self.S_MSG_DONE)

    # --------------------------------------------------------------------------
    # Set the wallpaper
    # --------------------------------------------------------------------------
    def _set_image(self):
        """
        Set the wallpaper
        """

        # ----------------------------------------------------------------------
        # show some text
        print(self.S_MSG_SET, end="", flush=True)
        log = self.S_MSG_SET

        # ----------------------------------------------------------------------

        # assume no cap
        path_img = self._path_img

        # or yes cap
        if self._dict_cfg[K.S_KEY_CAPTION][K.S_KEY_CAPTION_SHOW]:
            path_img = self._path_img_cap

        # call cmds to set wallpaper for light/dark (ubuntu only?)
        try:
            F.run(self.S_CMD_SET_LIGHT.format(path_img))
            F.run(self.S_CMD_SET_DARK.format(path_img))
        except F.CNRunError as error:
            F.printc(self.S_MSG_FAIL, fg=F.C_FG_RED, bold=True)
            print(self.S_ERR_SET.format(error))

            self._logger.error(log + self.S_MSG_FAIL)
            self._logger.error(self.S_ERR_SET.format(error))

            self._teardown()
            sys.exit(-1)

        # show some text
        F.printc(self.S_MSG_DONE, fg=F.C_FG_GREEN, bold=True)
        self._logger.info(log + self.S_MSG_DONE)

    # --------------------------------------------------------------------------
    # Boilerplate to use at the end of main
    # --------------------------------------------------------------------------
    def _teardown(self):
        """
        Boilerplate to use at the end of main

        Perform some mundane stuff like saving properties.
        """

        # just print a blank line before exiting
        print()

        # do super (save dict)
        super()._teardown()

__init__()

Initialize the new object

Initializes a new instance of the class, setting the default values of its properties, and any other code that needs to run to create a new object.

Source code in src/spaceoddity.py
def __init__(self):
    """
    Initialize the new object

    Initializes a new instance of the class, setting the default values
    of its properties, and any other code that needs to run to create a
    new object.
    """

    # do super init
    super().__init__()

    # set default cfg dict
    self._dict_cfg = self.D_DEFAULT.copy()

    # paths to images
    self._path_img = B.P_DIR_CONF / self.S_NAME_PNG
    self._path_img_cap = B.P_DIR_CONF / self.S_NAME_CAP_PNG

main()

The main method of the program

This method is the main entry point for the program, initializing the program, and performing its steps.

Source code in src/spaceoddity.py
def main(self):
    """
    The main method of the program

    This method is the main entry point for the program, initializing the
    program, and performing its steps.
    """

    self._logger.info("--------------------------------------------------")

    # call boilerplate code
    self._setup()

    # NB: self._dict_args/self._dict_cfg now available

    # sanitize dict_cfg (in situ)
    sanitize.do_sanitize(self._dict_cfg)

    # ----------------------------------------------------------------------
    # check for edit option next
    # NB: this will continue when done
    arg_edit = self._dict_args[self.S_ARG_EDIT_DEST]
    if arg_edit:
        self._handle_edit()  # ignore all others and continue

    # ----------------------------------------------------------------------
    # NEXT: check for gui option next
    # NB: this will continue when done
    # arg_gui = self._dict_args[self.S_ARG_GUI_DEST]
    # if arg_gui:
    #     self._handle_gui(self._arg_debug)  # -d and cont if ok/save/apply

    # ----------------------------------------------------------------------
    # check for log option first
    # NB: this will exit when done
    arg_log = self._dict_args[self.S_ARG_LOG_DEST]
    if arg_log:
        self._handle_log()  # ignore all others and quit

    # ----------------------------------------------------------------------
    # handle enable/disable next
    # NB: whether you edit the file by hand or not, cmd line overrides it
    self._handle_cron()  # ignore all others and quit if --disable

    # ----------------------------------------------------------------------

    # get json from nasa and make a dict
    dict_new = self._get_apod_dict()

    # check if media type is good and the url has changed
    should_dl = self._check_media_type(
        dict_new
    ) and not self._check_same_url(dict_new)

    # ----------------------------------------------------------------------

    # image is valid type and url has changed
    if should_dl:

        # apply new dict to config
        self._dict_cfg[K.S_KEY_APOD] = dict_new

        # download new image
        self._get_apod_image()
        self._convert_to_png()
        self._get_screen_size()
        self._resize_and_crop()

    # ----------------------------------------------------------------------

    # make new caption
    # NB: always do this to check for cfg changes
    self._do_caption()

    # always do this
    self._set_image()

    # ----------------------------------------------------------------------
    # teardown

    # call boilerplate code
    self._teardown()