diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..00339d5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,12 @@ +{ + "image": "python:3.10", + "extensions" : [ + "eamodio.gitlens", + "mhutchie.git-graph", + "ms-azuretools.vscode-docker" + ], + "runArgs": [ + "--privileged", + ], + "onCreateCommand": "apt update && apt --yes install git && pip install -r requirements/requirements.python.txt -r requirements/requirements.python.test.txt && git config --global --add safe.directory $PWD" +} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e2654ae --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + + test: + runs-on: ubuntu-latest + container: + image: python:3.10 + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies. + run : | + python -m pip install -r requirements/requirements.python.txt -r requirements/requirements.python.test.txt + + - name: Run tests. + run : | + python -m pytest tests + + install-as-package-and-test: + runs-on: [ubuntu-latest] + container: + image: python:3.10 + steps: + - uses: actions/checkout@v4 + with: + path: package-sources + + - name: Install as package. + run : | + # If this repository becomes public, be sure to use the following instead: + # pip install git+https://github.com/uliegecsm/apt-helpers.git@${{ github.sha }} + pip install -e package-sources + rm -rf package-sources + + - name: Test as CLI directly. + run : | + apt-helpers install-packages --update --clean --upgrade --packages jq diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f378353 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 ULiege CSM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a6d062e..58daa74 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# apt-helpers +# `apt` helpers + This repository contains useful standalone helper scripts for `apt`. diff --git a/apt_helpers/alternatives.py b/apt_helpers/alternatives.py new file mode 100644 index 0000000..0fc115b --- /dev/null +++ b/apt_helpers/alternatives.py @@ -0,0 +1,25 @@ +import pathlib +import subprocess +import typing + +import typeguard + +@typeguard.typechecked +def update_alternatives(*, + for_each_of : typing.Dict, + 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/apt_helpers/helpers.py b/apt_helpers/helpers.py new file mode 100644 index 0000000..912a0e8 --- /dev/null +++ b/apt_helpers/helpers.py @@ -0,0 +1,92 @@ +import argparse +import logging +import pathlib + +import typeguard + +from apt_helpers.alternatives import update_alternatives +from apt_helpers.install import apt_install_packages +from apt_helpers.utils import AppendKVPairsAction + +@typeguard.typechecked +def parse_args() -> argparse.Namespace: + """ + Parse CLI arguments. + """ + parser = argparse.ArgumentParser() + + subparsers = parser.add_subparsers(required = True) + + parser_ua = subparsers.add_parser('update-alternatives') + + parser_ua.add_argument( + '--prefix', + help = "Directory where alternatives are created.", + required = False, type = pathlib.Path, default = pathlib.Path("/usr/bin") + ) + + parser_ua.add_argument( + '--for-each-of', + help = "List of link-command pairs.", + nargs='*', + required = True, action = AppendKVPairsAction, + ) + + parser_ua.add_argument( + '--level', + help = "Priority level.", + required = False, type = int, default = 10 + ) + + parser_ua.add_argument( + '--display', + help = "Whether to display information.", + required = False, action = 'store_true', default = True + ) + + parser_ua.set_defaults(func = update_alternatives) + + 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 = apt_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/apt_helpers/install.py b/apt_helpers/install.py new file mode 100644 index 0000000..659dc0d --- /dev/null +++ b/apt_helpers/install.py @@ -0,0 +1,82 @@ +import logging +import pathlib +import shutil +import subprocess +import typing + +import typeguard + +@typeguard.typechecked +def get_list_of_packages_from_requirements_file(*, file : pathlib.Path) -> 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 apt_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 apt_install_packages(*, + packages : typing.Optional[typing.List[str]] = None, + requirements : typing.Optional[typing.List[pathlib.Path]] = None, + update : bool = True, + upgrade : bool = False, + clean: bool = True, +) -> 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) + + 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 = apt_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/apt_helpers/utils.py b/apt_helpers/utils.py new file mode 100644 index 0000000..f3e5039 --- /dev/null +++ b/apt_helpers/utils.py @@ -0,0 +1,25 @@ +import argparse +import typing + +import typeguard + +@typeguard.typechecked +def dict_to_list(dic : dict, key_prefix : str = '', sep : str = "=", join : typing.Optional[str] = None) -> list | str: + """ + Transform a dictionary to a list. + """ + res = [ + key_prefix + k + sep + v + for k, v in dic.items() + ] + return res if not join else join.join(res) + +class AppendKVPairsAction(argparse.Action): + """ + Action to parse a list of key-value pairs and append to a dictionary. + """ + @typeguard.typechecked + def __call__(self, parser : argparse.ArgumentParser, args : argparse.Namespace, values : typing.List[str], option_string : typing.Optional[str] = None, **kwargs): + kvpairs = getattr(args, self.dest) or {} + kvpairs.update(dict(map(lambda x: x.split("=", 2), values))) + setattr(args, self.dest, kvpairs) diff --git a/requirements/requirements.python.test.txt b/requirements/requirements.python.test.txt new file mode 100644 index 0000000..fce6d42 --- /dev/null +++ b/requirements/requirements.python.test.txt @@ -0,0 +1,2 @@ +pytest +pytest_console_scripts diff --git a/requirements/requirements.python.txt b/requirements/requirements.python.txt new file mode 100644 index 0000000..5eddfc0 --- /dev/null +++ b/requirements/requirements.python.txt @@ -0,0 +1,2 @@ +# This forwards 'pip' to the 'setup.py' install requirements. +. diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c92d05e --- /dev/null +++ b/setup.py @@ -0,0 +1,12 @@ +from setuptools import setup, find_packages + +setup( + name = 'apt-helpers', + version = '0.1', + install_requires = [ + 'typeguard', + ], + packages = find_packages(), + license = 'MIT', + url = 'https://github.com/uliegecsm/apt-helpers' +) diff --git a/tests/test_apt_install.py b/tests/test_apt_install.py new file mode 100644 index 0000000..27b286d --- /dev/null +++ b/tests/test_apt_install.py @@ -0,0 +1,96 @@ +import pathlib +import tempfile +import typing +import unittest.mock + +import pytest +import pytest_console_scripts +import typeguard + +from apt_helpers 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 requirement 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 requirement 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:`tools.installers.helpers.apt_install_packages`. + """ + @classmethod + def setup_class(cls): + """ + Retrieve the script path. + """ + cls.base_path = pathlib.Path(__file__).parent.parent + assert cls.base_path.is_dir() + + cls.script_path = cls.base_path / 'apt_helpers' / 'helpers.py' + assert cls.script_path.is_file() + + 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, None]) as mocker: + install.apt_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_requirement_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.apt_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_ompi_get_distribution(self, mocker, script_runner : pytest_console_scripts.ScriptRunner, requirements): + """ + Install many `APT` through requirement-like files and package names given to the CLI. + """ + import itertools + result = script_runner.run([ + str(self.script_path), + '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]), + ])