import codecs
import locale
import os
import posixpath
import shutil
import subprocess
import sys
import tarfile
import tempfile
import textwrap
import unittest
import zipfile
from contextlib import closing
from functools import partial
from io import BytesIO, StringIO
from typing import Optional
from xml.etree import ElementTree as ET

import mock

from check_manifest import rmtree


CAN_SKIP_TESTS = os.getenv('SKIP_NO_TESTS', '') == ''


try:
    codecs.lookup('oem')
except LookupError:
    HAS_OEM_CODEC = False
else:
    # Python >= 3.6 on Windows
    HAS_OEM_CODEC = True


class MockUI:

    def __init__(self, verbosity=1):
        self.verbosity = verbosity
        self.warnings = []
        self.errors = []

    def info(self, message):
        pass

    def info_begin(self, message):
        pass

    def info_cont(self, message):
        pass

    def info_end(self, message):
        pass

    def warning(self, message):
        self.warnings.append(message)

    def error(self, message):
        self.errors.append(message)


class Tests(unittest.TestCase):

    def setUp(self):
        self.ui = MockUI()

    def make_temp_dir(self):
        tmpdir = tempfile.mkdtemp(prefix='test-', suffix='-check-manifest')
        self.addCleanup(rmtree, tmpdir)
        return tmpdir

    def create_file(self, filename, contents):
        with open(filename, 'w') as f:
            f.write(contents)

    def create_zip_file(self, filename, filenames):
        with closing(zipfile.ZipFile(filename, 'w')) as zf:
            for fn in filenames:
                zf.writestr(fn, '')

    def create_tar_file(self, filename, filenames):
        with closing(tarfile.TarFile(filename, 'w')) as tf:
            for fn in filenames:
                tf.addfile(tarfile.TarInfo(fn), BytesIO())

    def test_run_success(self):
        from check_manifest import run
        self.assertEqual(run(["true"]), "")

    def test_run_failure(self):
        from check_manifest import CommandFailed, run
        with self.assertRaises(CommandFailed) as cm:
            run(["false"])
        self.assertEqual(str(cm.exception),
                         "['false'] failed (status 1):\n")

    def test_run_no_such_program(self):
        from check_manifest import Failure, run
        with self.assertRaises(Failure) as cm:
            run(["there-is-really-no-such-program"])
        # Linux says "[Errno 2] No such file or directory"
        # Windows says "[Error 2] The system cannot find the file specified"
        # but on 3.x it's "[WinErr 2] The system cannot find the file specified"
        should_start_with = "could not run ['there-is-really-no-such-program']:"
        self.assertTrue(
            str(cm.exception).startswith(should_start_with),
            '\n%r does not start with\n%r' % (str(cm.exception),
                                              should_start_with))

    def test_mkdtemp_readonly_files(self):
        from check_manifest import mkdtemp
        with mkdtemp(hint='-test-readonly') as d:
            fn = os.path.join(d, 'file.txt')
            with open(fn, 'w'):
                pass
            os.chmod(fn, 0o444)  # readonly
        assert not os.path.exists(d)

    @unittest.skipIf(sys.platform == 'win32',
                     "No POSIX-like unreadable directories on Windows")
    def test_rmtree_unreadable_directories(self):
        d = self.make_temp_dir()
        sd = os.path.join(d, 'subdir')
        os.mkdir(sd)
        os.chmod(sd, 0)  # a bad mode for a directory, oops
        # The onerror API of shutil.rmtree doesn't let us recover from
        # os.listdir() failures.
        with self.assertRaises(OSError):
            rmtree(sd)
        os.chmod(sd, 0o755)  # so we can clean up

    def test_rmtree_readonly_directories(self):
        d = self.make_temp_dir()
        sd = os.path.join(d, 'subdir')
        fn = os.path.join(sd, 'file.txt')
        os.mkdir(sd)
        open(fn, 'w').close()
        os.chmod(sd, 0o444)  # a bad mode for a directory, oops
        rmtree(sd)
        assert not os.path.exists(sd)

    def test_rmtree_readonly_directories_and_files(self):
        d = self.make_temp_dir()
        sd = os.path.join(d, 'subdir')
        fn = os.path.join(sd, 'file.txt')
        os.mkdir(sd)
        open(fn, 'w').close()
        os.chmod(fn, 0o444)  # readonly
        os.chmod(sd, 0o444)  # a bad mode for a directory, oops
        rmtree(sd)
        assert not os.path.exists(sd)

    def test_copy_files(self):
        from check_manifest import copy_files
        actions = []
        n = os.path.normpath
        with mock.patch('os.path.isdir', lambda d: d in ('b', n('/dest/dir'))):
            with mock.patch('os.makedirs',
                            lambda d: actions.append('makedirs %s' % d)):
                with mock.patch('os.mkdir',
                                lambda d: actions.append('mkdir %s' % d)):
                    with mock.patch('shutil.copy2',
                                    lambda s, d: actions.append(f'cp {s} {d}')):
                        copy_files(['a', 'b', n('c/d/e')], n('/dest/dir'))
        self.assertEqual(
            actions,
            [
                'cp a %s' % n('/dest/dir/a'),
                'mkdir %s' % n('/dest/dir/b'),
                'makedirs %s' % n('/dest/dir/c/d'),
                'cp %s %s' % (n('c/d/e'), n('/dest/dir/c/d/e')),
            ])

    def test_get_one_file_in(self):
        from check_manifest import get_one_file_in
        with mock.patch('os.listdir', lambda dir: ['a']):
            self.assertEqual(get_one_file_in(os.path.normpath('/some/dir')),
                             os.path.normpath('/some/dir/a'))

    def test_get_one_file_in_empty_directory(self):
        from check_manifest import Failure, get_one_file_in
        with mock.patch('os.listdir', lambda dir: []):
            with self.assertRaises(Failure) as cm:
                get_one_file_in('/some/dir')
            self.assertEqual(str(cm.exception),
                             "No files found in /some/dir")

    def test_get_one_file_in_too_many(self):
        from check_manifest import Failure, get_one_file_in
        with mock.patch('os.listdir', lambda dir: ['b', 'a']):
            with self.assertRaises(Failure) as cm:
                get_one_file_in('/some/dir')
            self.assertEqual(str(cm.exception),
                             "More than one file exists in /some/dir:\na\nb")

    def test_unicodify(self):
        from check_manifest import unicodify
        nonascii = "\u00E9.txt"
        self.assertEqual(unicodify(nonascii), nonascii)
        self.assertEqual(
            unicodify(nonascii.encode(locale.getpreferredencoding())),
            nonascii)

    def test_get_archive_file_list_unrecognized_archive(self):
        from check_manifest import Failure, get_archive_file_list
        with self.assertRaises(Failure) as cm:
            get_archive_file_list('/path/to/archive.rar')
        self.assertEqual(str(cm.exception),
                         'Unrecognized archive type: archive.rar')

    def test_get_archive_file_list_zip(self):
        from check_manifest import get_archive_file_list
        filename = os.path.join(self.make_temp_dir(), 'archive.zip')
        self.create_zip_file(filename, ['a', 'b/c'])
        self.assertEqual(get_archive_file_list(filename),
                         ['a', 'b/c'])

    def test_get_archive_file_list_zip_nonascii(self):
        from check_manifest import get_archive_file_list
        filename = os.path.join(self.make_temp_dir(), 'archive.zip')
        nonascii = "\u00E9.txt"
        self.create_zip_file(filename, [nonascii])
        self.assertEqual(get_archive_file_list(filename),
                         [nonascii])

    def test_get_archive_file_list_tar(self):
        from check_manifest import get_archive_file_list
        filename = os.path.join(self.make_temp_dir(), 'archive.tar')
        self.create_tar_file(filename, ['a', 'b/c'])
        self.assertEqual(get_archive_file_list(filename),
                         ['a', 'b/c'])

    def test_get_archive_file_list_tar_nonascii(self):
        from check_manifest import get_archive_file_list
        filename = os.path.join(self.make_temp_dir(), 'archive.tar')
        nonascii = "\u00E9.txt"
        self.create_tar_file(filename, [nonascii])
        self.assertEqual(get_archive_file_list(filename),
                         [nonascii])

    def test_format_list(self):
        from check_manifest import format_list
        self.assertEqual(format_list([]), "")
        self.assertEqual(format_list(['a']), "  a")
        self.assertEqual(format_list(['a', 'b']), "  a\n  b")

    def test_format_missing(self):
        from check_manifest import format_missing
        self.assertEqual(
            format_missing(set(), set(), "1st", "2nd"),
            "")
        self.assertEqual(
            format_missing({"c"}, {"a"}, "1st", "2nd"),
            "missing from 1st:\n"
            "  c\n"
            "missing from 2nd:\n"
            "  a")

    def test_strip_toplevel_name_empty_list(self):
        from check_manifest import strip_toplevel_name
        self.assertEqual(strip_toplevel_name([]), [])

    def test_strip_toplevel_name_no_common_prefix(self):
        from check_manifest import Failure, strip_toplevel_name
        self.assertRaises(Failure, strip_toplevel_name, ["a/b", "c/d"])

    def test_detect_vcs_no_vcs(self):
        from check_manifest import Failure, detect_vcs
        ui = MockUI()
        with mock.patch('check_manifest.VCS.detect', staticmethod(lambda *a: False)):
            with mock.patch('check_manifest.Git.detect', staticmethod(lambda *a: False)):
                with self.assertRaises(Failure) as cm:
                    detect_vcs(ui)
                self.assertEqual(str(cm.exception),
                                 "Couldn't find version control data"
                                 " (git/hg/bzr/svn supported)")

    def test_normalize_names(self):
        from check_manifest import normalize_names
        j = os.path.join
        self.assertEqual(normalize_names(["a", j("b", ""), j("c", "d"),
                                          j("e", "f", ""),
                                          j("g", "h", "..", "i")]),
                         ["a", "b", "c/d", "e/f", "g/i"])

    def test_canonical_file_list(self):
        from check_manifest import canonical_file_list
        j = os.path.join
        self.assertEqual(
            canonical_file_list(['b', 'a', 'c', j('c', 'd'), j('e', 'f'),
                                 'g', j('g', 'h', 'i', 'j')]),
            ['a', 'b', 'c/d', 'e/f', 'g/h/i/j'])

    def test_file_matches(self):
        from check_manifest import file_matches
        patterns = ['setup.cfg', '*.egg-info', '*.egg-info/*']
        self.assertFalse(file_matches('setup.py', patterns))
        self.assertTrue(file_matches('setup.cfg', patterns))
        self.assertTrue(file_matches('src/zope.foo.egg-info', patterns))
        self.assertTrue(file_matches('src/zope.foo.egg-info/SOURCES.txt',
                                     patterns))

    def test_strip_sdist_extras(self):
        from check_manifest import (
            IgnoreList,
            canonical_file_list,
            strip_sdist_extras,
        )
        filelist = canonical_file_list([
            '.github',
            '.github/ISSUE_TEMPLATE',
            '.github/ISSUE_TEMPLATE/bug_report.md',
            '.gitignore',
            '.travis.yml',
            'setup.py',
            'setup.cfg',
            'README.txt',
            'src',
            'src/.gitignore',
            'src/zope',
            'src/zope/__init__.py',
            'src/zope/foo',
            'src/zope/foo/__init__.py',
            'src/zope/foo/language.po',
            'src/zope/foo/language.mo',
            'src/zope.foo.egg-info',
            'src/zope.foo.egg-info/SOURCES.txt',
        ])
        expected = canonical_file_list([
            'setup.py',
            'README.txt',
            'src',
            'src/zope',
            'src/zope/__init__.py',
            'src/zope/foo',
            'src/zope/foo/__init__.py',
            'src/zope/foo/language.po',
        ])
        ignore = IgnoreList.default()
        self.assertEqual(strip_sdist_extras(ignore, filelist), expected)

    def test_strip_sdist_extras_with_manifest(self):
        from check_manifest import (
            IgnoreList,
            _get_ignore_from_manifest_lines,
            canonical_file_list,
            strip_sdist_extras,
        )
        manifest_in = textwrap.dedent("""
            graft src
            exclude *.cfg
            global-exclude *.mo
            prune src/dump
            recursive-exclude src/zope *.sh
        """)
        filelist = canonical_file_list([
            '.github/ISSUE_TEMPLATE/bug_report.md',
            '.gitignore',
            'setup.py',
            'setup.cfg',
            'MANIFEST.in',
            'README.txt',
            'src',
            'src/helper.sh',
            'src/dump',
            'src/dump/__init__.py',
            'src/zope',
            'src/zope/__init__.py',
            'src/zope/zopehelper.sh',
            'src/zope/foo',
            'src/zope/foo/__init__.py',
            'src/zope/foo/language.po',
            'src/zope/foo/language.mo',
            'src/zope/foo/config.cfg',
            'src/zope/foo/foohelper.sh',
            'src/zope.foo.egg-info',
            'src/zope.foo.egg-info/SOURCES.txt',
        ])
        expected = canonical_file_list([
            'setup.py',
            'MANIFEST.in',
            'README.txt',
            'src',
            'src/helper.sh',
            'src/zope',
            'src/zope/__init__.py',
            'src/zope/foo',
            'src/zope/foo/__init__.py',
            'src/zope/foo/language.po',
            'src/zope/foo/config.cfg',
        ])
        ignore = IgnoreList.default()
        ignore += _get_ignore_from_manifest_lines(manifest_in.splitlines(), self.ui)
        result = strip_sdist_extras(ignore, filelist)
        self.assertEqual(result, expected)

    def test_find_bad_ideas(self):
        from check_manifest import find_bad_ideas
        filelist = [
            '.gitignore',
            'setup.py',
            'setup.cfg',
            'README.txt',
            'src',
            'src/zope',
            'src/zope/__init__.py',
            'src/zope/foo',
            'src/zope/foo/__init__.py',
            'src/zope/foo/language.po',
            'src/zope/foo/language.mo',
            'src/zope.foo.egg-info',
            'src/zope.foo.egg-info/SOURCES.txt',
        ]
        expected = [
            'src/zope/foo/language.mo',
            'src/zope.foo.egg-info',
        ]
        self.assertEqual(find_bad_ideas(filelist), expected)

    def test_find_suggestions(self):
        from check_manifest import find_suggestions
        self.assertEqual(find_suggestions(['buildout.cfg']),
                         (['include buildout.cfg'], []))
        self.assertEqual(find_suggestions(['unknown.file~']),
                         ([], ['unknown.file~']))
        self.assertEqual(find_suggestions(['README.txt', 'CHANGES.txt']),
                         (['include *.txt'], []))
        filelist = [
            'docs/index.rst',
            'docs/image.png',
            'docs/Makefile',
            'docs/unknown-file',
            'src/etc/blah/blah/Makefile',
        ]
        expected_rules = [
            'recursive-include docs *.png',
            'recursive-include docs *.rst',
            'recursive-include docs Makefile',
            'recursive-include src Makefile',
        ]
        expected_unknowns = ['docs/unknown-file']
        self.assertEqual(find_suggestions(filelist),
                         (expected_rules, expected_unknowns))

    def test_find_suggestions_generic_fallback_rules(self):
        from check_manifest import find_suggestions
        self.assertEqual(find_suggestions(['Changelog']),
                         (['include Changelog'], []))
        self.assertEqual(find_suggestions(['id-lang.map']),
                         (['include *.map'], []))
        self.assertEqual(find_suggestions(['src/id-lang.map']),
                         (['recursive-include src *.map'], []))

    def test_is_package(self):
        from check_manifest import is_package
        j = os.path.join
        exists = {j('a', 'setup.py'), j('c', 'pyproject.toml')}
        with mock.patch('os.path.exists', lambda fn: fn in exists):
            self.assertTrue(is_package('a'))
            self.assertFalse(is_package('b'))
            self.assertTrue(is_package('c'))

    def test_extract_version_from_filename(self):
        from check_manifest import extract_version_from_filename as e
        self.assertEqual(e('dist/foo_bar-1.2.3.dev4+g12345.zip'), '1.2.3.dev4+g12345')
        self.assertEqual(e('dist/foo_bar-1.2.3.dev4+g12345.tar.gz'), '1.2.3.dev4+g12345')

    def test_get_ignore_from_manifest_lines(self):
        from check_manifest import IgnoreList, _get_ignore_from_manifest_lines
        parse = partial(_get_ignore_from_manifest_lines, ui=self.ui)
        self.assertEqual(parse([]),
                         IgnoreList())
        self.assertEqual(parse(['', ' ']),
                         IgnoreList())
        self.assertEqual(parse(['exclude *.cfg']),
                         IgnoreList().exclude('*.cfg'))
        self.assertEqual(parse(['exclude          *.cfg']),
                         IgnoreList().exclude('*.cfg'))
        self.assertEqual(parse(['\texclude\t*.cfg foo.*   bar.txt']),
                         IgnoreList().exclude('*.cfg', 'foo.*', 'bar.txt'))
        self.assertEqual(parse(['exclude some/directory/*.cfg']),
                         IgnoreList().exclude('some/directory/*.cfg'))
        self.assertEqual(parse(['include *.cfg']),
                         IgnoreList())
        self.assertEqual(parse(['global-exclude *.pyc']),
                         IgnoreList().global_exclude('*.pyc'))
        self.assertEqual(parse(['global-exclude *.pyc *.sh']),
                         IgnoreList().global_exclude('*.pyc', '*.sh'))
        self.assertEqual(parse(['recursive-exclude dir *.pyc']),
                         IgnoreList().recursive_exclude('dir', '*.pyc'))
        self.assertEqual(parse(['recursive-exclude dir *.pyc foo*.sh']),
                         IgnoreList().recursive_exclude('dir', '*.pyc', 'foo*.sh'))
        self.assertEqual(parse(['recursive-exclude dir nopattern.xml']),
                         IgnoreList().recursive_exclude('dir', 'nopattern.xml'))
        # We should not fail when a recursive-exclude line is wrong:
        self.assertEqual(parse(['recursive-exclude dirwithoutpattern']),
                         IgnoreList())
        self.assertEqual(parse(['prune dir']),
                         IgnoreList().prune('dir'))
        # And a mongo test case of everything at the end
        text = textwrap.dedent("""
            exclude *.02
            exclude *.03 04.*   bar.txt
            exclude          *.05
            exclude some/directory/*.cfg
            global-exclude *.10 *.11
            global-exclude *.12
            include *.20
            prune 30
            recursive-exclude    40      *.41
            recursive-exclude 42 *.43 44.*
        """).splitlines()
        self.assertEqual(
            parse(text),
            IgnoreList()
            .exclude('*.02', '*.03', '04.*', 'bar.txt', '*.05', 'some/directory/*.cfg')
            .global_exclude('*.10', '*.11', '*.12')
            .prune('30')
            .recursive_exclude('40', '*.41')
            .recursive_exclude('42', '*.43', '44.*')
        )

    def test_get_ignore_from_manifest_lines_warns(self):
        from check_manifest import IgnoreList, _get_ignore_from_manifest_lines
        parse = partial(_get_ignore_from_manifest_lines, ui=self.ui)
        text = textwrap.dedent("""
            graft a/
            recursive-include /b *.txt
        """).splitlines()
        self.assertEqual(parse(text), IgnoreList())
        self.assertEqual(self.ui.warnings, [
            'ERROR: Trailing slashes are not allowed in MANIFEST.in on Windows: a/',
            'ERROR: Leading slashes are not allowed in MANIFEST.in on Windows: /b',
        ])

    def test_get_ignore_from_manifest(self):
        from check_manifest import IgnoreList, _get_ignore_from_manifest
        filename = os.path.join(self.make_temp_dir(), 'MANIFEST.in')
        self.create_file(filename, textwrap.dedent('''
           exclude \\
              # yes, this is allowed!
              test.dat

           # https://github.com/mgedmin/check-manifest/issues/66
           # docs/ folder
        '''))
        ui = MockUI()
        self.assertEqual(_get_ignore_from_manifest(filename, ui),
                         IgnoreList().exclude('test.dat'))
        self.assertEqual(ui.warnings, [])

    def test_get_ignore_from_manifest_warnings(self):
        from check_manifest import IgnoreList, _get_ignore_from_manifest
        filename = os.path.join(self.make_temp_dir(), 'MANIFEST.in')
        self.create_file(filename, textwrap.dedent('''
           # this is bad: a file should not end with a backslash
           exclude test.dat \\
        '''))
        ui = MockUI()
        self.assertEqual(_get_ignore_from_manifest(filename, ui),
                         IgnoreList().exclude('test.dat'))
        self.assertEqual(ui.warnings, [
            "%s, line 2: continuation line immediately precedes end-of-file" % filename,
        ])

    def test_should_use_pep517_no_pyproject_toml(self):
        from check_manifest import cd, should_use_pep_517
        src_dir = self.make_temp_dir()
        with cd(src_dir):
            self.assertFalse(should_use_pep_517())

    def test_should_use_pep517_no_build_system(self):
        from check_manifest import cd, should_use_pep_517
        src_dir = self.make_temp_dir()
        filename = os.path.join(src_dir, 'pyproject.toml')
        self.create_file(filename, textwrap.dedent('''
            [tool.check-manifest]
        '''))
        with cd(src_dir):
            self.assertFalse(should_use_pep_517())

    def test_should_use_pep517_no_build_backend(self):
        from check_manifest import cd, should_use_pep_517
        src_dir = self.make_temp_dir()
        filename = os.path.join(src_dir, 'pyproject.toml')
        self.create_file(filename, textwrap.dedent('''
            [build-system]
            requires = [
                "setuptools >= 40.6.0",
                "wheel",
            ]
        '''))
        with cd(src_dir):
            self.assertFalse(should_use_pep_517())

    def test_should_use_pep517_yes_please(self):
        from check_manifest import cd, should_use_pep_517
        src_dir = self.make_temp_dir()
        filename = os.path.join(src_dir, 'pyproject.toml')
        self.create_file(filename, textwrap.dedent('''
            [build-system]
            requires = [
                "setuptools >= 40.6.0",
                "wheel",
            ]
            build-backend = "setuptools.build_meta"
        '''))
        with cd(src_dir):
            self.assertTrue(should_use_pep_517())

    def _test_build_sdist_pep517(self, build_isolation):
        from check_manifest import build_sdist, cd, get_one_file_in
        src_dir = self.make_temp_dir()
        filename = os.path.join(src_dir, 'pyproject.toml')
        self.create_file(filename, textwrap.dedent('''
            [build-system]
            requires = [
                "setuptools >= 40.6.0",
                "wheel",
            ]
            build-backend = "setuptools.build_meta"
        '''))
        out_dir = self.make_temp_dir()
        python = os.path.abspath(sys.executable)
        with cd(src_dir):
            build_sdist(out_dir, python=python, build_isolation=build_isolation)
        self.assertTrue(get_one_file_in(out_dir))

    def test_build_sdist_pep517_isolated(self):
        self._test_build_sdist_pep517(build_isolation=True)

    def test_build_sdist_pep517_no_isolation(self):
        self._test_build_sdist_pep517(build_isolation=False)


