1from __future__ import (absolute_import, division, print_function)
2__metaclass__ = type
3
4import json
5import os
6import os.path
7import re
8import sys
9import warnings
10
11from collections import defaultdict
12
13try:
14    from setuptools import setup, find_packages
15    from setuptools.command.build_py import build_py as BuildPy
16    from setuptools.command.install_lib import install_lib as InstallLib
17    from setuptools.command.install_scripts import install_scripts as InstallScripts
18except ImportError:
19    print("Ansible now needs setuptools in order to build. Install it using"
20          " your package manager (usually python-setuptools) or via pip (pip"
21          " install setuptools).", file=sys.stderr)
22    sys.exit(1)
23
24# `distutils` must be imported after `setuptools` or it will cause explosions
25# with `setuptools >=48.0.0, <49.1`.
26# Refs:
27# * https://github.com/ansible/ansible/issues/70456
28# * https://github.com/pypa/setuptools/issues/2230
29# * https://github.com/pypa/setuptools/commit/bd110264
30from distutils.command.build_scripts import build_scripts as BuildScripts
31from distutils.command.sdist import sdist as SDist
32
33
34def find_package_info(*file_paths):
35    try:
36        with open(os.path.join(*file_paths), 'r') as f:
37            info_file = f.read()
38    except Exception:
39        raise RuntimeError("Unable to find package info.")
40
41    # The version line must have the form
42    # __version__ = 'ver'
43    version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]",
44                              info_file, re.M)
45    author_match = re.search(r"^__author__ = ['\"]([^'\"]*)['\"]",
46                             info_file, re.M)
47
48    if version_match and author_match:
49        return version_match.group(1), author_match.group(1)
50    raise RuntimeError("Unable to find package info.")
51
52
53def _validate_install_ansible_core():
54    """Validate that we can install ansible-core. This checks if
55    ansible<=2.9 or ansible-base>=2.10 are installed.
56    """
57    # Skip common commands we can ignore
58    # Do NOT add bdist_wheel here, we don't ship wheels
59    # and bdist_wheel is the only place we can prevent pip
60    # from installing, as pip creates a wheel, and installs the wheel
61    # and we have no influence over installation within a wheel
62    if set(('sdist', 'egg_info')).intersection(sys.argv):
63        return
64
65    if os.getenv('ANSIBLE_SKIP_CONFLICT_CHECK', '') not in ('', '0'):
66        return
67
68    # Save these for later restoring things to pre invocation
69    sys_modules = sys.modules.copy()
70    sys_modules_keys = set(sys_modules)
71
72    # Make sure `lib` isn't in `sys.path` that could confuse this
73    sys_path = sys.path[:]
74    abspath = os.path.abspath
75    sys.path[:] = [p for p in sys.path if abspath(p) != abspath('lib')]
76
77    try:
78        from ansible.release import __version__
79    except ImportError:
80        pass
81    else:
82        version_tuple = tuple(int(v) for v in __version__.split('.')[:2])
83        if version_tuple >= (2, 11):
84            return
85        elif version_tuple == (2, 10):
86            ansible_name = 'ansible-base'
87        else:
88            ansible_name = 'ansible'
89
90        stars = '*' * 76
91        raise RuntimeError(
92            '''
93
94    %s
95
96    Cannot install ansible-core with a pre-existing %s==%s
97    installation.
98
99    Installing ansible-core with ansible-2.9 or older, or ansible-base-2.10
100    currently installed with pip is known to cause problems. Please uninstall
101    %s and install the new version:
102
103        pip uninstall %s
104        pip install ansible-core
105
106    If you want to skip the conflict checks and manually resolve any issues
107    afterwards, set the ANSIBLE_SKIP_CONFLICT_CHECK environment variable:
108
109        ANSIBLE_SKIP_CONFLICT_CHECK=1 pip install ansible-core
110
111    %s
112            ''' % (stars, ansible_name, __version__, ansible_name, ansible_name, stars))
113    finally:
114        sys.path[:] = sys_path
115        for key in sys_modules_keys.symmetric_difference(sys.modules):
116            sys.modules.pop(key, None)
117        sys.modules.update(sys_modules)
118
119
120_validate_install_ansible_core()
121
122
123SYMLINK_CACHE = 'SYMLINK_CACHE.json'
124
125
126def _find_symlinks(topdir, extension=''):
127    """Find symlinks that should be maintained
128
129    Maintained symlinks exist in the bin dir or are modules which have
130    aliases.  Our heuristic is that they are a link in a certain path which
131    point to a file in the same directory.
132
133    .. warn::
134
135        We want the symlinks in :file:`bin/` that link into :file:`lib/ansible/*` (currently,
136        :command:`ansible`, :command:`ansible-test`, and :command:`ansible-connection`) to become
137        real files on install.  Updates to the heuristic here *must not* add them to the symlink
138        cache.
139    """
140    symlinks = defaultdict(list)
141    for base_path, dirs, files in os.walk(topdir):
142        for filename in files:
143            filepath = os.path.join(base_path, filename)
144            if os.path.islink(filepath) and filename.endswith(extension):
145                target = os.readlink(filepath)
146                if target.startswith('/'):
147                    # We do not support absolute symlinks at all
148                    continue
149
150                if os.path.dirname(target) == '':
151                    link = filepath[len(topdir):]
152                    if link.startswith('/'):
153                        link = link[1:]
154                    symlinks[os.path.basename(target)].append(link)
155                else:
156                    # Count how many directory levels from the topdir we are
157                    levels_deep = os.path.dirname(filepath).count('/')
158
159                    # Count the number of directory levels higher we walk up the tree in target
160                    target_depth = 0
161                    for path_component in target.split('/'):
162                        if path_component == '..':
163                            target_depth += 1
164                            # If we walk past the topdir, then don't store
165                            if target_depth >= levels_deep:
166                                break
167                        else:
168                            target_depth -= 1
169                    else:
170                        # If we managed to stay within the tree, store the symlink
171                        link = filepath[len(topdir):]
172                        if link.startswith('/'):
173                            link = link[1:]
174                        symlinks[target].append(link)
175
176    return symlinks
177
178
179def _cache_symlinks(symlink_data):
180    with open(SYMLINK_CACHE, 'w') as f:
181        json.dump(symlink_data, f)
182
183
184def _maintain_symlinks(symlink_type, base_path):
185    """Switch a real file into a symlink"""
186    try:
187        # Try the cache first because going from git checkout to sdist is the
188        # only time we know that we're going to cache correctly
189        with open(SYMLINK_CACHE, 'r') as f:
190            symlink_data = json.load(f)
191    except (IOError, OSError) as e:
192        # IOError on py2, OSError on py3.  Both have errno
193        if e.errno == 2:
194            # SYMLINKS_CACHE doesn't exist.  Fallback to trying to create the
195            # cache now.  Will work if we're running directly from a git
196            # checkout or from an sdist created earlier.
197            library_symlinks = _find_symlinks('lib', '.py')
198            library_symlinks.update(_find_symlinks('test/lib'))
199
200            symlink_data = {'script': _find_symlinks('bin'),
201                            'library': library_symlinks,
202                            }
203
204            # Sanity check that something we know should be a symlink was
205            # found.  We'll take that to mean that the current directory
206            # structure properly reflects symlinks in the git repo
207            if 'ansible-playbook' in symlink_data['script']['ansible']:
208                _cache_symlinks(symlink_data)
209            else:
210                raise RuntimeError(
211                    "Pregenerated symlink list was not present and expected "
212                    "symlinks in ./bin were missing or broken. "
213                    "Perhaps this isn't a git checkout?"
214                )
215        else:
216            raise
217    symlinks = symlink_data[symlink_type]
218
219    for source in symlinks:
220        for dest in symlinks[source]:
221            dest_path = os.path.join(base_path, dest)
222            if not os.path.islink(dest_path):
223                try:
224                    os.unlink(dest_path)
225                except OSError as e:
226                    if e.errno == 2:
227                        # File does not exist which is all we wanted
228                        pass
229                os.symlink(source, dest_path)
230
231
232class BuildPyCommand(BuildPy):
233    def run(self):
234        BuildPy.run(self)
235        _maintain_symlinks('library', self.build_lib)
236
237
238class BuildScriptsCommand(BuildScripts):
239    def run(self):
240        BuildScripts.run(self)
241        _maintain_symlinks('script', self.build_dir)
242
243
244class InstallLibCommand(InstallLib):
245    def run(self):
246        InstallLib.run(self)
247        _maintain_symlinks('library', self.install_dir)
248
249
250class InstallScriptsCommand(InstallScripts):
251    def run(self):
252        InstallScripts.run(self)
253        _maintain_symlinks('script', self.install_dir)
254
255
256class SDistCommand(SDist):
257    def run(self):
258        # have to generate the cache of symlinks for release as sdist is the
259        # only command that has access to symlinks from the git repo
260        library_symlinks = _find_symlinks('lib', '.py')
261        library_symlinks.update(_find_symlinks('test/lib'))
262
263        symlinks = {'script': _find_symlinks('bin'),
264                    'library': library_symlinks,
265                    }
266        _cache_symlinks(symlinks)
267
268        SDist.run(self)
269
270        # Print warnings at the end because no one will see warnings before all the normal status
271        # output
272        if os.environ.get('_ANSIBLE_SDIST_FROM_MAKEFILE', False) != '1':
273            warnings.warn('When setup.py sdist is run from outside of the Makefile,'
274                          ' the generated tarball may be incomplete.  Use `make snapshot`'
275                          ' to create a tarball from an arbitrary checkout or use'
276                          ' `cd packaging/release && make release version=[..]` for official builds.',
277                          RuntimeWarning)
278
279
280def read_file(file_name):
281    """Read file and return its contents."""
282    with open(file_name, 'r') as f:
283        return f.read()
284
285
286def read_requirements(file_name):
287    """Read requirements file as a list."""
288    reqs = read_file(file_name).splitlines()
289    if not reqs:
290        raise RuntimeError(
291            "Unable to read requirements from the %s file"
292            "That indicates this copy of the source code is incomplete."
293            % file_name
294        )
295    return reqs
296
297
298PYCRYPTO_DIST = 'pycrypto'
299
300
301def get_crypto_req():
302    """Detect custom crypto from ANSIBLE_CRYPTO_BACKEND env var.
303
304    pycrypto or cryptography. We choose a default but allow the user to
305    override it. This translates into pip install of the sdist deciding what
306    package to install and also the runtime dependencies that pkg_resources
307    knows about.
308    """
309    crypto_backend = os.environ.get('ANSIBLE_CRYPTO_BACKEND', '').strip()
310
311    if crypto_backend == PYCRYPTO_DIST:
312        # Attempt to set version requirements
313        return '%s >= 2.6' % PYCRYPTO_DIST
314
315    return crypto_backend or None
316
317
318def substitute_crypto_to_req(req):
319    """Replace crypto requirements if customized."""
320    crypto_backend = get_crypto_req()
321
322    if crypto_backend is None:
323        return req
324
325    def is_not_crypto(r):
326        CRYPTO_LIBS = PYCRYPTO_DIST, 'cryptography'
327        return not any(r.lower().startswith(c) for c in CRYPTO_LIBS)
328
329    return [r for r in req if is_not_crypto(r)] + [crypto_backend]
330
331
332def get_dynamic_setup_params():
333    """Add dynamically calculated setup params to static ones."""
334    return {
335        # Retrieve the long description from the README
336        'long_description': read_file('README.rst'),
337        'install_requires': substitute_crypto_to_req(
338            read_requirements('requirements.txt'),
339        ),
340    }
341
342
343here = os.path.abspath(os.path.dirname(__file__))
344__version__, __author__ = find_package_info(here, 'lib', 'ansible', 'release.py')
345static_setup_params = dict(
346    # Use the distutils SDist so that symlinks are not expanded
347    # Use a custom Build for the same reason
348    cmdclass={
349        'build_py': BuildPyCommand,
350        'build_scripts': BuildScriptsCommand,
351        'install_lib': InstallLibCommand,
352        'install_scripts': InstallScriptsCommand,
353        'sdist': SDistCommand,
354    },
355    name='ansible-core',
356    version=__version__,
357    description='Radically simple IT automation',
358    author=__author__,
359    author_email='info@ansible.com',
360    url='https://ansible.com/',
361    project_urls={
362        'Bug Tracker': 'https://github.com/ansible/ansible/issues',
363        'CI: Azure Pipelines': 'https://dev.azure.com/ansible/ansible/',
364        'Code of Conduct': 'https://docs.ansible.com/ansible/latest/community/code_of_conduct.html',
365        'Documentation': 'https://docs.ansible.com/ansible/',
366        'Mailing lists': 'https://docs.ansible.com/ansible/latest/community/communication.html#mailing-list-information',
367        'Source Code': 'https://github.com/ansible/ansible',
368    },
369    license='GPLv3+',
370    # Ansible will also make use of a system copy of python-six and
371    # python-selectors2 if installed but use a Bundled copy if it's not.
372    python_requires='>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*',
373    package_dir={'': 'lib',
374                 'ansible_test': 'test/lib/ansible_test'},
375    packages=find_packages('lib') + find_packages('test/lib'),
376    include_package_data=True,
377    classifiers=[
378        'Development Status :: 5 - Production/Stable',
379        'Environment :: Console',
380        'Intended Audience :: Developers',
381        'Intended Audience :: Information Technology',
382        'Intended Audience :: System Administrators',
383        'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
384        'Natural Language :: English',
385        'Operating System :: POSIX',
386        'Programming Language :: Python :: 2',
387        'Programming Language :: Python :: 2.7',
388        'Programming Language :: Python :: 3',
389        'Programming Language :: Python :: 3.5',
390        'Programming Language :: Python :: 3.6',
391        'Programming Language :: Python :: 3.7',
392        'Programming Language :: Python :: 3.8',
393        'Programming Language :: Python :: 3.9',
394        'Topic :: System :: Installation/Setup',
395        'Topic :: System :: Systems Administration',
396        'Topic :: Utilities',
397    ],
398    scripts=[
399        'bin/ansible',
400        'bin/ansible-playbook',
401        'bin/ansible-pull',
402        'bin/ansible-doc',
403        'bin/ansible-galaxy',
404        'bin/ansible-console',
405        'bin/ansible-connection',
406        'bin/ansible-vault',
407        'bin/ansible-config',
408        'bin/ansible-inventory',
409        'bin/ansible-test',
410    ],
411    data_files=[],
412    # Installing as zip files would break due to references to __file__
413    zip_safe=False
414)
415
416
417def main():
418    """Invoke installation process using setuptools."""
419    setup_params = dict(static_setup_params, **get_dynamic_setup_params())
420    ignore_warning_regex = (
421        r"Unknown distribution option: '(project_urls|python_requires)'"
422    )
423    warnings.filterwarnings(
424        'ignore',
425        message=ignore_warning_regex,
426        category=UserWarning,
427        module='distutils.dist',
428    )
429    setup(**setup_params)
430    warnings.resetwarnings()
431
432
433if __name__ == '__main__':
434    main()
435