# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import absolute_import
from __future__ import print_function

import os.path
import shlex
import sys

import fixtures
import packaging.utils
import testtools
import textwrap

from pbr._compat.five import ConfigParser
from pbr.tests import fixtures as pbr_fixtures
from pbr.tests.functional import base
from pbr.tests import util

PIPFLAGS = shlex.split(os.environ.get('PIPFLAGS', ''))
PIPVERSION = os.environ.get('PIPVERSION', 'pip')
PBRVERSION = os.environ.get('PBRVERSION', 'pbr')
REPODIR = os.environ.get('REPODIR', '')
WHEELHOUSE = os.environ.get('WHEELHOUSE', '')
PIP_CMD = ['-m', 'pip'] + PIPFLAGS + ['install', '-f', WHEELHOUSE]
PROJECTS = shlex.split(os.environ.get('PROJECTS', ''))


def all_projects():
    if os.environ.get('PBR_INTEGRATION', None) != '1':
        return

    if not REPODIR:
        return

    yielded = 0

    # Future: make this path parameterisable.
    excludes = set(['tempest', 'requirements'])
    for name in PROJECTS:
        name = name.strip()
        short_name = name.split('/')[-1]
        try:
            with open(
                os.path.join(REPODIR, short_name, 'setup.py'), 'rt'
            ) as f:
                if 'pbr' not in f.read():
                    continue
        except IOError:
            continue
        if short_name in excludes:
            continue
        yielded += 1
        yield (short_name, {'name': name, 'short_name': short_name})

    if not yielded:
        raise Exception(
            'no projects found: is PROJECTS set, and do the paths exist'
        )


class TestIntegration(base.BaseTestCase):

    scenarios = list(all_projects())

    def setUp(self):
        # Integration tests need a higher default - big repos can be slow to
        # clone, particularly under guest load.
        env = fixtures.EnvironmentVariable(
            'OS_TEST_TIMEOUT', os.environ.get('OS_TEST_TIMEOUT', '600')
        )
        with env:
            super(TestIntegration, self).setUp()
        util.config_git()

    @testtools.skipUnless(
        os.environ.get('PBR_INTEGRATION', None) == '1',
        'integration tests not enabled',
    )
    def test_integration(self):
        # Test that we can:
        # - run sdist from the repo in a venv
        # - install the resulting tarball in a new venv
        # - pip install the repo
        # - pip install -e the repo
        # We don't break these into separate tests because we'd need separate
        # source dirs to isolate from side effects of running pip, and the
        # overheads of setup would start to beat the benefits of parallelism.
        path = os.path.join(REPODIR, self.short_name)
        setup_cfg = os.path.join(path, 'setup.cfg')
        project_name = packaging.utils.canonicalize_name(
            self.short_name
        ).lower()
        # These projects should all have setup.cfg files but we'll be careful
        if os.path.exists(setup_cfg):
            config = ConfigParser()
            config.read(setup_cfg)
            if config.has_section('metadata'):
                raw_name = config.get(
                    'metadata', 'name', fallback='notapackagename'
                )
                # Technically we should really only need to use the raw
                # name because all our projects should be good and use
                # normalized names but they don't...
                project_name = packaging.utils.canonicalize_name(
                    raw_name
                ).lower()
        constraints = os.path.join(
            REPODIR, 'requirements', 'upper-constraints.txt'
        )
        tmp_constraints = os.path.join(
            self.useFixture(fixtures.TempDir()).path, 'upper-constraints.txt'
        )
        # We need to filter out the package we are installing to avoid
        # conflicts with the constraints.
        with open(constraints, 'r') as src:
            with open(tmp_constraints, 'w') as dest:
                for line in src:
                    constraint = line.split('===')[0]
                    constraint = packaging.utils.canonicalize_name(
                        constraint
                    ).lower()
                    if project_name != constraint:
                        dest.write(line)
        pip_cmd = PIP_CMD + ['-c', tmp_constraints]

        with pbr_fixtures.Venv(
            'sdist',
            modules=['pip', 'wheel', 'setuptools<80', PBRVERSION],
            pip_cmd=PIP_CMD,
        ) as venv:
            python = venv.python
            pbr_fixtures.CapturedSubprocess(
                'sdist',
                [python, 'setup.py', 'sdist'],
                cwd=path,
                test_case=self,
            )

        with pbr_fixtures.Venv(
            'tarball',
            modules=['pip', 'wheel', 'setuptools<80', PBRVERSION],
            pip_cmd=PIP_CMD,
        ) as venv:
            python = venv.python
            filename = os.path.join(
                path, 'dist', os.listdir(os.path.join(path, 'dist'))[0]
            )
            pbr_fixtures.CapturedSubprocess(
                'tarball',
                [python] + pip_cmd + [filename],
                test_case=self,
            )

        with pbr_fixtures.Venv(
            'install-git',
            modules=['pip', 'wheel', 'setuptools<80', PBRVERSION],
            pip_cmd=PIP_CMD,
        ) as venv:
            root = venv.path
            python = venv.python
            pbr_fixtures.CapturedSubprocess(
                'install-git',
                [python] + pip_cmd + ['git+file://' + path],
                test_case=self,
            )
            if self.short_name == 'nova':
                found = False
                for _, _, filenames in os.walk(root):
                    if 'alembic.ini' in filenames:
                        found = True
                self.assertTrue(found)

        with pbr_fixtures.Venv(
            'install-editable',
            modules=['pip', 'wheel', 'setuptools<80', PBRVERSION],
            pip_cmd=PIP_CMD,
        ) as venv:
            python = venv.python
            pbr_fixtures.CapturedSubprocess(
                'install-editable',
                [python] + pip_cmd + ['-e', path],
                test_case=self,
            )