class TestConfiguration(unittest.TestCase):

    def setUp(self):
        self.oldpwd = os.getcwd()
        self.tmpdir = tempfile.mkdtemp(prefix='test-', suffix='-check-manifest')
        os.chdir(self.tmpdir)
        self.ui = MockUI()

    def tearDown(self):
        os.chdir(self.oldpwd)
        rmtree(self.tmpdir)

    def test_read_config_no_config(self):
        import check_manifest
        ignore, ignore_bad_ideas = check_manifest.read_config()
        self.assertEqual(ignore, check_manifest.IgnoreList.default())

    def test_read_setup_config_no_section(self):
        import check_manifest
        with open('setup.cfg', 'w') as f:
            f.write('[pep8]\nignore =\n')
        ignore, ignore_bad_ideas = check_manifest.read_config()
        self.assertEqual(ignore, check_manifest.IgnoreList.default())

    def test_read_pyproject_config_no_section(self):
        import check_manifest
        with open('pyproject.toml', 'w') as f:
            f.write('[tool.pep8]\nignore = []\n')
        ignore, ignore_bad_ideas = check_manifest.read_config()
        self.assertEqual(ignore, check_manifest.IgnoreList.default())

    def test_read_setup_config_no_option(self):
        import check_manifest
        with open('setup.cfg', 'w') as f:
            f.write('[check-manifest]\n')
        ignore, ignore_bad_ideas = check_manifest.read_config()
        self.assertEqual(ignore, check_manifest.IgnoreList.default())

    def test_read_pyproject_config_no_option(self):
        import check_manifest
        with open('pyproject.toml', 'w') as f:
            f.write('[tool.check-manifest]\n')
        ignore, ignore_bad_ideas = check_manifest.read_config()
        self.assertEqual(ignore, check_manifest.IgnoreList.default())

    def test_read_setup_config_extra_ignores(self):
        import check_manifest
        with open('setup.cfg', 'w') as f:
            f.write('[check-manifest]\nignore = foo\n  bar*\n')
        ignore, ignore_bad_ideas = check_manifest.read_config()
        expected = check_manifest.IgnoreList.default().global_exclude('foo', 'bar*')
        self.assertEqual(ignore, expected)

    def test_read_pyproject_config_extra_ignores(self):
        import check_manifest
        with open('pyproject.toml', 'w') as f:
            f.write('[tool.check-manifest]\nignore = ["foo", "bar*"]\n')
        ignore, ignore_bad_ideas = check_manifest.read_config()
        expected = check_manifest.IgnoreList.default().global_exclude('foo', 'bar*')
        self.assertEqual(ignore, expected)

    def test_read_setup_config_override_ignores(self):
        import check_manifest
        with open('setup.cfg', 'w') as f:
            f.write('[check-manifest]\nignore = foo\n\n  bar\n')
            f.write('ignore-default-rules = yes\n')
        ignore, ignore_bad_ideas = check_manifest.read_config()
        expected = check_manifest.IgnoreList().global_exclude('foo', 'bar')
        self.assertEqual(ignore, expected)

    def test_read_pyproject_config_override_ignores(self):
        import check_manifest
        with open('pyproject.toml', 'w') as f:
            f.write('[tool.check-manifest]\nignore = ["foo", "bar"]\n')
            f.write('ignore-default-rules = true\n')
        ignore, ignore_bad_ideas = check_manifest.read_config()
        expected = check_manifest.IgnoreList().global_exclude('foo', 'bar')
        self.assertEqual(ignore, expected)

    def test_read_setup_config_ignore_bad_ideas(self):
        import check_manifest
        with open('setup.cfg', 'w') as f:
            f.write('[check-manifest]\n'
                    'ignore-bad-ideas = \n'
                    '  foo\n'
                    '  bar*\n')
        ignore, ignore_bad_ideas = check_manifest.read_config()
        expected = check_manifest.IgnoreList().global_exclude('foo', 'bar*')
        self.assertEqual(ignore_bad_ideas, expected)

    def test_read_pyproject_config_ignore_bad_ideas(self):
        import check_manifest
        with open('pyproject.toml', 'w') as f:
            f.write('[tool.check-manifest]\n'
                    'ignore-bad-ideas = ["foo", "bar*"]\n')
        ignore, ignore_bad_ideas = check_manifest.read_config()
        expected = check_manifest.IgnoreList().global_exclude('foo', 'bar*')
        self.assertEqual(ignore_bad_ideas, expected)

    def test_read_manifest_no_manifest(self):
        import check_manifest
        ignore = check_manifest.read_manifest(self.ui)
        self.assertEqual(ignore, check_manifest.IgnoreList())

    def test_read_manifest(self):
        import check_manifest
        from check_manifest import IgnoreList
        with open('MANIFEST.in', 'w') as f:
            f.write('exclude *.gif\n')
            f.write('global-exclude *.png\n')
        ignore = check_manifest.read_manifest(self.ui)
        self.assertEqual(ignore, IgnoreList().exclude('*.gif').global_exclude('*.png'))


