From 96c0699d7886ac11680dd2dc3b60789b128772d0 Mon Sep 17 00:00:00 2001 From: romintomasetti Date: Sat, 28 Sep 2024 02:09:14 +0000 Subject: [PATCH] first working version --- .devcontainer/devcontainer.json | 14 +++ .devcontainer/dockerfile | 12 +++ .github/workflows/test.yml | 52 +++++++++++ LICENSE | 21 +++++ README.md | 13 ++- requirements/requirements.python.test.txt | 2 + setup.py | 25 +++++ system_helpers/apt/install.py | 85 +++++++++++++++++ system_helpers/apt/script.py | 60 ++++++++++++ .../update_alternatives/alternatives.py | 25 +++++ .../update_alternatives/argparse.py | 17 ++++ system_helpers/update_alternatives/script.py | 56 +++++++++++ tests/apt/test_install.py | 93 +++++++++++++++++++ tests/update_alternatives/test_script.py | 43 +++++++++ 14 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/dockerfile create mode 100644 .github/workflows/test.yml create mode 100644 LICENSE create mode 100644 requirements/requirements.python.test.txt create mode 100644 setup.py create mode 100644 system_helpers/apt/install.py create mode 100644 system_helpers/apt/script.py create mode 100644 system_helpers/update_alternatives/alternatives.py create mode 100644 system_helpers/update_alternatives/argparse.py create mode 100644 system_helpers/update_alternatives/script.py create mode 100644 tests/apt/test_install.py create mode 100644 tests/update_alternatives/test_script.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4cc0830 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,14 @@ +{ + "dockerFile": "dockerfile", + "context": "..", + "extensions" : [ + "eamodio.gitlens", + "mhutchie.git-graph", + "ms-python.python", + "GitHub.vscode-pull-request-github", + ], + "runArgs": [ + "--privileged", + ], + "onCreateCommand": "git config --global --add safe.directory $PWD" +} diff --git a/.devcontainer/dockerfile b/.devcontainer/dockerfile new file mode 100644 index 0000000..217b56a --- /dev/null +++ b/.devcontainer/dockerfile @@ -0,0 +1,12 @@ +FROM python:3.10 + +RUN --mount=target=/requirements,type=bind,source=requirements < typing.List[str]: + """ + Get list of packages from a requirements file. + + Note that this function only supports skipping empty lines and comment lines, *i.e.*, + not all features from https://pip.pypa.io/en/stable/reference/requirements-file-format/ + are supported. + """ + packages = [] + with file.open(mode = "r") as file: + for line in file: + line = line.strip() + if line and not line.startswith('#'): + packages.extend(line.strip().split()) + return packages + +@typeguard.typechecked +def install_command(*, yes : bool = True, no_install_recommends : bool = True) -> typing.List[str]: + """ + Get the `apt` command to install packages. + """ + cmd = ['apt'] + + if yes: + cmd.append('--yes') + + if no_install_recommends: + cmd.append('--no-install-recommends') + + cmd.append('install') + return cmd + +@typeguard.typechecked +def install_packages(*, + packages : typing.Optional[typing.List[str]] = None, + requirements : typing.Optional[typing.List[pathlib.Path]] = None, + update : bool = False, + upgrade : bool = False, + clean: bool = False, +) -> None: + """ + Install list of packages by using `apt`. + + Optionally: + * update + * upgrade + * clean + """ + to_be_installed = [] + + if packages: + to_be_installed += packages + + if requirements: + for file in requirements: + to_be_installed += get_list_of_packages_from_requirements_file(file = file) + + if len(to_be_installed) == 0: + raise RuntimeError('There is no package to be installed.') + + logging.info(f"Installing 'apt' packages {to_be_installed} (update={update}, upgrade={upgrade}, clean={clean})") + + if update: + subprocess.check_call(['apt', 'update']) + + if upgrade: + subprocess.check_call(['apt', '--yes', 'upgrade']) + + args = install_command(yes = True, no_install_recommends = True) + args += to_be_installed + + subprocess.check_call(args) + + if clean: + subprocess.check_call(['apt', 'clean']) + shutil.rmtree(pathlib.Path("/var/lib/apt/lists")) diff --git a/system_helpers/apt/script.py b/system_helpers/apt/script.py new file mode 100644 index 0000000..a4152fc --- /dev/null +++ b/system_helpers/apt/script.py @@ -0,0 +1,60 @@ +import argparse +import logging +import pathlib + +import typeguard + +from system_helpers.apt.install import install_packages + +@typeguard.typechecked +def parse_args() -> argparse.Namespace: + """ + Parse CLI arguments. + """ + parser = argparse.ArgumentParser() + + subparsers = parser.add_subparsers(required = True) + + parser_ip = subparsers.add_parser('install-packages') + + parser_ip.add_argument('--clean', action = 'store_true') + parser_ip.add_argument('--update', action = 'store_true') + parser_ip.add_argument('--upgrade', action = 'store_true') + + parser_ip.add_argument( + '--packages', + help = "List of packages to install.", + nargs = '*', + required = False, + dest = 'packages', + ) + + parser_ip.add_argument( + '--requirement', + help = 'Requirement file à la pip.', + dest = 'requirements', + action = 'append', + type = pathlib.Path, + required = False, + ) + + parser_ip.set_defaults(func = install_packages) + + return parser.parse_args() + +@typeguard.typechecked +def main() -> None: + + logging.basicConfig(level=logging.INFO) + + args = parse_args() + + kwargs = vars(args) + + func = kwargs.pop('func') + + func(**kwargs) + +if __name__ == "__main__": + + main() diff --git a/system_helpers/update_alternatives/alternatives.py b/system_helpers/update_alternatives/alternatives.py new file mode 100644 index 0000000..9c47e99 --- /dev/null +++ b/system_helpers/update_alternatives/alternatives.py @@ -0,0 +1,25 @@ +import pathlib +import subprocess +import typing + +import typeguard + +@typeguard.typechecked +def update_alternatives(*, + for_each_of : typing.Dict[str, str], + prefix : pathlib.Path, + level : int, + display : bool, +) -> None: + """ + Update alternatives for a list of 'apps'. + """ + for link, command in for_each_of.items(): + subprocess.check_call([ + 'update-alternatives', + '--install', prefix / link, link, prefix / command, + str(level), + ]) + + if display: + subprocess.check_call(['update-alternatives', '--display', link]) diff --git a/system_helpers/update_alternatives/argparse.py b/system_helpers/update_alternatives/argparse.py new file mode 100644 index 0000000..9b5d4d7 --- /dev/null +++ b/system_helpers/update_alternatives/argparse.py @@ -0,0 +1,17 @@ +import argparse + +import typeguard + +class ParseKwargs(argparse.Action): + """ + Parse dictionary-like key-value pairs. + + References: + * https://sumit-ghosh.com/posts/parsing-dictionary-key-value-pairs-kwargs-argparse-python/ + """ + @typeguard.typechecked + def __call__(self, parser : argparse.ArgumentParser, namespace : argparse.Namespace, values : list, *args, **kwargs): + setattr(namespace, self.dest, dict()) + for value in values: + key, value = value.split('=', 2) + getattr(namespace, self.dest)[key] = value diff --git a/system_helpers/update_alternatives/script.py b/system_helpers/update_alternatives/script.py new file mode 100644 index 0000000..fe6ab54 --- /dev/null +++ b/system_helpers/update_alternatives/script.py @@ -0,0 +1,56 @@ +import argparse +import logging +import pathlib + +import typeguard + +from system_helpers.update_alternatives.alternatives import update_alternatives +from system_helpers.update_alternatives.argparse import ParseKwargs + +@typeguard.typechecked +def parse_args() -> argparse.Namespace: + """ + Parse CLI arguments. + """ + parser = argparse.ArgumentParser() + + parser.add_argument( + '--prefix', + help = "Directory where alternatives are created.", + required = False, type = pathlib.Path, default = pathlib.Path("/usr/bin"), + ) + + parser.add_argument( + '--for-each-of', + help = "List of link-command pairs.", + nargs ='*', + required = True, + action = ParseKwargs, + ) + + parser.add_argument( + '--level', + help = "Priority level.", + required = False, type = int, default = 10, + ) + + parser.add_argument( + '--display', + help = "Whether to display information.", + required = False, action = 'store_true', default = True, + ) + + return parser.parse_args() + +@typeguard.typechecked +def main() -> None: + + logging.basicConfig(level=logging.INFO) + + args = parse_args() + + update_alternatives(**vars(args)) + +if __name__ == "__main__": + + main() diff --git a/tests/apt/test_install.py b/tests/apt/test_install.py new file mode 100644 index 0000000..be89fed --- /dev/null +++ b/tests/apt/test_install.py @@ -0,0 +1,93 @@ +import itertools +import pathlib +import tempfile +import typing +import unittest.mock + +import pytest +import pytest_console_scripts +import typeguard + +from system_helpers.apt import install + +@pytest.fixture +@typeguard.typechecked +def requirements() -> typing.Generator[typing.Tuple[typing.List[pathlib.Path], typing.List[str]], None, None]: + """ + Get requirements files *à la* `pip`. + """ + with tempfile.NamedTemporaryFile(mode = 'w+') as req_1, \ + tempfile.NamedTemporaryFile(mode = 'w+') as req_2: + req_1.write("# Let's start this first requirements file with a comment. Then, add some packages.\n") + req_1.write("git\n") + req_1.write("and\n") + req_1.write("\n") + req_1.write("# Some other useless comments here.\n") + req_1.write("whatnot\n") + req_1.flush() + + req_2.write("# Let's start this other requirements file with a comment. Then, add some packages.\n") + req_2.write("cmake\n") + req_2.write("is\n") + req_2.write("nice even with many whatever\n") + req_2.flush() + + packages = ['git', 'and', 'whatnot', 'cmake', 'is', 'nice', 'even', 'with', 'many', 'whatever'] + + yield ([pathlib.Path(req_1.name), pathlib.Path(req_2.name)], packages) + +class TestAptInstall: + """ + Test :py:class:`apt_helpers.install.apt_install_packages`. + """ + @staticmethod + @typeguard.typechecked + def get_script(): + """ + Retrieve script path. + """ + return pathlib.Path(__file__).parent.parent.parent / 'system_helpers' / 'apt' / 'script.py' + + def test_list_of_package_names(self): + """ + Test for a provided list of package names. + """ + packages = ['git', 'and', 'whatnot'] + + with unittest.mock.patch(target = 'subprocess.check_call', side_effect = [None, None]) as mocker: + install.install_packages(packages = packages, update = False, upgrade = True, clean = False) + + mocker.assert_has_calls(calls = [ + unittest.mock.call(['apt', '--yes', 'upgrade']), + unittest.mock.call(['apt', '--yes', '--no-install-recommends', 'install'] + packages), + ]) + + def test_list_of_requirements_files(self, requirements): + """ + Test for a provided list of requirement files, *à la* `pip`. + """ + with unittest.mock.patch(target = 'subprocess.check_call', side_effect = [None]) as mocker: + install.install_packages(requirements = requirements[0], update = False, upgrade = False, clean = False) + + mocker.assert_has_calls(calls = [ + unittest.mock.call(['apt', '--yes', '--no-install-recommends', 'install'] + requirements[1]), + ]) + + @unittest.mock.patch(target = 'subprocess.check_call', side_effect = [None]) + @pytest.mark.script_launch_mode('inprocess') + def test_install_packages_from_cli(self, mocker, script_runner : pytest_console_scripts.ScriptRunner, requirements): + """ + Install many `APT` through requirement-like files and package names given to the CLI. + """ + result = script_runner.run([ + str(self.get_script()), + 'install-packages', + '--packages', 'one', 'two', + *list(itertools.chain.from_iterable([['--requirement', str(x)] for x in requirements[0]])) + ], print_result = True) + + assert result.returncode == 0 + + mocker.assert_has_calls(calls = [ + unittest.mock.call(['apt', '--yes', '--no-install-recommends', 'install', 'one', 'two'] + requirements[1]), + ]) diff --git a/tests/update_alternatives/test_script.py b/tests/update_alternatives/test_script.py new file mode 100644 index 0000000..521875c --- /dev/null +++ b/tests/update_alternatives/test_script.py @@ -0,0 +1,43 @@ +import pathlib +import unittest.mock + +import pytest +import pytest_console_scripts +import typeguard + +class TestUpdateAlternatives: + """ + Test :py:class:`update_alternatives.helpers.update_alternatives`. + """ + @staticmethod + @typeguard.typechecked + def get_script(): + """ + Retrieve script path. + """ + return pathlib.Path(__file__).parent.parent.parent / 'system_helpers' / 'update_alternatives' / 'script.py' + + @unittest.mock.patch(target = 'subprocess.check_call', side_effect = 6 * [None]) + @pytest.mark.script_launch_mode('inprocess') + def test_many_alternatives_in_script_mode(self, mocker, script_runner : pytest_console_scripts.ScriptRunner): + """ + Test for a provided list of alternatives. + """ + result = script_runner.run([ + str(self.get_script()), + '--for-each-of', + 'gcc=gcc-10', + 'g++=g++-10', + 'python=python-3.12', + ], print_result = True) + + assert result.returncode == 0 + + mocker.assert_has_calls(calls = [ + unittest.mock.call(['update-alternatives', '--install', pathlib.Path('/usr/bin/gcc'), 'gcc', pathlib.Path('/usr/bin/gcc-10'), '10']), + unittest.mock.call(['update-alternatives', '--display', 'gcc']), + unittest.mock.call(['update-alternatives', '--install', pathlib.Path('/usr/bin/g++'), 'g++', pathlib.Path('/usr/bin/g++-10'), '10']), + unittest.mock.call(['update-alternatives', '--display', 'g++']), + unittest.mock.call(['update-alternatives', '--install', pathlib.Path('/usr/bin/python'), 'python', pathlib.Path('/usr/bin/python-3.12'), '10']), + unittest.mock.call(['update-alternatives', '--display', 'python']), + ])