class TestInstallWithoutPbr(base.BaseTestCase):

    # TODO(clarkb) This test should be reimagined with modern packaging tools
    # and expectations.
    @testtools.skipUnless(
        os.environ.get('PBR_INTEGRATION', None) == '1',
        'integration tests not enabled',
    )
    def test_install_without_pbr(self):
        # Test easy-install of a thing that depends on a thing using pbr
        tempdir = self.useFixture(fixtures.TempDir()).path
        # A directory containing sdists of the things we're going to depend on
        # in using-package.
        dist_dir = os.path.join(tempdir, 'distdir')
        os.mkdir(dist_dir)
        self._run_cmd(
            sys.executable,
            ('setup.py', 'sdist', '-d', dist_dir),
            allow_fail=False,
            cwd=pbr_fixtures.PBR_ROOT,
        )
        # testpkg - this requires a pbr-using package
        test_pkg_dir = os.path.join(tempdir, 'testpkg')
        os.mkdir(test_pkg_dir)
        pkgs = {
            'pkgTest': {
                # override setup.py, setup.cfg since we don't want pbr used for
                # this package
                'setup.py': textwrap.dedent(
                    """\
                    #!/usr/bin/env python
                    import setuptools
                    setuptools.setup(
                        name = 'pkgTest',
                        # TODO(clarkb) should we use a random prefix to
                        # avoid collisions?
                        install_requires = ['pkgReq'],
                    )
                    """
                ),
                'setup.cfg': textwrap.dedent(
                    """\
                    [easy_install]
                    find_links = %s
                    """
                    % dist_dir
                ),
            },
            # We don't need to use PBRVERSION here because we precreate the
            # pbr sdist and point to it with find_links.
            'pkgReq': {
                # ...but we use the standard setup.py, setup.cfg provided by
                # the fixture since we do want pbr here, in the dependency
                'requirements.txt': textwrap.dedent(
                    """\
                    pbr
                    """
                ),
                'pkgReq/__init__.py': "",
                'pkgReq/__main__.py': textwrap.dedent(
                    """\
                    print("FakeTest loaded and ran")
                    """
                ),
            },
        }
        pkg_dirs = self.useFixture(pbr_fixtures.Packages(pkgs)).package_dirs
        test_pkg_dir = pkg_dirs['pkgTest']
        req_pkg_dir = pkg_dirs['pkgReq']

        self._run_cmd(
            sys.executable,
            ('setup.py', 'sdist', '-d', dist_dir),
            allow_fail=False,
            cwd=req_pkg_dir,
        )
        # A venv to test within
        # We install setuptools because we rely on setup.py below.
        # FIXME(stephenfin): We should not need to pin setuptools
        # https://github.com/pypa/setuptools/commit/ef4cd2960d75f2d49f40f5495347523be62d20e5
        with pbr_fixtures.Venv(
            'nopbr', ['pip', 'wheel', 'setuptools<80']
        ) as venv:
            python = venv.python
            # Install both packages
            pbr_fixtures.CapturedSubprocess(
                'nopbr',
                [python] + ['setup.py', 'install'],
                cwd=test_pkg_dir,
                test_case=self,
            )
            # Execute code that should only be present if the install worked.
            pbr_fixtures.CapturedSubprocess(
                'nopbr',
                [python] + ['-m', 'pkgReq'],
                cwd=test_pkg_dir,
                test_case=self,
            )
            pbr_cmd = os.path.join(venv.path, 'bin', 'pbr')
            pbr_fixtures.CapturedSubprocess(
                'nopbr',
                [pbr_cmd] + ['freeze'],
                cwd=test_pkg_dir,
                test_case=self,
            )