class TestMain(unittest.TestCase):

    def setUp(self):
        self._cm_patcher = mock.patch('check_manifest.check_manifest')
        self._check_manifest = self._cm_patcher.start()
        self._se_patcher = mock.patch('sys.exit')
        self._sys_exit = self._se_patcher.start()
        self.ui = MockUI()
        self._ui_patcher = mock.patch('check_manifest.UI', self._make_ui)
        self._ui_patcher.start()
        self._orig_sys_argv = sys.argv
        sys.argv = ['check-manifest']

    def tearDown(self):
        sys.argv = self._orig_sys_argv
        self._se_patcher.stop()
        self._cm_patcher.stop()
        self._ui_patcher.stop()

    def _make_ui(self, verbosity):
        self.ui.verbosity = verbosity
        return self.ui

    def test(self):
        from check_manifest import main
        sys.argv.append('-v')
        main()

    def test_exit_code_1_on_error(self):
        from check_manifest import main
        self._check_manifest.return_value = False
        main()
        self._sys_exit.assert_called_with(1)

    def test_exit_code_2_on_failure(self):
        from check_manifest import Failure, main
        self._check_manifest.side_effect = Failure('msg')
        main()
        self.assertEqual(self.ui.errors, ['msg'])
        self._sys_exit.assert_called_with(2)

    def test_extra_ignore_args(self):
        import check_manifest
        sys.argv.append('--ignore=x,y,z*')
        check_manifest.main()
        ignore = check_manifest.IgnoreList().global_exclude('x', 'y', 'z*')
        self.assertEqual(self._check_manifest.call_args.kwargs['extra_ignore'],
                         ignore)

    def test_ignore_bad_ideas_args(self):
        import check_manifest
        sys.argv.append('--ignore-bad-ideas=x,y,z*')
        check_manifest.main()
        ignore = check_manifest.IgnoreList().global_exclude('x', 'y', 'z*')
        self.assertEqual(self._check_manifest.call_args.kwargs['extra_ignore_bad_ideas'],
                         ignore)

    def test_verbose_arg(self):
        import check_manifest
        sys.argv.append('--verbose')
        check_manifest.main()
        self.assertEqual(self.ui.verbosity, 2)

    def test_quiet_arg(self):
        import check_manifest
        sys.argv.append('--quiet')
        check_manifest.main()
        self.assertEqual(self.ui.verbosity, 0)

    def test_verbose_and_quiet_arg(self):
        import check_manifest
        sys.argv.append('--verbose')
        sys.argv.append('--quiet')
        check_manifest.main()
        # the two arguments cancel each other out:
        # 1 (default verbosity) + 1 - 1 = 1.
        self.assertEqual(self.ui.verbosity, 1)


