# Copyright 2021 The Kubeflow Authors
#
# 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.
"""Tests for `components` command group in KFP CLI."""
import contextlib
import functools
import os
import pathlib
import subprocess
import textwrap
from typing import List, Optional, Union
import unittest
from unittest import mock

from click import testing
import docker

docker = mock.Mock()

from kfp.cli import component


def _make_component(
    func_name: str,
    base_image: Optional[str] = None,
    target_image: Optional[str] = None,
    packages_to_install: Optional[List[str]] = None,
    output_component_file: Optional[str] = None,
    pip_index_urls: Optional[List[str]] = None,
    pip_trusted_hosts: Optional[List[str]] = None,
) -> str:
    return textwrap.dedent('''
    from kfp.dsl import *

    @component(
        base_image={base_image},
        target_image={target_image},
        packages_to_install={packages_to_install},
        output_component_file={output_component_file},
        pip_index_urls={pip_index_urls},
        pip_trusted_hosts={pip_trusted_hosts})
    def {func_name}():
        pass
    ''').format(
        base_image=repr(base_image),
        target_image=repr(target_image),
        packages_to_install=repr(packages_to_install),
        output_component_file=repr(output_component_file),
        pip_index_urls=repr(pip_index_urls),
        func_name=func_name,
        pip_trusted_hosts=repr(pip_trusted_hosts))


def _write_file(filename: str, file_contents: str):
    filepath = pathlib.Path(filename)
    filepath.parent.mkdir(exist_ok=True, parents=True)
    filepath.write_text(file_contents)


def _write_components(filename: str, component_template: Union[List[str], str]):
    if isinstance(component_template, list):
        file_contents = '\n\n'.join(component_template)
    else:
        file_contents = component_template
    _write_file(filename=filename, file_contents=file_contents)


