2
\$\begingroup\$

I work with Linux-based devices, and, both at work and in my personal life, it's necessary to wipe these devices every so often - sometimes as frequently as multiple times per week. In this context, it's obviously very useful to have some sort of script which reinstalls a suite of useful software.

For the past couple of years I've made do with a Shell script, but, since GitHib decided to encourage the use of access tokens ever more strongly, I switched to using Python. Setting up Git is one of the script's most crucial actions. Other responsibilities:

  • Upgrading Python as required.
  • Installing a number of useful packages via APT.
  • Cloning and installing some of my own repositories.

I'm going to post the code for the script's core class below. If you want to run the script yourself, you can download the full repo - it's only a few files - here. But beware that it will start trying to install stuff!

The core class:

"""
This code defines a class which installs the various packages and repositories
required on this computer.
"""

# Standard imports.
import os
import pathlib
import shutil
import subprocess
import urllib.parse

# Local imports.
from git_credentials import set_up_git_credentials, \
    DEFAULT_PATH_TO_GIT_CREDENTIALS, DEFAULT_PATH_TO_PAT, \
    DEFAULT_USERNAME as DEFAULT_GIT_USERNAME, DEFAULT_EMAIL_ADDRESS

# Local constants.
DEFAULT_OS = "ubuntu"
DEFAULT_TARGET_DIR = str(pathlib.Path.home())
DEFAULT_PATH_TO_WALLPAPER_DIR = \
    os.path.join(DEFAULT_TARGET_DIR, "hmss/wallpaper/")

##############
# MAIN CLASS #
##############