class TestZestIntegration(unittest.TestCase):

    def setUp(self):
        sys.modules['zest'] = mock.Mock()
        sys.modules['zest.releaser'] = mock.Mock()
        sys.modules['zest.releaser.utils'] = mock.Mock()
        self.ask = sys.modules['zest.releaser.utils'].ask
        self.ui = MockUI()
        self._ui_patcher = mock.patch('check_manifest.UI', return_value=self.ui)
        self._ui_patcher.start()

    def tearDown(self):
        self._ui_patcher.stop()
        del sys.modules['zest.releaser.utils']
        del sys.modules['zest.releaser']
        del sys.modules['zest']

    @mock.patch('check_manifest.is_package', lambda d: False)
    @mock.patch('check_manifest.check_manifest')
    def test_zest_releaser_check_not_a_package(self, check_manifest):
        from check_manifest import zest_releaser_check
        zest_releaser_check(dict(workingdir='.'))
        check_manifest.assert_not_called()

    @mock.patch('check_manifest.is_package', lambda d: True)
    @mock.patch('check_manifest.check_manifest')
    def test_zest_releaser_check_user_disagrees(self, check_manifest):
        from check_manifest import zest_releaser_check
        self.ask.return_value = False
        zest_releaser_check(dict(workingdir='.'))
        check_manifest.assert_not_called()

    @mock.patch('check_manifest.is_package', lambda d: True)
    @mock.patch('sys.exit')
    @mock.patch('check_manifest.check_manifest')
    def test_zest_releaser_check_all_okay(self, check_manifest, sys_exit):
        from check_manifest import zest_releaser_check
        self.ask.return_value = True
        check_manifest.return_value = True
        zest_releaser_check(dict(workingdir='.'))
        sys_exit.assert_not_called()

    @mock.patch('check_manifest.is_package', lambda d: True)
    @mock.patch('sys.exit')
    @mock.patch('check_manifest.check_manifest')
    def test_zest_releaser_check_error_user_aborts(self, check_manifest,
                                                   sys_exit):
        from check_manifest import zest_releaser_check
        self.ask.side_effect = [True, False]
        check_manifest.return_value = False
        zest_releaser_check(dict(workingdir='.'))
        sys_exit.assert_called_with(1)

    @mock.patch('check_manifest.is_package', lambda d: True)
    @mock.patch('sys.exit')
    @mock.patch('check_manifest.check_manifest')
    def test_zest_releaser_check_error_user_plods_on(self, check_manifest,
                                                     sys_exit):
        from check_manifest import zest_releaser_check
        self.ask.side_effect = [True, True]
        check_manifest.return_value = False
        zest_releaser_check(dict(workingdir='.'))
        sys_exit.assert_not_called()

    @mock.patch('check_manifest.is_package', lambda d: True)
    @mock.patch('sys.exit')
    @mock.patch('check_manifest.check_manifest')
    def test_zest_releaser_check_failure_user_aborts(self, check_manifest,
                                                     sys_exit):
        from check_manifest import Failure, zest_releaser_check
        self.ask.side_effect = [True, False]
        check_manifest.side_effect = Failure('msg')
        zest_releaser_check(dict(workingdir='.'))
        self.assertEqual(self.ui.errors, ['msg'])
        sys_exit.assert_called_with(2)

    @mock.patch('check_manifest.is_package', lambda d: True)
    @mock.patch('sys.exit')
    @mock.patch('check_manifest.check_manifest')
    def test_zest_releaser_check_failure_user_plods_on(self, check_manifest,
                                                       sys_exit):
        from check_manifest import Failure, zest_releaser_check
        self.ask.side_effect = [True, True]
        check_manifest.side_effect = Failure('msg')
        zest_releaser_check(dict(workingdir='.'))
        self.assertEqual(self.ui.errors, ['msg'])
        sys_exit.assert_not_called()


