diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..183abe2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,104 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +### Ansible template +*.retry + diff --git a/Dockerfile.test b/Dockerfile.test index 0ae10e8..42eec75 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -15,14 +15,13 @@ RUN apt-get update \ RUN pip install setuptools wheel RUN pip install ansible==2.3.2.0 -ADD tests/requirements.yml /tmp/requirements.yml +ADD tests/integration/requirements.yml /tmp/requirements.yml RUN ansible-galaxy install -r /tmp/requirements.yml -ADD tests/requirements.txt /tmp/requirements.txt +ADD tests/integration/requirements.txt /tmp/requirements.txt RUN pip install -r /tmp/requirements.txt VOLUME "${DATA_DIRECTORY}" +WORKDIR "${DATA_DIRECTORY}" -CMD ansible-galaxy install -r "${DATA_DIRECTORY}/tests/requirements.yml" \ - && pip install -r "${DATA_DIRECTORY}/tests/requirements.txt" \ - && ansible-playbook -vvv -e ansible_python_interpreter=$(which python) -c local "${DATA_DIRECTORY}/tests/site.yml" +CMD "${DATA_DIRECTORY}/run-tests.sh" diff --git a/README.md b/README.md index e5bd502..961a4ea 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Options (= is mandatory): - extra_args Extra arguments passed to conda [Default: None] -= name +- name The name of a Python library to install [Default: None] - state diff --git a/conda.py b/conda.py index c7f126c..dc40ad2 100755 --- a/conda.py +++ b/conda.py @@ -9,61 +9,157 @@ > Manage Python libraries via conda. Can install, update, and remove packages. -author: Synthicity +author: + - Synthicity + - Colin Nolan (@colin-nolan) notes: > Requires conda to already be installed. - Will look under the home directory for a conda executable. options: name: - description: The name of a Python library to install + description: The name of a Python package to install. required: true - default: null version: - description: A specific version of a library to install + description: The specific version of a package to install. required: false - default: null state: - description: State in which to leave the Python package + description: State in which to leave the Python package. "present" will install a package of the specified version + if it is not installed (will not upgrade to latest if version is unspecified - will only install + latest); "latest" will both install and subsequently upgrade a package to the latest version on each + run; "absent" will uninstall the package if installed. required: false default: present choices: [ "present", "absent", "latest" ] channels: - description: Extra channels to use when installing packages + description: Extra channels to use when installing packages. required: false - default: null executable: - description: Full path to the conda executable + description: Full path to the conda executable. required: false - default: null extra_args: - description: Extra arguments passed to conda + description: Extra arguments passed to conda. required: false - default: null """ EXAMPLES = """ - name: install numpy via conda - conda: name=numpy state=latest + conda: + name: numpy + state: latest - name: install scipy 0.14 via conda - conda: name=scipy version="0.14" + conda: + name: scipy + version: "0.14" - name: remove matplotlib from conda - conda: name=matplotlib state=absent + conda: + name: matplotlib + state: absent """ +RETURN = """ +output: + description: JSON output from Conda + returned: `changed == True` + type: dict +stderr: + description: stderr content written by Conda + returned: `changed == True` + type: str +""" + + from distutils.spawn import find_executable import os.path import json +from ansible.module_utils.basic import AnsibleModule + + +def run_package_operation(conda, name, version, state, dry_run, command_runner, on_failure, on_success): + """ + Runs Conda package operation. + + This method is intentionally decoupled from `AnsibleModule` to allow it to be easily tested in isolation. + :param conda: location of the Conda executable + :param name: name of the package of interest + :param version: version of the package (`None` for latest) + :param state: state the package should be in + :param dry_run: will "pretend" to make changes only if `True` + :param command_runner: method that executes a given Conda command (given as list of string arguments), which returns + JSON and returns a tuple where the first argument is the outputted JSON and the second is anything written to stderr + :param on_failure: method that takes any kwargs to be called on failure + :param on_success: method that takes any kwargs to be called on success + """ + correct_version_installed = check_package_installed(command_runner, conda, name, version) + + # TODO: State should be an "enum" (or whatever the Py2.7 equivalent is) + if not correct_version_installed and state != 'absent': + try: + output, stderr = install_package(command_runner, conda, name, version, dry_run=dry_run) + on_success(changed=True, output=output, stderr=stderr) + except CondaPackageNotFoundError: + on_failure(msg='Conda package "%s" not found' % (get_install_target(name, version, ))) + + elif state == 'absent': + try: + output, stderr = uninstall_package(command_runner, conda, name, dry_run=dry_run) + on_success(changed=True, output=output, stderr=stderr) + except CondaPackageNotFoundError: + on_success(changed=False) + + else: + on_success(changed=False) + + +def check_package_installed(command_runner, conda, name, version): + """ + Check whether a package with the given name and version is installed. + :param command_runner: method that executes a given Conda command (given as list of string arguments), which returns + JSON and returns a tuple where the first argument is the outputted JSON and the second is anything written to stderr + :param name: the name of the package to check if installed + :param version: the version of the package to check if installed (`None` if check for latest) + :return: `True` if a package with the given name and version is installed + :raises CondaUnexpectedOutputError: if the JSON returned by Conda was unexpected + """ + output, stderr = run_conda_package_command( + command_runner, name, version, [conda, 'install', '--json', '--dry-run', get_install_target(name, version)]) + + if 'message' in output and output['message'] == 'All requested packages already installed.': + return True + elif 'actions' in output and len(output['actions']) > 0: + return False + else: + raise CondaUnexpectedOutputError(output, stderr) + +def install_package(command_runner, conda, name, version=None, dry_run=False): + """ + Install a package with the given name and version. Version will default to latest if `None`. + """ + command = [conda, 'install', '--yes', '--json', get_install_target(name, version)] + if dry_run: + command.insert(-1, '--dry-run') + + return run_conda_package_command(command_runner, name, version, command) + + +def uninstall_package(command_runner, conda, name, dry_run=False): + """ + Use Conda to remove a package with the given name. + """ + command = [conda, 'remove', '--yes', '--json', name] + if dry_run: + command.insert(-1, '--dry-run') + + return run_conda_package_command(command_runner, name, None, command) -def _find_conda(module, executable): + +def find_conda(executable): """ If `executable` is not None, checks whether it points to a valid file and returns it if this is the case. Otherwise tries to find the `conda` executable in the path. Calls `fail_json` if either of these fail. - """ if not executable: conda = find_executable('conda') @@ -73,14 +169,13 @@ def _find_conda(module, executable): if os.path.isfile(executable): return executable - module.fail_json(msg="could not find conda executable") + raise CondaExecutableNotFoundError() -def _add_channels_to_command(command, channels): +def add_channels_to_command(command, channels): """ Add extra channels to a conda command by splitting the channels and putting "--channel" before each one. - """ if channels: channels = channels.strip().split() @@ -94,11 +189,10 @@ def _add_channels_to_command(command, channels): return command -def _add_extras_to_command(command, extras): +def add_extras_to_command(command, extras): """ Add extra arguments to a conda command by splitting the arguments on white space and inserting them after the second item in the command. - """ if extras: extras = extras.strip().split() @@ -107,163 +201,144 @@ def _add_extras_to_command(command, extras): return command -def _check_installed(module, conda, name): +def parse_conda_stdout(stdout): """ - Check whether a package is installed. Returns (bool, version_str). - + Parses the given output from Conda. + :param stdout: the output from stdout + :return: standard out as parsed JSON else `None` if non-JSON format """ - command = [ - conda, - 'list', - '^' + name + '$', - '--json' - ] - command = _add_extras_to_command(command, module.params['extra_args']) - - rc, stdout, stderr = module.run_command(command) - - if rc != 0: - return False, None - - installed = False - version = None - - data = json.loads(stdout) - if data: - # At this point data will be a list of len 1, with the element of - # the format: "channel::package-version-py35_1" - line = data[0] - if "::" in line: - channel, other = line.split('::') - else: - other = line - - if isinstance(other, dict): - pname = other.get('name', '') - pversion = other.get('version', '') + # Conda spews loading progress reports onto stdout(!?), which need ignoring. Bug observed in Conda version 4.3.25. + split_lines = stdout.strip().split("\n") + while len(split_lines) > 0: + line = split_lines.pop(0).strip('\x00') + try: + line_content = json.loads(line) + if "progress" not in line_content and "maxval" not in line_content: + # Looks like this was the output, not a progress update + return line_content + except ValueError: + split_lines.insert(0, line) + break + + try: + return json.loads("".join(split_lines)) + except ValueError: + return None + + +def run_conda_package_command(command_runner, name, version, command): + """ + Runs a Conda command related to a particular package. + :param command_runner: runner of Conda commands + :param name: the name of the package the command refers to + :param version: the version of the package that the command is referring to + :param command: the Conda command + :raises CondaPackageNotFoundError: if the package referred to by this command is not found + """ + try: + return command_runner(command) + except CondaCommandJsonDescribedError as e: + if 'exception_name' in e.output and e.output['exception_name'] == 'PackageNotFoundError': + raise CondaPackageNotFoundError(name, version) else: - # split carefully as some package names have "-" in them (scikit-learn) - pname, pversion, pdist = other.rsplit('-', 2) + raise - if pname == name: # verify match for safety - installed = True - version = pversion - return installed, version - - -def _remove_package(module, conda, installed, name): +def get_install_target(name, version): """ - Use conda to remove a given package if it is installed. - + Gets install target string for a package with the given name and version. + :param name: the package name + :param version: the package version (`None` if latest) + :return: the target string that Conda can refer to the given package as """ - if module.check_mode and installed: - module.exit_json(changed=True) - - if not installed: - module.exit_json(changed=False) + install_target = name + if version is not None: + install_target = '%s=%s' % (name, version) + return install_target - command = [ - conda, - 'remove', - '--yes', - name - ] - command = _add_extras_to_command(command, module.params['extra_args']) - rc, stdout, stderr = module.run_command(command) - - if rc != 0: - module.fail_json(msg='failed to remove package ' + name, stderr=stderr) - - module.exit_json(changed=True, name=name, stdout=stdout, stderr=stderr) - - -def _install_package( - module, conda, installed, name, version, installed_version): +class CondaCommandError(Exception): """ - Install a package at a specific version, or install a missing package at - the latest version if no version is specified. - + Error raised when a Conda command fails. """ - if installed and (version is None or installed_version == version): - module.exit_json(changed=False, name=name, version=version) + def __init__(self, command, stdout, stderr): + self.command = command + self.stdout = stdout + self.stderr = stderr - if module.check_mode: - if not installed or (installed and installed_version != version): - module.exit_json(changed=True) + stdout = ' stdout: %s.' % self.stdout if self.stdout.strip() != '' else '' + stderr = ' stderr: %s.' % self.stderr if self.stderr.strip() != '' else '' - if version: - install_str = name + '=' + version - else: - install_str = name + super(CondaCommandError, self).__init__( + 'Error running command: %s.%s%s' % (self.command, stdout, stderr)) - command = [ - conda, - 'install', - '--yes', - install_str - ] - command = _add_channels_to_command(command, module.params['channels']) - command = _add_extras_to_command(command, module.params['extra_args']) - rc, stdout, stderr = module.run_command(command) +class CondaCommandJsonDescribedError(CondaCommandError): + """ + Error raised when a Conda command does not output JSON. + """ + def __init__(self, command, output, stderr): + self.output = output + super(CondaCommandJsonDescribedError, self).__init__(command, json.dumps(output), stderr) - if rc != 0: - module.fail_json(msg='failed to install package ' + name, stderr=stderr) - module.exit_json( - changed=True, name=name, version=version, stdout=stdout, stderr=stderr) +class CondaPackageNotFoundError(Exception): + """ + Error raised when a Conda package has not been found in the package repositories that were searched. + """ + def __int__(self, name, version): + self.name = name + self.version = version + super(CondaPackageNotFoundError, self).__init__( + 'Conda package "%s" not found' % (get_install_target(self.name, self.version), )) -def _update_package(module, conda, installed, name): +class CondaUnexpectedOutputError(Exception): """ - Make sure an installed package is at its latest version. - + Error raised when the running of a Conda command has resulted in an unexpected output. """ - if not installed: - module.fail_json(msg='can\'t update a package that is not installed') - - # see if it's already installed at the latest version - command = [ - conda, - 'update', - '--dry-run', - name - ] - command = _add_channels_to_command(command, module.params['channels']) - command = _add_extras_to_command(command, module.params['extra_args']) + def __int__(self, output, stderr): + self.output = output + self.stderr = stderr - rc, stdout, stderr = module.run_command(command) + stderr = 'stderr: %s' % self.stderr if self.stderr.strip() != '' else '' + super(CondaUnexpectedOutputError, self).__init__( + 'Unexpected output from Conda (may be due to a change in Conda\'s output format): "%output".%s' + % (self.output, stderr)) - if rc != 0: - module.fail_json(msg='can\'t update a package that is not installed', stderr=stderr) - if 'requested packages already installed' in stdout: - module.exit_json(changed=False, name=name) +class CondaExecutableNotFoundError(Exception): + """ + Error raised when the Conda executable was not found. + """ + def __init__(self): + super(CondaExecutableNotFoundError, self).__init__('Conda executable not found.') - # now we're definitely gonna update the package - if module.check_mode: - module.exit_json(changed=True, name=name) - command = [ - conda, - 'update', - '--yes', - name - ] - command = _add_channels_to_command(command, module.params['channels']) - command = _add_extras_to_command(command, module.params['extra_args']) +def _run_conda_command(module, command): + """ + Runs the given Conda command. + :param module: Ansible module + :param command: the Conda command to run, which must return JSON + """ + command = add_channels_to_command(command, module.params['channels']) + command = add_extras_to_command(command, module.params['extra_args']) rc, stdout, stderr = module.run_command(command) + output = parse_conda_stdout(stdout) + if output is None: + raise CondaCommandError(command, stdout, stderr) if rc != 0: - module.fail_json(msg='failed to update package ' + name, stderr=stderr) + raise CondaCommandJsonDescribedError(command, output, stderr) - module.exit_json(changed=True, name=name, stdout=stdout, stderr=stderr) + return output, stderr -def main(): +def _main(): + """ + Entrypoint. + """ module = AnsibleModule( argument_spec={ 'name': {'required': True, 'type': 'str'}, @@ -279,24 +354,20 @@ def main(): }, supports_check_mode=True) - conda = _find_conda(module, module.params['executable']) + conda = find_conda(module.params['executable']) name = module.params['name'] state = module.params['state'] version = module.params['version'] - installed, installed_version = _check_installed(module, conda, name) + if state == 'latest' and version is not None: + module.fail_json(msg='`version` must not be set if `state == "latest"` (`latest` upgrades to newest version)') - if state == 'absent': - _remove_package(module, conda, installed, name) - elif state == 'present' or (state == 'latest' and not installed): - _install_package( - module, conda, installed, name, version, installed_version) - elif state == 'latest': - _update_package(module, conda, installed, name) + def command_runner(command): + return _run_conda_command(module, command) + run_package_operation( + conda, name, version, state, module.check_mode, command_runner, module.fail_json, module.exit_json) -# import module snippets -from ansible.module_utils.basic import * if __name__ == '__main__': - main() + _main() diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000..1f68fe5 --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euf -o pipefail + +# Setup +ansible-galaxy install -r "${DATA_DIRECTORY}/tests/integration/requirements.yml" +pip install -r "${DATA_DIRECTORY}/tests/integration/requirements.txt" + +# Run unit tests +PYTHONPATH=. python -m unittest discover -v -s tests/unit + +# Run integration tests +ansible-playbook -vvv -e ansible_python_interpreter=$(which python) -c local "${DATA_DIRECTORY}/tests/integration/site.yml" diff --git a/tests/defaults/main.yml b/tests/integration/defaults/main.yml similarity index 100% rename from tests/defaults/main.yml rename to tests/integration/defaults/main.yml diff --git a/tests/integration/library/conda.py b/tests/integration/library/conda.py new file mode 120000 index 0000000..d69432a --- /dev/null +++ b/tests/integration/library/conda.py @@ -0,0 +1 @@ +../../../conda.py \ No newline at end of file diff --git a/tests/meta/main.yml b/tests/integration/meta/main.yml similarity index 100% rename from tests/meta/main.yml rename to tests/integration/meta/main.yml diff --git a/tests/requirements.txt b/tests/integration/requirements.txt similarity index 100% rename from tests/requirements.txt rename to tests/integration/requirements.txt diff --git a/tests/requirements.yml b/tests/integration/requirements.yml similarity index 100% rename from tests/requirements.yml rename to tests/integration/requirements.yml diff --git a/tests/site.yml b/tests/integration/site.yml similarity index 100% rename from tests/site.yml rename to tests/integration/site.yml diff --git a/tests/tasks/install.yml b/tests/integration/tasks/install.yml similarity index 100% rename from tests/tasks/install.yml rename to tests/integration/tasks/install.yml diff --git a/tests/tasks/main.yml b/tests/integration/tasks/main.yml similarity index 52% rename from tests/tasks/main.yml rename to tests/integration/tasks/main.yml index cf1b4d6..a448baf 100644 --- a/tests/tasks/main.yml +++ b/tests/integration/tasks/main.yml @@ -3,6 +3,11 @@ - include: install.yml - include: tear-down.yml +- block: + - include: test-uninstall.yml + always: + - include: tear-down.yml + - block: - include: test-install-latest.yml always: @@ -13,6 +18,11 @@ always: - include: tear-down.yml +- block: + - include: test-install-loosely-fixed-version.yml + always: + - include: tear-down.yml + - block: - include: test-upgrade.yml always: @@ -22,3 +32,13 @@ - include: test-downgrade.yml always: - include: tear-down.yml + +- block: + - include: test-invalid-setups.yml + always: + - include: tear-down.yml + +- block: + - include: test-failures.yml + always: + - include: tear-down.yml diff --git a/tests/tasks/set-install-facts.yml b/tests/integration/tasks/set-install-facts.yml similarity index 92% rename from tests/tasks/set-install-facts.yml rename to tests/integration/tasks/set-install-facts.yml index b51284f..b55faeb 100644 --- a/tests/tasks/set-install-facts.yml +++ b/tests/integration/tasks/set-install-facts.yml @@ -1,6 +1,6 @@ --- -- name: find installed Conda matching packages +- name: find installed matching Conda packages shell: "{{ conda_tests_conda_executable }} search --json ^{{ conda_tests_install_example }}$" no_log: True register: installed_raw diff --git a/tests/tasks/tear-down.yml b/tests/integration/tasks/tear-down.yml similarity index 100% rename from tests/tasks/tear-down.yml rename to tests/integration/tasks/tear-down.yml diff --git a/tests/tasks/test-downgrade.yml b/tests/integration/tasks/test-downgrade.yml similarity index 87% rename from tests/tasks/test-downgrade.yml rename to tests/integration/tasks/test-downgrade.yml index 3cb0f81..dd75e12 100644 --- a/tests/tasks/test-downgrade.yml +++ b/tests/integration/tasks/test-downgrade.yml @@ -1,12 +1,12 @@ --- -- name: install latest package via conda +- name: install latest Conda package conda: name: "{{ conda_tests_install_example }}" state: latest executable: "{{ conda_tests_conda_executable }}" -- name: install old package via conda +- name: install old Conda package conda: name: "{{ conda_tests_install_example }}" version: "{{ conda_tests_old_version }}" diff --git a/tests/integration/tasks/test-failures.yml b/tests/integration/tasks/test-failures.yml new file mode 100644 index 0000000..12f8ec5 --- /dev/null +++ b/tests/integration/tasks/test-failures.yml @@ -0,0 +1,39 @@ +--- + +- name: install Conda packge that does not exist (expect failure) + conda: + name: this_packge_hopefully_does_not_exist + state: present + executable: "{{ conda_tests_conda_executable }}" + register: non_existent_install + ignore_errors: yes + +- name: verify failure + assert: + that: non_existent_install.failed + +- name: install Conda packge version that does not exist (expect failure) + conda: + name: "{{ conda_tests_install_example }}" + state: present + version: 9999 + executable: "{{ conda_tests_conda_executable }}" + register: non_existent_install + ignore_errors: yes + +- name: verify failure + assert: + that: non_existent_install.failed + +- name: install latest Conda packge, fixed at a specific version (expect failure) + conda: + name: "{{ conda_tests_install_example }}" + state: latest + version: "{{ conda_tests_minimum_latest_version }}" + executable: "{{ conda_tests_conda_executable }}" + register: invalid_setup + ignore_errors: yes + +- name: verify failure + assert: + that: invalid_setup.failed diff --git a/tests/tasks/test-install-fixed-version.yml b/tests/integration/tasks/test-install-fixed-version.yml similarity index 90% rename from tests/tasks/test-install-fixed-version.yml rename to tests/integration/tasks/test-install-fixed-version.yml index 1755970..3edaf4f 100644 --- a/tests/tasks/test-install-fixed-version.yml +++ b/tests/integration/tasks/test-install-fixed-version.yml @@ -1,6 +1,6 @@ --- -- name: install package via conda +- name: install Conda package conda: name: "{{ conda_tests_install_example }}" version: "{{ conda_tests_old_version }}" @@ -16,7 +16,7 @@ that: example_package.installed that: example_package.version | version_compare(conda_tests_old_version, '=') -- name: install package via conda (again) +- name: install Conda package (again) conda: name: "{{ conda_tests_install_example }}" version: "{{ conda_tests_old_version }}" diff --git a/tests/integration/tasks/test-install-latest.yml b/tests/integration/tasks/test-install-latest.yml new file mode 100644 index 0000000..2056b42 --- /dev/null +++ b/tests/integration/tasks/test-install-latest.yml @@ -0,0 +1,51 @@ +--- + +- name: install latest version of the Conda package + conda: + name: "{{ conda_tests_install_example }}" + state: latest + executable: "{{ conda_tests_conda_executable }}" + register: first_install + +- include: set-install-facts.yml + +- name: verify installed + assert: + that: first_install.changed + that: example_package.installed + that: example_package.version | version_compare(conda_tests_minimum_latest_version, '>=') + +# TODO: Until tests are ran against local package repository, this test would be flaky in the (rare) case that the +# package is updated mid-test +- name: install latest version of the Conda package (again) + conda: + name: "{{ conda_tests_install_example }}" + state: latest + executable: "{{ conda_tests_conda_executable }}" + register: second_install + +- name: verify idempotence + assert: + that: not second_install.changed + +- block: + - name: install larger Conda package with dependencies + conda: + name: "{{ conda_tests_larger_install_example }}" + state: present + executable: "{{ conda_tests_conda_executable }}" + register: larger_install + + - include: set-install-facts.yml + vars: + conda_tests_install_example: "{{ conda_tests_larger_install_example }}" + + - name: verify installed + assert: + that: larger_install.changed + that: example_package.installed + + always: + - include: tear-down.yml + vars: + conda_tests_install_example: "{{ conda_tests_larger_install_example }}" \ No newline at end of file diff --git a/tests/integration/tasks/test-install-loosely-fixed-version.yml b/tests/integration/tasks/test-install-loosely-fixed-version.yml new file mode 100644 index 0000000..207eff7 --- /dev/null +++ b/tests/integration/tasks/test-install-loosely-fixed-version.yml @@ -0,0 +1,59 @@ +--- + +- name: install Conda package using major version number + conda: + name: "{{ conda_tests_install_example }}" + version: "{{ conda_tests_minimum_latest_major_version }}" + state: present + executable: "{{ conda_tests_conda_executable }}" + register: first_install + +- include: set-install-facts.yml + +- name: verify installed + assert: + that: first_install.changed + that: example_package.installed + that: example_package.version | version_compare(conda_tests_minimum_latest_version, '>=') + +- name: install Conda package using major version number (again) + conda: + name: "{{ conda_tests_install_example }}" + version: "{{ conda_tests_minimum_latest_major_version }}" + state: present + executable: "{{ conda_tests_conda_executable }}" + register: second_install + +- name: verify idempotence + assert: + that: not second_install.changed + + +- name: verify setup for testing package upgrade + assert: + that: conda_tests_old_version.split('.')[0] == conda_tests_minimum_latest_major_version + +- name: install older Conda package + conda: + name: "{{ conda_tests_install_example }}" + version: "{{ conda_tests_old_version }}" + state: present + executable: "{{ conda_tests_conda_executable }}" + +- name: upgrade Conda package + conda: + name: "{{ conda_tests_install_example }}" + version: "{{ conda_tests_minimum_latest_major_version }}" + state: present + executable: "{{ conda_tests_conda_executable }}" + register: upgrade_install + +- include: set-install-facts.yml + +- name: verify upgrade + assert: + that: upgrade_install.changed + that: example_package.installed + that: example_package.version | version_compare(conda_tests_minimum_latest_version, '>=') + + diff --git a/tests/integration/tasks/test-invalid-setups.yml b/tests/integration/tasks/test-invalid-setups.yml new file mode 100644 index 0000000..4e37556 --- /dev/null +++ b/tests/integration/tasks/test-invalid-setups.yml @@ -0,0 +1,14 @@ +--- + +- name: install Conda packge with fixed version but `latest` state + conda: + name: "{{ conda_tests_install_example }}" + version: "{{ conda_tests_old_version }}" + state: latest + executable: "{{ conda_tests_conda_executable }}" + register: versioned_and_latest_install + ignore_errors: yes + +- name: verify invalid setup + assert: + that: versioned_and_latest_install.failed diff --git a/tests/integration/tasks/test-uninstall.yml b/tests/integration/tasks/test-uninstall.yml new file mode 100644 index 0000000..99a57a3 --- /dev/null +++ b/tests/integration/tasks/test-uninstall.yml @@ -0,0 +1,35 @@ +--- + +- name: install Conda package + conda: + name: "{{ conda_tests_install_example }}" + state: latest + executable: "{{ conda_tests_conda_executable }}" + register: first_install + +- name: uninstall Conda package + conda: + name: "{{ conda_tests_install_example }}" + state: absent + executable: "{{ conda_tests_conda_executable }}" + register: first_uninstall + +- include: set-install-facts.yml + +- name: verify package not installed + assert: + that: first_uninstall.changed + that: not example_package.installed + +- name: uninstall Conda package again + conda: + name: "{{ conda_tests_install_example }}" + state: absent + executable: "{{ conda_tests_conda_executable }}" + register: second_uninstall + +- include: set-install-facts.yml + +- name: verify idempotence + assert: + that: not second_uninstall.changed diff --git a/tests/tasks/test-upgrade.yml b/tests/integration/tasks/test-upgrade.yml similarity index 87% rename from tests/tasks/test-upgrade.yml rename to tests/integration/tasks/test-upgrade.yml index 011853c..9c11613 100644 --- a/tests/tasks/test-upgrade.yml +++ b/tests/integration/tasks/test-upgrade.yml @@ -1,13 +1,13 @@ --- -- name: install old package via conda +- name: install old Conda package conda: name: "{{ conda_tests_install_example }}" version: "{{ conda_tests_old_version }}" state: present executable: "{{ conda_tests_conda_executable }}" -- name: install latest package via conda +- name: install latest Conda package conda: name: "{{ conda_tests_install_example }}" state: latest diff --git a/tests/vars/main.yml b/tests/integration/vars/main.yml similarity index 68% rename from tests/vars/main.yml rename to tests/integration/vars/main.yml index 2db4d07..3591995 100644 --- a/tests/vars/main.yml +++ b/tests/integration/vars/main.yml @@ -3,4 +3,7 @@ # XXX: better to hit local package repository with dummy package conda_tests_install_example: translationstring conda_tests_minimum_latest_version: "1.3" +conda_tests_minimum_latest_major_version: "1" conda_tests_old_version: "1.1" + +conda_tests_larger_install_example: curl diff --git a/tests/library/conda.py b/tests/library/conda.py deleted file mode 120000 index 797b559..0000000 --- a/tests/library/conda.py +++ /dev/null @@ -1 +0,0 @@ -../../conda.py \ No newline at end of file diff --git a/tests/site.retry b/tests/site.retry deleted file mode 100644 index 2fbb50c..0000000 --- a/tests/site.retry +++ /dev/null @@ -1 +0,0 @@ -localhost diff --git a/tests/tasks/test-install-latest.yml b/tests/tasks/test-install-latest.yml deleted file mode 100644 index dd93e10..0000000 --- a/tests/tasks/test-install-latest.yml +++ /dev/null @@ -1,27 +0,0 @@ ---- - -- name: install package via conda - conda: - name: "{{ conda_tests_install_example }}" - state: latest - executable: "{{ conda_tests_conda_executable }}" - register: first_install - -- include: set-install-facts.yml - -- name: verify installed - assert: - that: first_install.changed - that: example_package.installed - that: example_package.version | version_compare(conda_tests_minimum_latest_version, '>=') - -- name: install package via conda (again) - conda: - name: "{{ conda_tests_install_example }}" - state: latest - executable: "{{ conda_tests_conda_executable }}" - register: second_install - -- name: verify idempotence - assert: - that: not second_install.changed diff --git a/tests/unit/test_conda.py b/tests/unit/test_conda.py new file mode 100644 index 0000000..8bf19f3 --- /dev/null +++ b/tests/unit/test_conda.py @@ -0,0 +1,33 @@ +import json +import unittest + +from conda import parse_conda_stdout + + +class TestParseCondaStdout(unittest.TestCase): + """ + Tests for `parse_conda_stdout`. + """ + _VALID_STDOUT = """ + { + "actions": {}, + "success": true + } + """ + + def test_parses_invalid_stdout(self): + self.assertIsNone(parse_conda_stdout("fail")) + + def test_parses_valid_stdout(self): + self.assertEqual( + json.loads(TestParseCondaStdout._VALID_STDOUT), parse_conda_stdout(TestParseCondaStdout._VALID_STDOUT)) + + def test_parses_valid_stdout_with_progress_reports(self): + stdout = '{"maxval": 17685, "finished": false, "fetch": "translationstr", "progress": 0}\n\x00' \ + '{"maxval": 17685, "finished": true, "fetch": "translationstr", "progress": 17685}\n\x00%s' \ + % (TestParseCondaStdout._VALID_STDOUT,) + self.assertEqual(json.loads(TestParseCondaStdout._VALID_STDOUT), parse_conda_stdout(stdout)) + + +if __name__ == "__main__": + unittest.main()