class HMSoftwareInstaller:
    """ The class in question. """
    # Class constants.
    CHROME_DEB = "google-chrome-stable_current_amd64.deb"
    CHROME_STEM = "https://dl.google.com/linux/direct/"
    EXPECTED_PATH_TO_GOOGLE_CHROME_COMMAND = "/usr/bin/google-chrome"
    GIT_URL_STEM = "https://github.com/"
    MISSING_FROM_CHROME = ("eog", "nautilus")
    OTHER_THIRD_PARTY = ("gedit-plugins", "inkscape")
    SUPPORTED_OSS = set(("ubuntu", "chrome-os", "raspian", "linux-based"))
    WALLPAPER_STEM = "wallpaper_t"
    WALLPAPER_EXT = ".png"

    def __init__(
            self,
            this_os=DEFAULT_OS,
            target_dir=DEFAULT_TARGET_DIR,
            thunderbird_num=None,
            path_to_git_credentials=DEFAULT_PATH_TO_GIT_CREDENTIALS,
            path_to_pat=DEFAULT_PATH_TO_PAT,
            git_username=DEFAULT_GIT_USERNAME,
            email_address=DEFAULT_EMAIL_ADDRESS,
            path_to_wallpaper_dir=DEFAULT_PATH_TO_WALLPAPER_DIR
        ):
        self.this_os = this_os
        self.target_dir = target_dir
        self.thunderbird_num = thunderbird_num
        self.path_to_git_credentials = path_to_git_credentials
        self.path_to_pat = path_to_pat
        self.git_username = git_username
        self.email_address = email_address
        self.path_to_wallpaper_dir = path_to_wallpaper_dir
        self.failure_log = []

    def check_os(self):
        """ Test whether the OS we're using is supported. """
        if self.this_os in self.SUPPORTED_OSS:
            return True
        return False

    def move_to_target_dir(self):
        """ Change into the directory where we want to install stuff. """
        os.chdir(self.target_dir)

    def set_up_git(self):
        """ Install Git and set up a personal access token. """
        install_result = install_via_apt("git")
        if not install_result:
            return False
        pat_result = \
            set_up_git_credentials(
                username=self.git_username,
                email_address=self.email_address,
                path_to_git_credentials=self.path_to_git_credentials,
                path_to_pat=self.path_to_pat
            )
        if not pat_result:
            return False
        return True

    def install_google_chrome(self):
        """ Ronseal. """
        if (
            check_command_exists("google-chrome") or
            self.this_os == "chrome-os"
        ):
            return True
        chrome_url = urllib.parse.urljoin(self.CHROME_STEM, self.CHROME_DEB)
        chrome_deb_path = "./"+self.CHROME_DEB
        download_process = subprocess.run(["wget", chrome_url])
        if download_process.returncode != 0:
            return False
        if not install_via_apt(chrome_deb_path):
            return False
        os.remove(chrome_deb_path)
        return True

    def change_wallpaper(self):
        """ Change the wallpaper on the desktop of this computer. """
        if not os.path.exists(self.path_to_wallpaper_dir):
            return False
        if self.thunderbird_num:
            wallpaper_filename = (
                self.WALLPAPER_STEM+
                str(self.thunderbird_num)+
                self.WALLPAPER_EXT
            )
        else:
            wallpaper_filename = "default.jpg"
        wallpaper_path = \
            os.path.join(self.path_to_wallpaper_dir, wallpaper_filename)
        if self.this_os == "ubuntu":
            arguments = [
                "gsettings",
                "set",
                "org.gnome.desktop.background",
                "picture-uri",
                "file:///"+wallpaper_path
            ]
        elif self.this_os == "raspbian":
            arguments = ["pcmanfm", "--set-wallpaper", wallpaper_path]
        else:
            return False
        result = run_with_indulgence(arguments)
        return result

    def make_git_url(self, repo_name):
        """ Make the URL pointing to a given repo. """
        suffix = self.git_username+"/"+repo_name+".git"
        result = urllib.parse.urljoin(self.GIT_URL_STEM, suffix)
        return result

    def install_own_repo(
            self,
            repo_name,
            dependent_packages=None,
            installation_arguments=None
        ):
        """ Install a custom repo. """
        if os.path.exists(repo_name):
            print("Looks like "+repo_name+" already exists...")
            return True
        if dependent_packages:
            for package_name in dependent_packages:
                if not install_via_apt(package_name):
                    return False
        arguments = ["git", "clone", self.make_git_url(repo_name)]
        if not run_with_indulgence(arguments):
            return False
        os.chdir(repo_name)
        if installation_arguments:
            if not run_with_indulgence(arguments):
                os.chdir(self.target_dir)
                return False
        os.chdir(self.target_dir)
        return True

    def install_kingdom_of_cyprus(self):
        """ Install the Kingdom of Cyprus repo. """
        repo_name = "kingdom-of-cyprus"
        dependent_packages = ("sqlite", "sqlitebrowser", "nodejs", "npm")
        result = \
            self.install_own_repo(
                repo_name,
                dependent_packages=dependent_packages
            )
        return result

    def install_chancery(self):
        """ Install the Chancery repos. """
        repo_name = "chancery"
        if not self.install_own_repo(repo_name):
            return False
        repo_name_b = "chancery-b"
        installation_arguments_b = ("sh", "install_3rd_party")
        result = \
            self.install_own_repo(
                repo_name_b,
                installation_arguments=installation_arguments_b
            )
        return result

    def install_hmss(self):
        """ Install the HMSS repo. """
        result = self.install_own_repo("hmss")
        return result

    def install_hgmj(self):
        """ Install the HGMJ repo. """
        repo_name = "hgmj"
        installation_arguments = ("sh", "install_3rd_party")
        result = \
            self.install_own_repo(
                repo_name,
                installation_arguments=installation_arguments
            )
        return result

    def install_other_third_party(self):
        """ Install some other useful packages. """
        result = True
        for package in self.OTHER_THIRD_PARTY:
            if not install_via_apt(package):
                result = False
        if self.this_os == "chrome-os":
            for package in self.MISSING_FROM_CHROME:
                if not install_via_apt(package):
                    result = False
        return result

    def run_essentials(self):
        """ Run those processes which, if they fail, we will have to stop
        the entire program there. """
        print("Checking OS...")
        if not self.check_os():
            self.failure_log.append("Check OS")
            return False
        print("Updating and upgrading...")
        if not update_and_upgrade():
            self.failure_log.append("Update and upgrade")
            return False
        print("Upgrading Python...")
        if not upgrade_python():
            self.failure_log.append("Upgrade Python")
            return False
        print("Setting up Git...")
        if not self.set_up_git():
            self.failure_log.append("Set up Git")
            return False
        return True

    def run_non_essentials(self):
        """ Run the installation processes. """
        result = True
        print("Installing Google Chrome...")
        if not self.install_google_chrome():
            self.failure_log.append("Install Google Chrome")
            result = False
        print("Installing HMSS...")
        if not self.install_hmss():
            self.failure_log.append("Install HMSS")
        print("Installing Kingdom of Cyprus...")
        if not self.install_kingdom_of_cyprus():
            self.failure_log.append("Install Kingdom of Cyprus")
            result = False
        print("Installing Chancery repos...")
        if not self.install_chancery():
            self.failure_log.append("Install Chancery repos")
            result = False
        print("Installing HGMJ...")
        if not self.install_hgmj():
            self.failure_log.append("Install HGMJ")
            result = False
        print("Installing other third party...")
        if not self.install_other_third_party():
            self.failure_log.append("Install other third party")
            result = False
        print("Changing wallpaper...")
        if not self.change_wallpaper():
            self.failure_log.append("Change wallpaper")
            # It doesn't matter too much if this fails.
        return result

    def print_outcome(self, passed, with_flying_colours):
        """ Print a list of what failed to the screen. """
        if passed and with_flying_colours:
            print("Installation PASSED with flying colours!")
            return
        if passed:
            print("Installation PASSED but with non-essential failures.")
        else:
            print("Installation FAILED.")
        print("\nThe following items failed:\n")
        for item in self.failure_log:
            print("    * "+item)
        print(" ")

    def run(self):
        """ Run the software installer. """
        print("Running His Majesty's Software Installer...")
        get_sudo()
        self.move_to_target_dir()
        if not self.run_essentials():
            print("\nFinished.\n\n")
            self.print_outcome(False, False)
            return False
        with_flying_colours = self.run_non_essentials()
        print("\nComplete!\n")
        self.print_outcome(True, with_flying_colours)
        return True

