diff --git a/lib/ansible/modules/pip.py b/lib/ansible/modules/pip.py index 33591aa0ee9..df84fbd9352 100644 --- a/lib/ansible/modules/pip.py +++ b/lib/ansible/modules/pip.py @@ -110,6 +110,13 @@ options: to specify desired umask mode as an octal string, (e.g., "0022"). type: str version_added: "2.1" + break_system_packages: + description: + - Allow pip to modify an externally-managed Python installation as defined by PEP 668. + - This is typically required when installing packages outside a virtual environment on modern systems. + type: bool + default: false + version_added: "2.17" extends_documentation_fragment: - action_common_attributes attributes: @@ -121,7 +128,7 @@ attributes: platforms: posix notes: - Python installations marked externally-managed (as defined by PEP668) cannot be updated by pip versions >= 23.0.1 without the use of - a virtual environment or setting the environment variable ``PIP_BREAK_SYSTEM_PACKAGES=1``. + a virtual environment or setting the O(break_system_packages) option. - The virtualenv (U(http://www.virtualenv.org/)) must be installed on the remote host if the virtualenv parameter is specified and the virtualenv needs to be created. @@ -685,6 +692,7 @@ def main(): chdir=dict(type='path'), executable=dict(type='path'), umask=dict(type='str'), + break_system_packages=dict(type='bool', default=False), ), required_one_of=[['name', 'requirements']], mutually_exclusive=[['name', 'requirements'], ['executable', 'virtualenv']], @@ -789,6 +797,11 @@ def main(): if extra_args: cmd.extend(shlex.split(extra_args)) + if module.params['break_system_packages']: + # Using an env var instead of the `--break-system-packages` option, to avoid failing under pip 23.0.0 and earlier. + # See: https://github.com/pypa/pip/pull/11780 + os.environ['PIP_BREAK_SYSTEM_PACKAGES'] = '1' + if name: cmd.extend(to_native(p) for p in packages) elif requirements: diff --git a/test/integration/targets/pip/aliases b/test/integration/targets/pip/aliases index aa159d93aea..9ad637e3941 100644 --- a/test/integration/targets/pip/aliases +++ b/test/integration/targets/pip/aliases @@ -1,2 +1,3 @@ destructive shippable/posix/group2 +needs/root diff --git a/test/integration/targets/pip/files/sample-project/pyproject.toml b/test/integration/targets/pip/files/sample-project/pyproject.toml new file mode 100644 index 00000000000..d1ea3b1c1eb --- /dev/null +++ b/test/integration/targets/pip/files/sample-project/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "sample-project" +version = "1.0.0" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/test/integration/targets/pip/files/sample-project/src/sample_project/__init__.py b/test/integration/targets/pip/files/sample-project/src/sample_project/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/pip/tasks/break_system_packages.yml b/test/integration/targets/pip/tasks/break_system_packages.yml new file mode 100644 index 00000000000..3683720473f --- /dev/null +++ b/test/integration/targets/pip/tasks/break_system_packages.yml @@ -0,0 +1,59 @@ +- name: Get the pip version + command: "{{ ansible_python_interpreter }} -c 'import pip; print(pip.__version__)'" + register: pip_version + +- when: pip_version.stdout is version("23.0.1", ">=") + block: + - name: Locate the Python externally-managed marker file + command: | + {{ ansible_python_interpreter }} -c 'import sys, sysconfig; print(f"""{sysconfig.get_path("stdlib", sysconfig.get_default_scheme() + if sys.version_info >= (3, 10) else sysconfig._get_default_scheme())}/EXTERNALLY-MANAGED""")' + register: externally_managed_marker + + - name: Detect if Python is externally-managed + stat: + path: "{{ externally_managed_marker.stdout }}" + register: externally_managed + + - name: Mark Python as externally managed + file: + path: "{{ externally_managed_marker.stdout }}" + state: touch + when: not externally_managed.stat.exists + + - block: + - name: Copy the sample project to the target + copy: + src: sample-project/ + dest: "{{ remote_sample_project }}" + + - name: Attempt to pip install the sample project without a venv + pip: + name: "{{ remote_sample_project }}" + register: pip_install + failed_when: pip_install is success + + - name: Attempt to pip install the sample project without a venv using break_system_packages + pip: + name: "{{ remote_sample_project }}" + break_system_packages: true + + - name: Remove the sample project without using break_system_packages + pip: + name: sample-project + state: absent + register: pip_uninstall + failed_when: pip_uninstall is success + + - name: Remove the sample project using break_system_packages + pip: + name: sample-project + state: absent + break_system_packages: true + + always: + - name: Unmark Python as externally managed + file: + path: "{{ externally_managed_marker.stdout }}" + state: absent + when: not externally_managed.stat.exists diff --git a/test/integration/targets/pip/tasks/main.yml b/test/integration/targets/pip/tasks/main.yml index a3770702238..b05b04f908b 100644 --- a/test/integration/targets/pip/tasks/main.yml +++ b/test/integration/targets/pip/tasks/main.yml @@ -1,6 +1,9 @@ # Current pip unconditionally uses md5. # We can re-enable if pip switches to a different hash or allows us to not check md5. +- include_tasks: + file: break_system_packages.yml + - name: Python 2 when: ansible_python.version.major == 2 block: diff --git a/test/integration/targets/pip/vars/main.yml b/test/integration/targets/pip/vars/main.yml index 2e87abccfa4..34d481b26ee 100644 --- a/test/integration/targets/pip/vars/main.yml +++ b/test/integration/targets/pip/vars/main.yml @@ -11,3 +11,4 @@ pip_test_pkg_ver_unsatisfied: pip_test_modules: - sample - jiphy +remote_sample_project: "{{ remote_tmp_dir }}/sample-project"