class VCSHelper:

    # override in subclasses
    command = None  # type: Optional[str]

    def is_installed(self):
        try:
            p = subprocess.Popen([self.command, '--version'],
                                 stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
            stdout, stderr = p.communicate()
            rc = p.wait()
            return (rc == 0)
        except OSError:
            return False

    def _run(self, *command):
        # Windows doesn't like Unicode arguments to subprocess.Popen(), on Py2:
        # https://github.com/mgedmin/check-manifest/issues/23#issuecomment-33933031
        if str is bytes:
            command = [s.encode(locale.getpreferredencoding()) for s in command]
        print('$', ' '.join(command))
        p = subprocess.Popen(command, stdout=subprocess.PIPE,
                             stderr=subprocess.STDOUT)
        stdout, stderr = p.communicate()
        rc = p.wait()
        if stdout:
            print(
                stdout if isinstance(stdout, str) else
                stdout.decode('ascii', 'backslashreplace')
            )
        if rc:
            raise subprocess.CalledProcessError(rc, command[0], output=stdout)


class VCSMixin:

    def setUp(self):
        if not self.vcs.is_installed() and CAN_SKIP_TESTS:
            self.skipTest("%s is not installed" % self.vcs.command)
        self.tmpdir = tempfile.mkdtemp(prefix='test-', suffix='-check-manifest')
        self.olddir = os.getcwd()
        os.chdir(self.tmpdir)
        self.ui = MockUI()

    def tearDown(self):
        os.chdir(self.olddir)
        rmtree(self.tmpdir)

    def _create_file(self, filename):
        assert not os.path.isabs(filename)
        basedir = os.path.dirname(filename)
        if basedir and not os.path.isdir(basedir):
            os.makedirs(basedir)
        open(filename, 'w').close()

    def _create_files(self, filenames):
        for filename in filenames:
            self._create_file(filename)

    def _init_vcs(self):
        self.vcs._init_vcs()

    def _add_to_vcs(self, filenames):
        self.vcs._add_to_vcs(filenames)

    def _commit(self):
        self.vcs._commit()

    def _create_and_add_to_vcs(self, filenames):
        self._create_files(filenames)
        self._add_to_vcs(filenames)

    def test_get_vcs_files(self):
        from check_manifest import get_vcs_files
        self._init_vcs()
        self._create_and_add_to_vcs(['a.txt', 'b/b.txt', 'b/c/d.txt'])
        self._commit()
        self._create_files(['b/x.txt', 'd/d.txt', 'i.txt'])
        self.assertEqual(get_vcs_files(self.ui),
                         ['a.txt', 'b/b.txt', 'b/c/d.txt'])

    def test_get_vcs_files_added_but_uncommitted(self):
        from check_manifest import get_vcs_files
        self._init_vcs()
        self._create_and_add_to_vcs(['a.txt', 'b/b.txt', 'b/c/d.txt'])
        self._create_files(['b/x.txt', 'd/d.txt', 'i.txt'])
        self.assertEqual(get_vcs_files(self.ui),
                         ['a.txt', 'b/b.txt', 'b/c/d.txt'])

    def test_get_vcs_files_deleted_but_not_removed(self):
        if self.vcs.command == 'bzr':
            self.skipTest("this cosmetic feature is not supported with bzr")
            # see the longer explanation in test_missing_source_files
        from check_manifest import get_vcs_files
        self._init_vcs()
        self._create_and_add_to_vcs(['a.txt'])
        self._commit()
        os.unlink('a.txt')
        self.assertEqual(get_vcs_files(self.ui), ['a.txt'])

    def test_get_vcs_files_in_a_subdir(self):
        from check_manifest import get_vcs_files
        self._init_vcs()
        self._create_and_add_to_vcs(['a.txt', 'b/b.txt', 'b/c/d.txt'])
        self._commit()
        self._create_files(['b/x.txt', 'd/d.txt', 'i.txt'])
        os.chdir('b')
        self.assertEqual(get_vcs_files(self.ui), ['b.txt', 'c/d.txt'])

    def test_get_vcs_files_nonascii_filenames(self):
        # This test will fail if your locale is incapable of expressing
        # "eacute".  UTF-8 or Latin-1 should work.
        from check_manifest import get_vcs_files
        self._init_vcs()
        filename = "\u00E9.txt"
        self._create_and_add_to_vcs([filename])
        self.assertEqual(get_vcs_files(self.ui), [filename])

    def test_get_vcs_files_empty(self):
        from check_manifest import get_vcs_files
        self._init_vcs()
        self.assertEqual(get_vcs_files(self.ui), [])


class GitHelper(VCSHelper):

    command = 'git'

    def _init_vcs(self):
        self._run('git', 'init')
        self._run('git', 'config', 'user.name', 'Unit Test')
        self._run('git', 'config', 'user.email', 'test@example.com')

    def _add_to_vcs(self, filenames):
        # Note that we use --force to prevent errors when we want to
        # add foo.egg-info and the user running the tests has
        # '*.egg-info' in her global .gitignore file.
        self._run('git', 'add', '--force', '--', *filenames)

    def _commit(self):
        self._run('git', 'commit', '-m', 'Initial')


class TestGit(VCSMixin, unittest.TestCase):
    vcs = GitHelper()

    def _init_repo_with_files(self, dirname, filenames):
        os.mkdir(dirname)
        os.chdir(dirname)
        self._init_vcs()
        self._create_and_add_to_vcs(filenames)
        self._commit()
        os.chdir(self.tmpdir)

    def _add_submodule(self, repo, subdir, subrepo):
        os.chdir(repo)
        self.vcs._run('git', 'submodule', 'add', subrepo, subdir)
        self._commit()
        os.chdir(self.tmpdir)

    def test_detect_git_submodule(self):
        from check_manifest import Failure, detect_vcs
        with self.assertRaises(Failure) as cm:
            detect_vcs(self.ui)
        self.assertEqual(str(cm.exception),
                         "Couldn't find version control data"
                         " (git/hg/bzr/svn supported)")
        # now create a .git file like in a submodule
        open(os.path.join(self.tmpdir, '.git'), 'w').close()
        self.assertEqual(detect_vcs(self.ui).metadata_name, '.git')

    def test_get_versioned_files_with_git_submodules(self):
        from check_manifest import get_vcs_files
        self._init_repo_with_files('repo1', ['file1', 'file2'])
        self._init_repo_with_files('repo2', ['file3'])
        self._init_repo_with_files('repo3', ['file4'])
        self._add_submodule('repo2', 'sub3', '../repo3')
        self._init_repo_with_files('main', ['file5', 'subdir/file6'])
        self._add_submodule('main', 'sub1', '../repo1')
        self._add_submodule('main', 'subdir/sub2', '../repo2')
        os.chdir('main')
        self.vcs._run('git', 'submodule', 'update', '--init', '--recursive')
        self.assertEqual(
            get_vcs_files(self.ui),
            [
                '.gitmodules',
                'file5',
                'sub1/file1',
                'sub1/file2',
                'subdir/file6',
                'subdir/sub2/.gitmodules',
                'subdir/sub2/file3',
                'subdir/sub2/sub3/file4',
            ])

    def test_get_versioned_files_with_git_submodules_with_git_index_file_set(self):
        with mock.patch.dict(os.environ, {"GIT_INDEX_FILE": ".git/index"}):
            self.test_get_versioned_files_with_git_submodules()


class BzrHelper(VCSHelper):

    command = 'bzr'

    def _init_vcs(self):
        self._run('bzr', 'init')
        self._run('bzr', 'whoami', '--branch', 'Unit Test <test@example.com>')

    def _add_to_vcs(self, filenames):
        self._run('bzr', 'add', '--', *filenames)

    def _commit(self):
        self._run('bzr', 'commit', '-m', 'Initial')


class TestBzr(VCSMixin, unittest.TestCase):
    vcs = BzrHelper()


@unittest.skipIf(HAS_OEM_CODEC,
                 "Python 3.6 lets us use 'oem' codec instead of guessing")
class TestBzrTerminalCharsetDetectionOnOldPythons(unittest.TestCase):

    @mock.patch('sys.stdin')
    @mock.patch('sys.stdout')
    def test_terminal_encoding_not_known(self, mock_stdout, mock_stdin):
        from check_manifest import Bazaar
        mock_stdout.encoding = None
        mock_stdin.encoding = None
        self.assertEqual(Bazaar._get_terminal_encoding(), None)

    @mock.patch('sys.stdout')
    def test_terminal_encoding_stdout_known(self, mock_stdout):
        from check_manifest import Bazaar
        mock_stdout.encoding = 'UTF-8'
        self.assertEqual(Bazaar._get_terminal_encoding(), 'UTF-8')

    @mock.patch('sys.stdin')
    @mock.patch('sys.stdout')
    def test_terminal_encoding_stdin_known(self, mock_stdout, mock_stdin):
        from check_manifest import Bazaar
        mock_stdout.encoding = None
        mock_stdin.encoding = 'UTF-8'
        self.assertEqual(Bazaar._get_terminal_encoding(), 'UTF-8')

    @mock.patch('sys.stdout')
    def test_terminal_encoding_cp0(self, mock_stdout):
        from check_manifest import Bazaar
        mock_stdout.encoding = 'cp0'
        self.assertEqual(Bazaar._get_terminal_encoding(), None)


@unittest.skipIf(not HAS_OEM_CODEC,
                 "'oem' codec not available on Python before 3.6")
class TestBzrTerminalCharsetDetectionOnNewPythons(unittest.TestCase):

    def test_terminal_encoding_cp0(self):
        from check_manifest import Bazaar
        self.assertEqual(Bazaar._get_terminal_encoding(), "oem")


class HgHelper(VCSHelper):

    command = 'hg'

    def _init_vcs(self):
        self._run('hg', 'init')
        with open('.hg/hgrc', 'a') as f:
            f.write('\n[ui]\nusername = Unit Test <test@example.com\n')

    def _add_to_vcs(self, filenames):
        self._run('hg', 'add', '--', *filenames)

    def _commit(self):
        self._run('hg', 'commit', '-m', 'Initial')


class TestHg(VCSMixin, unittest.TestCase):
    vcs = HgHelper()


class SvnHelper(VCSHelper):

    command = 'svn'

    def _init_vcs(self):
        self._run('svnadmin', 'create', 'repo')
        self._run('svn', 'co', 'file:///' + os.path.abspath('repo').replace(os.path.sep, '/'), 'checkout')
        os.chdir('checkout')

    def _add_directories_and_sort(self, filelist):
        from check_manifest import normalize_names
        names = set(normalize_names(filelist))
        names.update([posixpath.dirname(fn) for fn in names])
        return sorted(names - {''})

    def _add_to_vcs(self, filenames):
        self._run('svn', 'add', '-N', '--', *self._add_directories_and_sort(filenames))

    def _commit(self):
        self._run('svn', 'commit', '-m', 'Initial')


class TestSvn(VCSMixin, unittest.TestCase):
    vcs = SvnHelper()

    def test_svn_externals(self):
        from check_manifest import get_vcs_files
        self.vcs._run('svnadmin', 'create', 'repo2')
        repo2_url = 'file:///' + os.path.abspath('repo2').replace(os.path.sep, '/')
        self.vcs._init_vcs()
        self.vcs._run('svn', 'propset', 'svn:externals', 'ext %s' % repo2_url, '.')
        self.vcs._run('svn', 'up')
        self._create_files(['a.txt', 'ext/b.txt'])
        self.vcs._run('svn', 'add', 'a.txt', 'ext/b.txt')
        self.assertEqual(get_vcs_files(self.ui),
                         ['a.txt', 'ext/b.txt'])


class TestSvnExtraErrors(unittest.TestCase):

    def test_svn_xml_parsing_warning(self):
        from check_manifest import Subversion
        ui = MockUI()
        svn = Subversion(ui)
        entry = ET.XML('<entry path="foo/bar.txt"></entry>')
        self.assertFalse(svn.is_interesting(entry))
        self.assertEqual(
            ui.warnings,
            ['svn status --xml parse error:'
             ' <entry path="foo/bar.txt"> without <wc-status>'])


class TestUserInterface(unittest.TestCase):

    def make_ui(self, verbosity=1):
        from check_manifest import UI
        ui = UI(verbosity=verbosity)
        ui.stdout = StringIO()
        ui.stderr = StringIO()
        return ui

    def test_info(self):
        ui = self.make_ui(verbosity=1)
        ui.info("Reticulating splines")
        self.assertEqual(ui.stdout.getvalue(),
                         "Reticulating splines\n")

    def test_info_verbose(self):
        ui = self.make_ui(verbosity=2)
        ui.info("Reticulating splines")
        self.assertEqual(ui.stdout.getvalue(),
                         "Reticulating splines\n")

    def test_info_quiet(self):
        ui = self.make_ui(verbosity=0)
        ui.info("Reticulating splines")
        self.assertEqual(ui.stdout.getvalue(), "")

    def test_info_begin_continue_end(self):
        ui = self.make_ui(verbosity=1)
        ui.info_begin("Reticulating splines...")
        ui.info_continue(" nearly done...")
        ui.info_continue(" almost done...")
        ui.info_end(" done!")
        self.assertEqual(ui.stdout.getvalue(), "")

    def test_info_begin_continue_end_verbose(self):
        ui = self.make_ui(verbosity=2)
        ui.info_begin("Reticulating splines...")
        ui.info_continue(" nearly done...")
        ui.info_continue(" almost done...")
        ui.info_end(" done!")
        self.assertEqual(
            ui.stdout.getvalue(),
            "Reticulating splines... nearly done... almost done... done!\n")

    def test_info_emits_newline_when_needed(self):
        ui = self.make_ui(verbosity=1)
        ui.info_begin("Computering...")
        ui.info("Forgot to turn the gas off!")
        self.assertEqual(
            ui.stdout.getvalue(),
            "Forgot to turn the gas off!\n")

    def test_info_emits_newline_when_needed_verbose(self):
        ui = self.make_ui(verbosity=2)
        ui.info_begin("Computering...")
        ui.info("Forgot to turn the gas off!")
        self.assertEqual(
            ui.stdout.getvalue(),
            "Computering...\n"
            "Forgot to turn the gas off!\n")

    def test_warning(self):
        ui = self.make_ui(verbosity=1)
        ui.info_begin("Computering...")
        ui.warning("Forgot to turn the gas off!")
        self.assertEqual(ui.stdout.getvalue(), "")
        self.assertEqual(
            ui.stderr.getvalue(),
            "Forgot to turn the gas off!\n")

    def test_warning_verbose(self):
        ui = self.make_ui(verbosity=2)
        ui.info_begin("Computering...")
        ui.warning("Forgot to turn the gas off!")
        self.assertEqual(
            ui.stdout.getvalue(),
            "Computering...\n")
        self.assertEqual(
            ui.stderr.getvalue(),
            "Forgot to turn the gas off!\n")

    def test_error(self):
        ui = self.make_ui(verbosity=1)
        ui.info_begin("Computering...")
        ui.error("Forgot to turn the gas off!")
        self.assertEqual(ui.stdout.getvalue(), "")
        self.assertEqual(
            ui.stderr.getvalue(),
            "Forgot to turn the gas off!\n")

    def test_error_verbose(self):
        ui = self.make_ui(verbosity=2)
        ui.info_begin("Computering...")
        ui.error("Forgot to turn the gas off!")
        self.assertEqual(
            ui.stdout.getvalue(),
            "Computering...\n")
        self.assertEqual(
            ui.stderr.getvalue(),
            "Forgot to turn the gas off!\n")


class TestIgnoreList(unittest.TestCase):

    def setUp(self):
        from check_manifest import IgnoreList
        self.ignore = IgnoreList()

    def test_repr(self):
        from check_manifest import IgnoreList
        ignore = IgnoreList()
        self.assertEqual(repr(ignore), "IgnoreList([])")

    def test_exclude_pattern(self):
        self.ignore.exclude('*.txt')
        self.assertEqual(self.ignore.filter([
            'foo.md',
            'bar.txt',
            'subdir/bar.txt',
        ]), [
            'foo.md',
            'subdir/bar.txt',
        ])

    def test_exclude_file(self):
        self.ignore.exclude('bar.txt')
        self.assertEqual(self.ignore.filter([
            'foo.md',
            'bar.txt',
            'subdir/bar.txt',
        ]), [
            'foo.md',
            'subdir/bar.txt',
        ])

    def test_exclude_doest_apply_to_directories(self):
        self.ignore.exclude('subdir')
        self.assertEqual(self.ignore.filter([
            'foo.md',
            'subdir/bar.txt',
        ]), [
            'foo.md',
            'subdir/bar.txt',
        ])

    def test_global_exclude(self):
        self.ignore.global_exclude('a*.txt')
        self.assertEqual(self.ignore.filter([
            'bar.txt',        # make sure full filenames are matched
            'afile.txt',
            'subdir/afile.txt',
            'adir/file.txt',  # make sure * doesn't match /
        ]), [
            'bar.txt',
            'adir/file.txt',
        ])

    def test_global_exclude_does_not_apply_to_directories(self):
        self.ignore.global_exclude('subdir')
        self.assertEqual(self.ignore.filter([
            'bar.txt',
            'subdir/afile.txt',
        ]), [
            'bar.txt',
            'subdir/afile.txt',
        ])

    def test_recursive_exclude(self):
        self.ignore.recursive_exclude('subdir', 'a*.txt')
        self.assertEqual(self.ignore.filter([
            'afile.txt',
            'subdir/afile.txt',
            'subdir/extra/afile.txt',
            'subdir/adir/file.txt',
            'other/afile.txt',
        ]), [
            'afile.txt',
            'subdir/adir/file.txt',
            'other/afile.txt',
        ])

    def test_recursive_exclude_does_not_apply_to_directories(self):
        self.ignore.recursive_exclude('subdir', 'dir')
        self.assertEqual(self.ignore.filter([
            'afile.txt',
            'subdir/dir/afile.txt',
        ]), [
            'afile.txt',
            'subdir/dir/afile.txt',
        ])

    def test_recursive_exclude_can_prune(self):
        self.ignore.recursive_exclude('subdir', '*')
        self.assertEqual(self.ignore.filter([
            'afile.txt',
            'subdir/afile.txt',
            'subdir/dir/afile.txt',
            'subdir/dir/dir/afile.txt',
        ]), [
            'afile.txt',
        ])

    def test_prune(self):
        self.ignore.prune('subdir')
        self.assertEqual(self.ignore.filter([
            'foo.md',
            'subdir/bar.txt',
            'unrelated/subdir/baz.txt',
        ]), [
            'foo.md',
            'unrelated/subdir/baz.txt',
        ])

    def test_prune_subdir(self):
        self.ignore.prune('a/b')
        self.assertEqual(self.ignore.filter([
            'foo.md',
            'a/b/bar.txt',
            'a/c/bar.txt',
        ]), [
            'foo.md',
            'a/c/bar.txt',
        ])

    def test_prune_glob(self):
        self.ignore.prune('su*r')
        self.assertEqual(self.ignore.filter([
            'foo.md',
            'subdir/bar.txt',
            'unrelated/subdir/baz.txt',
        ]), [
            'foo.md',
            'unrelated/subdir/baz.txt',
        ])

    def test_prune_glob_is_not_too_greedy(self):
        self.ignore.prune('su*r')
        self.assertEqual(self.ignore.filter([
            'foo.md',
            # super-unrelated/subdir matches su*r if you allow * to match /,
            # which fnmatch does!
            'super-unrelated/subdir/qux.txt',
        ]), [
            'foo.md',
            'super-unrelated/subdir/qux.txt',
        ])

    def test_default_excludes_pkg_info(self):
        from check_manifest import IgnoreList
        ignore = IgnoreList.default()
        self.assertEqual(ignore.filter([
            'PKG-INFO',
            'bar.txt',
        ]), [
            'bar.txt',
        ])

    def test_default_excludes_egg_info(self):
        from check_manifest import IgnoreList
        ignore = IgnoreList.default()
        self.assertEqual(ignore.filter([
            'mypackage.egg-info/PKG-INFO',
            'mypackage.egg-info/SOURCES.txt',
            'mypackage.egg-info/requires.txt',
            'bar.txt',
        ]), [
            'bar.txt',
        ])

    def test_default_excludes_egg_info_in_a_subdirectory(self):
        from check_manifest import IgnoreList
        ignore = IgnoreList.default()
        self.assertEqual(ignore.filter([
            'src/mypackage.egg-info/PKG-INFO',
            'src/mypackage.egg-info/SOURCES.txt',
            'src/mypackage.egg-info/requires.txt',
            'bar.txt',
        ]), [
            'bar.txt',
        ])


def pick_installed_vcs():
    preferred_order = [GitHelper, HgHelper, BzrHelper, SvnHelper]
    force = os.getenv('FORCE_TEST_VCS')
    if force:
        for cls in preferred_order:
            if force == cls.command:
                return cls()
        raise ValueError('Unsupported FORCE_TEST_VCS=%s (supported: %s)'
                         % (force, '/'.join(cls.command for cls in preferred_order)))
    for cls in preferred_order:
        vcs = cls()
        if vcs.is_installed():
            return vcs
    return None


class TestCheckManifest(unittest.TestCase):

    _vcs = pick_installed_vcs()

    def setUp(self):
        if self._vcs is None:
            self.fail('at least one version control system should be installed')
        self.oldpwd = os.getcwd()
        self.tmpdir = tempfile.mkdtemp(prefix='test-', suffix='-check-manifest')
        os.chdir(self.tmpdir)
        self._stdout_patcher = mock.patch('sys.stdout', StringIO())
        self._stdout_patcher.start()
        self._stderr_patcher = mock.patch('sys.stderr', StringIO())
        self._stderr_patcher.start()

    def tearDown(self):
        self._stderr_patcher.stop()
        self._stdout_patcher.stop()
        os.chdir(self.oldpwd)
        rmtree(self.tmpdir)

    def _create_repo_with_code(self, add_to_vcs=True):
        self._vcs._init_vcs()
        with open('setup.py', 'w') as f:
            f.write("from setuptools import setup\n")
            f.write("setup(name='sample', py_modules=['sample'])\n")
        with open('sample.py', 'w') as f:
            f.write("# wow. such code. so amaze\n")
        if add_to_vcs:
            self._vcs._add_to_vcs(['setup.py', 'sample.py'])

    def _create_repo_with_code_in_subdir(self):
        os.mkdir('subdir')
        os.chdir('subdir')
        self._create_repo_with_code()
        # NB: when self._vcs is SvnHelper, we're actually in
        # ./subdir/checkout rather than in ./subdir
        subdir = os.path.basename(os.getcwd())
        os.chdir(os.pardir)
        return subdir

    def _add_to_vcs(self, filename, content=''):
        if os.path.sep in filename and not os.path.isdir(os.path.dirname(filename)):
            os.makedirs(os.path.dirname(filename))
        with open(filename, 'w') as f:
            f.write(content)
        self._vcs._add_to_vcs([filename])

    def test_not_python_project(self):
        from check_manifest import Failure, check_manifest
        with self.assertRaises(Failure) as cm:
            check_manifest()
        self.assertEqual(
            str(cm.exception),
            "This is not a Python project (no setup.py/pyproject.toml).")

    def test_forgot_to_git_add_anything(self):
        from check_manifest import Failure, check_manifest
        self._create_repo_with_code(add_to_vcs=False)
        with self.assertRaises(Failure) as cm:
            check_manifest()
        self.assertEqual(str(cm.exception),
                         "There are no files added to version control!")

    def test_all_is_well(self):
        from check_manifest import check_manifest
        self._create_repo_with_code()
        self.assertTrue(check_manifest(), sys.stderr.getvalue())

    def test_relative_pathname(self):
        from check_manifest import check_manifest
        subdir = self._create_repo_with_code_in_subdir()
        self.assertTrue(check_manifest(subdir), sys.stderr.getvalue())

    def test_relative_python(self):
        # https://github.com/mgedmin/check-manifest/issues/36
        from check_manifest import check_manifest
        subdir = self._create_repo_with_code_in_subdir()
        python = os.path.relpath(sys.executable)
        self.assertTrue(check_manifest(subdir, python=python),
                        sys.stderr.getvalue())

    def test_python_from_path(self):
        # https://github.com/mgedmin/check-manifest/issues/57
        from check_manifest import check_manifest

        # We need a Python interpeter to be in PATH.
        python = 'python'
        if hasattr(shutil, 'which'):
            for python in 'python', 'python3', os.path.basename(sys.executable):
                if shutil.which(python):
                    break
        subdir = self._create_repo_with_code_in_subdir()
        self.assertTrue(check_manifest(subdir, python=python),
                        sys.stderr.getvalue())

    def test_extra_ignore(self):
        from check_manifest import IgnoreList, check_manifest
        self._create_repo_with_code()
        self._add_to_vcs('unrelated.txt')
        ignore = IgnoreList().global_exclude('*.txt')
        self.assertTrue(check_manifest(extra_ignore=ignore),
                        sys.stderr.getvalue())

    def test_suggestions(self):
        from check_manifest import check_manifest
        self._create_repo_with_code()
        self._add_to_vcs('unrelated.txt')
        self.assertFalse(check_manifest())
        self.assertIn("missing from sdist:\n  unrelated.txt",
                      sys.stderr.getvalue())
        self.assertIn("suggested MANIFEST.in rules:\n  include *.txt",
                      sys.stdout.getvalue())

    def test_suggestions_create(self):
        from check_manifest import check_manifest
        self._create_repo_with_code()
        self._add_to_vcs('unrelated.txt')
        self.assertFalse(check_manifest(create=True))
        self.assertIn("missing from sdist:\n  unrelated.txt",
                      sys.stderr.getvalue())
        self.assertIn("suggested MANIFEST.in rules:\n  include *.txt",
                      sys.stdout.getvalue())
        self.assertIn("creating MANIFEST.in",
                      sys.stdout.getvalue())
        with open('MANIFEST.in') as f:
            self.assertEqual(f.read(), "include *.txt\n")

    def test_suggestions_update(self):
        from check_manifest import check_manifest
        self._create_repo_with_code()
        self._add_to_vcs('unrelated.txt')
        self._add_to_vcs('MANIFEST.in', '#tbd')
        self.assertFalse(check_manifest(update=True))
        self.assertIn("missing from sdist:\n  unrelated.txt",
                      sys.stderr.getvalue())
        self.assertIn("suggested MANIFEST.in rules:\n  include *.txt",
                      sys.stdout.getvalue())
        self.assertIn("updating MANIFEST.in",
                      sys.stdout.getvalue())
        with open('MANIFEST.in') as f:
            self.assertEqual(
                f.read(),
                "#tbd\n# added by check-manifest\ninclude *.txt\n")

    def test_suggestions_all_unknown_patterns(self):
        from check_manifest import check_manifest
        self._create_repo_with_code()
        self._add_to_vcs('.dunno-what-to-do-with-this')
        self.assertFalse(check_manifest(update=True))
        self.assertIn("missing from sdist:\n  .dunno-what-to-do-with-this",
                      sys.stderr.getvalue())
        self.assertIn(
            "don't know how to come up with rules matching any of the files, sorry!",
            sys.stdout.getvalue())

    def test_suggestions_some_unknown_patterns(self):
        from check_manifest import check_manifest
        self._create_repo_with_code()
        self._add_to_vcs('.dunno-what-to-do-with-this')
        self._add_to_vcs('unrelated.txt')
        self.assertFalse(check_manifest(update=True))
        self.assertIn(
            "don't know how to come up with rules matching\n  .dunno-what-to-do-with-this",
            sys.stdout.getvalue())
        self.assertIn("creating MANIFEST.in",
                      sys.stdout.getvalue())
        with open('MANIFEST.in') as f:
            self.assertEqual(f.read(), "include *.txt\n")

    def test_MANIFEST_in_does_not_need_to_be_added_to_be_considered(self):
        from check_manifest import check_manifest
        self._create_repo_with_code()
        self._add_to_vcs('unrelated.txt')
        with open('MANIFEST.in', 'w') as f:
            f.write("include *.txt\n")
        self.assertFalse(check_manifest())
        self.assertIn("missing from VCS:\n  MANIFEST.in", sys.stderr.getvalue())
        self.assertNotIn("missing from sdist", sys.stderr.getvalue())

    def test_setup_py_does_not_need_to_be_added_to_be_considered(self):
        from check_manifest import check_manifest
        self._create_repo_with_code(add_to_vcs=False)
        self._add_to_vcs('sample.py')
        self.assertFalse(check_manifest())
        self.assertIn("missing from VCS:\n  setup.py", sys.stderr.getvalue())
        self.assertNotIn("missing from sdist", sys.stderr.getvalue())

    def test_bad_ideas(self):
        from check_manifest import check_manifest
        self._create_repo_with_code()
        self._add_to_vcs('foo.egg-info')
        self._add_to_vcs('moo.mo')
        self.assertFalse(check_manifest())
        self.assertIn("you have foo.egg-info in source control!",
                      sys.stderr.getvalue())
        self.assertIn("this also applies to the following:\n  moo.mo",
                      sys.stderr.getvalue())

    def test_ignore_bad_ideas(self):
        from check_manifest import IgnoreList, check_manifest
        self._create_repo_with_code()
        with open('setup.cfg', 'w') as f:
            f.write('[check-manifest]\n'
                    'ignore =\n'
                    '  subdir/bar.egg-info\n'
                    'ignore-bad-ideas =\n'
                    '  subdir/bar.egg-info\n')
        self._add_to_vcs('foo.egg-info')
        self._add_to_vcs('moo.mo')
        self._add_to_vcs(os.path.join('subdir', 'bar.egg-info'))
        ignore = IgnoreList().global_exclude('*.mo')
        self.assertFalse(check_manifest(extra_ignore_bad_ideas=ignore))
        self.assertIn("you have foo.egg-info in source control!",
                      sys.stderr.getvalue())
        self.assertNotIn("moo.mo", sys.stderr.getvalue())
        self.assertNotIn("bar.egg-info", sys.stderr.getvalue())

    def test_missing_source_files(self):
        # https://github.com/mgedmin/check-manifest/issues/32
        from check_manifest import check_manifest
        self._create_repo_with_code()
        self._add_to_vcs('missing.py')
        os.unlink('missing.py')
        check_manifest()
        if self._vcs.command != 'bzr':
            # 'bzr ls' doesn't list files that were deleted but not
            # marked for deletion.  'bzr st' does, but it doesn't list
            # unmodified files.  Importing bzrlib and using the API to
            # get the file list we need is (a) complicated, (b) opens
            # the optional dependency can of worms, and (c) not viable
            # under Python 3 unless we fork off a Python 2 subprocess.
            # Manually combining 'bzr ls' and 'bzr st' outputs just to
            # produce a cosmetic warning message seems like overkill.
            self.assertIn(
                "some files listed as being under source control are missing:\n"
                "  missing.py",
                sys.stderr.getvalue())