# Handle various compatability issues with pip and setuptools versions against
# python3 versions. Unfortunately python3.12 in particular isn't very backward
# compatible with pip and setuptools.
# TODO(clarkb) add other distros like EL9 and EL10
if sys.version_info[0:3] < (3, 10, 0):
    lts_scenarios = [
        ('Bionic', {'modules': ['pip==9.0.1', 'setuptools==39.0.1']}),
        ('Stretch', {'modules': ['pip==9.0.1', 'setuptools==33.1.1']}),
        ('EL8', {'modules': ['pip==9.0.3', 'setuptools==39.2.0']}),
        ('Buster', {'modules': ['pip==18.1', 'setuptools==40.8.0']}),
        ('Focal', {'modules': ['pip==20.0.2', 'setuptools==45.2.0']}),
    ]
elif sys.version_info[0:3] < (3, 12, 0):
    lts_scenarios = [
        ('Bullseye', {'modules': ['pip==20.3.4', 'setuptools==52.0.0']}),
        ('Bookworm', {'modules': ['pip==23.0.1', 'setuptools==66.1.1']}),
        ('Focal', {'modules': ['pip==20.0.2', 'setuptools==45.2.0']}),
        ('Jammy', {'modules': ['pip==22.0.2', 'setuptools==59.6.0']}),
    ]
else:
    lts_scenarios = [
        ('Noble', {'modules': ['pip==24.0.0', 'setuptools==68.1.2']}),
    ]


class TestMarkersPip(base.BaseTestCase):

    scenarios = [
        ('pip-latest', {'modules': ['pip', 'setuptools']})
    ] + lts_scenarios

    @testtools.skipUnless(
        os.environ.get('PBR_INTEGRATION', None) == '1',
        'integration tests not enabled',
    )
    def test_pip_versions(self):
        pkgs = {
            'test_markers': {
                'requirements.txt': textwrap.dedent(
                    """\
                    pkg_a; python_version=='1.2'
                    pkg_b; python_version!='1.2'
                """
                )
            },
            'pkg_a': {},
            'pkg_b': {},
        }
        pkg_dirs = self.useFixture(pbr_fixtures.Packages(pkgs)).package_dirs
        temp_dir = self.useFixture(fixtures.TempDir()).path
        repo_dir = os.path.join(temp_dir, 'repo')
        with pbr_fixtures.Venv('markers') as venv:
            bin_python = venv.python
            os.mkdir(repo_dir)
            for module in self.modules:
                pbr_fixtures.CapturedSubprocess(
                    'pip-version',
                    [
                        bin_python,
                        '-m',
                        'pip',
                        'install',
                        '--upgrade',
                        module,
                    ],
                    cwd=venv.path,
                    test_case=self,
                )
            # TODO(clarkb) do we need to install PBR from source here to avoid
            # using the latest release?
            for pkg in pkg_dirs:
                self._run_cmd(
                    bin_python,
                    ['setup.py', 'sdist', '-d', repo_dir],
                    cwd=pkg_dirs[pkg],
                    allow_fail=False,
                )
            self._run_cmd(
                bin_python,
                [
                    '-m',
                    'pip',
                    'install',
                    '--no-index',
                    # With --no-index above we can't install deps for isolated
                    # builds. Just rely on the existing installs for now.
                    '--no-build-isolation',
                    '-f',
                    repo_dir,
                    'test_markers',
                ],
                cwd=venv.path,
                allow_fail=False,
            )
            pkgs = self._run_cmd(
                bin_python,
                ['-m', 'pip', 'freeze'],
                cwd=venv.path,
                allow_fail=False,
            )[0]
            # Depending on the version of pip/setuptools etc the name of the
            # installed package may be noramlized to 'pkg-b'. As of March 2024
            # 'pkg_b' is what we get and previously 'pkg-b' was the result.
            self.assertTrue('pkg_b' in pkgs or 'pkg-b' in pkgs)


class TestLTSSupport(base.BaseTestCase):

    scenarios = lts_scenarios

    @testtools.skipUnless(
        os.environ.get('PBR_INTEGRATION', None) == '1',
        'integration tests not enabled',
    )
    def test_lts_venv_default_versions(self):
        with pbr_fixtures.Venv('setuptools', modules=self.modules) as venv:
            bin_python = venv.python
            pbr = 'file://%s#egg=pbr' % pbr_fixtures.PBR_ROOT
            # Installing PBR is a reasonable indication that we are not broken
            # on this particular combination of setuptools and pip.
            pbr_fixtures.CapturedSubprocess(
                'lts-support',
                [bin_python, '-m', 'pip', 'install', pbr],
                cwd=venv.path,
                test_case=self,
            )
