From f8e30f7801b0786b8ce49dce46d3a578902a088a Mon Sep 17 00:00:00 2001 From: Terry Moschou Date: Sun, 20 May 2018 22:08:15 +0930 Subject: [PATCH 1/2] Initial commit of conda module Added integration tests Lint errors Skip freebsd integration tests, enable osx --- .github/BOTMETA.yml | 1 + examples/ansible.cfg | 6 +- lib/ansible/config/base.yml | 2 +- .../modules/packaging/language/conda.py | 433 ++++++++++++++++++ test/integration/targets/conda/aliases | 2 + .../targets/conda/defaults/main.yml | 7 + .../targets/conda/tasks/darwin.yml | 13 + .../integration/targets/conda/tasks/linux.yml | 13 + test/integration/targets/conda/tasks/main.yml | 2 + test/integration/targets/conda/tasks/run.yml | 3 + .../integration/targets/conda/tasks/setup.yml | 22 + test/integration/targets/conda/tasks/test.yml | 119 +++++ .../targets/conda/templates/.condarc.j2 | 2 + test/integration/targets/conda/vars/main.yml | 2 + 14 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 lib/ansible/modules/packaging/language/conda.py create mode 100644 test/integration/targets/conda/aliases create mode 100644 test/integration/targets/conda/defaults/main.yml create mode 100644 test/integration/targets/conda/tasks/darwin.yml create mode 100644 test/integration/targets/conda/tasks/linux.yml create mode 100644 test/integration/targets/conda/tasks/main.yml create mode 100644 test/integration/targets/conda/tasks/run.yml create mode 100644 test/integration/targets/conda/tasks/setup.yml create mode 100644 test/integration/targets/conda/tasks/test.yml create mode 100644 test/integration/targets/conda/templates/.condarc.j2 create mode 100644 test/integration/targets/conda/vars/main.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 0e19faf049bad6..deaf0c1d419e37 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -528,6 +528,7 @@ files: $modules/notification/typetalk.py: tksmd $modules/packaging/language/bower.py: mwarkentin $modules/packaging/language/bundler.py: thoiberg + $modules/packaging/language/conda.py: tmoschou $modules/packaging/language/cpanm.py: $modules/packaging/language/easy_install.py: mattupstate $modules/packaging/language/gem.py: $team_ansible diff --git a/examples/ansible.cfg b/examples/ansible.cfg index fde72571639aad..7eb65e634f9068 100644 --- a/examples/ansible.cfg +++ b/examples/ansible.cfg @@ -246,7 +246,7 @@ # when looping. Instead of calling the module once per with_ item, the # module is called once with all items at once. Currently this only works # under limited circumstances, and only with parameters named 'name'. -#squash_actions = apk,apt,dnf,homebrew,pacman,pkgng,yum,zypper +#squash_actions = apk,apt,conda,dnf,homebrew,openbsd_pkg,pacman,pkgng,yum,zypper # prevents logging of task data, off by default #no_log = False @@ -402,8 +402,8 @@ # only be disabled if your sftp version has problems with batch mode #sftp_batch_mode = False -# The -tt argument is passed to ssh when pipelining is not enabled because sudo -# requires a tty by default. +# The -tt argument is passed to ssh when pipelining is not enabled because sudo +# requires a tty by default. #use_tty = True [persistent_connection] diff --git a/lib/ansible/config/base.yml b/lib/ansible/config/base.yml index 46e28c959ce872..bcf77e4e69087a 100644 --- a/lib/ansible/config/base.yml +++ b/lib/ansible/config/base.yml @@ -959,7 +959,7 @@ DEFAULT_SFTP_BATCH_MODE: yaml: {key: ssh_connection.sftp_batch_mode} DEFAULT_SQUASH_ACTIONS: name: Squashable actions - default: apk, apt, dnf, homebrew, openbsd_pkg, pacman, pkgng, yum, zypper + default: apk, apt, conda, dnf, homebrew, openbsd_pkg, pacman, pkgng, yum, zypper description: - Ansible can optimise actions that call modules that support list parameters when using ``with_`` looping. Instead of calling the module once for each item, the module is called once with the full list. diff --git a/lib/ansible/modules/packaging/language/conda.py b/lib/ansible/modules/packaging/language/conda.py new file mode 100644 index 00000000000000..3a59de64a53b06 --- /dev/null +++ b/lib/ansible/modules/packaging/language/conda.py @@ -0,0 +1,433 @@ +#!/usr/bin/python + +# Copyright: (c) 2018, Data to Decisions CRC +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = """ +--- +module: conda +short_description: Manage conda packages +description: > + Manage packages via conda. + Can install, update, and remove packages. +version_added: "2.6" +author: Terry Moschou (@tmoschou) +notes: + - Requires the conda executable to already be installed. +requirements: + - conda +options: + name: + description: > + A list of package to install like C(foo). A package specification may be used + for C(state: present) or C(state: latest) such C(foo=1.0|1.2*). + required: false + + state: + description: > + State in which to leave the conda package. Note that packages with + a specification such as C(numpy=1.11*=*nomkl*) are always passed to the + conda install command when C(state: present) in addition to C(state: latest), + regardless if the current specification requirements are already met. + required: false + default: present + choices: + - present + - absent + - latest + + channels: + description: > + List of extra channels to use when installing packages. Specified + in priority order + required: false + + executable: + description: > + Path to the conda executable to use. Default is to search C(PATH) environment. + required: false + + update_dependencies: + description: > + Whether to update dependencies when installing/updating. The default is to + update dependencies if C(state: latest), otherwise not to if C(state: present) + required: false + type: bool + + prefix: + description: The prefix of conda installation to manage. Mutually exclusive to I(env). + required: false + + env: + description: The conda environment to manage. Mutually exclusive to I(prefix). + required: false + + force: + description: > + Force install (even when package already installed) or forces removal of a + package without removing packages that depend on it. + required: false + type: bool +""" + +EXAMPLES = """ +- name: install packages + conda: + name: + - numpy=1.11* + - matplotlib + state: present + update_dependencies: yes + +- name: update conda itself + conda: + name: conda + state: latest + executable: "{{ conda_path }}/bin/conda" + update_dependencies: yes + +- name: uninstall packages + conda: + name: numpy + state: absent +""" + +RETURN = """ +packages: + description: a list of packages requested to be installed, updated or removed + type: list + returned: when the conda command line utility is called + sample: + - package1=1.2.* + - package2 + +cmd: + description: the conda command used to execute the state changing task + type: list + returned: when packages list is non-empty + sample: + - /usr/local/conda/bin/conda + - install + - --json + - --yes + - --quiet + - --no-update-dependencies + - install + - conda + - anaconda-client + +stdout_json: + description: the json result from the conda command line utility + type: dict + returned: when cmd is present + sample: + +stderr: + description: error output from conda + type: string + returned: when cmd is present +""" + +import json +import re +import traceback + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six import text_type +from ansible.module_utils._text import to_native + + +def run_conda_command(module, command): + """Runs conda command line executable and parses the json response. + + :return: rc, stdout_json, stderr + """ + + rc, stdout, stderr = module.run_command(command) + + # Bug in 4.4.10 where they don't respect the --quiet with --json flag and + # they emit lots of progress json blobs delimited by '\0'. E.g + # {"fetch":"openssl 1.0.2n","finished":false,"maxval":1,"progress":0.995565} + # Simply grab the last blob (if any), which is what we want + stdout = stdout.split("\0")[-1] + + stdout_json = None + try: + stdout_json = json.loads(stdout) + except Exception as e: + module.fail_json( + msg="Could not parse JSON {0}".format(to_native(e)), + exception=traceback.format_exc(), + cmd=command, + rc=rc, + stdout=stdout, + stderr=stderr + ) + + return rc, stdout_json, stderr + + +def get_lookup_func(module, conda, conda_args): + """Returns a function that accepts a single package name argument are returns a dictionary + of 'installed' boolean status and 'version'/'channel' details if installed + """ + + list_command_prefix = [conda, 'list', '--full-name'] + conda_args + pattern = re.compile(r"(?P.*::)?(?P.*)-(?P[^-]*)-(?P[^-]*)") + + def lookup(package): + + result = {'name': package} + + list_command = list_command_prefix + [package] + rc, stdout_json, stderr = run_conda_command(module, list_command) + + # since we specified --full-name we only expect there to be one or zero hits + if len(stdout_json) == 1: + pkg = stdout_json[0] + if isinstance(pkg, text_type): + # Conda 4.2 format returns list of strings + match = pattern.match(pkg) + if match: + result['channel'] = match.group('channel') + result['version'] = match.group('version') + else: + # Conda 4.3+ format returns list of dictionaries + result['channel'] = pkg['channel'] + result['version'] = pkg['version'] + + result['installed'] = True + elif len(stdout_json) == 0: + result['installed'] = False + else: + module.fail_json( + msg="Unexpected format of command result", + cmd=list_command, + rc=rc, + stdout_json=stdout_json, + stderr=stderr + ) + + return result + + return lookup + + +def add_mutable_command_args(module, conda_args): + """Adds common command line args for install/remove sub-commands""" + + conda_args.extend(['--yes', '--quiet']) + + if module.params.get('force'): + conda_args.append('--force') + + if module.check_mode: + conda_args.append('--dry-run') + + channels = module.params['channels'] + if channels: + for channel in channels: + conda_args.append('--channel') + conda_args.append(channel) + + +def did_change(result): + """Determines if the conda command was state changing, or would + have cause change if running not running in dry run mode. + + :param result: + the json dictionary returned from the conda command + :return: + true if a package was installed/updated/uninstalled etc, false otherwise + """ + + actions = result.get('actions', {}) + + # Bug in certain versions of conda. in dry-run mode, actions is wrapped in a singleton list + if isinstance(actions, list): + if actions: # if not empty + actions = actions[0] + + link = actions.get('LINK') # packages installed or updated (new version/channel) + unlink = actions.get('UNLINK') # packages uninstalled or updated (old version/channel) + symlink_conda = actions.get('SYMLINK_CONDA') + + return bool(link) or bool(unlink) or bool(symlink_conda) + + +def remove_package(module, conda, conda_args, to_remove): + """Use conda to remove list of packages if they are installed.""" + + if len(to_remove) == 0: + module.exit_json(changed=False, msg="No packages to remove") + + add_mutable_command_args(module, conda_args) + + remove_command = [conda, 'remove'] + conda_args + to_remove + rc, stdout_json, stderr = run_conda_command(module, remove_command) + + if rc != 0 or not stdout_json.get('success', False): + module.fail_json( + msg='failed to remove packages', + packages=to_remove, + rc=rc, + cmd=remove_command, + stdout_json=stdout_json, + stderr=stderr + ) + + changed = did_change(stdout_json) + module.exit_json( + changed=changed, + packages=to_remove, + cmd=remove_command, + stdout_json=stdout_json, + stderr=stderr + ) + + +def install_package(module, conda, conda_args, to_install): + """Install a packages consistent with its version specification, or install missing packages at + the latest version if no version is specified. + """ + if len(to_install) == 0: + module.exit_json(changed=False, msg="no packages to install") + + add_mutable_command_args(module, conda_args) + + if module.params['update_dependencies'] is not None: + if module.params['update_dependencies']: + conda_args.append('--update-dependencies') + else: + conda_args.append('--no-update-dependencies') + else: + if module.params['state'] == 'latest': + conda_args.append('--update-dependencies') + else: # state: present + conda_args.append('--no-update-dependencies') + + install_command = [conda, 'install'] + conda_args + to_install + rc, stdout_json, stderr = run_conda_command(module, install_command) + + if rc != 0 or not stdout_json.get('success', False): + module.fail_json( + msg='failed to install packages', + packages=to_install, + rc=rc, + cmd=install_command, + stdout_json=stdout_json, + stderr=stderr + ) + + changed = did_change(stdout_json) + module.exit_json( + changed=changed, + packages=to_install, + cmd=install_command, + stdout_json=stdout_json, + stderr=stderr + ) + + +def main(): + + module = AnsibleModule( + argument_spec={ + 'channels': { + 'default': None, + 'required': False, + 'type': 'list' + }, + 'env': { + 'required': False, + 'type': 'path' + }, + 'executable': { + 'default': None, + 'type': 'path' + }, + 'force': { + 'default': False, + 'type': 'bool' + }, + 'name': { + 'required': True, + 'type': 'list' + }, + 'prefix': { + 'required': False, + 'type': 'path' + }, + 'state': { + 'default': 'present', + 'required': False, + 'choices': [ + 'present', + 'absent', + 'latest' + ] + }, + 'update_dependencies': { + 'required': False, + 'type': 'bool', + 'default': None + } + }, + mutually_exclusive=[ + ['prefix', 'env'] + ], + supports_check_mode=True + ) + + conda = module.params['executable'] or module.get_bin_path("conda", required=True) + + packages = module.params['name'] + state = module.params['state'] + + conda_args = ['--json'] + + if module.params.get('prefix'): + conda_args.extend(['--prefix', module.params['prefix']]) + elif module.params.get('env'): + conda_args.extend(['--name', module.params['env']]) + + get_package_status = get_lookup_func(module, conda, conda_args) + + if state == 'absent': + + lookups = [get_package_status(x) for x in packages] + to_remove = [package['name'] for package in lookups if package['installed']] + remove_package(module, conda, conda_args, to_remove) + + elif state == 'present': + + spec_chars_regex = re.compile(r'[ =<>!]') + + to_install = [] + for package in packages: + # if a user specifies a spec, E.g. 'pkgname=1.2.*' we will always add it + if spec_chars_regex.match(package): + to_install.append(package) + else: + meta = get_package_status(package) + if not meta['installed']: + to_install.append(package) + + install_package(module, conda, conda_args, to_install) + + elif state == 'latest': + + install_package(module, conda, conda_args, packages) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/conda/aliases b/test/integration/targets/conda/aliases new file mode 100644 index 00000000000000..e99145bc96b4bb --- /dev/null +++ b/test/integration/targets/conda/aliases @@ -0,0 +1,2 @@ +posix/ci/group1 +skip/freebsd diff --git a/test/integration/targets/conda/defaults/main.yml b/test/integration/targets/conda/defaults/main.yml new file mode 100644 index 00000000000000..0b2d26448975bc --- /dev/null +++ b/test/integration/targets/conda/defaults/main.yml @@ -0,0 +1,7 @@ +--- +conda_rc: + channels: + - conda-forge + - r + - defaults + auto_update_conda: false diff --git a/test/integration/targets/conda/tasks/darwin.yml b/test/integration/targets/conda/tasks/darwin.yml new file mode 100644 index 00000000000000..d877dcdac6b30c --- /dev/null +++ b/test/integration/targets/conda/tasks/darwin.yml @@ -0,0 +1,13 @@ +--- +- include_tasks: run.yml + vars: + conda_bootstrap: "{{ item }}" + conda_variant: MacOSX + loop: + # format of 'conda list' changes between 4.2 and 4.3 + - version: 4.2.12 + checksum: md5:73463d2644d5e428419b7c1d304e89fa. + + # Bug in 4.4.10 where the --quiet flag is ignored when --json present + - version: 4.4.10 + checksum: md5:268ec716435aa19212901510f00815fd diff --git a/test/integration/targets/conda/tasks/linux.yml b/test/integration/targets/conda/tasks/linux.yml new file mode 100644 index 00000000000000..eb766f543b1dfc --- /dev/null +++ b/test/integration/targets/conda/tasks/linux.yml @@ -0,0 +1,13 @@ +--- +- include_tasks: run.yml + vars: + conda_bootstrap: "{{ item }}" + conda_variant: Linux + loop: + # format of 'conda list' changes between 4.2 and 4.3 + - version: 4.2.12 + checksum: md5:d0c7c71cc5659e54ab51f2005a8d96f3. + + # Bug in 4.4.10 where the --quiet flag is ignored when --json present + - version: 4.4.10 + checksum: md5:bec6203dbb2f53011e974e9bf4d46e93 diff --git a/test/integration/targets/conda/tasks/main.yml b/test/integration/targets/conda/tasks/main.yml new file mode 100644 index 00000000000000..282b25b7ade961 --- /dev/null +++ b/test/integration/targets/conda/tasks/main.yml @@ -0,0 +1,2 @@ +--- +- include_tasks: "{{ ansible_system | lower }}.yml" diff --git a/test/integration/targets/conda/tasks/run.yml b/test/integration/targets/conda/tasks/run.yml new file mode 100644 index 00000000000000..1920a8bca5cd5a --- /dev/null +++ b/test/integration/targets/conda/tasks/run.yml @@ -0,0 +1,3 @@ +--- +- include_tasks: setup.yml +- include_tasks: test.yml diff --git a/test/integration/targets/conda/tasks/setup.yml b/test/integration/targets/conda/tasks/setup.yml new file mode 100644 index 00000000000000..e9cffc99d948ab --- /dev/null +++ b/test/integration/targets/conda/tasks/setup.yml @@ -0,0 +1,22 @@ +--- +- name: delete conda installation directory + file: + name: "{{ conda_path }}" + state: absent + +- name: get miniconda3 installation script + get_url: + url: "https://repo.continuum.io/miniconda/Miniconda3-{{ conda_bootstrap.version }}-{{ conda_variant }}-x86_64.sh" + dest: "{{ output_dir }}/Miniconda3-{{ conda_bootstrap.version }}-{{ conda_variant }}-x86_64.sh" + checksum: "{{ conda_bootstrap.checksum }}" + mode: 0755 + +- name: run miniconda3 installation script + command: "{{ output_dir }}/Miniconda3-{{ conda_bootstrap.version }}-{{ conda_variant }}-x86_64.sh -b -f -p {{ conda_path }}" + args: + creates: "{{ conda_path }}/bin/conda" + +- name: configure conda + template: + src: .condarc.j2 + dest: "{{ conda_path }}/.condarc" diff --git a/test/integration/targets/conda/tasks/test.yml b/test/integration/targets/conda/tasks/test.yml new file mode 100644 index 00000000000000..49b0c7bb2e41fe --- /dev/null +++ b/test/integration/targets/conda/tasks/test.yml @@ -0,0 +1,119 @@ +--- +# these packages should always be present in an initial install +- name: ensure that python and conda-env are present + conda: + name: + - python + - conda-env + state: present + executable: "{{ conda_path }}/bin/conda" + register: result1 + +- name: installing python and conda-env should return not changed + assert: + that: + - not result1.changed + + +- name: ensure package is not installed + conda: + name: flask + state: absent + executable: "{{ conda_path }}/bin/conda" + register: result2 + +- name: removing a flask should return not changed + assert: + that: + - not result2.changed + + +- name: ensure package is installed at version spec + conda: + name: flask=0.12* + state: present + executable: "{{ conda_path }}/bin/conda" + register: result3 + +- name: installing flask should return changed + assert: + that: + - result3.changed + + +- name: ensure package is installed at version spec - repeat + conda: + name: flask=0.12* + state: present + executable: "{{ conda_path }}/bin/conda" + register: result4 + +- name: installing flask at should return not changed + assert: + that: + - not result4.changed + + +- name: ensure package is installed at any version + conda: + name: flask + state: present + executable: "{{ conda_path }}/bin/conda" + register: result5 + +- name: installing flask at any version should return not changed + assert: + that: + - not result5.changed + + +- name: ensure package is installed at latest version + conda: + name: flask + state: latest + executable: "{{ conda_path }}/bin/conda" + register: result6 + +- name: installing flask at latest version should return changed + assert: + that: + - result6.changed + + +- name: ensure package is installed at latest version - repeat + conda: + name: flask + state: latest + executable: "{{ conda_path }}/bin/conda" + register: result7 + +- name: installing flask at latest version should return not changed + assert: + that: + - not result7.changed + + +- name: ensure package is uninstalled + conda: + name: flask + state: absent + executable: "{{ conda_path }}/bin/conda" + register: result8 + +- name: removing flask should return changed + assert: + that: + - result8.changed + + +- name: ensure package is uninstalled + conda: + name: flask + state: absent + executable: "{{ conda_path }}/bin/conda" + register: result9 + +- name: removing flask should return not changed + assert: + that: + - not result9.changed diff --git a/test/integration/targets/conda/templates/.condarc.j2 b/test/integration/targets/conda/templates/.condarc.j2 new file mode 100644 index 00000000000000..1fa0e143f8b648 --- /dev/null +++ b/test/integration/targets/conda/templates/.condarc.j2 @@ -0,0 +1,2 @@ +--- +{{ conda_rc | to_yaml }} diff --git a/test/integration/targets/conda/vars/main.yml b/test/integration/targets/conda/vars/main.yml new file mode 100644 index 00000000000000..1f83d96b5cf9fe --- /dev/null +++ b/test/integration/targets/conda/vars/main.yml @@ -0,0 +1,2 @@ +--- +conda_path: "{{ output_dir }}/conda" From 0dfc6affc15c05c23a2924ab5ea6b01fb962200e Mon Sep 17 00:00:00 2001 From: Terry Moschou Date: Mon, 4 Jun 2018 12:40:48 +0930 Subject: [PATCH 2/2] Renamed env argument to env_name, updated comments/doco --- .../modules/packaging/language/conda.py | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/lib/ansible/modules/packaging/language/conda.py b/lib/ansible/modules/packaging/language/conda.py index 3a59de64a53b06..93cc3106273572 100644 --- a/lib/ansible/modules/packaging/language/conda.py +++ b/lib/ansible/modules/packaging/language/conda.py @@ -29,7 +29,7 @@ name: description: > A list of package to install like C(foo). A package specification may be used - for C(state: present) or C(state: latest) such C(foo=1.0|1.2*). + for C(state: present) or C(state: latest) such as C(foo=1.0|1.2*). required: false state: @@ -64,11 +64,14 @@ type: bool prefix: - description: The prefix of conda installation to manage. Mutually exclusive to I(env). + description: > + The full path to the conda environment to manage. Mutually exclusive to + I(env_name). required: false - env: - description: The conda environment to manage. Mutually exclusive to I(prefix). + env_name: + description: > + The conda environment name to manage. Mutually exclusive to I(prefix). required: false force: @@ -154,9 +157,9 @@ def run_conda_command(module, command): rc, stdout, stderr = module.run_command(command) - # Bug in 4.4.10 where they don't respect the --quiet with --json flag and - # they emit lots of progress json blobs delimited by '\0'. E.g - # {"fetch":"openssl 1.0.2n","finished":false,"maxval":1,"progress":0.995565} + # Bug in 4.4.10 at least where --quiet is ignored when --json is present + # and conda emits progress json blobs delimited by '\0'. E.g + # {"fetch":"openssl 1.0.2n","finished":false,"maxval":1,"progress":0.9955} # Simply grab the last blob (if any), which is what we want stdout = stdout.split("\0")[-1] @@ -177,8 +180,9 @@ def run_conda_command(module, command): def get_lookup_func(module, conda, conda_args): - """Returns a function that accepts a single package name argument are returns a dictionary - of 'installed' boolean status and 'version'/'channel' details if installed + """Returns a function that accepts a single package name argument are + returns a dictionary of 'installed' boolean status and 'version', + 'channel' details if installed. """ list_command_prefix = [conda, 'list', '--full-name'] + conda_args @@ -242,7 +246,7 @@ def add_mutable_command_args(module, conda_args): def did_change(result): """Determines if the conda command was state changing, or would - have cause change if running not running in dry run mode. + have cause change if for not running in check mode. :param result: the json dictionary returned from the conda command @@ -347,9 +351,9 @@ def main(): 'required': False, 'type': 'list' }, - 'env': { + 'env_name': { 'required': False, - 'type': 'path' + 'type': 'str' }, 'executable': { 'default': None, @@ -383,7 +387,7 @@ def main(): } }, mutually_exclusive=[ - ['prefix', 'env'] + ['prefix', 'env_name'] ], supports_check_mode=True ) @@ -397,8 +401,8 @@ def main(): if module.params.get('prefix'): conda_args.extend(['--prefix', module.params['prefix']]) - elif module.params.get('env'): - conda_args.extend(['--name', module.params['env']]) + elif module.params.get('env_name'): + conda_args.extend(['--name', module.params['env_name']]) get_package_status = get_lookup_func(module, conda, conda_args)