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
asked Jan 3, 2022 at 21:34
\$\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\$ Commented Jan 4, 2022 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\$ Commented Jan 4, 2022 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.

answered Jan 3, 2022 at 23:59
\$\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")
 ])
answered Jan 4, 2022 at 12:42
\$\endgroup\$
0

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.