####################
# HELPER FUNCTIONS #
####################

def get_sudo():
    """ Get superuser privileges. """
    print("I'm going to need superuser privileges for this...")
    subprocess.run(
        ["sudo", "echo", "Superuser privileges: activate!"],
        check=True
    )

def run_with_indulgence(arguments, show_output=False):
    """ Run a command, and don't panic immediately if we get a non-zero
    return code. """
    if show_output:
        print("Running subprocess.run() with arguments:")
        print(arguments)
        process = subprocess.run(arguments)
    else:
        process = subprocess.run(arguments, stdout=subprocess.DEVNULL)
    if process.returncode == 0:
        return True
    return False

def run_apt_with_argument(argument):
    """ Run APT with an argument, and tell me how it went. """
    arguments = ["sudo", "apt-get", argument]
    result = run_with_indulgence(arguments)
    return result

def check_against_dpkg(package_name):
    """ Check whether a given package is on the books with DPKG. """
    result = run_with_indulgence(["dpkg", "--status", package_name])
    return result

def check_command_exists(command):
    """ Check whether a given command exists on this computer. """
    if shutil.which(command):
        return True
    return False

def install_via_apt(package_name, command=None):
    """ Attempt to install a package, and tell me how it went. """
    if not command:
        command = package_name
    if check_command_exists(command):
        return True
    arguments = ["sudo", "apt-get", "--yes", "install", package_name]
    result = run_with_indulgence(arguments)
    return result

def update_and_upgrade():
    """ Update and upgrade the existing software. """
    if not run_apt_with_argument("update"):
        return False
    if not run_apt_with_argument("upgrade"):
        return False
    if not install_via_apt("software-properties-common"):
        return False
    return True

def pip3_install(package):
    """ Run `pip3 install [package]`. """
    if run_with_indulgence(["pip3", "install", package]):
        return True
    return False

def upgrade_python():
    """ Install PIP3 and other useful Python hangers-on. """
    result = True
    if not install_via_apt("python3-pip"):
        result = False
    if not pip3_install("pylint"):
        result = False
    if not pip3_install("pytest"):
        result = False
    return result
\$\endgroup\$
2
  • 1
    \$\begingroup\$ Your code is not for Linux-based devices, it is for Debian-based devices. It is entirely useless on any Linux distribution, not using apt or the dpkg format. \$\endgroup\$ Jan 4 at 12:25
  • 1
    \$\begingroup\$ @RichardNeumann I mean... the OP's statement is true, it's just non-specific. Ubuntu is a Debian-based OS; Debian is a Linux-based OS. I see no problem here. \$\endgroup\$
    – Reinderien
    Jan 4 at 14:08

2 Answers 2

3
\$\begingroup\$

The nuclear option

it's necessary to wipe these devices every so often - sometimes as frequently as multiple times per week

Having a script like this is useful, because reproducible environments are useful; so long as you're not relying on it to cover over other problems. Your two scenarios:

At work: we were manufacturing embedded Linux devices, and we needed to be absolutely sure that each new software release would work install properly on untouched hardware.

That's a great application of a setup script like this.

