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
2 Answers 2
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 deb
s 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.
-
1\$\begingroup\$ Let us continue this discussion in chat. \$\endgroup\$Reinderien– Reinderien2022年01月05日 16:17:03 +00:00Commented Jan 5, 2022 at 16:17
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")
])
apt
or the dpkg format. \$\endgroup\$