class Test(unittest.TestCase):

    def setUp(self) -> None:
        self.runner = testing.CliRunner()
        self.cli = component.component
        component._DOCKER_IS_PRESENT = True

        patcher = mock.patch('docker.from_env')
        self._docker_client = patcher.start().return_value
        self._docker_client.images.build.return_value = [{
            'stream': 'Build logs'
        }]
        self._docker_client.images.push.return_value = [{'status': 'Pushed'}]
        self.addCleanup(patcher.stop)
        self.current_dir = os.path.dirname(os.path.abspath(__file__))

        with contextlib.ExitStack() as stack:
            stack.enter_context(self.runner.isolated_filesystem())
            self._working_dir = pathlib.Path.cwd()
            self.addCleanup(stack.pop_all().close)

        return super().setUp()

    def assert_file_exists(self, path: str):
        path_under_test_dir = self._working_dir / path
        self.assertTrue(path_under_test_dir, f'File {path} does not exist!')

    def assert_file_exists_and_contains(self, path: str, expected_content: str):
        self.assert_file_exists(path)
        path_under_test_dir = self._working_dir / path
        got_content = path_under_test_dir.read_text()
        self.assertEqual(got_content, expected_content)

    def test_kfp_config_for_single_file(self):
        preprocess_component = _make_component(
            func_name='preprocess', target_image='custom-image')
        train_component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py',
                          [preprocess_component, train_component])

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)

        self.assert_file_exists_and_contains(
            'kfp_config.ini',
            textwrap.dedent('''\
            [Components]
            preprocess = components.py
            train = components.py

            '''))

    def test_kfp_config_for_single_file_under_nested_directory(self):
        preprocess_component = _make_component(
            func_name='preprocess', target_image='custom-image')
        train_component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('dir1/dir2/dir3/components.py',
                          [preprocess_component, train_component])

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)

        self.assert_file_exists_and_contains(
            'kfp_config.ini',
            textwrap.dedent('''\
            [Components]
            preprocess = dir1/dir2/dir3/components.py
            train = dir1/dir2/dir3/components.py

            '''))

    def test_kfp_config_for_multiple_files(self):
        component = _make_component(
            func_name='preprocess', target_image='custom-image')
        _write_components('preprocess_component.py', component)

        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('train_component.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)

        self.assert_file_exists_and_contains(
            'kfp_config.ini',
            textwrap.dedent('''\
            [Components]
            preprocess = preprocess_component.py
            train = train_component.py

            '''))

    def test_kfp_config_for_multiple_files_under_nested_directories(self):
        component = _make_component(
            func_name='preprocess', target_image='custom-image')
        _write_components('preprocess/preprocess_component.py', component)

        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('train/train_component.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)

        self.assert_file_exists_and_contains(
            'kfp_config.ini',
            textwrap.dedent('''\
            [Components]
            preprocess = preprocess/preprocess_component.py
            train = train/train_component.py

            '''))

    def test_target_image_must_be_the_same_in_all_components(self):
        component_one = _make_component(func_name='one', target_image='image-1')
        component_two = _make_component(func_name='two', target_image='image-1')
        _write_components('one_two/one_two.py', [component_one, component_two])

        component_three = _make_component(
            func_name='three', target_image='image-2')
        component_four = _make_component(
            func_name='four', target_image='image-3')
        _write_components('three_four/three_four.py',
                          [component_three, component_four])

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 1)

    def test_target_image_must_be_the_same_in_all_components_with_base_image(
            self):
        component_one = _make_component(
            func_name='one', base_image='image-1', target_image='target-image')
        component_two = _make_component(
            func_name='two', base_image='image-1', target_image='target-image')
        _write_components('one_two/one_two.py', [component_one, component_two])

        component_three = _make_component(
            func_name='three',
            base_image='image-2',
            target_image='target-image')
        component_four = _make_component(
            func_name='four', base_image='image-3', target_image='target-image')
        _write_components('three_four/three_four.py',
                          [component_three, component_four])

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 1)

    def test_component_filepattern_can_be_used_to_restrict_discovery(self):
        component = _make_component(
            func_name='preprocess', target_image='custom-image')
        _write_components('preprocess/preprocess_component.py', component)

        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('train/train_component.py', component)

        result = self.runner.invoke(
            self.cli,
            [
                'build',
                str(self._working_dir), '--component-filepattern=train/*'
            ],
        )
        self.assertEqual(result.exit_code, 0)

        self.assert_file_exists_and_contains(
            'kfp_config.ini',
            textwrap.dedent('''\
            [Components]
            train = train/train_component.py

            '''))

    def test_nested_module_imports(self):
        module_two = 'two = 2'
        _write_file('module_two.py', module_two)

        module_one = 'from module_two import two\none = 1'
        _write_file('module_one.py', module_one)

        component = _make_component(
            func_name='comp', target_image='custom-image')
        component = 'from module_one import one\n' + component
        _write_components('component.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)

    def test_emptry_requirements_txt_file_is_generated(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)

        result = self.runner.invoke(self.cli, ['build', str(self._working_dir)])
        self.assertEqual(result.exit_code, 0)
        self.assert_file_exists_and_contains('runtime-requirements.txt',
                                             '# Generated by KFP.\n')

    def test_existing_requirements_txt_file_is_updated(self):
        component = _make_component(
            func_name='train',
            target_image='custom-image',
            packages_to_install=[
                'tensorflow==2.10.0', 'google-cloud-pipeline-components'
            ])
        _write_components('components.py', component)

        _write_file('runtime-requirements.txt', 'Some pre-existing content')

        result = self.runner.invoke(self.cli, ['build', str(self._working_dir)])
        self.assertEqual(result.exit_code, 0)
        self.assert_file_exists_and_contains(
            'runtime-requirements.txt',
            '# Generated by KFP.\ngoogle-cloud-pipeline-components\ntensorflow==2.10.0'
        )

    def test_user_requirements_txt_file_is_unchanged(self):
        component = _make_component(
            func_name='train',
            target_image='custom-image',
            packages_to_install=['tensorflow==2.10.0'])
        _write_components('components.py', component)

        _write_file('requirements.txt', 'Some pre-existing content')

        result = self.runner.invoke(self.cli, ['build', str(self._working_dir)])
        self.assertEqual(result.exit_code, 0)
        self.assert_file_exists_and_contains('requirements.txt',
                                             'Some pre-existing content')

    def test_docker_ignore_file_is_generated(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)

        result = self.runner.invoke(self.cli, ['build', str(self._working_dir)])
        self.assertEqual(result.exit_code, 0)
        self.assert_file_exists_and_contains(
            '.dockerignore',
            textwrap.dedent('''\
            # Generated by KFP.

            component_metadata/
            '''))

    def test_existing_docker_ignore_file_is_unchanged(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)

        _write_file('.dockerignore', 'Some pre-existing content')

        result = self.runner.invoke(self.cli, ['build', str(self._working_dir)])
        self.assertEqual(result.exit_code, 0)
        self.assert_file_exists_and_contains('.dockerignore',
                                             'Some pre-existing content')

    def test_docker_engine_is_supported(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)
        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir), '--engine=docker'],
        )
        self.assertEqual(result.exit_code, 0)
        self._docker_client.api.build.assert_called_once()
        self._docker_client.images.push.assert_called_once_with(
            'custom-image', stream=True, decode=True)

    def test_kaniko_engine_is_not_supported(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)
        with self.assertWarnsRegex(DeprecationWarning,
                                   r'The --engine option is deprecated'):
            result = self.runner.invoke(
                self.cli,
                ['build', str(self._working_dir), '--engine=kaniko'],
            )
        self.assertEqual(result.exit_code, 1)
        self._docker_client.api.build.assert_not_called()
        self._docker_client.images.push.assert_not_called()

    def test_cloud_build_engine_is_not_supported(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)

        with self.assertWarnsRegex(DeprecationWarning,
                                   r'The --engine option is deprecated'):
            result = self.runner.invoke(
                self.cli,
                ['build',
                 str(self._working_dir), '--engine=cloudbuild'],
            )

        self.assertEqual(result.exit_code, 1)
        self._docker_client.api.build.assert_not_called()
        self._docker_client.images.push.assert_not_called()

    def test_docker_client_is_called_to_build_and_push_by_default(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)

        self._docker_client.api.build.assert_called_once_with(
            path=mock.ANY,
            dockerfile='Dockerfile',
            tag=mock.ANY,
            decode=True,
            platform='linux/amd64',
        )
        self._docker_client.images.push.assert_called_once_with(
            'custom-image', stream=True, decode=True)

    def test_docker_client_is_not_called_to_build_or_push(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir), '--no-build-image'],
        )
        self.assertEqual(result.exit_code, 0)

        self._docker_client.api.build.assert_not_called()
        self._docker_client.images.push.assert_not_called()

    def test_docker_client_is_called_to_build_but_skips_pushing(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir), '--no-push-image'],
        )
        self.assertEqual(result.exit_code, 0)

        self._docker_client.api.build.assert_called_once()
        self._docker_client.images.push.assert_not_called()

    def test_docker_client_failed_to_build_img(self):
        self._docker_client.api.build.return_value = [{'error': 'Error log'}]
        created_component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', created_component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir), '--push-image'],
        )

        self.assertEqual(result.exit_code, 1)
        self._docker_client.api.build.assert_called_once()
        self._docker_client.images.push.assert_not_called()

    def test_docker_client_failed_to_push_img(self):
        self._docker_client.images.push.return_value = [{'error': 'Error log'}]
        created_component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', created_component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir), '--push-image'],
        )
        self.assertEqual(result.exit_code, 1)
        self._docker_client.images.push.assert_called_once()

    @mock.patch('kfp.__version__', '1.2.3')
    def test_docker_file_is_created_correctly(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)
        self._docker_client.api.build.assert_called_once()
        self.assert_file_exists_and_contains(
            'Dockerfile',
            textwrap.dedent('''\
                # Generated by KFP.

                FROM python:3.9

                WORKDIR /usr/local/src/kfp/components
                COPY runtime-requirements.txt runtime-requirements.txt
                RUN pip install --no-cache-dir -r runtime-requirements.txt

                RUN pip install --no-cache-dir kfp==1.2.3
                COPY . .
                '''))

    @mock.patch('kfp.__version__', '1.2.3')
    def test_docker_file_is_created_correctly_with_one_url(self):
        component = _make_component(
            func_name='train',
            target_image='custom-image',
            pip_index_urls=['https://pypi.org/simple'])
        _write_components('components.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)
        self._docker_client.api.build.assert_called_once()
        self.assert_file_exists_and_contains(
            'Dockerfile',
            textwrap.dedent('''\
                # Generated by KFP.

                FROM python:3.9

                WORKDIR /usr/local/src/kfp/components
                COPY runtime-requirements.txt runtime-requirements.txt
                RUN pip install --index-url https://pypi.org/simple --trusted-host https://pypi.org/simple --no-cache-dir -r runtime-requirements.txt

                RUN pip install --index-url https://pypi.org/simple --trusted-host https://pypi.org/simple --no-cache-dir kfp==1.2.3
                COPY . .
                '''))

    @mock.patch('kfp.__version__', '1.2.3')
    def test_docker_file_is_created_correctly_with_two_urls(self):
        component = _make_component(
            func_name='train',
            target_image='custom-image',
            pip_index_urls=[
                'https://pypi.org/simple', 'https://example.com/pypi/simple'
            ])
        _write_components('components.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)
        self._docker_client.api.build.assert_called_once()
        self.assert_file_exists_and_contains(
            'Dockerfile',
            textwrap.dedent('''\
                # Generated by KFP.

                FROM python:3.9

                WORKDIR /usr/local/src/kfp/components
                COPY runtime-requirements.txt runtime-requirements.txt
                RUN pip install --index-url https://pypi.org/simple --extra-index-url https://example.com/pypi/simple --trusted-host https://pypi.org/simple --trusted-host https://example.com/pypi/simple --no-cache-dir -r runtime-requirements.txt

                RUN pip install --index-url https://pypi.org/simple --extra-index-url https://example.com/pypi/simple --trusted-host https://pypi.org/simple --trusted-host https://example.com/pypi/simple --no-cache-dir kfp==1.2.3
                COPY . .
                '''))

    def test_existing_dockerfile_is_unchanged_by_default(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)
        _write_file('Dockerfile', 'Existing Dockerfile contents')

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)
        self._docker_client.api.build.assert_called_once()
        self.assert_file_exists_and_contains('Dockerfile',
                                             'Existing Dockerfile contents')

    @mock.patch('kfp.__version__', '1.2.3')
    def test_existing_dockerfile_can_be_overwritten(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)
        _write_file('Dockerfile', 'Existing Dockerfile contents')

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir), '--overwrite-dockerfile'],
        )
        self.assertEqual(result.exit_code, 0)
        self._docker_client.api.build.assert_called_once()
        self.assert_file_exists_and_contains(
            'Dockerfile',
            textwrap.dedent('''\
                # Generated by KFP.

                FROM python:3.9

                WORKDIR /usr/local/src/kfp/components
                COPY runtime-requirements.txt runtime-requirements.txt
                RUN pip install --no-cache-dir -r runtime-requirements.txt

                RUN pip install --no-cache-dir kfp==1.2.3
                COPY . .
                '''))

    @unittest.skip(
        "Skipping this test as it's failing. Refer to https://github.com/kubeflow/pipelines/issues/11038"
    )
    def test_dockerfile_can_contain_custom_kfp_package(self):
        component = _make_component(
            func_name='train', target_image='custom-image')
        _write_components('components.py', component)
        package_dir = os.path.dirname(os.path.dirname(self.current_dir))

        # suppresses large stdout from subprocess that builds kfp package
        with mock.patch.object(
                subprocess,
                'run',
                new=functools.partial(subprocess.run, capture_output=True)):
            result = self.runner.invoke(
                self.cli,
                [
                    'build',
                    str(self._working_dir), f'--kfp-package-path={package_dir}'
                ],
            )
        self.assertEqual(result.exit_code, 0)
        self._docker_client.api.build.assert_called_once()
        self.assert_file_exists('Dockerfile')
        with open('Dockerfile', 'r') as f:
            contents = f.read()
        file_start = textwrap.dedent('''\
                # Generated by KFP.

                FROM python:3.9

                WORKDIR /usr/local/src/kfp/components
                COPY runtime-requirements.txt runtime-requirements.txt
                RUN pip install --no-cache-dir -r runtime-requirements.txt
                ''')
        self.assertTrue(contents.startswith(file_start))
        self.assertRegex(contents, 'RUN pip install --no-cache-dir kfp-*')

    @mock.patch('kfp.__version__', '1.2.3')
    def test_docker_file_is_created_one_trusted_host(self):
        component = _make_component(
            func_name='train',
            target_image='custom-image',
            pip_index_urls=['https://pypi.org/simple'],
            pip_trusted_hosts=['pypi.org'])
        _write_components('components.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)
        self._docker_client.api.build.assert_called_once()
        self.assert_file_exists_and_contains(
            'Dockerfile',
            textwrap.dedent('''\
                    # Generated by KFP.
    
                    FROM python:3.9
    
                    WORKDIR /usr/local/src/kfp/components
                    COPY runtime-requirements.txt runtime-requirements.txt
                    RUN pip install --index-url https://pypi.org/simple --trusted-host pypi.org --no-cache-dir -r runtime-requirements.txt
    
                    RUN pip install --index-url https://pypi.org/simple --trusted-host pypi.org --no-cache-dir kfp==1.2.3
                    COPY . .
                    '''))

    @mock.patch('kfp.__version__', '1.2.3')
    def test_docker_file_is_created_two_trusted_host(self):
        component = _make_component(
            func_name='train',
            target_image='custom-image',
            pip_index_urls=['https://pypi.org/simple'],
            pip_trusted_hosts=['pypi.org', 'pypi.org:8888'])
        _write_components('components.py', component)

        result = self.runner.invoke(
            self.cli,
            ['build', str(self._working_dir)],
        )
        self.assertEqual(result.exit_code, 0)
        self._docker_client.api.build.assert_called_once()
        self.assert_file_exists_and_contains(
            'Dockerfile',
            textwrap.dedent('''\
                    # Generated by KFP.
    
                    FROM python:3.9
    
                    WORKDIR /usr/local/src/kfp/components
                    COPY runtime-requirements.txt runtime-requirements.txt
                    RUN pip install --index-url https://pypi.org/simple --trusted-host pypi.org --trusted-host pypi.org:8888 --no-cache-dir -r runtime-requirements.txt
    
                    RUN pip install --index-url https://pypi.org/simple --trusted-host pypi.org --trusted-host pypi.org:8888 --no-cache-dir kfp==1.2.3
                    COPY . .
                    '''))


if __name__ == '__main__':
    unittest.main()