At home: my laptop's a Chromebook with a Linux VM; every few months, I run into a problem in which it's quicker just to delete the VM and start again.

Fine-ish, so long as you understand what went wrong and were able to learn from it before nuking your OS.

Code

CHROME_DEB seems like a strange choice. You're on Ubuntu, so why not just install it from the repo?

You should be able to infer most of EXPECTED_PATH_TO_GOOGLE_CHROME_COMMAND from the result of a which.

set(("ubuntu", "chrome-os", "raspian", "linux-based")) should just be {"ubuntu", "chrome-os", "raspian", "linux-based"}.

The kind of rote repetition for member initialisation seen in your __init__ can be mostly eliminated by use of a @dataclass.

self.failure_log = [] implies that you're logging to memory. This is risky: if your process crashes, won't you lose your logs? Consider instead using the standard logging module and logging to a file.

This:

        if self.this_os in self.SUPPORTED_OSS:
            return True
        return False

should just be

return self.this_os in self.SUPPORTED_OSS

Avoid newline-escapes like in pat_result = \, which can just be

        pat_result = set_up_git_credentials(
                username=self.git_username,
                email_address=self.email_address,
                path_to_git_credentials=self.path_to_git_credentials,
                path_to_pat=self.path_to_pat
            )

This:

    if not pat_result:
        return False
    return True

can just be

return bool(pat_result)

This use of subprocess:

    download_process = subprocess.run(["wget", chrome_url])
    if download_process.returncode != 0:
        return False

should be replaced with subprocess.check_call. This will, rather than a boolean failure indicator, raise an exception - which you should also be doing. Use exceptions for failure states instead of boolean returns. Your "essential failures" should be represented by exceptions allowed to unwind the stack, and your "non-essential failures" should be logged in an except.

Replace calls to os.path.exists etc. with calls to the pathlib alternatives.

Your install_own_repo is curious. You accept a list of dependent packages required before installation of repo_name. This betrays a lack of proper packaging. Your own repos, when written properly, should build debs that express their own dependencies, so that you can just install the top-level package and the dependencies will be pulled in automatically. The alternative to making your own debs is to express dependencies in a distutils setup.py, which is preferable if all of the dependencies are pure Python.

\$\endgroup\$
1
2
\$\begingroup\$

Don't repeat yourself

    def run_non_essentials(self):
        """ Run the installation processes. """
        result = True
        print("Installing Google Chrome...")
        if not self.install_google_chrome():
            self.failure_log.append("Install Google Chrome")
            result = False
        print("Installing HMSS...")
        if not self.install_hmss():
            self.failure_log.append("Install HMSS")
        print("Installing Kingdom of Cyprus...")
        if not self.install_kingdom_of_cyprus():
            self.failure_log.append("Install Kingdom of Cyprus")
            result = False
        print("Installing Chancery repos...")
        if not self.install_chancery():
            self.failure_log.append("Install Chancery repos")
            result = False
        print("Installing HGMJ...")
        if not self.install_hgmj():
            self.failure_log.append("Install HGMJ")
            result = False
        print("Installing other third party...")
        if not self.install_other_third_party():
            self.failure_log.append("Install other third party")
            result = False
        print("Changing wallpaper...")
        if not self.change_wallpaper():
            self.failure_log.append("Change wallpaper")
            # It doesn't matter too much if this fails.
        return result

Can be generalized into:

OPTIONAL_PROGRAMS = {
    "Google Chrome": install_google_chrome,
    "HMSS": install_hmss,
    "Kingdom of Cyprus": install_kingdom_of_cyprus,
    ...
}

[...]

class HMSoftwareInstaller:
    [...]
    
    def install(name: str, function: Callable[[], bool]) -> bool:
        print(f"Installing {name}...")
        
        if not (result := function()):
            self.failure_log.append(f"Install {name}")
        
        return result

    def run_non_essentials(self) -> bool:
        """ Run the installation processes. """
        
        return all([self.install(*program) for program in OPTIONAL_PROGRAMS.items()])

The same applies to run_essentials().

The functions invoked do not use self anyway, so they should be redefined as free functions.

Consider using pathlib.Path

...to manage paths, rather than the os.* functions.

Use built-ins where possible

The function upgrade_python() does unnecessarily store state. You can rewrite it as:

def upgrade_python():
    """ Install PIP3 and other useful Python hangers-on. """
    return all([
        install_via_apt("python3-pip"),
        pip3_install("pylint"),
        pip3_install("pytest")
    ])
\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.