├── .ansible-lint ├── .gitignore ├── Makefile ├── README.md ├── galaxy.yml ├── issue10.yml ├── meta └── runtime.yml ├── plugins └── modules │ └── pip.py └── ruff.toml /.ansible-lint: -------------------------------------------------------------------------------- 1 | # https://ansible-lint.readthedocs.io/configuring/ 2 | # https://github.com/ansible/ansible-lint/issues/132 3 | --- 4 | offline: true 5 | 6 | skip_list: 7 | - fqcn # Ignore uses unqualified names (e.g. command) 8 | - key-order[task] # Ignore when: at end of block 9 | - yaml[document-start] # Ignore missing --- at start of file 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tox/ 2 | .venv/ 3 | *.tar.gz 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # ansible-galaxy collection install --force . 2 | # ansible-galaxy collection build 3 | # ansible-galaxy collection upload 4 | # ansible-galaxy collection publish 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ansible Collection - moreati.uv 2 | 3 | A module for managing Python packages and virtualenvs using Astral's [uv]. 4 | The module is adapted from on Ansible's builtin [ansible.builtin.pip], so it 5 | can be used as a drop in replacement. 6 | 7 | [ansible.builtin.pip]: https://docs.ansible.com/ansible/latest/collections/ansible/builtin/pip_module.html 8 | [uv]: https://docs.astral.sh/uv/ 9 | 10 | ## Example 11 | 12 | ```yaml 13 | - name: Demonstrate moreati.uv 14 | hosts: localhost 15 | gather_facts: false 16 | tasks: 17 | - name: Install requests to a virtualenv 18 | moreati.uv.pip: 19 | name: requests 20 | virtualenv: ~/venv 21 | ``` 22 | 23 | ```console 24 | $ ansible-playbook playbook.yml -v 25 | PLAY [Demonstrate moreati.uv] ************************************************** 26 | 27 | TASK [Install requests to a virtualenv] **************************************** 28 | changed: [localhost] => changed=true 29 | cmd: 30 | - /Users/alex/.cargo/bin/uv 31 | - pip 32 | - install 33 | - --python 34 | - /Users/alex/venv/bin/python 35 | - requests 36 | name: 37 | - requests 38 | requirements: null 39 | state: present 40 | stderr: |- 41 | Using CPython 3.13.0 interpreter at: /Users/alex/.local/share/uv/tools/ansible-core/bin/python 42 | Creating virtual environment at: /Users/alex/venv 43 | Activate with: source /Users/alex/venv/bin/activate 44 | Using Python 3.13.0 environment at /Users/alex/venv 45 | Resolved 5 packages in 44ms 46 | Installed 5 packages in 5ms 47 | + certifi==2024.8.30 48 | + charset-normalizer==3.4.0 49 | + idna==3.10 50 | + requests==2.32.3 51 | + urllib3==2.2.3 52 | stderr_lines: 53 | stdout: '' 54 | stdout_lines: 55 | version: null 56 | virtualenv: /Users/alex/venv 57 | 58 | PLAY RECAP ********************************************************************* 59 | localhost : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 60 | 61 | $ ~/venv/bin/python -c "import requests; print(requests.get('https://httpbin.org/get'))" 62 | 63 | ``` 64 | -------------------------------------------------------------------------------- /galaxy.yml: -------------------------------------------------------------------------------- 1 | namespace: moreati 2 | name: uv 3 | version: 0.0.3 4 | description: |- 5 | Drop in replacement for Ansible's pip module, using Astral uv 6 | 7 | authors: 8 | - Alex Willmer 9 | 10 | license: 11 | - GPL-3.0-or-later 12 | 13 | readme: README.md 14 | repository: http://github.com/moreati/ansible-uv 15 | issues: http://github.com/moreati/ansible-uv/issues 16 | 17 | build_ignore: 18 | - .DS_Store 19 | - .ansible-lint 20 | - .git 21 | - .gitignore 22 | - .tox 23 | - .venv 24 | - Makefile 25 | - ansible.cfg 26 | - bootstrap*.yml 27 | - galaxy.yml 28 | - issue*.yml 29 | - ruff.toml 30 | - tox.ini 31 | - uv_install.sh 32 | 33 | - "*.py[cdo]" 34 | - "*.retry" 35 | - "*.tar.gz" 36 | 37 | tags: 38 | - astral 39 | - pip 40 | - uv 41 | - venv 42 | - virtualenv 43 | -------------------------------------------------------------------------------- /issue10.yml: -------------------------------------------------------------------------------- 1 | - name: Issue 10 test 2 | hosts: localhost 3 | gather_facts: false 4 | vars: 5 | temp_dir: "{{ lookup('env', 'TMPDIR') | default('/tmp') | normpath }}" 6 | work_dir: "{{ temp_dir }}/moreati.uv.pip" 7 | test_projects: 8 | - sampleproject 9 | tasks: 10 | - name: Create work dir 11 | file: 12 | path: "{{ work_dir }}" 13 | state: directory 14 | mode: u=rwx,go=rx 15 | 16 | - moreati.uv.pip: 17 | name: "{{ test_projects }}" 18 | virtualenv: "{{ work_dir }}/issue10" 19 | #virtualenv_command: "{{ ansible_playbook_python }} -mvenv" 20 | -------------------------------------------------------------------------------- /meta/runtime.yml: -------------------------------------------------------------------------------- 1 | requires_ansible: '>=2.17' 2 | -------------------------------------------------------------------------------- /plugins/modules/pip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright: (c) 2012, Matt Wright 4 | # Copyright: (c) 2024, Alex Willmer 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | from __future__ import annotations 8 | 9 | 10 | DOCUMENTATION = ''' 11 | --- 12 | module: pip 13 | short_description: Manages Python library dependencies with Astral uv. 14 | description: 15 | - "Manage Python library dependencies. To use this module, one of the following keys is required: O(name) 16 | or O(requirements)." 17 | options: 18 | name: 19 | description: 20 | - The name of a Python library to install or the url(bzr+,hg+,git+,svn+) of the remote package. 21 | - This can be a list and contain version specifiers. 22 | type: list 23 | elements: str 24 | version: 25 | description: 26 | - The version number to install of the Python library specified in the O(name) parameter. 27 | type: str 28 | requirements: 29 | description: 30 | - The path to a requirements file, which should be local to the remote system. 31 | File can be specified as a relative path if using the chdir option. 32 | type: str 33 | virtualenv: 34 | description: 35 | - An optional path to a I(virtualenv) directory to install into. 36 | It cannot be specified together with the 'executable' parameter 37 | . 38 | If the virtualenv does not exist, it will be created before installing 39 | packages. The optional virtualenv_site_packages, virtualenv_command, 40 | and virtualenv_python options affect the creation of the virtualenv. 41 | type: path 42 | virtualenv_site_packages: 43 | description: 44 | - Whether the virtual environment will inherit packages from the 45 | global site-packages directory. Note that if this setting is 46 | changed on an already existing virtual environment it will not 47 | have any effect, the environment must be deleted and newly 48 | created. 49 | type: bool 50 | default: "no" 51 | virtualenv_command: 52 | type: str 53 | default: uv venv 54 | virtualenv_python: 55 | description: 56 | - The Python executable used for creating the virtual environment. 57 | For example V(python3.12), V(python2.7). When not specified, the 58 | Python version used to run the ansible module is used. 59 | type: str 60 | state: 61 | description: 62 | - The state of module 63 | type: str 64 | choices: [ absent, forcereinstall, latest, present ] 65 | default: present 66 | extra_args: 67 | description: 68 | - Extra arguments passed to uv pip. 69 | type: str 70 | editable: 71 | description: 72 | - Pass the editable flag. 73 | type: bool 74 | default: 'no' 75 | chdir: 76 | description: 77 | - cd into this directory before running the command 78 | type: path 79 | executable: 80 | type: str 81 | default: uv pip 82 | umask: 83 | description: 84 | - The system umask to apply before installing the pip package. This is 85 | useful, for example, when installing on systems that have a very 86 | restrictive umask by default (e.g., "0077") and you want to pip install 87 | packages which are to be used by all users. Note that this requires you 88 | to specify desired umask mode as an octal string, (e.g., "0022"). 89 | type: str 90 | break_system_packages: 91 | description: 92 | - Allow uv pip to modify an externally-managed Python installation as defined by PEP 668. 93 | - This is typically required when installing packages outside a virtual environment on modern systems. 94 | type: bool 95 | default: false 96 | extends_documentation_fragment: 97 | - action_common_attributes 98 | attributes: 99 | check_mode: 100 | support: full 101 | diff_mode: 102 | support: none 103 | platform: 104 | platforms: posix 105 | notes: 106 | - Python installations marked externally-managed (as defined by PEP668) cannot be updated by uv pip without the use of 107 | a virtual environment or setting the O(break_system_packages) option. 108 | - Astral uv must be installed on the remote host. 109 | - Astral uv >= 0.5.6 is required for O(state) = V(absent) in check mode. 110 | requirements: 111 | - uv 112 | author: 113 | - Matt Wright (@mattupstate) 114 | - Alex Willmer (@moreati) 115 | ''' 116 | 117 | EXAMPLES = ''' 118 | - name: Install bottle python package 119 | moreati.uv.pip: 120 | name: bottle 121 | 122 | - name: Install bottle python package on version 0.11 123 | moreati.uv.pip: 124 | name: bottle==0.11 125 | 126 | - name: Install bottle python package with version specifiers 127 | moreati.uv.pip: 128 | name: bottle>0.10,<0.20,!=0.11 129 | 130 | - name: Install multi python packages with version specifiers 131 | moreati.uv.pip: 132 | name: 133 | - django>1.11.0,<1.12.0 134 | - bottle>0.10,<0.20,!=0.11 135 | 136 | - name: Install python package using a proxy 137 | moreati.uv.pip: 138 | name: six 139 | environment: 140 | http_proxy: 'http://127.0.0.1:8080' 141 | https_proxy: 'https://127.0.0.1:8080' 142 | 143 | # You do not have to supply '-e' option in extra_args 144 | - name: Install MyApp using one of the remote protocols (bzr+,hg+,git+,svn+) 145 | moreati.uv.pip: 146 | name: svn+http://myrepo/svn/MyApp#egg=MyApp 147 | 148 | - name: Install MyApp using one of the remote protocols (bzr+,hg+,git+) 149 | moreati.uv.pip: 150 | name: git+http://myrepo/app/MyApp 151 | 152 | - name: Install MyApp from local tarball 153 | moreati.uv.pip: 154 | name: file:///path/to/MyApp.tar.gz 155 | 156 | - name: Install bottle into the specified (virtualenv), inheriting none of the globally installed modules 157 | moreati.uv.pip: 158 | name: bottle 159 | virtualenv: /my_app/venv 160 | 161 | - name: Install bottle into the specified (virtualenv), inheriting globally installed modules 162 | moreati.uv.pip: 163 | name: bottle 164 | virtualenv: /my_app/venv 165 | virtualenv_site_packages: yes 166 | 167 | - name: Install bottle into the specified (virtualenv), using Python 3.12 168 | moreati.uv.pip: 169 | name: bottle 170 | virtualenv: /my_app/venv 171 | virtualenv_python: python3.12 172 | 173 | - name: Install bottle within a user home directory 174 | moreati.uv.pip: 175 | name: bottle 176 | extra_args: --user 177 | 178 | - name: Install specified python requirements 179 | moreati.uv.pip: 180 | requirements: /my_app/requirements.txt 181 | 182 | - name: Install specified python requirements in indicated (virtualenv) 183 | moreati.uv.pip: 184 | requirements: /my_app/requirements.txt 185 | virtualenv: /my_app/venv 186 | 187 | - name: Install specified python requirements and custom Index URL 188 | moreati.uv.pip: 189 | requirements: /my_app/requirements.txt 190 | extra_args: -i https://example.com/pypi/simple 191 | 192 | - name: Install specified python requirements offline from a local directory with downloaded packages 193 | moreati.uv.pip: 194 | requirements: /my_app/requirements.txt 195 | extra_args: "--no-index --find-links=file:///my_downloaded_packages_dir" 196 | 197 | - name: Install bottle for Python 3.3 specifically, using the 'pip3.3' executable 198 | moreati.uv.pip: 199 | name: bottle 200 | executable: pip3.3 201 | 202 | - name: Install bottle, forcing reinstallation if it's already installed 203 | moreati.uv.pip: 204 | name: bottle 205 | state: forcereinstall 206 | 207 | - name: Install bottle while ensuring the umask is 0022 (to ensure other users can use it) 208 | moreati.uv.pip: 209 | name: bottle 210 | umask: "0022" 211 | become: True 212 | 213 | - name: Run a module inside a virtual environment 214 | block: 215 | - name: Ensure the virtual environment exists 216 | pip: 217 | name: psutil 218 | virtualenv: "{{ venv_dir }}" 219 | # On Debian-based systems the correct python*-venv package must be installed to use the `venv` module. 220 | virtualenv_command: "{{ ansible_python_interpreter }} -m venv" 221 | 222 | - name: Run a module inside the virtual environment 223 | wait_for: 224 | port: 22 225 | vars: 226 | # Alternatively, use a block to affect multiple tasks, or use set_fact to affect the remainder of the playbook. 227 | ansible_python_interpreter: "{{ venv_python }}" 228 | 229 | vars: 230 | venv_dir: /tmp/pick-a-better-venv-path 231 | venv_python: "{{ venv_dir }}/bin/python" 232 | ''' 233 | 234 | RETURN = ''' 235 | cmd: 236 | description: pip command used by the module 237 | returned: success 238 | type: str 239 | sample: pip2 install ansible six 240 | name: 241 | description: list of python modules targeted by pip 242 | returned: success 243 | type: list 244 | sample: ['ansible', 'six'] 245 | requirements: 246 | description: Path to the requirements file 247 | returned: success, if a requirements file was provided 248 | type: str 249 | sample: "/srv/git/project/requirements.txt" 250 | version: 251 | description: Version of the package specified in 'name' 252 | returned: success, if a name and version were provided 253 | type: str 254 | sample: "2.5.1" 255 | virtualenv: 256 | description: Path to the virtualenv 257 | returned: success, if a virtualenv path was provided 258 | type: str 259 | sample: "/tmp/virtualenv" 260 | ''' 261 | 262 | import argparse 263 | import os 264 | import re 265 | import sys 266 | import tempfile 267 | import operator 268 | import shlex 269 | 270 | from ansible.module_utils.common.text.converters import to_native 271 | from ansible.module_utils.basic import AnsibleModule, is_executable 272 | from ansible.module_utils.common.locale import get_best_parsable_locale 273 | 274 | 275 | _VCS_RE = re.compile(r'(svn|git|hg|bzr)\+') 276 | 277 | op_dict = {">=": operator.ge, "<=": operator.le, ">": operator.gt, 278 | "<": operator.lt, "==": operator.eq, "!=": operator.ne, "~=": operator.ge} 279 | 280 | 281 | def _is_vcs_url(name): 282 | """Test whether a name is a vcs url or not.""" 283 | return re.match(_VCS_RE, name) 284 | 285 | 286 | def _is_venv_command(command): 287 | venv_parser = argparse.ArgumentParser() 288 | venv_parser.add_argument('-m', type=str) 289 | argv = shlex.split(command) 290 | if argv[0] == 'pyvenv': 291 | return True 292 | args, dummy = venv_parser.parse_known_args(argv[1:]) 293 | if args.m == 'venv': 294 | return True 295 | return False 296 | 297 | 298 | def _is_probably_uv_venv_command(command): 299 | """Return True if command looks like an invocation of `uv venv`, False otherwise. 300 | 301 | >>> _is_probably_uv_venv_command(['uv', 'venv']) 302 | True 303 | >>> _is_probably_uv_venv_command(['/foo/bin/uv', '-x', 'venv', '--opt', 'baz']) 304 | True 305 | """ 306 | if os.path.basename(command[0]) == 'uv' and 'venv' in command: 307 | return True 308 | return False 309 | 310 | 311 | def _is_package_name(name): 312 | """Test whether the name is a package name or a version specifier.""" 313 | return not name.lstrip().startswith(tuple(op_dict.keys())) 314 | 315 | 316 | def _recover_package_name(names): 317 | """Recover package names as list from user's raw input. 318 | 319 | :input: a mixed and invalid list of names or version specifiers 320 | :return: a list of valid package name 321 | 322 | eg. 323 | input: ['django>1.11.1', '<1.11.3', 'ipaddress', 'simpleproject>1.1.0', '<2.0.0'] 324 | return: ['django>1.11.1,<1.11.3', 'ipaddress', 'simpleproject>1.1.0,<2.0.0'] 325 | 326 | input: ['django>1.11.1,<1.11.3,ipaddress', 'simpleproject>1.1.0,<2.0.0'] 327 | return: ['django>1.11.1,<1.11.3', 'ipaddress', 'simpleproject>1.1.0,<2.0.0'] 328 | """ 329 | # rebuild input name to a flat list so we can tolerate any combination of input 330 | tmp = [] 331 | for one_line in names: 332 | tmp.extend(one_line.split(",")) 333 | names = tmp 334 | 335 | # reconstruct the names 336 | name_parts = [] 337 | package_names = [] 338 | in_brackets = False 339 | for name in names: 340 | if _is_package_name(name) and not in_brackets: 341 | if name_parts: 342 | package_names.append(",".join(name_parts)) 343 | name_parts = [] 344 | if "[" in name: 345 | in_brackets = True 346 | if in_brackets and "]" in name: 347 | in_brackets = False 348 | name_parts.append(name) 349 | package_names.append(",".join(name_parts)) 350 | return package_names 351 | 352 | 353 | def _get_cmd_options(module, cmd): 354 | thiscmd = cmd + " --help" 355 | rc, stdout, stderr = module.run_command(thiscmd) 356 | if rc != 0: 357 | module.fail_json(msg="Could not get output from %s: %s" % (thiscmd, stdout + stderr)) 358 | 359 | words = stdout.strip().split() 360 | cmd_options = [x for x in words if x.startswith('--')] 361 | return cmd_options 362 | 363 | 364 | def _get_packages(module, pip, chdir): 365 | '''Return results of pip command to get packages.''' 366 | # Try 'pip list' command first. 367 | command = pip + ['list', '--format=freeze'] 368 | locale = get_best_parsable_locale(module) 369 | lang_env = {'LANG': locale, 'LC_ALL': locale, 'LC_MESSAGES': locale} 370 | rc, out, err = module.run_command(command, cwd=chdir, environ_update=lang_env) 371 | 372 | if rc != 0: 373 | _fail(module, command, out, err) 374 | 375 | return ' '.join(command), out, err 376 | 377 | 378 | def _get_pip(module, env=None, executable=None): 379 | candidate_argvs = ( 380 | ['uv', 'pip'], 381 | ) 382 | pip = None 383 | if executable is not None: 384 | argv = shlex.split(executable) 385 | if os.path.isabs(argv[0]): 386 | pip = argv 387 | else: 388 | # If you define your own executable that executable should be the only candidate. 389 | # As noted in the docs, executable doesn't work with virtualenvs. 390 | candidate_argvs = (argv,) 391 | 392 | if pip is None: 393 | opt_dirs = [] 394 | for basename, *rest in candidate_argvs: 395 | uv = module.get_bin_path(basename, False, opt_dirs) 396 | if uv is not None: 397 | pip = [uv, *rest] 398 | break 399 | else: 400 | # For-else: Means that we did not break out of the loop 401 | # (therefore, that pip was not found) 402 | basenames = ', '.join(argv[0] for argv in candidate_argvs) 403 | module.fail_json(msg=f'Unable to find any of {basenames} to use. uv needs to be installed.') 404 | 405 | return pip 406 | 407 | 408 | def _fail(module, cmd, out, err): 409 | msg = '' 410 | if out: 411 | msg += "stdout: %s" % (out, ) 412 | if err: 413 | msg += "\n:stderr: %s" % (err, ) 414 | module.fail_json(cmd=cmd, msg=msg) 415 | 416 | 417 | def setup_virtualenv(module, env, chdir, out, err): 418 | if module.check_mode: 419 | module.exit_json(changed=True) 420 | 421 | cmd = shlex.split(module.params['virtualenv_command']) 422 | 423 | # Find the binary for the command in the PATH 424 | # and switch the command for the explicit path. 425 | if os.path.basename(cmd[0]) == cmd[0]: 426 | cmd[0] = module.get_bin_path(cmd[0], True) 427 | 428 | # Add the system-site-packages option if that 429 | # is enabled, otherwise explicitly set the option 430 | # to not use system-site-packages if that is an 431 | # option provided by the command's help function. 432 | if module.params['virtualenv_site_packages']: 433 | cmd.append('--system-site-packages') 434 | else: 435 | cmd_opts = _get_cmd_options(module, cmd[0]) 436 | if '--no-site-packages' in cmd_opts: 437 | cmd.append('--no-site-packages') 438 | 439 | # Only use already installed Python interpreters. 440 | # Don't download them, for now. 441 | if _is_probably_uv_venv_command(cmd): 442 | cmd.extend(['--python-preference', 'only-system']) 443 | 444 | virtualenv_python = module.params['virtualenv_python'] 445 | # -p is a virtualenv option, not compatible with pyenv or venv 446 | # this conditional validates if the command being used is not any of them 447 | if not _is_venv_command(module.params['virtualenv_command']): 448 | if virtualenv_python: 449 | cmd.append('-p%s' % virtualenv_python) 450 | else: 451 | # This code mimics the upstream behaviour of using the python 452 | # which invoked virtualenv to determine which python is used 453 | # inside of the virtualenv (when none are specified). 454 | cmd.append('-p%s' % sys.executable) 455 | 456 | # if venv or pyvenv are used and virtualenv_python is defined, then 457 | # virtualenv_python is ignored, this has to be acknowledged 458 | elif module.params['virtualenv_python']: 459 | module.fail_json( 460 | msg='virtualenv_python should not be used when' 461 | ' using the venv module or pyvenv as virtualenv_command' 462 | ) 463 | 464 | cmd.append(env) 465 | rc, out_venv, err_venv = module.run_command(cmd, cwd=chdir) 466 | out += out_venv 467 | err += err_venv 468 | if rc != 0: 469 | _fail(module, cmd, out, err) 470 | return out, err 471 | 472 | 473 | class Package: 474 | """Python distribution package metadata wrapper. 475 | 476 | A wrapper class for Requirement, which provides 477 | API to parse package name, version specifier, 478 | test whether a package is already satisfied. 479 | """ 480 | 481 | # https://packaging.python.org/en/latest/specifications/name-normalization/#name-normalization 482 | _CANONICALIZE_RE = re.compile(r'[-_.]+') 483 | 484 | # https://packaging.python.org/en/latest/specifications/name-normalization/#name-format 485 | _NAME_RE = re.compile(r'[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]', re.IGNORECASE) 486 | 487 | _VERSION_OPS_RE = re.compile('|'.join(re.escape(s) for s in op_dict)) 488 | 489 | def __init__(self, requirement): 490 | self._unparsed = requirement 491 | self.name = self._NAME_RE.match(requirement) 492 | 493 | @property 494 | def has_version_specifier(self): 495 | if self._VERSION_OPS_RE.search(self._unparsed): 496 | return True 497 | return False 498 | 499 | @staticmethod 500 | def canonicalize_name(name): 501 | # This is taken from PEP 503. 502 | return Package._CANONICALIZE_RE.sub("-", name).lower() 503 | 504 | def __str__(self): 505 | return self._unparsed 506 | 507 | def __repr__(self): 508 | return f'{self.__class__.__name__}({self._unparsed})' 509 | 510 | 511 | def main(): 512 | state_map = dict( 513 | present=['install'], 514 | absent=['uninstall'], 515 | latest=['install', '-U'], 516 | forcereinstall=['install', '-U', '--force-reinstall'], 517 | ) 518 | 519 | module = AnsibleModule( 520 | argument_spec=dict( 521 | state=dict(type='str', default='present', choices=list(state_map.keys())), 522 | name=dict(type='list', elements='str'), 523 | version=dict(type='str'), 524 | requirements=dict(type='str'), 525 | virtualenv=dict(type='path'), 526 | virtualenv_site_packages=dict(type='bool', default=False), 527 | virtualenv_command=dict(type='str', default='uv venv'), 528 | virtualenv_python=dict(type='str'), 529 | extra_args=dict(type='str'), 530 | editable=dict(type='bool', default=False), 531 | chdir=dict(type='path'), 532 | executable=dict(type='str', default='uv pip'), 533 | umask=dict(type='str'), 534 | break_system_packages=dict(type='bool', default=False), 535 | ), 536 | required_one_of=[['name', 'requirements']], 537 | mutually_exclusive=[['name', 'requirements'], ['executable', 'virtualenv']], 538 | supports_check_mode=True, 539 | ) 540 | 541 | state = module.params['state'] 542 | name = module.params['name'] 543 | version = module.params['version'] 544 | requirements = module.params['requirements'] 545 | extra_args = module.params['extra_args'] 546 | chdir = module.params['chdir'] 547 | umask = module.params['umask'] 548 | env = module.params['virtualenv'] 549 | 550 | venv_created = False 551 | if env and chdir: 552 | env = os.path.join(chdir, env) 553 | 554 | if umask and not isinstance(umask, int): 555 | try: 556 | umask = int(umask, 8) 557 | except Exception: 558 | module.fail_json(msg="umask must be an octal integer", 559 | details=to_native(sys.exc_info()[1])) 560 | 561 | old_umask = None 562 | if umask is not None: 563 | old_umask = os.umask(umask) 564 | try: 565 | if state == 'latest' and version is not None: 566 | module.fail_json(msg='version is incompatible with state=latest') 567 | 568 | if chdir is None: 569 | # this is done to avoid permissions issues with privilege escalation and virtualenvs 570 | chdir = tempfile.gettempdir() 571 | 572 | err = '' 573 | out = '' 574 | 575 | if env: 576 | if not os.path.exists(os.path.join(env, 'bin', 'activate')): 577 | venv_created = True 578 | out, err = setup_virtualenv(module, env, chdir, out, err) 579 | py_bin = os.path.join(env, 'bin', 'python') 580 | else: 581 | py_bin = module.params['executable'] or sys.executable 582 | 583 | pip = _get_pip(module, env, module.params['executable']) 584 | 585 | cmd = pip + state_map[state] 586 | cmd.extend(['--python', py_bin]) 587 | 588 | # If there's a virtualenv we want things we install to be able to use other 589 | # installations that exist as binaries within this virtualenv. Example: we 590 | # install cython and then gevent -- gevent needs to use the cython binary, 591 | # not just a python package that will be found by calling the right python. 592 | # So if there's a virtualenv, we add that bin/ to the beginning of the PATH 593 | # in run_command by setting path_prefix here. 594 | path_prefix = None 595 | if env: 596 | path_prefix = os.path.join(env, 'bin') 597 | 598 | # Automatically apply -e option to extra_args when source is a VCS url. VCS 599 | # includes those beginning with svn+, git+, hg+ or bzr+ 600 | has_vcs = False 601 | if name: 602 | for pkg in name: 603 | if pkg and _is_vcs_url(pkg): 604 | has_vcs = True 605 | break 606 | 607 | # convert raw input package names to Package instances 608 | packages = [Package(pkg) for pkg in _recover_package_name(name)] 609 | # check invalid combination of arguments 610 | if version is not None: 611 | if len(packages) > 1: 612 | module.fail_json( 613 | msg="'version' argument is ambiguous when installing multiple package distributions. " 614 | "Please specify version restrictions next to each package in 'name' argument." 615 | ) 616 | if packages[0].has_version_specifier: 617 | module.fail_json( 618 | msg="The 'version' argument conflicts with any version specifier provided along with a package name. " 619 | "Please keep the version specifier, but remove the 'version' argument." 620 | ) 621 | # if the version specifier is provided by version, append that into the package 622 | packages[0] = Package(f'{packages[0]}=={version}') 623 | 624 | if module.params['editable']: 625 | args_list = [] # used if extra_args is not used at all 626 | if extra_args: 627 | args_list = extra_args.split(' ') 628 | if '-e' not in args_list: 629 | args_list.append('-e') 630 | # Ok, we will reconstruct the option string 631 | extra_args = ' '.join(args_list) 632 | 633 | if extra_args: 634 | cmd.extend(shlex.split(extra_args)) 635 | 636 | if module.params['break_system_packages']: 637 | # Using an env var instead of the `--break-system-packages` option, to avoid failing under pip 23.0.0 and earlier. 638 | # See: https://github.com/pypa/pip/pull/11780 639 | os.environ['PIP_BREAK_SYSTEM_PACKAGES'] = '1' 640 | 641 | if name: 642 | cmd.extend(to_native(p) for p in packages) 643 | elif requirements: 644 | cmd.extend(['-r', requirements]) 645 | else: 646 | module.exit_json( 647 | changed=False, 648 | warnings=["No valid name or requirements file found."], 649 | ) 650 | 651 | if module.check_mode: 652 | if extra_args or requirements or state == 'latest' or not name: 653 | module.exit_json(changed=True) 654 | 655 | # `uv pip install --dry-run` requires uv >= 0.1.18 (2024-03-13) 656 | # `uv pip uninstall --dry-run` requires uv >= 0.5.6 (2024-12-03) 657 | cmd += ['--dry-run'] 658 | rc, dryrun_out, dryrun_err = module.run_command(cmd, path_prefix=path_prefix, cwd=chdir) 659 | out += dryrun_out 660 | err += dryrun_err 661 | if rc != 0: 662 | _fail(module, cmd, out, err) 663 | dryrun_match = re.search(r'^(?:Would uninstall|Would install)', dryrun_err, re.MULTILINE) 664 | changed = bool(dryrun_match) 665 | module.exit_json(changed=changed, cmd=cmd, stdout=out, stderr=err) 666 | 667 | out_freeze_before = None 668 | if requirements or has_vcs: 669 | dummy, out_freeze_before, dummy = _get_packages(module, pip, chdir) 670 | 671 | rc, out_pip, err_pip = module.run_command(cmd, path_prefix=path_prefix, cwd=chdir) 672 | out += out_pip 673 | err += err_pip 674 | if rc == 1 and state == 'absent' and \ 675 | ('not installed' in out_pip or 'not installed' in err_pip): 676 | pass # rc is 1 when attempting to uninstall non-installed package 677 | elif rc != 0: 678 | _fail(module, cmd, out, err) 679 | 680 | if state == 'absent': 681 | changed = 'Successfully uninstalled' in out_pip 682 | else: 683 | if out_freeze_before is None: 684 | changed = 'Successfully installed' in out_pip 685 | else: 686 | dummy, out_freeze_after, dummy = _get_packages(module, pip, chdir) 687 | changed = out_freeze_before != out_freeze_after 688 | 689 | changed = changed or venv_created 690 | 691 | module.exit_json(changed=changed, cmd=cmd, name=name, version=version, 692 | state=state, requirements=requirements, virtualenv=env, 693 | stdout=out, stderr=err) 694 | finally: 695 | if old_umask is not None: 696 | os.umask(old_umask) 697 | 698 | 699 | if __name__ == '__main__': 700 | main() 701 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = "py38" 2 | 3 | [lint] 4 | ignore = [ 5 | "E402", # Module level import not at top of file 6 | ] 7 | --------------------------------------------------------------------------------