├── .github ├── FUNDING.yml ├── update_branch_protection_rules.py └── workflows │ └── build.yml ├── .gitignore ├── MANIFEST.in ├── .coveragerc ├── .pre-commit-hooks.yaml ├── LICENSE.rst ├── tox.ini ├── setup.cfg ├── Makefile ├── setup.py ├── README.rst ├── release.mk ├── CHANGES.rst ├── check_manifest.py └── tests.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: mgedmin 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | *.pyc 4 | __pycache__/ 5 | .tox/ 6 | *.egg-info/ 7 | dist/ 8 | tmp/ 9 | .coverage 10 | build/ 11 | tags 12 | coverage.xml 13 | .mypy_cache/ 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.py 3 | include *.yaml 4 | include tox.ini 5 | include Makefile 6 | include .gitignore 7 | include .coveragerc 8 | 9 | # added by check_manifest.py 10 | include *.mk 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = check_manifest 3 | 4 | [report] 5 | exclude_lines = 6 | pragma: nocover 7 | except ImportError: 8 | if __name__ == '__main__': 9 | if sys.platform == 'darwin': 10 | raise NotImplementedError 11 | [.][.][.]$ 12 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: check-manifest 2 | name: check-manifest 3 | description: Check the completeness of MANIFEST.in for Python packages. 4 | entry: check-manifest 5 | language: python 6 | language_version: python3 7 | pass_filenames: false 8 | always_run: true 9 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Marius Gedminas and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py310,py311,py312,py313,py314,pypy3,flake8 4 | 5 | [testenv] 6 | passenv = 7 | LANG 8 | LC_CTYPE 9 | LC_ALL 10 | MSYSTEM 11 | extras = test 12 | commands = 13 | pytest {posargs} 14 | 15 | [testenv:coverage] 16 | deps = 17 | coverage 18 | commands = 19 | coverage run -m pytest 20 | coverage report -m --fail-under=100 21 | 22 | [testenv:check-manifest] 23 | basepython = python3 24 | skip_install = true 25 | deps = check-manifest 26 | commands = check-manifest {posargs} 27 | 28 | [testenv:check-python-versions] 29 | basepython = python3 30 | skip_install = true 31 | deps = check-python-versions 32 | commands = check-python-versions {posargs} 33 | 34 | [testenv:flake8] 35 | basepython = python3 36 | skip_install = true 37 | deps = flake8 38 | commands = flake8 {posargs:check_manifest.py setup.py tests.py} 39 | 40 | [testenv:mypy] 41 | basepython = python3 42 | skip_install = true 43 | deps = mypy 44 | commands = mypy {posargs:check_manifest.py} 45 | 46 | [testenv:isort] 47 | basepython = python3 48 | skip_install = true 49 | deps = isort 50 | commands = isort {posargs: -c check_manifest.py setup.py tests.py} 51 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | norecursedirs = dist build tmp .* *.egg-info 3 | python_files = tests.py check_manifest.py 4 | addopts = --doctest-modules --ignore=setup.py 5 | 6 | [zest.releaser] 7 | python-file-with-version = check_manifest.py 8 | 9 | [flake8] 10 | ignore = E241,E501,E261,E126,E127,E128,E302,W503 11 | # E241: multiple spaces after ',' 12 | # E501: line too long 13 | # E261: at least two spaces before inline comment 14 | # E126: continuation line over-indented for hanging indent 15 | # E127: continuation line over-indented for visual indent 16 | # E128: continuation line under-indented for visual indent 17 | # E302: expected 2 blank lines, found 0 18 | # W503: line break before binary operator 19 | 20 | # empty [mypy] section required for mypy 0.800, see 21 | # https://github.com/python/mypy/issues/9940 22 | [mypy] 23 | strict = true 24 | 25 | # setuptools has no type hints 26 | [mypy-setuptools.command.egg_info] 27 | ignore_missing_imports = true 28 | 29 | # distutils got removed from Python 3.12, setuptools ships it now but w/o hints 30 | [mypy-distutils.text_file] 31 | ignore_missing_imports = true 32 | 33 | # zest.releaser has no type hints 34 | [mypy-zest.releaser.utils] 35 | ignore_missing_imports = true 36 | 37 | [isort] 38 | # from X import ( 39 | # a, 40 | # b, 41 | # ) 42 | multi_line_output = 3 43 | include_trailing_comma = true 44 | lines_after_imports = 2 45 | reverse_relative = true 46 | default_section = THIRDPARTY 47 | known_first_party = check_manifest 48 | # known_third_party = pytest, ... 49 | # skip = filename... 50 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: 3 | @echo "Nothing to build. Try 'make help' perhaps?" 4 | 5 | ##: Testing 6 | 7 | .PHONY: test 8 | test: ##: run tests 9 | tox -p auto 10 | 11 | .PHONY: check 12 | check: ##: run tests without skipping any 13 | # 'make check' is defined in release.mk and here's how you can override it 14 | define check_recipe = 15 | SKIP_NO_TESTS=1 tox 16 | endef 17 | 18 | .PHONY: coverage 19 | coverage: ##: measure test coverage 20 | tox -e coverage 21 | 22 | .PHONY: diff-cover 23 | diff-cover: coverage ##: show untested code in this branch 24 | coverage xml 25 | diff-cover coverage.xml 26 | 27 | ##: Linting 28 | 29 | .PHONY: lint 30 | lint: ##: run all linters 31 | tox -p auto -e flake8,mypy,isort,check-manifest,check-python-versions 32 | 33 | .PHONY: flake8 34 | flake8: ##: check for style problems 35 | tox -e flake8 36 | 37 | .PHONY: isort 38 | isort: ##: check for incorrect import ordering 39 | tox -e isort 40 | 41 | .PHONY: mypy 42 | mypy: ##: check for type errors 43 | tox -e mypy 44 | 45 | ##: GitHub maintenance 46 | 47 | .PHONY: update-github-branch-protection-rules 48 | 49 | update-github-branch-protection-rules: ##: update GitHub branch protection rules 50 | uv run --script .github/update_branch_protection_rules.py 51 | 52 | ##: Releasing 53 | 54 | .PHONY: distcheck 55 | distcheck: distcheck-self # also release.mk will add other checks 56 | 57 | .PHONY: distcheck-self 58 | distcheck-self: 59 | tox -e check-manifest 60 | 61 | .PHONY: releasechecklist 62 | releasechecklist: check-readme # also release.mk will add other checks 63 | 64 | .PHONY: check-readme 65 | check-readme: 66 | @rev_line=' rev: "'"`$(PYTHON) setup.py --version`"'"' && \ 67 | ! grep "rev: " README.rst | grep -qv "^$$rev_line$$" || { \ 68 | echo "README.rst doesn't specify $$rev_line"; \ 69 | echo "Please run make update-readme"; exit 1; } 70 | 71 | .PHONY: update-readme 72 | update-readme: 73 | sed -i -e 's/rev: ".*"/rev: "$(shell $(PYTHON) setup.py --version)"/' README.rst 74 | 75 | FILE_WITH_VERSION = check_manifest.py 76 | include release.mk 77 | HELP_INDENT = " " 78 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import ast 3 | import email.utils 4 | import os 5 | import re 6 | 7 | from setuptools import setup 8 | 9 | 10 | here = os.path.dirname(__file__) 11 | 12 | with open(os.path.join(here, 'README.rst')) as readme: 13 | with open(os.path.join(here, 'CHANGES.rst')) as changelog: 14 | long_description = readme.read() + '\n\n' + changelog.read() 15 | 16 | metadata = {} 17 | with open(os.path.join(here, 'check_manifest.py')) as f: 18 | rx = re.compile('(__version__|__author__|__url__|__licence__) = (.*)') 19 | for line in f: 20 | m = rx.match(line) 21 | if m: 22 | metadata[m.group(1)] = ast.literal_eval(m.group(2)) 23 | version = metadata['__version__'] 24 | author, author_email = email.utils.parseaddr(metadata['__author__']) 25 | url = metadata['__url__'] 26 | licence = metadata['__licence__'] 27 | 28 | setup( 29 | name='check-manifest', 30 | version=version, 31 | author=author, 32 | author_email=author_email, 33 | url=url, 34 | description='Check MANIFEST.in in a Python source package for completeness', 35 | long_description=long_description, 36 | long_description_content_type='text/x-rst', 37 | keywords=['distutils', 'setuptools', 'packaging', 'manifest', 'checker', 38 | 'linter'], 39 | classifiers=[ 40 | 'Development Status :: 4 - Beta', 41 | 'Environment :: Console', 42 | 'Intended Audience :: Developers', 43 | 'License :: OSI Approved :: MIT License', 44 | 'Operating System :: OS Independent', 45 | 'Programming Language :: Python', 46 | 'Programming Language :: Python :: 3', 47 | 'Programming Language :: Python :: 3.10', 48 | 'Programming Language :: Python :: 3.11', 49 | 'Programming Language :: Python :: 3.12', 50 | 'Programming Language :: Python :: 3.13', 51 | 'Programming Language :: Python :: 3.14', 52 | 'Programming Language :: Python :: Implementation :: CPython', 53 | 'Programming Language :: Python :: Implementation :: PyPy', 54 | ], 55 | license=licence, 56 | 57 | py_modules=['check_manifest'], 58 | zip_safe=False, 59 | python_requires=">=3.10", 60 | install_requires=[ 61 | 'build>=0.1', 62 | 'setuptools', 63 | 'tomli;python_version < "3.11"', 64 | ], 65 | extras_require={ 66 | 'test': [ 67 | 'pytest', 68 | 'wheel', 69 | ], 70 | }, 71 | entry_points={ 72 | 'console_scripts': [ 73 | 'check-manifest = check_manifest:main', 74 | ], 75 | 'zest.releaser.prereleaser.before': [ 76 | 'check-manifest = check_manifest:zest_releaser_check', 77 | ], 78 | }, 79 | ) 80 | -------------------------------------------------------------------------------- /.github/update_branch_protection_rules.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S uv run --script 2 | # /// script 3 | # dependencies = [ 4 | # "pyyaml", 5 | # ] 6 | # /// 7 | 8 | import argparse 9 | import pathlib 10 | import shlex 11 | import shutil 12 | import subprocess 13 | import sys 14 | 15 | import yaml 16 | 17 | 18 | REPO = "mgedmin/check-manifest" 19 | BRANCH = "master" 20 | 21 | 22 | here = pathlib.Path(__file__).parent 23 | 24 | 25 | def pretty_print_command(command: list[str], width: int | None = None) -> None: 26 | terminal_width = shutil.get_terminal_size().columns - 2 27 | if not width: 28 | width = terminal_width - 2 # leave space for the \ at the end 29 | 30 | words: list[str] = [] 31 | can_join = False 32 | for arg in command: 33 | if arg.startswith('-'): 34 | can_join = False 35 | if can_join: 36 | words[-1] += ' ' + shlex.quote(arg) 37 | can_join = False 38 | else: 39 | words.append(shlex.quote(arg)) 40 | if arg.startswith('-'): 41 | can_join = True 42 | 43 | indent = ' ' * len(words[0]) 44 | 45 | lines: list[str] = [] 46 | cur_line: list[str] = [] 47 | cur_width = 0 48 | for word in words: 49 | space_len = 1 if cur_line else 0 50 | if cur_width + space_len + len(word) <= width: 51 | # word fits 52 | cur_line.append(word) 53 | cur_width += space_len + len(word) 54 | else: 55 | # word does not fit, need to wrap 56 | cur_line.append('\\') 57 | lines.append(' '.join(cur_line)) 58 | cur_line = [indent, word] 59 | cur_width = len(indent) + 1 + len(word) 60 | if cur_line: 61 | lines.append(' '.join(cur_line)) 62 | 63 | # align the \ on the right 64 | longest_width = max( 65 | len(line) for line in lines if len(line) <= terminal_width 66 | ) 67 | lines[:-1] = [ 68 | line.rstrip('\\').ljust(longest_width) + '\\' 69 | for line in lines[:-1] 70 | ] 71 | 72 | print(*lines, sep='\n') 73 | 74 | 75 | def main() -> None: 76 | parser = argparse.ArgumentParser( 77 | description="Update GitHub branch protection rules" 78 | ) 79 | parser.add_argument( 80 | "-n", 81 | "--dry-run", 82 | action="store_true", 83 | help="Print the gh api command without executing it", 84 | ) 85 | args = parser.parse_args() 86 | 87 | with open(here / "workflows" / "build.yml") as fp: 88 | workflow = yaml.safe_load(fp) 89 | test_name_template = workflow['jobs']['build']['name'] 90 | lint_name_template = workflow['jobs']['lint']['name'] 91 | test_matrix = workflow['jobs']['build']['strategy']['matrix'] 92 | lint_matrix = workflow['jobs']['lint']['strategy']['matrix'] 93 | 94 | assert '${{ matrix.python-version }}' in test_name_template 95 | assert '${{ matrix.os }}' in test_name_template 96 | assert '${{ matrix.vcs }}' in test_name_template 97 | pythons = test_matrix['python-version'] 98 | oses = test_matrix['os'] 99 | vcses = test_matrix['vcs'] 100 | test_names = [ 101 | test_name_template 102 | .replace('${{ matrix.python-version }}', python_version) 103 | .replace('${{ matrix.os }}', os) 104 | .replace('${{ matrix.vcs }}', vcs) 105 | for python_version in pythons 106 | for os in oses 107 | for vcs in vcses 108 | ] 109 | 110 | assert lint_name_template == '${{ matrix.toxenv }}' 111 | lint_names = lint_matrix['toxenv'] 112 | 113 | check_names = test_names + lint_names 114 | 115 | command = [ 116 | 'gh', 117 | 'api', 118 | '-X', 'PUT', 119 | "-H", "Accept: application/vnd.github+json", 120 | "-H", "X-GitHub-Api-Version: 2022-11-28", 121 | ( 122 | f"/repos/{REPO}/branches/{BRANCH}/protection/" 123 | "required_status_checks/contexts" 124 | ), 125 | ] 126 | for name in check_names: 127 | command += ["-f", f"contexts[]={name}"] 128 | 129 | # Using a shorter width because I don't want multiple -f contexts[]=one -f 130 | # contexts[]=two args to be squished into each line near the end 131 | pretty_print_command(command, width=40) 132 | if not args.dry_run: 133 | rc = subprocess.run(command).returncode 134 | sys.exit(rc) 135 | 136 | 137 | if __name__ == "__main__": 138 | main() 139 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # NB: this name is used in the status badge 2 | name: build 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: 9 | branches: 10 | - master 11 | workflow_dispatch: 12 | schedule: 13 | - cron: "0 5 * * 6" # 5:00 UTC every Saturday 14 | 15 | jobs: 16 | build: 17 | name: Python ${{ matrix.python-version }} on ${{ matrix.os }}, ${{ matrix.vcs }} 18 | runs-on: ${{ matrix.os }} 19 | 20 | strategy: 21 | fail-fast: true 22 | matrix: 23 | python-version: 24 | - "3.10" 25 | - "3.11" 26 | - "3.12" 27 | - "3.13" 28 | - "3.14" 29 | - "pypy3.10" 30 | os: 31 | - ubuntu-22.04 # ubuntu-latest aka ubuntu-24.04 has busted bzr 32 | - windows-latest 33 | vcs: 34 | - bzr 35 | - git 36 | - hg 37 | - svn 38 | 39 | steps: 40 | - name: Install OS-level dependencies (Linux) 41 | # NB: at some point I'll want to switch from legacy Bazaar (apt package 42 | # bzr) to Breezy (apt package brz). They're both available in 43 | # ubuntu-18.04 and ubuntu-20.04. 44 | if: runner.os == 'Linux' 45 | run: | 46 | sudo apt-get install -y bzr git mercurial subversion 47 | 48 | - name: Install OS-level dependencies (Windows) 49 | if: runner.os == 'Windows' 50 | run: | 51 | choco install --no-progress bzr hg svn 52 | 53 | - name: Git clone 54 | uses: actions/checkout@v4 55 | 56 | - name: Set up Python ${{ matrix.python-version }} 57 | uses: actions/setup-python@v5 58 | with: 59 | python-version: "${{ matrix.python-version }}" 60 | cache: pip 61 | cache-dependency-path: | 62 | setup.py 63 | 64 | - name: Install Python dependencies 65 | run: | 66 | python -m pip install -U pip 67 | python -m pip install -U setuptools wheel 68 | python -m pip install -U coverage pytest flake8 69 | python -m pip install -e .[test] 70 | 71 | - name: Run tests 72 | shell: bash 73 | run: | 74 | # It is utter nonsense that I have to do this 75 | case "$RUNNER_OS" in 76 | Windows) 77 | PATH=$PATH:"/c/Program Files (x86)/Bazaar" 78 | PATH=$PATH:"/c/Program Files (x86)/Subversion/bin" 79 | PATH=$PATH:"/c/Program Files/Mercurial" 80 | ;; 81 | esac 82 | 83 | set -x 84 | 85 | git --version 86 | hg --version 87 | svn --version 88 | bzr --version 89 | 90 | coverage run -m pytest 91 | env: 92 | SKIP_NO_TESTS: "1" 93 | FORCE_TEST_VCS: ${{ matrix.vcs }} 94 | 95 | - name: Check test coverage 96 | run: | 97 | coverage xml 98 | coverage report -m --fail-under=${{ runner.os == 'Windows' && '98' || matrix.vcs == 'bzr' && 99 || 100 }} 99 | 100 | - name: Run check-manifest on itself 101 | run: python check_manifest.py 102 | 103 | - name: Report to coveralls 104 | uses: coverallsapp/github-action@v2 105 | with: 106 | file: coverage.xml 107 | 108 | lint: 109 | name: ${{ matrix.toxenv }} 110 | runs-on: ubuntu-latest 111 | 112 | strategy: 113 | matrix: 114 | toxenv: 115 | - flake8 116 | - mypy 117 | - isort 118 | - check-manifest 119 | - check-python-versions 120 | 121 | steps: 122 | - name: Git clone 123 | uses: actions/checkout@v4 124 | 125 | - name: Set up Python ${{ env.default_python || '3.12' }} 126 | uses: actions/setup-python@v5 127 | with: 128 | python-version: "${{ env.default_python || '3.12' }}" 129 | 130 | - name: Pip cache 131 | uses: actions/cache@v4 132 | with: 133 | path: ~/.cache/pip 134 | key: ${{ runner.os }}-pip-${{ matrix.toxenv }}-${{ hashFiles('tox.ini', 'setup.py') }} 135 | restore-keys: | 136 | ${{ runner.os }}-pip-${{ matrix.toxenv }}- 137 | ${{ runner.os }}-pip- 138 | 139 | - name: Install dependencies 140 | run: | 141 | python -m pip install -U pip 142 | python -m pip install -U setuptools wheel 143 | python -m pip install -U tox 144 | 145 | - name: Run ${{ matrix.toxenv }} 146 | run: python -m tox -e ${{ matrix.toxenv }} 147 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | check-manifest 2 | ============== 3 | 4 | |buildstatus|_ |coverage|_ 5 | 6 | Are you a Python developer? Have you uploaded packages to the Python Package 7 | Index? Have you accidentally uploaded *broken* packages with some files 8 | missing? If so, check-manifest is for you. 9 | 10 | Quick start 11 | ----------- 12 | 13 | :: 14 | 15 | $ pip install check-manifest 16 | 17 | $ cd ~/src/mygreatpackage 18 | $ check-manifest 19 | 20 | You can ask the script to help you update your MANIFEST.in:: 21 | 22 | $ check-manifest -u -v 23 | listing source files under version control: 6 files and directories 24 | building an sdist: check-manifest-0.7.tar.gz: 4 files and directories 25 | lists of files in version control and sdist do not match! 26 | missing from sdist: 27 | tests.py 28 | tox.ini 29 | suggested MANIFEST.in rules: 30 | include *.py 31 | include tox.ini 32 | updating MANIFEST.in 33 | 34 | $ cat MANIFEST.in 35 | include *.rst 36 | 37 | # added by check_manifest.py 38 | include *.py 39 | include tox.ini 40 | 41 | 42 | Command-line reference 43 | ---------------------- 44 | 45 | :: 46 | 47 | $ check-manifest --help 48 | usage: check-manifest [-h] [--version] [-v] [-c] [-u] [-p PYTHON] 49 | [--ignore patterns] 50 | [source_tree] 51 | 52 | Check a Python MANIFEST.in file for completeness 53 | 54 | positional arguments: 55 | source_tree location for the source tree (default: .) 56 | 57 | optional arguments: 58 | -h, --help show this help message and exit 59 | --version show program's version number and exit 60 | -v, --verbose more verbose output (default: False) 61 | -c, --create create a MANIFEST.in if missing (default: False) 62 | -u, --update append suggestions to MANIFEST.in (implies --create) 63 | (default: False) 64 | -p PYTHON, --python PYTHON 65 | use this Python interpreter for running setup.py sdist 66 | (default: /home/mg/.venv/bin/python) 67 | --ignore patterns ignore files/directories matching these comma- 68 | separated patterns (default: None) 69 | --ignore-bad-ideas patterns 70 | ignore bad idea files/directories matching these 71 | comma-separated patterns (default: []) 72 | 73 | 74 | Configuration 75 | ------------- 76 | 77 | You can configure check-manifest to ignore certain file patterns using 78 | a ``[tool.check-manifest]`` section in your ``pyproject.toml`` file or 79 | a ``[check-manifest]`` section in either ``setup.cfg`` or 80 | ``tox.ini``. Examples:: 81 | 82 | # pyproject.toml 83 | [tool.check-manifest] 84 | ignore = [".travis.yml"] 85 | 86 | # setup.cfg or tox.ini 87 | [check-manifest] 88 | ignore = 89 | .travis.yml 90 | 91 | Note that lists are newline separated in the ``setup.cfg`` and 92 | ``tox.ini`` files. 93 | 94 | The following options are recognized: 95 | 96 | ignore 97 | A list of filename patterns that will be ignored by check-manifest. 98 | Use this if you want to keep files in your version control system 99 | that shouldn't be included in your source distributions. The 100 | default ignore list is :: 101 | 102 | PKG-INFO 103 | *.egg-info 104 | *.egg-info/* 105 | setup.cfg 106 | .hgtags 107 | .hgsigs 108 | .hgignore 109 | .gitignore 110 | .bzrignore 111 | .gitattributes 112 | .github/* 113 | .travis.yml 114 | Jenkinsfile 115 | *.mo 116 | 117 | ignore-default-rules 118 | If set to ``true``, your ``ignore`` patterns will replace the default 119 | ignore list instead of adding to it. 120 | 121 | ignore-bad-ideas 122 | A list of filename patterns that will be ignored by 123 | check-manifest's generated files check. Use this if you want to 124 | keep generated files in your version control system, even though 125 | it is generally a bad idea. 126 | 127 | 128 | Version control integration 129 | --------------------------- 130 | 131 | With `pre-commit `_, check-manifest can be part of your 132 | git-workflow. Add the following to your ``.pre-commit-config.yaml``. 133 | 134 | .. code-block:: yaml 135 | 136 | repos: 137 | - repo: https://github.com/mgedmin/check-manifest 138 | rev: "0.51" 139 | hooks: 140 | - id: check-manifest 141 | 142 | If you are running pre-commit without a network, you can utilize 143 | ``args: [--no-build-isolation]`` to prevent a ``pip install`` reaching out to 144 | PyPI. This makes ``python -m build`` ignore your ``build-system.requires``, 145 | so you'll want to list them all in ``additional_dependencies``. 146 | 147 | .. code-block:: yaml 148 | 149 | repos: 150 | - repo: https://github.com/mgedmin/check-manifest 151 | rev: "0.51" 152 | hooks: 153 | - id: check-manifest 154 | args: [--no-build-isolation] 155 | additional_dependencies: [setuptools, wheel, setuptools-scm] 156 | 157 | 158 | .. |buildstatus| image:: https://github.com/mgedmin/check-manifest/actions/workflows/build.yml/badge.svg?branch=master 159 | .. _buildstatus: https://github.com/mgedmin/check-manifest/actions 160 | 161 | .. |coverage| image:: https://coveralls.io/repos/mgedmin/check-manifest/badge.svg?branch=master 162 | .. _coverage: https://coveralls.io/r/mgedmin/check-manifest 163 | -------------------------------------------------------------------------------- /release.mk: -------------------------------------------------------------------------------- 1 | # release.mk version 2.2.3 (2024-10-10) 2 | # 3 | # Helpful Makefile rules for releasing Python packages. 4 | # https://github.com/mgedmin/python-project-skel 5 | 6 | # You might want to change these 7 | FILE_WITH_VERSION ?= setup.py 8 | FILE_WITH_CHANGELOG ?= CHANGES.rst 9 | CHANGELOG_DATE_FORMAT ?= %Y-%m-%d 10 | CHANGELOG_FORMAT ?= $(changelog_ver) ($(changelog_date)) 11 | DISTCHECK_DIFF_OPTS ?= $(DISTCHECK_DIFF_DEFAULT_OPTS) 12 | 13 | # These should be fine 14 | PYTHON ?= python3 15 | PYPI_PUBLISH ?= rm -rf dist && $(PYTHON) -m build && twine check dist/* && twine upload dist/* 16 | LATEST_RELEASE_MK_URL = https://raw.githubusercontent.com/mgedmin/python-project-skel/master/release.mk 17 | DISTCHECK_DIFF_DEFAULT_OPTS = -x PKG-INFO -x setup.cfg -x '*.egg-info' -x .github -I'^\#' 18 | 19 | # These should be fine, as long as you use Git 20 | VCS_GET_LATEST ?= git pull 21 | VCS_STATUS ?= git status --porcelain 22 | VCS_EXPORT ?= git archive --format=tar --prefix=tmp/tree/ HEAD | tar -xf - 23 | VCS_TAG ?= git tag -s $(changelog_ver) -m \"Release $(changelog_ver)\" 24 | VCS_COMMIT_AND_PUSH ?= git commit -av -m "Post-release version bump" && git push && git push --tags 25 | 26 | # These are internal implementation details 27 | changelog_ver = `$(PYTHON) setup.py --version` 28 | changelog_date = `LC_ALL=C date +'$(CHANGELOG_DATE_FORMAT)'` 29 | 30 | # Tweaking the look of 'make help'; most of these are awk literals and need the quotes 31 | HELP_INDENT = "" 32 | HELP_PREFIX = "make " 33 | HELP_WIDTH = 24 34 | HELP_SEPARATOR = " \# " 35 | HELP_SECTION_SEP = "\n" 36 | 37 | .PHONY: help 38 | help: 39 | @grep -Eh -e '^[a-zA-Z0-9_ -]+:.*?##: .*$$' -e '^##:' $(MAKEFILE_LIST) \ 40 | | awk 'BEGIN {FS = "(^|:[^#]*)##: "; section=""}; \ 41 | /^##:/ {printf "%s%s\n%s", section, $$2, $(HELP_SECTION_SEP); section=$(HELP_SECTION_SEP)} \ 42 | /^[^#]/ {printf "%s\033[36m%-$(HELP_WIDTH)s\033[0m%s%s\n", \ 43 | $(HELP_INDENT), $(HELP_PREFIX) $$1, $(HELP_SEPARATOR), $$2}' 44 | 45 | .PHONY: dist 46 | dist: 47 | $(PYTHON) -m build 48 | 49 | # Provide a default 'make check' to be the same as 'make test', since that's 50 | # what 80% of my projects use, but make it possible to override. Now 51 | # overriding Make rules is painful, so instead of a regular rule definition 52 | # you'll have to override the check_recipe macro. 53 | .PHONY: check 54 | check: 55 | $(check_recipe) 56 | 57 | ifndef check_recipe 58 | define check_recipe = 59 | @$(MAKE) test 60 | endef 61 | endif 62 | 63 | .PHONY: distcheck 64 | distcheck: distcheck-vcs distcheck-sdist 65 | 66 | .PHONY: distcheck-vcs 67 | distcheck-vcs: 68 | ifndef FORCE 69 | # Bit of a chicken-and-egg here, but if the tree is unclean, make 70 | # distcheck-sdist will fail. 71 | @test -z "`$(VCS_STATUS) 2>&1`" || { echo; echo "Your working tree is not clean:" 1>&2; $(VCS_STATUS) 1>&2; exit 1; } 72 | endif 73 | 74 | # NB: do not use $(MAKE) in rules with multiple shell commands joined by && 75 | # because then make -n distcheck will actually run those instead of just 76 | # printing what it does 77 | 78 | # TBH this could (and probably should) be replaced by check-manifest 79 | 80 | .PHONY: distcheck-sdist 81 | distcheck-sdist: dist 82 | pkg_and_version=`$(PYTHON) setup.py --name|tr A-Z.- a-z__`-`$(PYTHON) setup.py --version` && \ 83 | rm -rf tmp && \ 84 | mkdir tmp && \ 85 | $(VCS_EXPORT) && \ 86 | cd tmp && \ 87 | tar -xzf ../dist/$$pkg_and_version.tar.gz && \ 88 | diff -ur $$pkg_and_version tree $(DISTCHECK_DIFF_OPTS) && \ 89 | cd $$pkg_and_version && \ 90 | make dist check && \ 91 | cd .. && \ 92 | mkdir one two && \ 93 | cd one && \ 94 | tar -xzf ../../dist/$$pkg_and_version.tar.gz && \ 95 | cd ../two/ && \ 96 | tar -xzf ../$$pkg_and_version/dist/$$pkg_and_version.tar.gz && \ 97 | cd .. && \ 98 | diff -ur one two -x SOURCES.txt -I'^#:' && \ 99 | cd .. && \ 100 | rm -rf tmp && \ 101 | echo "sdist seems to be ok" 102 | 103 | .PHONY: check-latest-rules 104 | check-latest-rules: 105 | ifndef FORCE 106 | @curl -s $(LATEST_RELEASE_MK_URL) | cmp -s release.mk || { printf "\nYour release.mk does not match the latest version at\n$(LATEST_RELEASE_MK_URL)\n\n" 1>&2; exit 1; } 107 | endif 108 | 109 | .PHONY: check-latest-version 110 | check-latest-version: 111 | $(VCS_GET_LATEST) 112 | 113 | .PHONY: check-version-number 114 | check-version-number: 115 | @$(PYTHON) setup.py --version | grep -qv dev || { \ 116 | echo "Please remove the 'dev' suffix from the version number in $(FILE_WITH_VERSION)"; exit 1; } 117 | 118 | .PHONY: check-long-description 119 | check-long-description: 120 | @$(PYTHON) setup.py --long-description | rst2html --exit-status=2 > /dev/null 121 | 122 | .PHONY: check-changelog 123 | check-changelog: 124 | @ver_and_date="$(CHANGELOG_FORMAT)" && \ 125 | grep -q "^$$ver_and_date$$" $(FILE_WITH_CHANGELOG) || { \ 126 | echo "$(FILE_WITH_CHANGELOG) has no entry for $$ver_and_date"; exit 1; } 127 | 128 | 129 | # NB: the Makefile that includes release.mk may want to add additional 130 | # dependencies to the releasechecklist target, but I want 'make distcheck' to 131 | # happen last, so that's why I put it into the recipe and not at the end of the 132 | # list of dependencies. 133 | 134 | .PHONY: releasechecklist 135 | releasechecklist: check-latest-rules check-latest-version check-version-number check-long-description check-changelog 136 | $(MAKE) distcheck 137 | 138 | .PHONY: release 139 | release: releasechecklist do-release ##: prepare a new PyPI release 140 | 141 | .PHONY: do-release 142 | do-release: 143 | $(release_recipe) 144 | 145 | define default_release_recipe_publish_and_tag = 146 | # I'm chicken so I won't actually do these things yet 147 | @echo "Please run" 148 | @echo 149 | @echo " $(PYPI_PUBLISH)" 150 | @echo " $(VCS_TAG)" 151 | @echo 152 | endef 153 | define default_release_recipe_increment_and_push = 154 | @echo "Please increment the version number in $(FILE_WITH_VERSION)" 155 | @echo "and add a new empty entry at the top of the changelog in $(FILE_WITH_CHANGELOG), then" 156 | @echo 157 | @echo ' $(VCS_COMMIT_AND_PUSH)' 158 | @echo 159 | endef 160 | ifndef release_recipe 161 | define release_recipe = 162 | $(default_release_recipe_publish_and_tag) 163 | $(default_release_recipe_increment_and_push) 164 | endef 165 | endif 166 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 5 | 0.52 (unreleased) 6 | ----------------- 7 | 8 | - Drop Python 3.8 and 3.9 support. 9 | 10 | 11 | 0.51 (2025-10-15) 12 | ----------------- 13 | 14 | - Add Python 3.14 support. 15 | - Drop Python 3.7 support. 16 | 17 | 18 | 0.50 (2024-10-09) 19 | ----------------- 20 | 21 | - Add Python 3.12 and 3.13 support. 22 | 23 | 24 | 0.49 (2022-12-05) 25 | ----------------- 26 | 27 | - Add Python 3.11 support. 28 | 29 | - Drop Python 3.6 support. 30 | 31 | - Exclude more common dev/test files. 32 | 33 | 34 | 0.48 (2022-03-13) 35 | ----------------- 36 | 37 | - Add Python 3.10 support. 38 | 39 | - Switch to tomli instead of toml, after hearing about PEP-680. tomli will be 40 | included in the Python 3.11 standard library as tomllib, while toml is 41 | apparently unmaintained. 42 | 43 | - Fix submodule support when ``.gitmodules`` exists in a subdirectory 44 | (`#153 `_). 45 | Note that this reverts a fix for `#124 46 | `_: git versions before 47 | 2.11 are no longer supported. 48 | 49 | 50 | 0.47 (2021-09-22) 51 | ----------------- 52 | 53 | - Fix ``setuptools_scm`` workaround for packages with dashes in the name 54 | (`#145 `_). 55 | 56 | 57 | 0.46 (2021-01-04) 58 | ----------------- 59 | 60 | - The `pre-commit `__ hook now always uses Python 3. 61 | 62 | 63 | 0.45 (2020-10-31) 64 | ----------------- 65 | 66 | - Add Python 3.9 support. 67 | 68 | - Drop Python 3.5 support. 69 | 70 | - Switch from ``pep517`` to `python-build `__ ( 71 | `#128 `__). 72 | 73 | - Add ``--no-build-isolation`` option so check-manifest can succeed building 74 | pep517-based distributions without an internet connection. With 75 | ``--no-build-isolation``, you must preinstall the ``build-system.requires`` 76 | beforehand. (`#128 `__). 77 | 78 | 79 | 0.44 (2020-10-03) 80 | ----------------- 81 | 82 | - Try to avoid passing ``--recurse-submodules`` to ``git ls`` if the project 83 | doesn't use git submodules (i.e. doesn't have a ``.gitsubmodules`` file). 84 | This should make check-manifest work again with older git versions, as long 85 | as you don't use submodules (`#124 86 | `__). 87 | 88 | 89 | 0.43 (2020-09-21) 90 | ----------------- 91 | 92 | - Fix collecting files versioned by ``git`` when a project has submodules and 93 | ``GIT_INDEX_FILE`` is set. This bug was triggered when ``check-manifest`` 94 | was run as part of a git hook ( 95 | `#122 `__, 96 | `#123 `__). 97 | 98 | Note: check-manifest 0.43 requires ``git`` version 2.11 or later. 99 | 100 | 101 | 0.42 (2020-05-03) 102 | ----------------- 103 | 104 | - Added ``-q``/``--quiet`` command line argument. This will reduce the verbosity 105 | of informational output, e.g. for use in a CI pipeline. 106 | 107 | - Rewrote the ignore logic to be more compatible with setuptools. This might 108 | have introduced some regressions, so please file bugs! One side effect of 109 | this is that ``--ignore`` (or the ``ignore`` setting in the config file) 110 | is now handled the same way as ``global-exclude`` in a ``MANIFEST.in``, which 111 | means: 112 | 113 | - it's matched anywhere in the file tree 114 | - it's ignored if it matches a directory 115 | 116 | You can ignore directories only by ignoring every file inside it. You 117 | can use ``--ignore=dir/**`` to do that. 118 | 119 | This decision is not cast in stone: I may in the future change the 120 | handling of ``--ignore`` to match files and directories, because there's no 121 | reason it has to be setuptools-compatible. 122 | 123 | - Drop Python 2.7 support. 124 | 125 | 126 | 0.41 (2020-02-25) 127 | ----------------- 128 | 129 | - Support `PEP 517`_, i.e. packages using pyproject.toml instead of a setup.py 130 | (`#105 `_). 131 | 132 | .. _PEP 517: https://www.python.org/dev/peps/pep-0517/ 133 | 134 | - Ignore subcommand stderr unless the subcommand fails. This avoids treating 135 | warning messages as filenames. (`#110 136 | `_.) 137 | 138 | 139 | 0.40 (2019-10-15) 140 | ----------------- 141 | 142 | - Add Python 3.8 support. 143 | 144 | 145 | 0.39 (2019-06-06) 146 | ----------------- 147 | 148 | - You can now use check-manifest as a `pre-commit `_ 149 | hook (`#100 `__). 150 | 151 | 152 | 0.38 (2019-04-23) 153 | ----------------- 154 | 155 | - Add Python 3.7 support. 156 | 157 | - Drop Python 3.4 support. 158 | 159 | - Added GitHub templates to default ignore patterns. 160 | 161 | - Added reading check-manifest config out of ``tox.ini`` or ``pyproject.toml``. 162 | 163 | 164 | 0.37 (2018-04-12) 165 | ----------------- 166 | 167 | - Drop Python 3.3 support. 168 | 169 | - Support packages using ``setuptools_scm`` 170 | (`#68 `__). 171 | 172 | Note that ``setuptools_scm`` usually makes MANIFEST.in files obsolete. 173 | Having one is helpful only if you intend to build an sdist and then use that 174 | sdist to perform further builds, instead of building from a source checkout. 175 | 176 | 177 | 0.36 (2017-11-21) 178 | ----------------- 179 | 180 | - Handle empty VCS repositories more gracefully 181 | (`#84 `__). 182 | 183 | 184 | 0.35 (2017-01-30) 185 | ----------------- 186 | 187 | - Python 3.6 support. 188 | 189 | 190 | 0.34 (2016-09-14) 191 | ----------------- 192 | 193 | - Fix WindowsError due to presence of read-only files 194 | (`#74 `__). 195 | 196 | 197 | 0.33 (2016-08-29) 198 | ----------------- 199 | 200 | - Fix WindowsError due to git submodules in subdirectories 201 | (`#73 `__). 202 | Contributed by Loren Gordon. 203 | 204 | 205 | 0.32 (2016-08-16) 206 | ----------------- 207 | 208 | * New config/command line option to ignore bad ideas (ignore-bad-ideas) 209 | (`issue #67 `__). 210 | Contributed by Brecht Machiels. 211 | 212 | * Files named ``.hgsigs`` are ignored by default. Contributed by Jakub Wilk. 213 | 214 | 215 | 0.31 (2016-01-28) 216 | ----------------- 217 | 218 | - Drop Python 3.2 support. 219 | 220 | - Ignore commented-out lines in MANIFEST.in 221 | (`issue #66 `__). 222 | 223 | 224 | 0.30 (2015-12-10) 225 | ----------------- 226 | 227 | * Support git submodules 228 | (`issue #61 `__). 229 | 230 | * Revert the zc.buildout support hack from 0.26 because it causes breakage 231 | (`issue #56 `__). 232 | 233 | * Improve non-ASCII filename handling with Bazaar on Windows. 234 | 235 | 236 | 0.29 (2015-11-21) 237 | ----------------- 238 | 239 | * Fix --python with just a command name, to be found in path (`issue #57 240 | `__). 241 | 242 | 243 | 0.28 (2015-11-11) 244 | ----------------- 245 | 246 | * Fix detection of git repositories when .git is a file and not a directory (`#53 247 | `__). One situation 248 | where this occurs is when the project is checked out as a git submodule. 249 | 250 | * Apply ignore patterns in subdirectories too (`#54 251 | `__). 252 | 253 | 254 | 0.27 (2015-11-02) 255 | ----------------- 256 | 257 | * Fix utter breakage on Windows, introduced in 0.26 (`issue #52 258 | `__). 259 | (The bug -- clearing the environment unnecessarily -- could probably 260 | also cause locale-related problems on other OSes.) 261 | 262 | 263 | 0.26 (2015-10-30) 264 | ----------------- 265 | 266 | * Do not complain about missing ``.gitattributes`` file (`PR #50 267 | `__). 268 | 269 | * Normalize unicode representation and case of filenames. (`issue #47 270 | `__). 271 | 272 | * Support installation via zc.buildout better (`issue #35 273 | `__). 274 | 275 | * Drop Python 2.6 support because one of our test dependencies (mock) dropped 276 | it. This also means we no longer use environment markers. 277 | 278 | 279 | 0.25 (2015-05-27) 280 | ----------------- 281 | 282 | * Stop dynamic computation of install_requires in setup.py: this doesn't work 283 | well in the presence of the pip 7 wheel cache. Use PEP-426 environment 284 | markers instead (this means we now require setuptools >= 0.7, and pip >= 6.0, 285 | and wheel >= 0.24). 286 | 287 | 288 | 0.24 (2015-03-26) 289 | ----------------- 290 | 291 | * Make sure ``setup.py`` not being added to the VCS doesn't cause 292 | hard-to-understand errors (`issue #46 293 | `__). 294 | 295 | 296 | 0.23 (2015-02-12) 297 | ----------------- 298 | 299 | * More reliable svn status parsing; now handles svn externals (`issue #45 300 | `__). 301 | 302 | * The test suite now skips tests for version control systems that aren't 303 | installed (`issue #42 304 | `__). 305 | 306 | 307 | 0.22 (2014-12-23) 308 | ----------------- 309 | 310 | * More terse output by default; use the new ``-v`` (``--verbose``) flag 311 | to see all the details. 312 | 313 | * Warn the user if MANIFEST.in is missing (`issue #31 314 | `__). 315 | 316 | * Fix IOError when files listed under version control are missing (`issue #32 317 | `__). 318 | 319 | * Improved wording of the match/do not match messages (`issue #34 320 | `__). 321 | 322 | * Handle a relative --python path (`issue #36 323 | `__). 324 | 325 | * Warn about leading and trailing slashes in MANIFEST.in (`issue #37 326 | `__). 327 | 328 | * Ignore .travis.yml by default (`issue #39 329 | `__). 330 | 331 | * Suggest a rule for Makefile found deeper in the source tree. 332 | 333 | 334 | 0.21 (2014-06-13) 335 | ----------------- 336 | 337 | * Don't drop setup.cfg when copying version-controlled files into a clean 338 | temporary directory (`issue #29 339 | `__). 340 | 341 | 342 | 0.20 (2014-05-14) 343 | ----------------- 344 | 345 | * Restore warning about files included in the sdist but not added to the 346 | version control system (`issue #27 347 | `__). 348 | 349 | * Fix ``check-manifest relative/pathname`` (`issue #28 350 | `__). 351 | 352 | 353 | 0.19 (2014-02-09) 354 | ----------------- 355 | 356 | * More correct MANIFEST.in parsing for exclusion rules. 357 | * Some effort was expended towards Windows compatibility. 358 | * Handles non-ASCII filenames, as long as they're valid in your locale 359 | (`issue #23 `__, 360 | `#25 `__). 361 | 362 | 363 | 0.18 (2014-01-30) 364 | ----------------- 365 | 366 | * Friendlier error message when an external command cannot be found 367 | (`issue #21 `__). 368 | * Add suggestion pattern for `.coveragerc`. 369 | * Python 2.6 support 370 | (`issue #22 `__). 371 | 372 | 373 | 0.17 (2013-10-10) 374 | ----------------- 375 | 376 | * Read the existing MANIFEST.in file for files to ignore 377 | (`issue #19 `__). 378 | 379 | 380 | 0.16 (2013-10-01) 381 | ----------------- 382 | 383 | * Fix Subversion status parsing in the presence of svn usernames longer than 12 384 | characters (`issue #18 `__). 385 | 386 | 387 | 0.15 (2013-09-20) 388 | ----------------- 389 | 390 | * Normalize the paths of all files, avoiding some duplicate misses of 391 | directories. (`issue #16 `__). 392 | [maurits] 393 | 394 | 395 | 0.14 (2013-08-28) 396 | ----------------- 397 | 398 | * Supports packages that do not live in the root of a version control 399 | repository (`issue #15 `__). 400 | 401 | * More reliable svn support: detect files that have been added but not 402 | committed (or committed but not updated). 403 | 404 | * Licence changed from GPL (v2 or later) to MIT 405 | (`issue #12 `__). 406 | 407 | 408 | 0.13 (2013-07-31) 409 | ----------------- 410 | 411 | * New command line option: --ignore 412 | (`issue #11 `__). 413 | Contributed by Steven Myint. 414 | 415 | * New command line option: -p, --python. Defaults to the Python you used to 416 | run check-manifest. Fixes issues with packages that require Python 3 to run 417 | setup.py (`issue #13 `__). 418 | 419 | 420 | 0.12 (2013-05-15) 421 | ----------------- 422 | 423 | * Add suggestion pattern for `Makefile`. 424 | 425 | * More generic suggestion patterns, should cover almost anything. 426 | 427 | * zest.releaser_ integration: skip check-release for non-Python packages 428 | (`issue #9 `__). 429 | 430 | 431 | 0.11 (2013-03-20) 432 | ----------------- 433 | 434 | * Make sure ``MANIFEST.in`` is not ignored even if it hasn't been added to the 435 | VCS yet (`issue #7 `__). 436 | 437 | 438 | 0.10 (2013-03-17) 439 | ----------------- 440 | 441 | * ``check-manifest --version`` now prints the version number. 442 | 443 | * Don't apologize for not adding rules for directories (especially after adding 444 | rules that include files inside that directory). 445 | 446 | * Python 3 support contributed by Steven Myint. 447 | 448 | * Default ignore patterns can be configured in ``setup.cfg`` 449 | (`issue #3 `_). 450 | 451 | 452 | 0.9 (2013-03-06) 453 | ---------------- 454 | 455 | * Add suggestion pattern for `.travis.yml`. 456 | 457 | * When check-manifest -u (or -c) doesn't know how to write a rule matching a 458 | particular file, it now apologizes explicitly. 459 | 460 | * Copy the source tree to a temporary directory before running python setup.py 461 | sdist to avoid side effects from setuptools plugins or stale 462 | \*.egg-info/SOURCES.txt files 463 | (`issue #1 `_). 464 | 465 | * Warn if `*.egg-info` or `*.mo` is actually checked into the VCS. 466 | 467 | * Don't complain if `*.mo` files are present in the sdist but not in the VCS 468 | (`issue #2 `_). 469 | 470 | 471 | 0.8 (2013-03-06) 472 | ---------------- 473 | 474 | * Entry point for zest.releaser_. If you install both zest.releaser and 475 | check-manifest, you will be asked if you want to check your manifest during 476 | ``fullrelease``. 477 | 478 | .. _zest.releaser: https://pypi.python.org/pypi/zest.releaser 479 | 480 | 481 | 0.7 (2013-03-05) 482 | ---------------- 483 | 484 | * First release available from the Python Package Index. 485 | 486 | * Moved from https://gist.github.com/4277075 487 | to https://github.com/mgedmin/check-manifest 488 | 489 | * Added README.rst, CHANGES.rst, setup.py, tox.ini (but no real tests yet), 490 | MANIFEST.in, and a Makefile. 491 | 492 | * Fixed a bug in error reporting (when setup.py failed, the user would get 493 | `TypeError: descriptor '__init__' requires an 'exceptions.Exception' object 494 | but received a 'str'`). 495 | -------------------------------------------------------------------------------- /check_manifest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Check the MANIFEST.in file in a Python source package for completeness. 3 | 4 | This script works by building a source distribution archive (by running 5 | setup.py sdist), then checking the file list in the archive against the 6 | file list in version control (Subversion, Git, Mercurial, Bazaar are 7 | supported). 8 | 9 | Since the first check can fail to catch missing MANIFEST.in entries when 10 | you've got the right setuptools version control system support plugins 11 | installed, the script copies all the versioned files into a temporary 12 | directory and builds the source distribution again. This also avoids issues 13 | with stale egg-info/SOURCES.txt files that may cause files not mentioned in 14 | MANIFEST.in to be included nevertheless. 15 | """ 16 | 17 | from __future__ import annotations 18 | 19 | import argparse 20 | import codecs 21 | import configparser 22 | import fnmatch 23 | import locale 24 | import os 25 | import posixpath 26 | import re 27 | import shutil 28 | import stat 29 | import subprocess 30 | import sys 31 | import tarfile 32 | import tempfile 33 | import unicodedata 34 | import zipfile 35 | from contextlib import contextmanager 36 | from types import TracebackType 37 | from typing import ( 38 | Any, 39 | Callable, 40 | Collection, 41 | Generator, 42 | Iterable, 43 | Literal, 44 | TypedDict, 45 | overload, 46 | ) 47 | from xml.etree import ElementTree as ET 48 | 49 | 50 | if sys.version_info >= (3, 11): 51 | import tomllib # pragma: nocover 52 | else: 53 | import tomli as tomllib # pragma: nocover 54 | 55 | from setuptools.command.egg_info import translate_pattern 56 | 57 | 58 | # import distutils after setuptools to avoid a warning 59 | from distutils.text_file import TextFile # isort:skip 60 | 61 | 62 | __version__ = '0.52.dev0' 63 | __author__ = 'Marius Gedminas ' 64 | __licence__ = 'MIT' 65 | __url__ = 'https://github.com/mgedmin/check-manifest' 66 | 67 | 68 | class Failure(Exception): 69 | """An expected failure (as opposed to a bug in this script).""" 70 | 71 | 72 | # 73 | # User interface 74 | # 75 | 76 | class UI: 77 | 78 | def __init__(self, verbosity: int = 1) -> None: 79 | self.verbosity = verbosity 80 | self._to_be_continued = False 81 | self.stdout = sys.stdout 82 | self.stderr = sys.stderr 83 | 84 | @property 85 | def quiet(self) -> bool: 86 | return self.verbosity < 1 87 | 88 | @property 89 | def verbose(self) -> bool: 90 | return self.verbosity >= 2 91 | 92 | def _check_tbc(self) -> None: 93 | if self._to_be_continued: 94 | print(file=self.stdout) 95 | self._to_be_continued = False 96 | 97 | def info(self, message: str) -> None: 98 | if self.quiet: 99 | return 100 | self._check_tbc() 101 | print(message, file=self.stdout) 102 | 103 | def info_begin(self, message: str) -> None: 104 | if not self.verbose: 105 | return 106 | self._check_tbc() 107 | print(message, end="", file=self.stdout) 108 | self._to_be_continued = True 109 | 110 | def info_continue(self, message: str) -> None: 111 | if not self.verbose: 112 | return 113 | print(message, end="", file=self.stdout) 114 | self._to_be_continued = True 115 | 116 | def info_end(self, message: str) -> None: 117 | if not self.verbose: 118 | return 119 | print(message, file=self.stdout) 120 | self._to_be_continued = False 121 | 122 | def error(self, message: str) -> None: 123 | self._check_tbc() 124 | print(message, file=self.stderr) 125 | 126 | def warning(self, message: str) -> None: 127 | self._check_tbc() 128 | print(message, file=self.stderr) 129 | 130 | 131 | def format_list(list_of_strings: list[str]) -> str: 132 | return "\n".join(" " + s for s in list_of_strings) 133 | 134 | 135 | def format_missing( 136 | missing_from_a: Collection[str], 137 | missing_from_b: Collection[str], 138 | name_a: str, 139 | name_b: str, 140 | ) -> str: 141 | res = [] 142 | if missing_from_a: 143 | res.append("missing from %s:\n%s" 144 | % (name_a, format_list(sorted(missing_from_a)))) 145 | if missing_from_b: 146 | res.append("missing from %s:\n%s" 147 | % (name_b, format_list(sorted(missing_from_b)))) 148 | return '\n'.join(res) 149 | 150 | 151 | # 152 | # Filesystem/OS utilities 153 | # 154 | 155 | class CommandFailed(Failure): 156 | def __init__(self, command: list[str], status: int, output: str) -> None: 157 | super().__init__("%s failed (status %s):\n%s" % ( 158 | command, status, output)) 159 | 160 | 161 | @overload 162 | def run( 163 | command: list[str], 164 | *, 165 | encoding: str | None = None, 166 | decode: Literal[True] = True, 167 | cwd: str | None = None, 168 | ) -> str: 169 | ... 170 | 171 | 172 | @overload 173 | def run( 174 | command: list[str], 175 | *, 176 | encoding: str | None = None, 177 | decode: Literal[False], 178 | cwd: str | None = None, 179 | ) -> bytes: 180 | ... 181 | 182 | 183 | def run( 184 | command: list[str], 185 | *, 186 | encoding: str | None = None, 187 | decode: bool = True, 188 | cwd: str | None = None, 189 | ) -> str | bytes: 190 | """Run a command [cmd, arg1, arg2, ...]. 191 | 192 | Returns the output (stdout only). 193 | 194 | Raises CommandFailed in cases of error. 195 | """ 196 | if not encoding: 197 | encoding = locale.getpreferredencoding() 198 | try: 199 | pipe = subprocess.Popen(command, stdin=subprocess.DEVNULL, 200 | stdout=subprocess.PIPE, 201 | stderr=subprocess.PIPE, cwd=cwd) 202 | except OSError as e: 203 | raise Failure(f"could not run {command}: {e}") 204 | output, stderr = pipe.communicate() 205 | status = pipe.wait() 206 | if status != 0: 207 | raise CommandFailed(command, status, 208 | (output + stderr).decode(encoding, 'replace')) 209 | if decode: 210 | return output.decode(encoding) 211 | return output 212 | 213 | 214 | @contextmanager 215 | def cd(directory: str) -> Generator[None, None, None]: 216 | """Change the current working directory, temporarily. 217 | 218 | Use as a context manager: with cd(d): ... 219 | """ 220 | old_dir = os.getcwd() 221 | try: 222 | os.chdir(directory) 223 | yield 224 | finally: 225 | os.chdir(old_dir) 226 | 227 | 228 | @contextmanager 229 | def mkdtemp(hint: str = '') -> Generator[str, None, None]: 230 | """Create a temporary directory, then clean it up. 231 | 232 | Use as a context manager: with mkdtemp('-purpose'): ... 233 | """ 234 | dirname = tempfile.mkdtemp(prefix='check-manifest-', suffix=hint) 235 | try: 236 | yield dirname 237 | finally: 238 | rmtree(dirname) 239 | 240 | 241 | def chmod_plus(path: str, add_bits: int) -> None: 242 | """Change a file's mode by adding a few bits. 243 | 244 | Like chmod + in a Unix shell. 245 | """ 246 | try: 247 | os.chmod(path, stat.S_IMODE(os.stat(path).st_mode) | add_bits) 248 | except OSError: # pragma: nocover 249 | pass # well, we tried 250 | 251 | 252 | ExcInfo = tuple[type[BaseException], BaseException, TracebackType] 253 | 254 | 255 | def rmtree(path: str) -> None: 256 | """A version of rmtree that can deal with read-only files and directories. 257 | 258 | Needed because the stock shutil.rmtree() fails with an access error 259 | when there are read-only files in the directory on Windows, or when the 260 | directory itself is read-only on Unix. 261 | """ 262 | def onerror(func: Callable[..., Any], path: str, exc_info: ExcInfo) -> None: 263 | # Did you know what on Python 3.3 on Windows os.remove() and 264 | # os.unlink() are distinct functions? 265 | if func is os.remove or func is os.unlink or func is os.rmdir: 266 | if sys.platform != 'win32': 267 | chmod_plus(os.path.dirname(path), stat.S_IWUSR | stat.S_IXUSR) 268 | chmod_plus(path, stat.S_IWUSR) 269 | func(path) 270 | else: 271 | raise 272 | shutil.rmtree(path, onerror=onerror) 273 | 274 | 275 | def copy_files(filelist: list[str], destdir: str) -> None: 276 | """Copy a list of files to destdir, preserving directory structure. 277 | 278 | File names should be relative to the current working directory. 279 | """ 280 | for filename in filelist: 281 | destfile = os.path.join(destdir, filename) 282 | # filename should not be absolute, but let's double-check 283 | assert destfile.startswith(destdir + os.path.sep) 284 | destfiledir = os.path.dirname(destfile) 285 | if not os.path.isdir(destfiledir): 286 | os.makedirs(destfiledir) 287 | if os.path.isdir(filename): 288 | os.mkdir(destfile) 289 | else: 290 | shutil.copy2(filename, destfile) 291 | 292 | 293 | def get_one_file_in(dirname: str) -> str: 294 | """Return the pathname of the one file in a directory. 295 | 296 | Raises if the directory has no files or more than one file. 297 | """ 298 | files = os.listdir(dirname) 299 | if len(files) > 1: 300 | raise Failure('More than one file exists in %s:\n%s' % 301 | (dirname, '\n'.join(sorted(files)))) 302 | elif not files: 303 | raise Failure('No files found in %s' % dirname) 304 | return os.path.join(dirname, files[0]) 305 | 306 | 307 | # 308 | # File lists are a fundamental data structure here. We want them to have 309 | # the following properties: 310 | # 311 | # - contain Unicode filenames (normalized to NFC on OS X) 312 | # - be sorted 313 | # - use / as the directory separator 314 | # - list only files, but not directories 315 | # 316 | # We get these file lists from various sources (zip files, tar files, version 317 | # control systems) and we have to normalize them into our common format before 318 | # comparing. 319 | # 320 | 321 | 322 | def canonical_file_list(filelist: Iterable[str]) -> list[str]: 323 | """Return the file list convered to a canonical form. 324 | 325 | This means: 326 | 327 | - converted to Unicode normal form C, when running on Mac OS X 328 | - sorted alphabetically 329 | - use / as the directory separator 330 | - list files but not directories 331 | 332 | Caveat: since it works on file lists taken from archives and such, it 333 | doesn't know whether a particular filename refers to a file or a directory, 334 | unless it finds annother filename that is inside the first one. In other 335 | words, canonical_file_list() will not remove the names of empty directories 336 | if those appear in the initial file list. 337 | """ 338 | names = set(normalize_names(filelist)) 339 | for name in list(names): 340 | while name: 341 | name = posixpath.dirname(name) 342 | names.discard(name) 343 | return sorted(names) 344 | 345 | 346 | def get_sdist_file_list(sdist_filename: str, ignore: IgnoreList) -> list[str]: 347 | """Return the list of interesting files in a source distribution. 348 | 349 | Removes extra generated files like PKG-INFO and *.egg-info that are usually 350 | present only in the sdist, but not in the VCS. 351 | 352 | Supports .tar.gz and .zip sdists. 353 | """ 354 | return strip_sdist_extras( 355 | ignore, 356 | strip_toplevel_name(get_archive_file_list(sdist_filename))) 357 | 358 | 359 | def get_archive_file_list(archive_filename: str) -> list[str]: 360 | """Return the list of files in an archive. 361 | 362 | Supports .tar.gz and .zip. 363 | """ 364 | filelist: Iterable[str] 365 | if archive_filename.endswith('.zip'): 366 | with zipfile.ZipFile(archive_filename) as zf: 367 | filelist = zf.namelist() 368 | elif archive_filename.endswith(('.tar.gz', '.tar.bz2', '.tar')): 369 | with tarfile.open(archive_filename) as tf: 370 | # XXX: is unicodify() necessary now that Py2 is no longer supported? 371 | filelist = map(unicodify, tf.getnames()) 372 | else: 373 | raise Failure('Unrecognized archive type: %s' 374 | % os.path.basename(archive_filename)) 375 | return canonical_file_list(filelist) 376 | 377 | 378 | def unicodify(filename: str | bytes) -> str: 379 | """Make sure filename is Unicode. 380 | 381 | Because the tarfile module on Python 2 doesn't return Unicode. 382 | """ 383 | if isinstance(filename, bytes): 384 | # XXX: Ah, but is it right to use the locale encoding here, or should I 385 | # use sys.getfilesystemencoding()? A good question! 386 | return filename.decode(locale.getpreferredencoding()) 387 | else: 388 | return filename 389 | 390 | 391 | def strip_toplevel_name(filelist: list[str]) -> list[str]: 392 | """Strip toplevel name from a file list. 393 | 394 | >>> strip_toplevel_name(['a', 'a/b', 'a/c', 'a/c/d']) 395 | ['b', 'c', 'c/d'] 396 | 397 | >>> strip_toplevel_name(['a', 'a/', 'a/b', 'a/c', 'a/c/d']) 398 | ['b', 'c', 'c/d'] 399 | 400 | >>> strip_toplevel_name(['a/b', 'a/c', 'a/c/d']) 401 | ['b', 'c', 'c/d'] 402 | 403 | """ 404 | if not filelist: 405 | return filelist 406 | prefix = filelist[0] 407 | # so here's a function we assume / is the directory separator 408 | if '/' in prefix: 409 | prefix = prefix.partition('/')[0] + '/' 410 | names = filelist 411 | else: 412 | prefix = prefix + '/' 413 | names = filelist[1:] 414 | for name in names: 415 | if not name.startswith(prefix): 416 | raise Failure("File doesn't have the common prefix (%s): %s" 417 | % (name, prefix)) 418 | return [name[len(prefix):] for name in names if name != prefix] 419 | 420 | 421 | class VCS: 422 | 423 | metadata_name: str 424 | 425 | def __init__(self, ui: UI) -> None: 426 | self.ui = ui 427 | 428 | @classmethod 429 | def detect(cls, location: str) -> bool: 430 | return os.path.isdir(os.path.join(location, cls.metadata_name)) 431 | 432 | def get_versioned_files(self) -> list[str]: 433 | raise NotImplementedError('this is an abstract method') 434 | 435 | 436 | class Git(VCS): 437 | metadata_name = '.git' 438 | 439 | # Git for Windows uses UTF-8 instead of the locale encoding. 440 | # Git on POSIX systems uses the locale encoding. 441 | _encoding = 'UTF-8' if sys.platform == 'win32' else None 442 | 443 | @classmethod 444 | def detect(cls, location: str) -> bool: 445 | # .git can be a file for submodules 446 | return os.path.exists(os.path.join(location, cls.metadata_name)) 447 | 448 | def get_versioned_files(self) -> list[str]: 449 | """List all files versioned by git in the current directory.""" 450 | output = run( 451 | ["git", "ls-files", "-z", "--recurse-submodules"], 452 | encoding=self._encoding, 453 | ) 454 | # -z tells git to use \0 as a line terminator; split() treats it as a 455 | # line separator, so we always get one empty line at the end, which we 456 | # drop with the [:-1] slice 457 | return output.split("\0")[:-1] 458 | 459 | 460 | class Mercurial(VCS): 461 | metadata_name = '.hg' 462 | 463 | def get_versioned_files(self) -> list[str]: 464 | """List all files under Mercurial control in the current directory.""" 465 | output = run(['hg', 'status', '-ncamd', '.']) 466 | return output.splitlines() 467 | 468 | 469 | class Bazaar(VCS): 470 | metadata_name = '.bzr' 471 | 472 | @classmethod 473 | def _get_terminal_encoding(self) -> str | None: 474 | # Python 3.6 lets us name the OEM codepage directly, which is lucky 475 | # because it also breaks our old method of OEM codepage detection 476 | # (PEP-528 changed sys.stdout.encoding to UTF-8). 477 | try: 478 | codecs.lookup('oem') 479 | except LookupError: 480 | pass 481 | else: # pragma: nocover 482 | return 'oem' 483 | # Based on bzrlib.osutils.get_terminal_encoding() 484 | encoding = getattr(sys.stdout, 'encoding', None) 485 | if not encoding: 486 | encoding = getattr(sys.stdin, 'encoding', None) 487 | if encoding == 'cp0': # "no codepage" 488 | encoding = None 489 | # NB: bzrlib falls back on bzrlib.osutils.get_user_encoding(), 490 | # which is like locale.getpreferredencoding() on steroids, and 491 | # also includes a fallback from 'ascii' to 'utf-8' when 492 | # sys.platform is 'darwin'. This is probably something we might 493 | # want to do in run(), but I'll wait for somebody to complain 494 | # first, since I don't have a Mac OS X machine and cannot test. 495 | return encoding 496 | 497 | def get_versioned_files(self) -> list[str]: 498 | """List all files versioned in Bazaar in the current directory.""" 499 | encoding = self._get_terminal_encoding() 500 | output = run(['bzr', 'ls', '-VR'], encoding=encoding) 501 | return output.splitlines() 502 | 503 | 504 | class Subversion(VCS): 505 | metadata_name = '.svn' 506 | 507 | def get_versioned_files(self) -> list[str]: 508 | """List all files under SVN control in the current directory.""" 509 | output = run(['svn', 'st', '-vq', '--xml'], decode=False) 510 | tree = ET.XML(output) 511 | return sorted(entry.get('path') for entry in tree.findall('.//entry') # type: ignore 512 | if self.is_interesting(entry)) 513 | 514 | def is_interesting(self, entry: ET.Element) -> bool: 515 | """Is this entry interesting? 516 | 517 | ``entry`` is an XML node representing one entry of the svn status 518 | XML output. It looks like this:: 519 | 520 | 521 | 522 | 523 | mg 524 | 2015-02-06T07:52:38.163516Z 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | """ 542 | if entry.get('path') == '.': 543 | return False 544 | status = entry.find('wc-status') 545 | if status is None: 546 | self.ui.warning( 547 | 'svn status --xml parse error: without' 548 | ' ' % entry.get('path') 549 | ) 550 | return False 551 | # For SVN externals we get two entries: one mentioning the 552 | # existence of the external, and one about the status of the external. 553 | if status.get('item') in ('unversioned', 'external'): 554 | return False 555 | return True 556 | 557 | 558 | def detect_vcs(ui: UI) -> VCS: 559 | """Detect the version control system used for the current directory.""" 560 | location = os.path.abspath('.') 561 | while True: 562 | for vcs in Git, Mercurial, Bazaar, Subversion: 563 | if vcs.detect(location): 564 | return vcs(ui) 565 | parent = os.path.dirname(location) 566 | if parent == location: 567 | raise Failure("Couldn't find version control data" 568 | " (git/hg/bzr/svn supported)") 569 | location = parent 570 | 571 | 572 | def get_vcs_files(ui: UI) -> list[str]: 573 | """List all files under version control in the current directory.""" 574 | vcs = detect_vcs(ui) 575 | return canonical_file_list(vcs.get_versioned_files()) 576 | 577 | 578 | def normalize_names(names: Iterable[str]) -> list[str]: 579 | """Normalize file names.""" 580 | return [normalize_name(name) for name in names] 581 | 582 | 583 | def normalize_name(name: str) -> str: 584 | """Some VCS print directory names with trailing slashes. Strip them. 585 | 586 | Easiest is to normalize the path. 587 | 588 | And encodings may trip us up too, especially when comparing lists 589 | of files. Plus maybe lowercase versus uppercase. 590 | """ 591 | name = os.path.normpath(name).replace(os.path.sep, '/') 592 | name = unicodify(name) # XXX is this necessary? 593 | if sys.platform == 'darwin': 594 | # Mac OS X may have problems comparing non-ASCII filenames, so 595 | # we convert them. 596 | name = unicodedata.normalize('NFC', name) 597 | return name 598 | 599 | 600 | # 601 | # Packaging logic 602 | # 603 | 604 | class IgnoreList: 605 | 606 | def __init__(self) -> None: 607 | self._regexps: list[re.Pattern[str]] = [] 608 | 609 | @classmethod 610 | def default(cls) -> IgnoreList: 611 | return ( 612 | cls() 613 | # these are always generated 614 | .global_exclude('PKG-INFO') 615 | .global_exclude('*.egg-info/*') 616 | # setup.cfg is always generated, but sometimes also kept in source control 617 | .global_exclude('setup.cfg') 618 | # it's not a problem if the sdist is lacking these files: 619 | .global_exclude( 620 | '.hgtags', '.hgsigs', '.hgignore', '.gitignore', '.bzrignore', 621 | '.gitattributes', 622 | ) 623 | # GitHub template files 624 | .prune('.github') 625 | # we can do without these in sdists 626 | .global_exclude('.circleci/config.yml') 627 | .global_exclude('.gitpod.yml') 628 | .global_exclude('.travis.yml') 629 | .global_exclude('Jenkinsfile') 630 | # It's convenient to ship compiled .mo files in sdists, but they 631 | # shouldn't be checked in, so don't complain that they're missing 632 | # from VCS 633 | .global_exclude('*.mo') 634 | ) 635 | 636 | def clear(self) -> None: 637 | self._regexps = [] 638 | 639 | def __repr__(self) -> str: 640 | return 'IgnoreList(%r)' % (self._regexps) 641 | 642 | def __eq__(self, other: object) -> bool: 643 | return isinstance(other, IgnoreList) and self._regexps == other._regexps 644 | 645 | def __iadd__(self, other: IgnoreList) -> IgnoreList: 646 | assert isinstance(other, IgnoreList) 647 | self._regexps += other._regexps 648 | return self 649 | 650 | def _path(self, path: str) -> str: 651 | return path.replace('/', os.path.sep) 652 | 653 | def exclude(self, *patterns: str) -> IgnoreList: 654 | for pat in patterns: 655 | pat = self._path(pat) 656 | self._regexps.append(translate_pattern(pat)) 657 | return self 658 | 659 | def global_exclude(self, *patterns: str) -> IgnoreList: 660 | for pat in patterns: 661 | pat = os.path.join('**', self._path(pat)) 662 | self._regexps.append(translate_pattern(pat)) 663 | return self 664 | 665 | def recursive_exclude(self, dirname: str, *patterns: str) -> IgnoreList: 666 | dirname = self._path(dirname) 667 | for pat in patterns: 668 | pat = os.path.join(dirname, '**', self._path(pat)) 669 | self._regexps.append(translate_pattern(pat)) 670 | return self 671 | 672 | def prune(self, subdir: str) -> IgnoreList: 673 | pat = os.path.join(self._path(subdir), '**') 674 | self._regexps.append(translate_pattern(pat)) 675 | return self 676 | 677 | def filter(self, filelist: list[str]) -> list[str]: 678 | return [name for name in filelist 679 | if not any(rx.match(self._path(name)) for rx in self._regexps)] 680 | 681 | 682 | WARN_ABOUT_FILES_IN_VCS = [ 683 | # generated files should not be committed into the VCS 684 | 'PKG-INFO', 685 | '*.egg-info', 686 | '*.mo', 687 | '*.py[co]', 688 | '*.so', 689 | '*.pyd', 690 | '*~', 691 | '.*.sw[po]', 692 | '.#*', 693 | ] 694 | 695 | SUGGESTIONS = [(re.compile(pattern), suggestion) for pattern, suggestion in [ 696 | # regexp -> suggestion 697 | ('^([^/]+[.](cfg|ini))$', r'include \1'), 698 | ('^([.]travis[.]yml)$', r'include \1'), 699 | ('^([.]coveragerc)$', r'include \1'), 700 | ('^([A-Z]+)$', r'include \1'), 701 | ('^(Makefile)$', r'include \1'), 702 | ('^[^/]+[.](txt|rst|py)$', r'include *.\1'), 703 | ('^([a-zA-Z_][a-zA-Z_0-9]*)/' 704 | '.*[.](py|zcml|pt|mako|xml|html|txt|rst|css|png|jpg|dot|po|pot|mo|ui|desktop|bat)$', 705 | r'recursive-include \1 *.\2'), 706 | ('^([a-zA-Z_][a-zA-Z_0-9]*)(?:/.*)?/(Makefile)$', 707 | r'recursive-include \1 \2'), 708 | # catch-all rules that actually cover some of the above; somewhat 709 | # experimental: I fear false positives 710 | ('^([a-zA-Z_0-9]+)$', r'include \1'), 711 | ('^[^/]+[.]([a-zA-Z_0-9]+)$', r'include *.\1'), 712 | ('^([a-zA-Z_][a-zA-Z_0-9]*)/.*[.]([a-zA-Z_0-9]+)$', 713 | r'recursive-include \1 *.\2'), 714 | ]] 715 | 716 | CFG_SECTION_CHECK_MANIFEST = 'check-manifest' 717 | CFG_IGNORE_DEFAULT_RULES = (CFG_SECTION_CHECK_MANIFEST, 'ignore-default-rules') 718 | CFG_IGNORE = (CFG_SECTION_CHECK_MANIFEST, 'ignore') 719 | CFG_IGNORE_BAD_IDEAS = (CFG_SECTION_CHECK_MANIFEST, 'ignore-bad-ideas') 720 | 721 | 722 | def read_config() -> tuple[IgnoreList, IgnoreList]: 723 | """Read configuration from file if possible. 724 | 725 | Returns two IgnoreLists: one to suppress warnings about files missing in 726 | VCS, and one to suppress warnings about files being added to VCS. 727 | """ 728 | ignore = IgnoreList.default() 729 | ignore_bad_ideas = IgnoreList() 730 | config = _load_config() 731 | if config.get(CFG_IGNORE_DEFAULT_RULES[1], False): 732 | ignore.clear() 733 | if CFG_IGNORE[1] in config: 734 | for p in config[CFG_IGNORE[1]]: # type: ignore 735 | if p: 736 | ignore.global_exclude(p) 737 | if CFG_IGNORE_BAD_IDEAS[1] in config: 738 | for p in config[CFG_IGNORE_BAD_IDEAS[1]]: # type: ignore 739 | if p: 740 | ignore_bad_ideas.global_exclude(p) 741 | return ignore, ignore_bad_ideas 742 | 743 | 744 | ConfigDict = TypedDict( 745 | 'ConfigDict', 746 | { 747 | 'ignore-default-rules': bool, 748 | 'ignore': list[str], 749 | 'ignore-bad-ideas': list[str], 750 | }, 751 | total=False 752 | ) 753 | 754 | 755 | def _load_config() -> ConfigDict: 756 | """Searches for config files, reads them and returns a dictionary 757 | 758 | Looks for a ``check-manifest`` section in ``pyproject.toml``, 759 | ``setup.cfg``, and ``tox.ini``, in that order. The first file 760 | that exists and has that section will be loaded and returned as a 761 | dictionary. 762 | 763 | """ 764 | if os.path.exists("pyproject.toml"): 765 | with open('pyproject.toml', 'rb') as f: 766 | config = tomllib.load(f) 767 | if CFG_SECTION_CHECK_MANIFEST in config.get("tool", {}): 768 | return config["tool"][CFG_SECTION_CHECK_MANIFEST] # type: ignore 769 | 770 | search_files = ['setup.cfg', 'tox.ini'] 771 | config_parser = configparser.ConfigParser() 772 | for filename in search_files: 773 | if (config_parser.read([filename]) 774 | and config_parser.has_section(CFG_SECTION_CHECK_MANIFEST)): 775 | config = {} 776 | 777 | if config_parser.has_option(*CFG_IGNORE_DEFAULT_RULES): 778 | ignore_defaults = config_parser.getboolean(*CFG_IGNORE_DEFAULT_RULES) 779 | config[CFG_IGNORE_DEFAULT_RULES[1]] = ignore_defaults 780 | 781 | if config_parser.has_option(*CFG_IGNORE): 782 | patterns = [ 783 | p.strip() 784 | for p in config_parser.get(*CFG_IGNORE).splitlines() 785 | ] 786 | config[CFG_IGNORE[1]] = patterns 787 | 788 | if config_parser.has_option(*CFG_IGNORE_BAD_IDEAS): 789 | patterns = [ 790 | p.strip() 791 | for p in config_parser.get(*CFG_IGNORE_BAD_IDEAS).splitlines() 792 | ] 793 | config[CFG_IGNORE_BAD_IDEAS[1]] = patterns 794 | 795 | return config # type: ignore 796 | 797 | return {} 798 | 799 | 800 | def read_manifest(ui: UI) -> IgnoreList: 801 | """Read existing configuration from MANIFEST.in. 802 | 803 | We use that to ignore anything the MANIFEST.in ignores. 804 | """ 805 | if not os.path.isfile('MANIFEST.in'): 806 | return IgnoreList() 807 | return _get_ignore_from_manifest('MANIFEST.in', ui) 808 | 809 | 810 | LineNumber = int | tuple[int, int] | list[int] | None 811 | 812 | 813 | def _get_ignore_from_manifest(filename: str, ui: UI) -> IgnoreList: 814 | """Gather the various ignore patterns from a MANIFEST.in. 815 | 816 | Returns an IgnoreList instance. 817 | """ 818 | 819 | class MyTextFile(TextFile): # type: ignore 820 | def error(self, msg: str, line: LineNumber = None) -> None: # pragma: nocover 821 | # (this is never called by TextFile in current versions of CPython) 822 | raise Failure(self.gen_error(msg, line)) 823 | 824 | def warn(self, msg: str, line: LineNumber = None) -> None: 825 | ui.warning(self.gen_error(msg, line)) 826 | 827 | template = MyTextFile(filename, 828 | strip_comments=True, 829 | skip_blanks=True, 830 | join_lines=True, 831 | lstrip_ws=True, 832 | rstrip_ws=True, 833 | collapse_join=True) 834 | try: 835 | lines = template.readlines() 836 | finally: 837 | template.close() 838 | return _get_ignore_from_manifest_lines(lines, ui) 839 | 840 | 841 | def _get_ignore_from_manifest_lines(lines: list[str], ui: UI) -> IgnoreList: 842 | """Gather the various ignore patterns from a MANIFEST.in. 843 | 844 | 'lines' should be a list of strings with comments removed 845 | and continuation lines joined. 846 | 847 | Returns an IgnoreList instance. 848 | """ 849 | ignore = IgnoreList() 850 | for line in lines: 851 | try: 852 | cmd, rest = line.split(None, 1) 853 | except ValueError: 854 | # no whitespace, so not interesting 855 | continue 856 | for part in rest.split(): 857 | # distutils enforces these warnings on Windows only 858 | if part.startswith('/'): 859 | ui.warning("ERROR: Leading slashes are not allowed in MANIFEST.in on Windows: %s" % part) 860 | if part.endswith('/'): 861 | ui.warning("ERROR: Trailing slashes are not allowed in MANIFEST.in on Windows: %s" % part) 862 | if cmd == 'exclude': 863 | ignore.exclude(*rest.split()) 864 | elif cmd == 'global-exclude': 865 | ignore.global_exclude(*rest.split()) 866 | elif cmd == 'recursive-exclude': 867 | try: 868 | dirname, patterns = rest.split(None, 1) 869 | except ValueError: 870 | # Wrong MANIFEST.in line. 871 | ui.warning( 872 | "You have a wrong line in MANIFEST.in: %r\n" 873 | "'recursive-exclude' expects ..." 874 | % line 875 | ) 876 | continue 877 | ignore.recursive_exclude(dirname, *patterns.split()) 878 | elif cmd == 'prune': 879 | ignore.prune(rest) 880 | # XXX: This ignores all 'include'/'global-include'/'recusive-include'/'graft' commands, 881 | # which is wrong! Quoting the documentation: 882 | # 883 | # The order of commands in the manifest template matters: initially, 884 | # we have the list of default files as described above, and each 885 | # command in the template adds to or removes from that list of 886 | # files. 887 | # -- https://docs.python.org/3.8/distutils/sourcedist.html#specifying-the-files-to-distribute 888 | return ignore 889 | 890 | 891 | def file_matches(filename: str, patterns: list[str]) -> bool: 892 | """Does this filename match any of the patterns?""" 893 | return any(fnmatch.fnmatch(filename, pat) 894 | or fnmatch.fnmatch(os.path.basename(filename), pat) 895 | for pat in patterns) 896 | 897 | 898 | def strip_sdist_extras(ignore: IgnoreList, filelist: list[str]) -> list[str]: 899 | """Strip generated files that are only present in source distributions. 900 | 901 | We also strip files that are ignored for other reasons, like 902 | command line arguments, setup.cfg rules or MANIFEST.in rules. 903 | """ 904 | return ignore.filter(filelist) 905 | 906 | 907 | def find_bad_ideas(filelist: Iterable[str]) -> list[str]: 908 | """Find files matching WARN_ABOUT_FILES_IN_VCS patterns.""" 909 | return [name for name in filelist 910 | if file_matches(name, WARN_ABOUT_FILES_IN_VCS)] 911 | 912 | 913 | def find_suggestions(filelist: Iterable[str]) -> tuple[list[str], list[str]]: 914 | """Suggest MANIFEST.in patterns for missing files. 915 | 916 | Returns two lists: one with suggested MANIGEST.in commands, and one with 917 | files for which no suggestions were offered. 918 | """ 919 | suggestions = set() 920 | unknowns = [] 921 | for filename in filelist: 922 | for pattern, suggestion in SUGGESTIONS: 923 | m = pattern.match(filename) 924 | if m is not None: 925 | suggestions.add(pattern.sub(suggestion, filename)) 926 | break 927 | else: 928 | unknowns.append(filename) 929 | return sorted(suggestions), unknowns 930 | 931 | 932 | def is_package(source_tree: str = '.') -> bool: 933 | """Is the directory the root of a Python package? 934 | 935 | Note: the term "package" here refers to a collection of files 936 | with a setup.py/pyproject.toml, not to a directory with an __init__.py. 937 | """ 938 | return ( 939 | os.path.exists(os.path.join(source_tree, 'setup.py')) 940 | or os.path.exists(os.path.join(source_tree, 'pyproject.toml')) 941 | ) 942 | 943 | 944 | def extract_version_from_filename(filename: str) -> str: 945 | """Extract version number from sdist filename.""" 946 | filename = os.path.splitext(os.path.basename(filename))[0] 947 | if filename.endswith('.tar'): 948 | filename = os.path.splitext(filename)[0] 949 | return filename.split('-')[-1] 950 | 951 | 952 | def should_use_pep_517() -> bool: 953 | """Check if the project uses PEP-517 builds.""" 954 | # https://www.python.org/dev/peps/pep-0517/#build-system-table says 955 | # "If the pyproject.toml file is absent, or the build-backend key is 956 | # missing, the source tree is not using this specification, and tools 957 | # should revert to the legacy behaviour of running setup.py". 958 | if not os.path.exists('pyproject.toml'): 959 | return False 960 | with open('pyproject.toml', 'rb') as f: 961 | config = tomllib.load(f) 962 | if "build-system" not in config: 963 | return False 964 | if "build-backend" not in config["build-system"]: 965 | return False 966 | return True 967 | 968 | 969 | def build_sdist(tempdir: str, python: str = sys.executable, build_isolation: bool = True) -> None: 970 | """Build a source distribution in a temporary directory. 971 | 972 | Should be run with the current working directory inside the Python package 973 | you want to build. 974 | """ 975 | if should_use_pep_517(): 976 | # I could do this in-process with 977 | # import build.__main__ 978 | # build.__main__.build('.', tempdir) 979 | # but then it would print a bunch of things to stdout and I'd have to 980 | # worry about exceptions 981 | cmd = [python, '-m', 'build', '--sdist', '.', '--outdir', tempdir] 982 | if not build_isolation: 983 | cmd.append('--no-isolation') 984 | run(cmd) 985 | else: 986 | run([python, 'setup.py', 'sdist', '-d', tempdir]) 987 | 988 | 989 | def check_manifest( 990 | source_tree: str = '.', 991 | create: bool = False, 992 | update: bool = False, 993 | python: str = sys.executable, 994 | ui: UI | None = None, 995 | extra_ignore: IgnoreList | None = None, 996 | extra_ignore_bad_ideas: IgnoreList | None = None, 997 | build_isolation: bool = True, 998 | ) -> bool: 999 | """Compare a generated source distribution with list of files in a VCS. 1000 | 1001 | Returns True if the manifest is fine. 1002 | """ 1003 | if ui is None: 1004 | ui = UI() 1005 | all_ok = True 1006 | if os.path.sep in python: 1007 | python = os.path.abspath(python) 1008 | with cd(source_tree): 1009 | if not is_package(): 1010 | raise Failure( 1011 | 'This is not a Python project (no setup.py/pyproject.toml).') 1012 | ignore, ignore_bad_ideas = read_config() 1013 | ignore += read_manifest(ui) 1014 | if extra_ignore: 1015 | ignore += extra_ignore 1016 | if extra_ignore_bad_ideas: 1017 | ignore_bad_ideas += extra_ignore_bad_ideas 1018 | ui.info_begin("listing source files under version control") 1019 | all_source_files = get_vcs_files(ui) 1020 | source_files = strip_sdist_extras(ignore, all_source_files) 1021 | ui.info_continue(": %d files and directories" % len(source_files)) 1022 | if not all_source_files: 1023 | raise Failure('There are no files added to version control!') 1024 | ui.info_begin("building an sdist") 1025 | with mkdtemp('-sdist') as tempdir: 1026 | build_sdist(tempdir, python=python, build_isolation=build_isolation) 1027 | sdist_filename = get_one_file_in(tempdir) 1028 | ui.info_continue(": %s" % os.path.basename(sdist_filename)) 1029 | sdist_files = get_sdist_file_list(sdist_filename, ignore) 1030 | ui.info_continue(": %d files and directories" % len(sdist_files)) 1031 | version = extract_version_from_filename(sdist_filename) 1032 | existing_source_files = list(filter(os.path.exists, all_source_files)) 1033 | missing_source_files = sorted(set(all_source_files) - set(existing_source_files)) 1034 | if missing_source_files: 1035 | ui.warning("some files listed as being under source control are missing:\n%s" 1036 | % format_list(missing_source_files)) 1037 | ui.info_begin("copying source files to a temporary directory") 1038 | with mkdtemp('-sources') as tempsourcedir: 1039 | copy_files(existing_source_files, tempsourcedir) 1040 | for filename in 'MANIFEST.in', 'setup.py', 'pyproject.toml': 1041 | if filename not in source_files and os.path.exists(filename): 1042 | # See https://github.com/mgedmin/check-manifest/issues/7 1043 | # and https://github.com/mgedmin/check-manifest/issues/46: 1044 | # if we do this, the user gets a warning about files 1045 | # missing from source control; if we don't do this, 1046 | # things get very confusing for the user! 1047 | copy_files([filename], tempsourcedir) 1048 | ui.info_begin("building a clean sdist") 1049 | with cd(tempsourcedir): 1050 | with mkdtemp('-sdist') as tempdir: 1051 | os.environ['SETUPTOOLS_SCM_PRETEND_VERSION'] = version 1052 | build_sdist(tempdir, python=python, build_isolation=build_isolation) 1053 | sdist_filename = get_one_file_in(tempdir) 1054 | ui.info_continue(": %s" % os.path.basename(sdist_filename)) 1055 | clean_sdist_files = get_sdist_file_list(sdist_filename, ignore) 1056 | ui.info_continue(": %d files and directories" % len(clean_sdist_files)) 1057 | missing_from_manifest = set(source_files) - set(clean_sdist_files) 1058 | missing_from_VCS = set(sdist_files + clean_sdist_files) - set(source_files) 1059 | if not missing_from_manifest and not missing_from_VCS: 1060 | ui.info("lists of files in version control and sdist match") 1061 | else: 1062 | ui.error( 1063 | "lists of files in version control and sdist do not match!\n%s" 1064 | % format_missing(missing_from_VCS, missing_from_manifest, "VCS", "sdist")) 1065 | suggestions, unknowns = find_suggestions(missing_from_manifest) 1066 | user_asked_for_help = update or (create and not 1067 | os.path.exists('MANIFEST.in')) 1068 | if 'MANIFEST.in' not in existing_source_files: 1069 | if suggestions and not user_asked_for_help: 1070 | ui.info("no MANIFEST.in found; you can run 'check-manifest -c' to create one") 1071 | else: 1072 | ui.info("no MANIFEST.in found") 1073 | if suggestions: 1074 | ui.info("suggested MANIFEST.in rules:\n%s" % format_list(suggestions)) 1075 | if user_asked_for_help: 1076 | existed = os.path.exists('MANIFEST.in') 1077 | with open('MANIFEST.in', 'a') as f: 1078 | if not existed: 1079 | ui.info("creating MANIFEST.in") 1080 | else: 1081 | ui.info("updating MANIFEST.in") 1082 | f.write('\n# added by check-manifest\n') 1083 | f.write('\n'.join(suggestions) + '\n') 1084 | if unknowns: 1085 | ui.info("don't know how to come up with rules matching\n%s" 1086 | % format_list(unknowns)) 1087 | elif user_asked_for_help: 1088 | ui.info("don't know how to come up with rules matching any of the files, sorry!") 1089 | all_ok = False 1090 | bad_ideas = find_bad_ideas(all_source_files) 1091 | filtered_bad_ideas = ignore_bad_ideas.filter(bad_ideas) 1092 | if filtered_bad_ideas: 1093 | ui.warning( 1094 | "you have %s in source control!\n" 1095 | "that's a bad idea: auto-generated files should not be versioned" 1096 | % filtered_bad_ideas[0]) 1097 | if len(filtered_bad_ideas) > 1: 1098 | ui.warning("this also applies to the following:\n%s" 1099 | % format_list(filtered_bad_ideas[1:])) 1100 | all_ok = False 1101 | return all_ok 1102 | 1103 | 1104 | # 1105 | # Main script 1106 | # 1107 | 1108 | def main() -> None: 1109 | parser = argparse.ArgumentParser( 1110 | description="Check a Python MANIFEST.in file for completeness") 1111 | parser.add_argument( 1112 | 'source_tree', default='.', nargs='?', 1113 | help='location for the source tree (default: .)') 1114 | parser.add_argument( 1115 | '--version', action='version', 1116 | version='%(prog)s version ' + __version__) 1117 | parser.add_argument( 1118 | '-q', '--quiet', action='store_const', dest='quiet', 1119 | const=0, default=1, help='reduced output verbosity') 1120 | parser.add_argument( 1121 | '-v', '--verbose', action='store_const', dest='verbose', 1122 | const=1, default=0, help='more verbose output') 1123 | parser.add_argument( 1124 | '-c', '--create', action='store_true', 1125 | help='create a MANIFEST.in if missing (default: exit with an error)') 1126 | parser.add_argument( 1127 | '-u', '--update', action='store_true', 1128 | help='append suggestions to MANIFEST.in (implies --create)') 1129 | parser.add_argument( 1130 | '-p', '--python', default=sys.executable, 1131 | help=( 1132 | 'use this Python interpreter for running setup.py sdist' 1133 | ' (default: %(default)s)' 1134 | )) 1135 | parser.add_argument( 1136 | '--ignore', metavar='patterns', default=None, 1137 | help=( 1138 | 'ignore files/directories matching these comma-separated' 1139 | ' glob patterns' 1140 | )) 1141 | parser.add_argument( 1142 | '--ignore-bad-ideas', metavar='patterns', default=[], 1143 | help=( 1144 | 'ignore bad idea files/directories matching these' 1145 | ' comma-separated glob patterns' 1146 | )) 1147 | parser.add_argument( 1148 | '--no-build-isolation', dest='build_isolation', action='store_false', 1149 | help=( 1150 | 'disable isolation when building a modern source distribution' 1151 | ' (default: use build isolation).' 1152 | ' Build dependencies specified by pyproject.toml must be already' 1153 | ' installed if this option is used.' 1154 | )) 1155 | args = parser.parse_args() 1156 | 1157 | ignore = IgnoreList() 1158 | if args.ignore: 1159 | ignore.global_exclude(*args.ignore.split(',')) 1160 | 1161 | ignore_bad_ideas = IgnoreList() 1162 | if args.ignore_bad_ideas: 1163 | ignore_bad_ideas.global_exclude(*args.ignore_bad_ideas.split(',')) 1164 | 1165 | ui = UI(verbosity=args.quiet + args.verbose) 1166 | 1167 | try: 1168 | if not check_manifest(args.source_tree, create=args.create, 1169 | update=args.update, python=args.python, 1170 | ui=ui, extra_ignore=ignore, 1171 | extra_ignore_bad_ideas=ignore_bad_ideas, 1172 | build_isolation=args.build_isolation): 1173 | sys.exit(1) 1174 | except Failure as e: 1175 | ui.error(str(e)) 1176 | sys.exit(2) 1177 | 1178 | 1179 | # 1180 | # zest.releaser integration 1181 | # 1182 | 1183 | class DataDict(TypedDict): 1184 | workingdir: str 1185 | 1186 | 1187 | def zest_releaser_check(data: DataDict) -> None: 1188 | """Check the completeness of MANIFEST.in before the release. 1189 | 1190 | This is an entry point for zest.releaser. See the documentation at 1191 | https://zestreleaser.readthedocs.io/en/latest/entrypoints.html 1192 | """ 1193 | from zest.releaser.utils import ask 1194 | source_tree = data['workingdir'] 1195 | if not is_package(source_tree): 1196 | # You can use zest.releaser on things that are not Python packages. 1197 | # It's pointless to run check-manifest in those circumstances. 1198 | # See https://github.com/mgedmin/check-manifest/issues/9 for details. 1199 | return 1200 | if not ask("Do you want to run check-manifest?"): 1201 | return 1202 | ui = UI() 1203 | try: 1204 | if not check_manifest(source_tree, ui=ui): 1205 | if not ask("MANIFEST.in has problems." 1206 | " Do you want to continue despite that?", default=False): 1207 | sys.exit(1) 1208 | except Failure as e: 1209 | ui.error(str(e)) 1210 | if not ask("Something bad happened." 1211 | " Do you want to continue despite that?", default=False): 1212 | sys.exit(2) 1213 | 1214 | 1215 | if __name__ == '__main__': 1216 | main() 1217 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import locale 3 | import os 4 | import posixpath 5 | import re 6 | import shutil 7 | import subprocess 8 | import sys 9 | import tarfile 10 | import tempfile 11 | import textwrap 12 | import unittest 13 | import zipfile 14 | from contextlib import closing 15 | from functools import partial 16 | from io import BytesIO, StringIO 17 | from typing import Dict, Optional 18 | from unittest import mock 19 | from xml.etree import ElementTree as ET 20 | 21 | from check_manifest import rmtree 22 | 23 | 24 | CAN_SKIP_TESTS = os.getenv('SKIP_NO_TESTS', '') == '' 25 | 26 | 27 | try: 28 | codecs.lookup('oem') 29 | except LookupError: 30 | HAS_OEM_CODEC = False 31 | else: 32 | # Python >= 3.6 on Windows 33 | HAS_OEM_CODEC = True 34 | 35 | 36 | class MockUI: 37 | 38 | def __init__(self, verbosity=1): 39 | self.verbosity = verbosity 40 | self.warnings = [] 41 | self.errors = [] 42 | 43 | def info(self, message): 44 | pass 45 | 46 | def info_begin(self, message): 47 | pass 48 | 49 | def info_cont(self, message): 50 | pass 51 | 52 | def info_end(self, message): 53 | pass 54 | 55 | def warning(self, message): 56 | self.warnings.append(message) 57 | 58 | def error(self, message): 59 | self.errors.append(message) 60 | 61 | 62 | class Tests(unittest.TestCase): 63 | 64 | def setUp(self): 65 | self.ui = MockUI() 66 | 67 | def make_temp_dir(self): 68 | tmpdir = tempfile.mkdtemp(prefix='test-', suffix='-check-manifest') 69 | self.addCleanup(rmtree, tmpdir) 70 | return tmpdir 71 | 72 | def create_file(self, filename, contents): 73 | with open(filename, 'w') as f: 74 | f.write(contents) 75 | 76 | def create_zip_file(self, filename, filenames): 77 | with closing(zipfile.ZipFile(filename, 'w')) as zf: 78 | for fn in filenames: 79 | zf.writestr(fn, '') 80 | 81 | def create_tar_file(self, filename, filenames): 82 | with closing(tarfile.TarFile(filename, 'w')) as tf: 83 | for fn in filenames: 84 | tf.addfile(tarfile.TarInfo(fn), BytesIO()) 85 | 86 | def test_run_success(self): 87 | from check_manifest import run 88 | self.assertEqual(run(["true"]), "") 89 | 90 | def test_run_failure(self): 91 | from check_manifest import CommandFailed, run 92 | 93 | # /bin/false can return any non-zero status code, e.g. it returns 255 94 | # on OpenIndiana: https://github.com/mgedmin/check-manifest/issues/162 95 | command = [sys.executable, '-c', 'import sys; sys.exit(1)'] 96 | with self.assertRaises(CommandFailed) as cm: 97 | run(command) 98 | self.assertEqual(str(cm.exception), 99 | f"{command!r} failed (status 1):\n") 100 | 101 | def test_run_no_such_program(self): 102 | from check_manifest import Failure, run 103 | with self.assertRaises(Failure) as cm: 104 | run(["there-is-really-no-such-program"]) 105 | # Linux says "[Errno 2] No such file or directory" 106 | # Windows says "[Error 2] The system cannot find the file specified" 107 | # but on 3.x it's "[WinErr 2] The system cannot find the file specified" 108 | should_start_with = "could not run ['there-is-really-no-such-program']:" 109 | self.assertTrue( 110 | str(cm.exception).startswith(should_start_with), 111 | '\n%r does not start with\n%r' % (str(cm.exception), 112 | should_start_with)) 113 | 114 | def test_mkdtemp_readonly_files(self): 115 | from check_manifest import mkdtemp 116 | with mkdtemp(hint='-test-readonly') as d: 117 | fn = os.path.join(d, 'file.txt') 118 | with open(fn, 'w'): 119 | pass 120 | os.chmod(fn, 0o444) # readonly 121 | assert not os.path.exists(d) 122 | 123 | @unittest.skipIf(sys.platform == 'win32', 124 | "No POSIX-like unreadable directories on Windows") 125 | def test_rmtree_unreadable_directories(self): 126 | d = self.make_temp_dir() 127 | sd = os.path.join(d, 'subdir') 128 | os.mkdir(sd) 129 | os.chmod(sd, 0) # a bad mode for a directory, oops 130 | # The onerror API of shutil.rmtree doesn't let us recover from 131 | # os.listdir() failures. 132 | with self.assertRaises(OSError): 133 | rmtree(sd) 134 | os.chmod(sd, 0o755) # so we can clean up 135 | 136 | def test_rmtree_readonly_directories(self): 137 | d = self.make_temp_dir() 138 | sd = os.path.join(d, 'subdir') 139 | fn = os.path.join(sd, 'file.txt') 140 | os.mkdir(sd) 141 | open(fn, 'w').close() 142 | os.chmod(sd, 0o444) # a bad mode for a directory, oops 143 | rmtree(sd) 144 | assert not os.path.exists(sd) 145 | 146 | def test_rmtree_readonly_directories_and_files(self): 147 | d = self.make_temp_dir() 148 | sd = os.path.join(d, 'subdir') 149 | fn = os.path.join(sd, 'file.txt') 150 | os.mkdir(sd) 151 | open(fn, 'w').close() 152 | os.chmod(fn, 0o444) # readonly 153 | os.chmod(sd, 0o444) # a bad mode for a directory, oops 154 | rmtree(sd) 155 | assert not os.path.exists(sd) 156 | 157 | def test_copy_files(self): 158 | from check_manifest import copy_files 159 | actions = [] 160 | n = os.path.normpath 161 | with mock.patch('os.path.isdir', lambda d: d in ('b', n('/dest/dir'))): 162 | with mock.patch('os.makedirs', 163 | lambda d: actions.append('makedirs %s' % d)): 164 | with mock.patch('os.mkdir', 165 | lambda d: actions.append('mkdir %s' % d)): 166 | with mock.patch('shutil.copy2', 167 | lambda s, d: actions.append(f'cp {s} {d}')): 168 | copy_files(['a', 'b', n('c/d/e')], n('/dest/dir')) 169 | self.assertEqual( 170 | actions, 171 | [ 172 | 'cp a %s' % n('/dest/dir/a'), 173 | 'mkdir %s' % n('/dest/dir/b'), 174 | 'makedirs %s' % n('/dest/dir/c/d'), 175 | 'cp %s %s' % (n('c/d/e'), n('/dest/dir/c/d/e')), 176 | ]) 177 | 178 | def test_get_one_file_in(self): 179 | from check_manifest import get_one_file_in 180 | with mock.patch('os.listdir', lambda dir: ['a']): 181 | self.assertEqual(get_one_file_in(os.path.normpath('/some/dir')), 182 | os.path.normpath('/some/dir/a')) 183 | 184 | def test_get_one_file_in_empty_directory(self): 185 | from check_manifest import Failure, get_one_file_in 186 | with mock.patch('os.listdir', lambda dir: []): 187 | with self.assertRaises(Failure) as cm: 188 | get_one_file_in('/some/dir') 189 | self.assertEqual(str(cm.exception), 190 | "No files found in /some/dir") 191 | 192 | def test_get_one_file_in_too_many(self): 193 | from check_manifest import Failure, get_one_file_in 194 | with mock.patch('os.listdir', lambda dir: ['b', 'a']): 195 | with self.assertRaises(Failure) as cm: 196 | get_one_file_in('/some/dir') 197 | self.assertEqual(str(cm.exception), 198 | "More than one file exists in /some/dir:\na\nb") 199 | 200 | def test_unicodify(self): 201 | from check_manifest import unicodify 202 | nonascii = "\u00E9.txt" 203 | self.assertEqual(unicodify(nonascii), nonascii) 204 | self.assertEqual( 205 | unicodify(nonascii.encode(locale.getpreferredencoding())), 206 | nonascii) 207 | 208 | def test_get_archive_file_list_unrecognized_archive(self): 209 | from check_manifest import Failure, get_archive_file_list 210 | with self.assertRaises(Failure) as cm: 211 | get_archive_file_list('/path/to/archive.rar') 212 | self.assertEqual(str(cm.exception), 213 | 'Unrecognized archive type: archive.rar') 214 | 215 | def test_get_archive_file_list_zip(self): 216 | from check_manifest import get_archive_file_list 217 | filename = os.path.join(self.make_temp_dir(), 'archive.zip') 218 | self.create_zip_file(filename, ['a', 'b/c']) 219 | self.assertEqual(get_archive_file_list(filename), 220 | ['a', 'b/c']) 221 | 222 | def test_get_archive_file_list_zip_nonascii(self): 223 | from check_manifest import get_archive_file_list 224 | filename = os.path.join(self.make_temp_dir(), 'archive.zip') 225 | nonascii = "\u00E9.txt" 226 | self.create_zip_file(filename, [nonascii]) 227 | self.assertEqual(get_archive_file_list(filename), 228 | [nonascii]) 229 | 230 | def test_get_archive_file_list_tar(self): 231 | from check_manifest import get_archive_file_list 232 | filename = os.path.join(self.make_temp_dir(), 'archive.tar') 233 | self.create_tar_file(filename, ['a', 'b/c']) 234 | self.assertEqual(get_archive_file_list(filename), 235 | ['a', 'b/c']) 236 | 237 | def test_get_archive_file_list_tar_nonascii(self): 238 | from check_manifest import get_archive_file_list 239 | filename = os.path.join(self.make_temp_dir(), 'archive.tar') 240 | nonascii = "\u00E9.txt" 241 | self.create_tar_file(filename, [nonascii]) 242 | self.assertEqual(get_archive_file_list(filename), 243 | [nonascii]) 244 | 245 | def test_format_list(self): 246 | from check_manifest import format_list 247 | self.assertEqual(format_list([]), "") 248 | self.assertEqual(format_list(['a']), " a") 249 | self.assertEqual(format_list(['a', 'b']), " a\n b") 250 | 251 | def test_format_missing(self): 252 | from check_manifest import format_missing 253 | self.assertEqual( 254 | format_missing(set(), set(), "1st", "2nd"), 255 | "") 256 | self.assertEqual( 257 | format_missing({"c"}, {"a"}, "1st", "2nd"), 258 | "missing from 1st:\n" 259 | " c\n" 260 | "missing from 2nd:\n" 261 | " a") 262 | 263 | def test_strip_toplevel_name_empty_list(self): 264 | from check_manifest import strip_toplevel_name 265 | self.assertEqual(strip_toplevel_name([]), []) 266 | 267 | def test_strip_toplevel_name_no_common_prefix(self): 268 | from check_manifest import Failure, strip_toplevel_name 269 | self.assertRaises(Failure, strip_toplevel_name, ["a/b", "c/d"]) 270 | 271 | def test_detect_vcs_no_vcs(self): 272 | from check_manifest import Failure, detect_vcs 273 | ui = MockUI() 274 | with mock.patch('check_manifest.VCS.detect', staticmethod(lambda *a: False)): 275 | with mock.patch('check_manifest.Git.detect', staticmethod(lambda *a: False)): 276 | with self.assertRaises(Failure) as cm: 277 | detect_vcs(ui) 278 | self.assertEqual(str(cm.exception), 279 | "Couldn't find version control data" 280 | " (git/hg/bzr/svn supported)") 281 | 282 | def test_normalize_names(self): 283 | from check_manifest import normalize_names 284 | j = os.path.join 285 | self.assertEqual(normalize_names(["a", j("b", ""), j("c", "d"), 286 | j("e", "f", ""), 287 | j("g", "h", "..", "i")]), 288 | ["a", "b", "c/d", "e/f", "g/i"]) 289 | 290 | def test_canonical_file_list(self): 291 | from check_manifest import canonical_file_list 292 | j = os.path.join 293 | self.assertEqual( 294 | canonical_file_list(['b', 'a', 'c', j('c', 'd'), j('e', 'f'), 295 | 'g', j('g', 'h', 'i', 'j')]), 296 | ['a', 'b', 'c/d', 'e/f', 'g/h/i/j']) 297 | 298 | def test_file_matches(self): 299 | from check_manifest import file_matches 300 | patterns = ['setup.cfg', '*.egg-info', '*.egg-info/*'] 301 | self.assertFalse(file_matches('setup.py', patterns)) 302 | self.assertTrue(file_matches('setup.cfg', patterns)) 303 | self.assertTrue(file_matches('src/zope.foo.egg-info', patterns)) 304 | self.assertTrue(file_matches('src/zope.foo.egg-info/SOURCES.txt', 305 | patterns)) 306 | 307 | def test_strip_sdist_extras(self): 308 | from check_manifest import ( 309 | IgnoreList, 310 | canonical_file_list, 311 | strip_sdist_extras, 312 | ) 313 | filelist = canonical_file_list([ 314 | '.circleci/config.yml', 315 | '.github', 316 | '.github/ISSUE_TEMPLATE', 317 | '.github/ISSUE_TEMPLATE/bug_report.md', 318 | '.gitignore', 319 | '.gitpod.yml', 320 | '.travis.yml', 321 | 'setup.py', 322 | 'setup.cfg', 323 | 'README.txt', 324 | 'src', 325 | 'src/.gitignore', 326 | 'src/zope', 327 | 'src/zope/__init__.py', 328 | 'src/zope/foo', 329 | 'src/zope/foo/__init__.py', 330 | 'src/zope/foo/language.po', 331 | 'src/zope/foo/language.mo', 332 | 'src/zope.foo.egg-info', 333 | 'src/zope.foo.egg-info/SOURCES.txt', 334 | ]) 335 | expected = canonical_file_list([ 336 | 'setup.py', 337 | 'README.txt', 338 | 'src', 339 | 'src/zope', 340 | 'src/zope/__init__.py', 341 | 'src/zope/foo', 342 | 'src/zope/foo/__init__.py', 343 | 'src/zope/foo/language.po', 344 | ]) 345 | ignore = IgnoreList.default() 346 | self.assertEqual(strip_sdist_extras(ignore, filelist), expected) 347 | 348 | def test_strip_sdist_extras_with_manifest(self): 349 | from check_manifest import ( 350 | IgnoreList, 351 | _get_ignore_from_manifest_lines, 352 | canonical_file_list, 353 | strip_sdist_extras, 354 | ) 355 | manifest_in = textwrap.dedent(""" 356 | graft src 357 | exclude *.cfg 358 | global-exclude *.mo 359 | prune src/dump 360 | recursive-exclude src/zope *.sh 361 | """) 362 | filelist = canonical_file_list([ 363 | '.github/ISSUE_TEMPLATE/bug_report.md', 364 | '.gitignore', 365 | 'setup.py', 366 | 'setup.cfg', 367 | 'MANIFEST.in', 368 | 'README.txt', 369 | 'src', 370 | 'src/helper.sh', 371 | 'src/dump', 372 | 'src/dump/__init__.py', 373 | 'src/zope', 374 | 'src/zope/__init__.py', 375 | 'src/zope/zopehelper.sh', 376 | 'src/zope/foo', 377 | 'src/zope/foo/__init__.py', 378 | 'src/zope/foo/language.po', 379 | 'src/zope/foo/language.mo', 380 | 'src/zope/foo/config.cfg', 381 | 'src/zope/foo/foohelper.sh', 382 | 'src/zope.foo.egg-info', 383 | 'src/zope.foo.egg-info/SOURCES.txt', 384 | ]) 385 | expected = canonical_file_list([ 386 | 'setup.py', 387 | 'MANIFEST.in', 388 | 'README.txt', 389 | 'src', 390 | 'src/helper.sh', 391 | 'src/zope', 392 | 'src/zope/__init__.py', 393 | 'src/zope/foo', 394 | 'src/zope/foo/__init__.py', 395 | 'src/zope/foo/language.po', 396 | 'src/zope/foo/config.cfg', 397 | ]) 398 | ignore = IgnoreList.default() 399 | ignore += _get_ignore_from_manifest_lines(manifest_in.splitlines(), self.ui) 400 | result = strip_sdist_extras(ignore, filelist) 401 | self.assertEqual(result, expected) 402 | 403 | def test_find_bad_ideas(self): 404 | from check_manifest import find_bad_ideas 405 | filelist = [ 406 | '.gitignore', 407 | 'setup.py', 408 | 'setup.cfg', 409 | 'README.txt', 410 | 'src', 411 | 'src/zope', 412 | 'src/zope/__init__.py', 413 | 'src/zope/foo', 414 | 'src/zope/foo/__init__.py', 415 | 'src/zope/foo/language.po', 416 | 'src/zope/foo/language.mo', 417 | 'src/zope.foo.egg-info', 418 | 'src/zope.foo.egg-info/SOURCES.txt', 419 | ] 420 | expected = [ 421 | 'src/zope/foo/language.mo', 422 | 'src/zope.foo.egg-info', 423 | ] 424 | self.assertEqual(find_bad_ideas(filelist), expected) 425 | 426 | def test_find_suggestions(self): 427 | from check_manifest import find_suggestions 428 | self.assertEqual(find_suggestions(['buildout.cfg']), 429 | (['include buildout.cfg'], [])) 430 | self.assertEqual(find_suggestions(['unknown.file~']), 431 | ([], ['unknown.file~'])) 432 | self.assertEqual(find_suggestions(['README.txt', 'CHANGES.txt']), 433 | (['include *.txt'], [])) 434 | filelist = [ 435 | 'docs/index.rst', 436 | 'docs/image.png', 437 | 'docs/Makefile', 438 | 'docs/unknown-file', 439 | 'src/etc/blah/blah/Makefile', 440 | ] 441 | expected_rules = [ 442 | 'recursive-include docs *.png', 443 | 'recursive-include docs *.rst', 444 | 'recursive-include docs Makefile', 445 | 'recursive-include src Makefile', 446 | ] 447 | expected_unknowns = ['docs/unknown-file'] 448 | self.assertEqual(find_suggestions(filelist), 449 | (expected_rules, expected_unknowns)) 450 | 451 | def test_find_suggestions_generic_fallback_rules(self): 452 | from check_manifest import find_suggestions 453 | self.assertEqual(find_suggestions(['Changelog']), 454 | (['include Changelog'], [])) 455 | self.assertEqual(find_suggestions(['id-lang.map']), 456 | (['include *.map'], [])) 457 | self.assertEqual(find_suggestions(['src/id-lang.map']), 458 | (['recursive-include src *.map'], [])) 459 | 460 | def test_is_package(self): 461 | from check_manifest import is_package 462 | j = os.path.join 463 | exists = {j('a', 'setup.py'), j('c', 'pyproject.toml')} 464 | with mock.patch('os.path.exists', lambda fn: fn in exists): 465 | self.assertTrue(is_package('a')) 466 | self.assertFalse(is_package('b')) 467 | self.assertTrue(is_package('c')) 468 | 469 | def test_extract_version_from_filename(self): 470 | from check_manifest import extract_version_from_filename as e 471 | self.assertEqual(e('dist/foo_bar-1.2.3.dev4+g12345.zip'), '1.2.3.dev4+g12345') 472 | self.assertEqual(e('dist/foo_bar-1.2.3.dev4+g12345.tar.gz'), '1.2.3.dev4+g12345') 473 | self.assertEqual(e('dist/foo-bar-1.2.3.dev4+g12345.tar.gz'), '1.2.3.dev4+g12345') 474 | 475 | def test_get_ignore_from_manifest_lines(self): 476 | from check_manifest import IgnoreList, _get_ignore_from_manifest_lines 477 | parse = partial(_get_ignore_from_manifest_lines, ui=self.ui) 478 | self.assertEqual(parse([]), 479 | IgnoreList()) 480 | self.assertEqual(parse(['', ' ']), 481 | IgnoreList()) 482 | self.assertEqual(parse(['exclude *.cfg']), 483 | IgnoreList().exclude('*.cfg')) 484 | self.assertEqual(parse(['exclude *.cfg']), 485 | IgnoreList().exclude('*.cfg')) 486 | self.assertEqual(parse(['\texclude\t*.cfg foo.* bar.txt']), 487 | IgnoreList().exclude('*.cfg', 'foo.*', 'bar.txt')) 488 | self.assertEqual(parse(['exclude some/directory/*.cfg']), 489 | IgnoreList().exclude('some/directory/*.cfg')) 490 | self.assertEqual(parse(['include *.cfg']), 491 | IgnoreList()) 492 | self.assertEqual(parse(['global-exclude *.pyc']), 493 | IgnoreList().global_exclude('*.pyc')) 494 | self.assertEqual(parse(['global-exclude *.pyc *.sh']), 495 | IgnoreList().global_exclude('*.pyc', '*.sh')) 496 | self.assertEqual(parse(['recursive-exclude dir *.pyc']), 497 | IgnoreList().recursive_exclude('dir', '*.pyc')) 498 | self.assertEqual(parse(['recursive-exclude dir *.pyc foo*.sh']), 499 | IgnoreList().recursive_exclude('dir', '*.pyc', 'foo*.sh')) 500 | self.assertEqual(parse(['recursive-exclude dir nopattern.xml']), 501 | IgnoreList().recursive_exclude('dir', 'nopattern.xml')) 502 | # We should not fail when a recursive-exclude line is wrong: 503 | self.assertEqual(parse(['recursive-exclude dirwithoutpattern']), 504 | IgnoreList()) 505 | self.assertEqual(parse(['prune dir']), 506 | IgnoreList().prune('dir')) 507 | # And a mongo test case of everything at the end 508 | text = textwrap.dedent(""" 509 | exclude *.02 510 | exclude *.03 04.* bar.txt 511 | exclude *.05 512 | exclude some/directory/*.cfg 513 | global-exclude *.10 *.11 514 | global-exclude *.12 515 | include *.20 516 | prune 30 517 | recursive-exclude 40 *.41 518 | recursive-exclude 42 *.43 44.* 519 | """).splitlines() 520 | self.assertEqual( 521 | parse(text), 522 | IgnoreList() 523 | .exclude('*.02', '*.03', '04.*', 'bar.txt', '*.05', 'some/directory/*.cfg') 524 | .global_exclude('*.10', '*.11', '*.12') 525 | .prune('30') 526 | .recursive_exclude('40', '*.41') 527 | .recursive_exclude('42', '*.43', '44.*') 528 | ) 529 | 530 | def test_get_ignore_from_manifest_lines_warns(self): 531 | from check_manifest import IgnoreList, _get_ignore_from_manifest_lines 532 | parse = partial(_get_ignore_from_manifest_lines, ui=self.ui) 533 | text = textwrap.dedent(""" 534 | graft a/ 535 | recursive-include /b *.txt 536 | """).splitlines() 537 | self.assertEqual(parse(text), IgnoreList()) 538 | self.assertEqual(self.ui.warnings, [ 539 | 'ERROR: Trailing slashes are not allowed in MANIFEST.in on Windows: a/', 540 | 'ERROR: Leading slashes are not allowed in MANIFEST.in on Windows: /b', 541 | ]) 542 | 543 | def test_get_ignore_from_manifest(self): 544 | from check_manifest import IgnoreList, _get_ignore_from_manifest 545 | filename = os.path.join(self.make_temp_dir(), 'MANIFEST.in') 546 | self.create_file(filename, textwrap.dedent(''' 547 | exclude \\ 548 | # yes, this is allowed! 549 | test.dat 550 | 551 | # https://github.com/mgedmin/check-manifest/issues/66 552 | # docs/ folder 553 | ''')) 554 | ui = MockUI() 555 | self.assertEqual(_get_ignore_from_manifest(filename, ui), 556 | IgnoreList().exclude('test.dat')) 557 | self.assertEqual(ui.warnings, []) 558 | 559 | def test_get_ignore_from_manifest_warnings(self): 560 | from check_manifest import IgnoreList, _get_ignore_from_manifest 561 | filename = os.path.join(self.make_temp_dir(), 'MANIFEST.in') 562 | self.create_file(filename, textwrap.dedent(''' 563 | # this is bad: a file should not end with a backslash 564 | exclude test.dat \\ 565 | ''')) 566 | ui = MockUI() 567 | self.assertEqual(_get_ignore_from_manifest(filename, ui), 568 | IgnoreList().exclude('test.dat')) 569 | self.assertEqual(ui.warnings, [ 570 | "%s, line 2: continuation line immediately precedes end-of-file" % filename, 571 | ]) 572 | 573 | def test_should_use_pep517_no_pyproject_toml(self): 574 | from check_manifest import cd, should_use_pep_517 575 | src_dir = self.make_temp_dir() 576 | with cd(src_dir): 577 | self.assertFalse(should_use_pep_517()) 578 | 579 | def test_should_use_pep517_no_build_system(self): 580 | from check_manifest import cd, should_use_pep_517 581 | src_dir = self.make_temp_dir() 582 | filename = os.path.join(src_dir, 'pyproject.toml') 583 | self.create_file(filename, textwrap.dedent(''' 584 | [tool.check-manifest] 585 | ''')) 586 | with cd(src_dir): 587 | self.assertFalse(should_use_pep_517()) 588 | 589 | def test_should_use_pep517_no_build_backend(self): 590 | from check_manifest import cd, should_use_pep_517 591 | src_dir = self.make_temp_dir() 592 | filename = os.path.join(src_dir, 'pyproject.toml') 593 | self.create_file(filename, textwrap.dedent(''' 594 | [build-system] 595 | requires = [ 596 | "setuptools >= 40.6.0", 597 | "wheel", 598 | ] 599 | ''')) 600 | with cd(src_dir): 601 | self.assertFalse(should_use_pep_517()) 602 | 603 | def test_should_use_pep517_yes_please(self): 604 | from check_manifest import cd, should_use_pep_517 605 | src_dir = self.make_temp_dir() 606 | filename = os.path.join(src_dir, 'pyproject.toml') 607 | self.create_file(filename, textwrap.dedent(''' 608 | [build-system] 609 | requires = [ 610 | "setuptools >= 40.6.0", 611 | "wheel", 612 | ] 613 | build-backend = "setuptools.build_meta" 614 | ''')) 615 | with cd(src_dir): 616 | self.assertTrue(should_use_pep_517()) 617 | 618 | def _test_build_sdist_pep517(self, build_isolation): 619 | from check_manifest import build_sdist, cd, get_one_file_in 620 | src_dir = self.make_temp_dir() 621 | filename = os.path.join(src_dir, 'pyproject.toml') 622 | self.create_file(filename, textwrap.dedent(''' 623 | [build-system] 624 | requires = [ 625 | "setuptools >= 40.6.0", 626 | "wheel", 627 | ] 628 | build-backend = "setuptools.build_meta" 629 | ''')) 630 | out_dir = self.make_temp_dir() 631 | python = os.path.abspath(sys.executable) 632 | with cd(src_dir): 633 | build_sdist(out_dir, python=python, build_isolation=build_isolation) 634 | self.assertTrue(get_one_file_in(out_dir)) 635 | 636 | def test_build_sdist_pep517_isolated(self): 637 | self._test_build_sdist_pep517(build_isolation=True) 638 | 639 | def test_build_sdist_pep517_no_isolation(self): 640 | self._test_build_sdist_pep517(build_isolation=False) 641 | 642 | 643 | class TestConfiguration(unittest.TestCase): 644 | 645 | def setUp(self): 646 | self.oldpwd = os.getcwd() 647 | self.tmpdir = tempfile.mkdtemp(prefix='test-', suffix='-check-manifest') 648 | os.chdir(self.tmpdir) 649 | self.ui = MockUI() 650 | 651 | def tearDown(self): 652 | os.chdir(self.oldpwd) 653 | rmtree(self.tmpdir) 654 | 655 | def test_read_config_no_config(self): 656 | import check_manifest 657 | ignore, ignore_bad_ideas = check_manifest.read_config() 658 | self.assertEqual(ignore, check_manifest.IgnoreList.default()) 659 | 660 | def test_read_setup_config_no_section(self): 661 | import check_manifest 662 | with open('setup.cfg', 'w') as f: 663 | f.write('[pep8]\nignore =\n') 664 | ignore, ignore_bad_ideas = check_manifest.read_config() 665 | self.assertEqual(ignore, check_manifest.IgnoreList.default()) 666 | 667 | def test_read_pyproject_config_no_section(self): 668 | import check_manifest 669 | with open('pyproject.toml', 'w') as f: 670 | f.write('[tool.pep8]\nignore = []\n') 671 | ignore, ignore_bad_ideas = check_manifest.read_config() 672 | self.assertEqual(ignore, check_manifest.IgnoreList.default()) 673 | 674 | def test_read_setup_config_no_option(self): 675 | import check_manifest 676 | with open('setup.cfg', 'w') as f: 677 | f.write('[check-manifest]\n') 678 | ignore, ignore_bad_ideas = check_manifest.read_config() 679 | self.assertEqual(ignore, check_manifest.IgnoreList.default()) 680 | 681 | def test_read_pyproject_config_no_option(self): 682 | import check_manifest 683 | with open('pyproject.toml', 'w') as f: 684 | f.write('[tool.check-manifest]\n') 685 | ignore, ignore_bad_ideas = check_manifest.read_config() 686 | self.assertEqual(ignore, check_manifest.IgnoreList.default()) 687 | 688 | def test_read_setup_config_extra_ignores(self): 689 | import check_manifest 690 | with open('setup.cfg', 'w') as f: 691 | f.write('[check-manifest]\nignore = foo\n bar*\n') 692 | ignore, ignore_bad_ideas = check_manifest.read_config() 693 | expected = check_manifest.IgnoreList.default().global_exclude('foo', 'bar*') 694 | self.assertEqual(ignore, expected) 695 | 696 | def test_read_pyproject_config_extra_ignores(self): 697 | import check_manifest 698 | with open('pyproject.toml', 'w') as f: 699 | f.write('[tool.check-manifest]\nignore = ["foo", "bar*"]\n') 700 | ignore, ignore_bad_ideas = check_manifest.read_config() 701 | expected = check_manifest.IgnoreList.default().global_exclude('foo', 'bar*') 702 | self.assertEqual(ignore, expected) 703 | 704 | def test_read_setup_config_override_ignores(self): 705 | import check_manifest 706 | with open('setup.cfg', 'w') as f: 707 | f.write('[check-manifest]\nignore = foo\n\n bar\n') 708 | f.write('ignore-default-rules = yes\n') 709 | ignore, ignore_bad_ideas = check_manifest.read_config() 710 | expected = check_manifest.IgnoreList().global_exclude('foo', 'bar') 711 | self.assertEqual(ignore, expected) 712 | 713 | def test_read_pyproject_config_override_ignores(self): 714 | import check_manifest 715 | with open('pyproject.toml', 'w') as f: 716 | f.write('[tool.check-manifest]\nignore = ["foo", "bar"]\n') 717 | f.write('ignore-default-rules = true\n') 718 | ignore, ignore_bad_ideas = check_manifest.read_config() 719 | expected = check_manifest.IgnoreList().global_exclude('foo', 'bar') 720 | self.assertEqual(ignore, expected) 721 | 722 | def test_read_setup_config_ignore_bad_ideas(self): 723 | import check_manifest 724 | with open('setup.cfg', 'w') as f: 725 | f.write('[check-manifest]\n' 726 | 'ignore-bad-ideas = \n' 727 | ' foo\n' 728 | ' bar*\n') 729 | ignore, ignore_bad_ideas = check_manifest.read_config() 730 | expected = check_manifest.IgnoreList().global_exclude('foo', 'bar*') 731 | self.assertEqual(ignore_bad_ideas, expected) 732 | 733 | def test_read_pyproject_config_ignore_bad_ideas(self): 734 | import check_manifest 735 | with open('pyproject.toml', 'w') as f: 736 | f.write('[tool.check-manifest]\n' 737 | 'ignore-bad-ideas = ["foo", "bar*"]\n') 738 | ignore, ignore_bad_ideas = check_manifest.read_config() 739 | expected = check_manifest.IgnoreList().global_exclude('foo', 'bar*') 740 | self.assertEqual(ignore_bad_ideas, expected) 741 | 742 | def test_read_manifest_no_manifest(self): 743 | import check_manifest 744 | ignore = check_manifest.read_manifest(self.ui) 745 | self.assertEqual(ignore, check_manifest.IgnoreList()) 746 | 747 | def test_read_manifest(self): 748 | import check_manifest 749 | from check_manifest import IgnoreList 750 | with open('MANIFEST.in', 'w') as f: 751 | f.write('exclude *.gif\n') 752 | f.write('global-exclude *.png\n') 753 | ignore = check_manifest.read_manifest(self.ui) 754 | self.assertEqual(ignore, IgnoreList().exclude('*.gif').global_exclude('*.png')) 755 | 756 | 757 | class TestMain(unittest.TestCase): 758 | 759 | def setUp(self): 760 | self._cm_patcher = mock.patch('check_manifest.check_manifest') 761 | self._check_manifest = self._cm_patcher.start() 762 | self._se_patcher = mock.patch('sys.exit') 763 | self._sys_exit = self._se_patcher.start() 764 | self.ui = MockUI() 765 | self._ui_patcher = mock.patch('check_manifest.UI', self._make_ui) 766 | self._ui_patcher.start() 767 | self._orig_sys_argv = sys.argv 768 | sys.argv = ['check-manifest'] 769 | 770 | def tearDown(self): 771 | sys.argv = self._orig_sys_argv 772 | self._se_patcher.stop() 773 | self._cm_patcher.stop() 774 | self._ui_patcher.stop() 775 | 776 | def _make_ui(self, verbosity): 777 | self.ui.verbosity = verbosity 778 | return self.ui 779 | 780 | def test(self): 781 | from check_manifest import main 782 | sys.argv.append('-v') 783 | main() 784 | 785 | def test_exit_code_1_on_error(self): 786 | from check_manifest import main 787 | self._check_manifest.return_value = False 788 | main() 789 | self._sys_exit.assert_called_with(1) 790 | 791 | def test_exit_code_2_on_failure(self): 792 | from check_manifest import Failure, main 793 | self._check_manifest.side_effect = Failure('msg') 794 | main() 795 | self.assertEqual(self.ui.errors, ['msg']) 796 | self._sys_exit.assert_called_with(2) 797 | 798 | def test_extra_ignore_args(self): 799 | import check_manifest 800 | sys.argv.append('--ignore=x,y,z*') 801 | check_manifest.main() 802 | ignore = check_manifest.IgnoreList().global_exclude('x', 'y', 'z*') 803 | self.assertEqual(self._check_manifest.call_args.kwargs['extra_ignore'], 804 | ignore) 805 | 806 | def test_ignore_bad_ideas_args(self): 807 | import check_manifest 808 | sys.argv.append('--ignore-bad-ideas=x,y,z*') 809 | check_manifest.main() 810 | ignore = check_manifest.IgnoreList().global_exclude('x', 'y', 'z*') 811 | self.assertEqual(self._check_manifest.call_args.kwargs['extra_ignore_bad_ideas'], 812 | ignore) 813 | 814 | def test_verbose_arg(self): 815 | import check_manifest 816 | sys.argv.append('--verbose') 817 | check_manifest.main() 818 | self.assertEqual(self.ui.verbosity, 2) 819 | 820 | def test_quiet_arg(self): 821 | import check_manifest 822 | sys.argv.append('--quiet') 823 | check_manifest.main() 824 | self.assertEqual(self.ui.verbosity, 0) 825 | 826 | def test_verbose_and_quiet_arg(self): 827 | import check_manifest 828 | sys.argv.append('--verbose') 829 | sys.argv.append('--quiet') 830 | check_manifest.main() 831 | # the two arguments cancel each other out: 832 | # 1 (default verbosity) + 1 - 1 = 1. 833 | self.assertEqual(self.ui.verbosity, 1) 834 | 835 | 836 | class TestZestIntegration(unittest.TestCase): 837 | 838 | def setUp(self): 839 | sys.modules['zest'] = mock.Mock() 840 | sys.modules['zest.releaser'] = mock.Mock() 841 | sys.modules['zest.releaser.utils'] = mock.Mock() 842 | self.ask = sys.modules['zest.releaser.utils'].ask 843 | self.ui = MockUI() 844 | self._ui_patcher = mock.patch('check_manifest.UI', return_value=self.ui) 845 | self._ui_patcher.start() 846 | 847 | def tearDown(self): 848 | self._ui_patcher.stop() 849 | del sys.modules['zest.releaser.utils'] 850 | del sys.modules['zest.releaser'] 851 | del sys.modules['zest'] 852 | 853 | @mock.patch('check_manifest.is_package', lambda d: False) 854 | @mock.patch('check_manifest.check_manifest') 855 | def test_zest_releaser_check_not_a_package(self, check_manifest): 856 | from check_manifest import zest_releaser_check 857 | zest_releaser_check(dict(workingdir='.')) 858 | check_manifest.assert_not_called() 859 | 860 | @mock.patch('check_manifest.is_package', lambda d: True) 861 | @mock.patch('check_manifest.check_manifest') 862 | def test_zest_releaser_check_user_disagrees(self, check_manifest): 863 | from check_manifest import zest_releaser_check 864 | self.ask.return_value = False 865 | zest_releaser_check(dict(workingdir='.')) 866 | check_manifest.assert_not_called() 867 | 868 | @mock.patch('check_manifest.is_package', lambda d: True) 869 | @mock.patch('sys.exit') 870 | @mock.patch('check_manifest.check_manifest') 871 | def test_zest_releaser_check_all_okay(self, check_manifest, sys_exit): 872 | from check_manifest import zest_releaser_check 873 | self.ask.return_value = True 874 | check_manifest.return_value = True 875 | zest_releaser_check(dict(workingdir='.')) 876 | sys_exit.assert_not_called() 877 | 878 | @mock.patch('check_manifest.is_package', lambda d: True) 879 | @mock.patch('sys.exit') 880 | @mock.patch('check_manifest.check_manifest') 881 | def test_zest_releaser_check_error_user_aborts(self, check_manifest, 882 | sys_exit): 883 | from check_manifest import zest_releaser_check 884 | self.ask.side_effect = [True, False] 885 | check_manifest.return_value = False 886 | zest_releaser_check(dict(workingdir='.')) 887 | sys_exit.assert_called_with(1) 888 | 889 | @mock.patch('check_manifest.is_package', lambda d: True) 890 | @mock.patch('sys.exit') 891 | @mock.patch('check_manifest.check_manifest') 892 | def test_zest_releaser_check_error_user_plods_on(self, check_manifest, 893 | sys_exit): 894 | from check_manifest import zest_releaser_check 895 | self.ask.side_effect = [True, True] 896 | check_manifest.return_value = False 897 | zest_releaser_check(dict(workingdir='.')) 898 | sys_exit.assert_not_called() 899 | 900 | @mock.patch('check_manifest.is_package', lambda d: True) 901 | @mock.patch('sys.exit') 902 | @mock.patch('check_manifest.check_manifest') 903 | def test_zest_releaser_check_failure_user_aborts(self, check_manifest, 904 | sys_exit): 905 | from check_manifest import Failure, zest_releaser_check 906 | self.ask.side_effect = [True, False] 907 | check_manifest.side_effect = Failure('msg') 908 | zest_releaser_check(dict(workingdir='.')) 909 | self.assertEqual(self.ui.errors, ['msg']) 910 | sys_exit.assert_called_with(2) 911 | 912 | @mock.patch('check_manifest.is_package', lambda d: True) 913 | @mock.patch('sys.exit') 914 | @mock.patch('check_manifest.check_manifest') 915 | def test_zest_releaser_check_failure_user_plods_on(self, check_manifest, 916 | sys_exit): 917 | from check_manifest import Failure, zest_releaser_check 918 | self.ask.side_effect = [True, True] 919 | check_manifest.side_effect = Failure('msg') 920 | zest_releaser_check(dict(workingdir='.')) 921 | self.assertEqual(self.ui.errors, ['msg']) 922 | sys_exit.assert_not_called() 923 | 924 | 925 | class VCSHelper: 926 | 927 | # override in subclasses 928 | command: Optional[str] = None 929 | extra_env: Dict[str, str] = {} 930 | 931 | @property 932 | def version(self): 933 | if not hasattr(self, '_version'): 934 | if not self.is_installed(): 935 | self._version = None 936 | return self._version 937 | 938 | @property 939 | def version_tuple(self): 940 | return tuple(map(int, re.findall(r'\d+', self.version))) 941 | 942 | def is_installed(self): 943 | try: 944 | p = subprocess.Popen([self.command, '--version'], 945 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 946 | stdout, stderr = p.communicate() 947 | self._version = stdout.decode('ascii', 'backslashreplace').strip() 948 | rc = p.wait() 949 | return (rc == 0) 950 | except OSError: 951 | return False 952 | 953 | def _run(self, *command): 954 | # Windows doesn't like Unicode arguments to subprocess.Popen(), on Py2: 955 | # https://github.com/mgedmin/check-manifest/issues/23#issuecomment-33933031 956 | if str is bytes: 957 | command = [s.encode(locale.getpreferredencoding()) for s in command] 958 | print('$', ' '.join(command)) 959 | p = subprocess.Popen(command, stdout=subprocess.PIPE, 960 | stderr=subprocess.STDOUT, 961 | env={**os.environ, **self.extra_env}) 962 | stdout, stderr = p.communicate() 963 | rc = p.wait() 964 | if stdout: 965 | print(stdout.decode('ascii', 'backslashreplace')) 966 | if rc: 967 | raise subprocess.CalledProcessError(rc, command[0], output=stdout) 968 | 969 | 970 | class VCSMixin: 971 | 972 | def setUp(self): 973 | if not self.vcs.is_installed() and CAN_SKIP_TESTS: 974 | self.skipTest("%s is not installed" % self.vcs.command) 975 | self.tmpdir = tempfile.mkdtemp(prefix='test-', suffix='-check-manifest') 976 | self.olddir = os.getcwd() 977 | os.chdir(self.tmpdir) 978 | self.ui = MockUI() 979 | 980 | def tearDown(self): 981 | os.chdir(self.olddir) 982 | rmtree(self.tmpdir) 983 | 984 | def _create_file(self, filename): 985 | assert not os.path.isabs(filename) 986 | basedir = os.path.dirname(filename) 987 | if basedir and not os.path.isdir(basedir): 988 | os.makedirs(basedir) 989 | open(filename, 'w').close() 990 | 991 | def _create_files(self, filenames): 992 | for filename in filenames: 993 | self._create_file(filename) 994 | 995 | def _init_vcs(self): 996 | self.vcs._init_vcs() 997 | 998 | def _add_to_vcs(self, filenames): 999 | self.vcs._add_to_vcs(filenames) 1000 | 1001 | def _commit(self): 1002 | self.vcs._commit() 1003 | 1004 | def _create_and_add_to_vcs(self, filenames): 1005 | self._create_files(filenames) 1006 | self._add_to_vcs(filenames) 1007 | 1008 | def test_get_vcs_files(self): 1009 | from check_manifest import get_vcs_files 1010 | self._init_vcs() 1011 | self._create_and_add_to_vcs(['a.txt', 'b/b.txt', 'b/c/d.txt']) 1012 | self._commit() 1013 | self._create_files(['b/x.txt', 'd/d.txt', 'i.txt']) 1014 | self.assertEqual(get_vcs_files(self.ui), 1015 | ['a.txt', 'b/b.txt', 'b/c/d.txt']) 1016 | 1017 | def test_get_vcs_files_added_but_uncommitted(self): 1018 | from check_manifest import get_vcs_files 1019 | self._init_vcs() 1020 | self._create_and_add_to_vcs(['a.txt', 'b/b.txt', 'b/c/d.txt']) 1021 | self._create_files(['b/x.txt', 'd/d.txt', 'i.txt']) 1022 | self.assertEqual(get_vcs_files(self.ui), 1023 | ['a.txt', 'b/b.txt', 'b/c/d.txt']) 1024 | 1025 | def test_get_vcs_files_deleted_but_not_removed(self): 1026 | if self.vcs.command == 'bzr': 1027 | self.skipTest("this cosmetic feature is not supported with bzr") 1028 | # see the longer explanation in test_missing_source_files 1029 | from check_manifest import get_vcs_files 1030 | self._init_vcs() 1031 | self._create_and_add_to_vcs(['a.txt']) 1032 | self._commit() 1033 | os.unlink('a.txt') 1034 | self.assertEqual(get_vcs_files(self.ui), ['a.txt']) 1035 | 1036 | def test_get_vcs_files_in_a_subdir(self): 1037 | from check_manifest import get_vcs_files 1038 | self._init_vcs() 1039 | self._create_and_add_to_vcs(['a.txt', 'b/b.txt', 'b/c/d.txt']) 1040 | self._commit() 1041 | self._create_files(['b/x.txt', 'd/d.txt', 'i.txt']) 1042 | os.chdir('b') 1043 | self.assertEqual(get_vcs_files(self.ui), ['b.txt', 'c/d.txt']) 1044 | 1045 | @unittest.skipIf( 1046 | sys.platform == 'win32', 1047 | "Get yourself a real VCS running on a real OS, I can't support this mess", 1048 | ) 1049 | def test_get_vcs_files_nonascii_filenames(self): 1050 | # This test will fail if your locale is incapable of expressing 1051 | # "eacute". UTF-8 or Latin-1 should work. 1052 | from check_manifest import Bazaar 1053 | print("Detected terminal encoding: ", Bazaar._get_terminal_encoding()) 1054 | print("sys.stdin.isatty():", sys.stdin.isatty()) 1055 | print("sys.stdout.isatty():", sys.stdout.isatty()) 1056 | print("sys.stdin.encoding:", getattr(sys.stdin, 'encoding', 'missing')) 1057 | print("sys.stdout.encoding:", getattr(sys.stdout, 'encoding', 'missing')) 1058 | from check_manifest import get_vcs_files 1059 | self._init_vcs() 1060 | filename = "\u00E9.txt" 1061 | self._create_and_add_to_vcs([filename]) 1062 | self.assertEqual(get_vcs_files(self.ui), [filename]) 1063 | 1064 | def test_get_vcs_files_empty(self): 1065 | from check_manifest import get_vcs_files 1066 | self._init_vcs() 1067 | self.assertEqual(get_vcs_files(self.ui), []) 1068 | 1069 | 1070 | class GitHelper(VCSHelper): 1071 | 1072 | command = 'git' 1073 | extra_env = dict( 1074 | GIT_ALLOW_PROTOCOL='file', 1075 | ) 1076 | 1077 | def _init_vcs(self): 1078 | if self.version_tuple >= (2, 28): 1079 | self._run('git', 'init', '-b', 'main') 1080 | else: 1081 | self._run('git', 'init') 1082 | self._run('git', 'config', 'user.name', 'Unit Test') 1083 | self._run('git', 'config', 'user.email', 'test@example.com') 1084 | 1085 | def _add_to_vcs(self, filenames): 1086 | # Note that we use --force to prevent errors when we want to 1087 | # add foo.egg-info and the user running the tests has 1088 | # '*.egg-info' in her global .gitignore file. 1089 | self._run('git', 'add', '--force', '--', *filenames) 1090 | 1091 | def _commit(self): 1092 | self._run('git', 'commit', '-m', 'Initial') 1093 | 1094 | 1095 | class TestGit(VCSMixin, unittest.TestCase): 1096 | vcs = GitHelper() 1097 | 1098 | def _init_repo_with_files(self, dirname, filenames): 1099 | os.mkdir(dirname) 1100 | os.chdir(dirname) 1101 | self._init_vcs() 1102 | self._create_and_add_to_vcs(filenames) 1103 | self._commit() 1104 | os.chdir(self.tmpdir) 1105 | 1106 | def _add_submodule(self, repo, subdir, subrepo): 1107 | os.chdir(repo) 1108 | self.vcs._run('git', 'submodule', 'add', subrepo, subdir) 1109 | self._commit() 1110 | os.chdir(self.tmpdir) 1111 | 1112 | def test_detect_git_submodule(self): 1113 | from check_manifest import Failure, detect_vcs 1114 | with self.assertRaises(Failure) as cm: 1115 | detect_vcs(self.ui) 1116 | self.assertEqual(str(cm.exception), 1117 | "Couldn't find version control data" 1118 | " (git/hg/bzr/svn supported)") 1119 | # now create a .git file like in a submodule 1120 | open(os.path.join(self.tmpdir, '.git'), 'w').close() 1121 | self.assertEqual(detect_vcs(self.ui).metadata_name, '.git') 1122 | 1123 | def test_get_versioned_files_with_git_submodules(self): 1124 | from check_manifest import get_vcs_files 1125 | self._init_repo_with_files('repo1', ['file1', 'file2']) 1126 | self._init_repo_with_files('repo2', ['file3']) 1127 | self._init_repo_with_files('repo3', ['file4']) 1128 | self._add_submodule('repo2', 'sub3', '../repo3') 1129 | self._init_repo_with_files('main', ['file5', 'subdir/file6']) 1130 | self._add_submodule('main', 'sub1', '../repo1') 1131 | self._add_submodule('main', 'subdir/sub2', '../repo2') 1132 | os.chdir('main') 1133 | self.vcs._run('git', 'submodule', 'update', '--init', '--recursive') 1134 | self.assertEqual( 1135 | get_vcs_files(self.ui), 1136 | [ 1137 | '.gitmodules', 1138 | 'file5', 1139 | 'sub1/file1', 1140 | 'sub1/file2', 1141 | 'subdir/file6', 1142 | 'subdir/sub2/.gitmodules', 1143 | 'subdir/sub2/file3', 1144 | 'subdir/sub2/sub3/file4', 1145 | ]) 1146 | 1147 | def test_get_versioned_files_with_git_submodules_with_git_index_file_set(self): 1148 | with mock.patch.dict(os.environ, {"GIT_INDEX_FILE": ".git/index"}): 1149 | self.test_get_versioned_files_with_git_submodules() 1150 | 1151 | 1152 | class BzrHelper(VCSHelper): 1153 | 1154 | command = 'bzr' 1155 | 1156 | def _init_vcs(self): 1157 | self._run('bzr', 'init') 1158 | self._run('bzr', 'whoami', '--branch', 'Unit Test ') 1159 | 1160 | def _add_to_vcs(self, filenames): 1161 | self._run('bzr', 'add', '--', *filenames) 1162 | 1163 | def _commit(self): 1164 | self._run('bzr', 'commit', '-m', 'Initial') 1165 | 1166 | 1167 | class TestBzr(VCSMixin, unittest.TestCase): 1168 | vcs = BzrHelper() 1169 | 1170 | 1171 | @unittest.skipIf(HAS_OEM_CODEC, 1172 | "Python 3.6 lets us use 'oem' codec instead of guessing") 1173 | class TestBzrTerminalCharsetDetectionOnOldPythons(unittest.TestCase): 1174 | 1175 | @mock.patch('sys.stdin') 1176 | @mock.patch('sys.stdout') 1177 | def test_terminal_encoding_not_known(self, mock_stdout, mock_stdin): 1178 | from check_manifest import Bazaar 1179 | mock_stdout.encoding = None 1180 | mock_stdin.encoding = None 1181 | self.assertEqual(Bazaar._get_terminal_encoding(), None) 1182 | 1183 | @mock.patch('sys.stdout') 1184 | def test_terminal_encoding_stdout_known(self, mock_stdout): 1185 | from check_manifest import Bazaar 1186 | mock_stdout.encoding = 'UTF-8' 1187 | self.assertEqual(Bazaar._get_terminal_encoding(), 'UTF-8') 1188 | 1189 | @mock.patch('sys.stdin') 1190 | @mock.patch('sys.stdout') 1191 | def test_terminal_encoding_stdin_known(self, mock_stdout, mock_stdin): 1192 | from check_manifest import Bazaar 1193 | mock_stdout.encoding = None 1194 | mock_stdin.encoding = 'UTF-8' 1195 | self.assertEqual(Bazaar._get_terminal_encoding(), 'UTF-8') 1196 | 1197 | @mock.patch('sys.stdout') 1198 | def test_terminal_encoding_cp0(self, mock_stdout): 1199 | from check_manifest import Bazaar 1200 | mock_stdout.encoding = 'cp0' 1201 | self.assertEqual(Bazaar._get_terminal_encoding(), None) 1202 | 1203 | 1204 | @unittest.skipIf(not HAS_OEM_CODEC, 1205 | "'oem' codec not available on Python before 3.6") 1206 | class TestBzrTerminalCharsetDetectionOnNewPythons(unittest.TestCase): 1207 | 1208 | def test_terminal_encoding_cp0(self): 1209 | from check_manifest import Bazaar 1210 | self.assertEqual(Bazaar._get_terminal_encoding(), "oem") 1211 | 1212 | 1213 | class HgHelper(VCSHelper): 1214 | 1215 | command = 'hg' 1216 | 1217 | def _init_vcs(self): 1218 | self._run('hg', 'init') 1219 | with open('.hg/hgrc', 'a') as f: 1220 | f.write('\n[ui]\nusername = Unit Test ') 1278 | self.assertFalse(svn.is_interesting(entry)) 1279 | self.assertEqual( 1280 | ui.warnings, 1281 | ['svn status --xml parse error:' 1282 | ' without ']) 1283 | 1284 | 1285 | class TestUserInterface(unittest.TestCase): 1286 | 1287 | def make_ui(self, verbosity=1): 1288 | from check_manifest import UI 1289 | ui = UI(verbosity=verbosity) 1290 | ui.stdout = StringIO() 1291 | ui.stderr = StringIO() 1292 | return ui 1293 | 1294 | def test_info(self): 1295 | ui = self.make_ui(verbosity=1) 1296 | ui.info("Reticulating splines") 1297 | self.assertEqual(ui.stdout.getvalue(), 1298 | "Reticulating splines\n") 1299 | 1300 | def test_info_verbose(self): 1301 | ui = self.make_ui(verbosity=2) 1302 | ui.info("Reticulating splines") 1303 | self.assertEqual(ui.stdout.getvalue(), 1304 | "Reticulating splines\n") 1305 | 1306 | def test_info_quiet(self): 1307 | ui = self.make_ui(verbosity=0) 1308 | ui.info("Reticulating splines") 1309 | self.assertEqual(ui.stdout.getvalue(), "") 1310 | 1311 | def test_info_begin_continue_end(self): 1312 | ui = self.make_ui(verbosity=1) 1313 | ui.info_begin("Reticulating splines...") 1314 | ui.info_continue(" nearly done...") 1315 | ui.info_continue(" almost done...") 1316 | ui.info_end(" done!") 1317 | self.assertEqual(ui.stdout.getvalue(), "") 1318 | 1319 | def test_info_begin_continue_end_verbose(self): 1320 | ui = self.make_ui(verbosity=2) 1321 | ui.info_begin("Reticulating splines...") 1322 | ui.info_continue(" nearly done...") 1323 | ui.info_continue(" almost done...") 1324 | ui.info_end(" done!") 1325 | self.assertEqual( 1326 | ui.stdout.getvalue(), 1327 | "Reticulating splines... nearly done... almost done... done!\n") 1328 | 1329 | def test_info_emits_newline_when_needed(self): 1330 | ui = self.make_ui(verbosity=1) 1331 | ui.info_begin("Computering...") 1332 | ui.info("Forgot to turn the gas off!") 1333 | self.assertEqual( 1334 | ui.stdout.getvalue(), 1335 | "Forgot to turn the gas off!\n") 1336 | 1337 | def test_info_emits_newline_when_needed_verbose(self): 1338 | ui = self.make_ui(verbosity=2) 1339 | ui.info_begin("Computering...") 1340 | ui.info("Forgot to turn the gas off!") 1341 | self.assertEqual( 1342 | ui.stdout.getvalue(), 1343 | "Computering...\n" 1344 | "Forgot to turn the gas off!\n") 1345 | 1346 | def test_warning(self): 1347 | ui = self.make_ui(verbosity=1) 1348 | ui.info_begin("Computering...") 1349 | ui.warning("Forgot to turn the gas off!") 1350 | self.assertEqual(ui.stdout.getvalue(), "") 1351 | self.assertEqual( 1352 | ui.stderr.getvalue(), 1353 | "Forgot to turn the gas off!\n") 1354 | 1355 | def test_warning_verbose(self): 1356 | ui = self.make_ui(verbosity=2) 1357 | ui.info_begin("Computering...") 1358 | ui.warning("Forgot to turn the gas off!") 1359 | self.assertEqual( 1360 | ui.stdout.getvalue(), 1361 | "Computering...\n") 1362 | self.assertEqual( 1363 | ui.stderr.getvalue(), 1364 | "Forgot to turn the gas off!\n") 1365 | 1366 | def test_error(self): 1367 | ui = self.make_ui(verbosity=1) 1368 | ui.info_begin("Computering...") 1369 | ui.error("Forgot to turn the gas off!") 1370 | self.assertEqual(ui.stdout.getvalue(), "") 1371 | self.assertEqual( 1372 | ui.stderr.getvalue(), 1373 | "Forgot to turn the gas off!\n") 1374 | 1375 | def test_error_verbose(self): 1376 | ui = self.make_ui(verbosity=2) 1377 | ui.info_begin("Computering...") 1378 | ui.error("Forgot to turn the gas off!") 1379 | self.assertEqual( 1380 | ui.stdout.getvalue(), 1381 | "Computering...\n") 1382 | self.assertEqual( 1383 | ui.stderr.getvalue(), 1384 | "Forgot to turn the gas off!\n") 1385 | 1386 | 1387 | class TestIgnoreList(unittest.TestCase): 1388 | 1389 | def setUp(self): 1390 | from check_manifest import IgnoreList 1391 | self.ignore = IgnoreList() 1392 | 1393 | def test_repr(self): 1394 | from check_manifest import IgnoreList 1395 | ignore = IgnoreList() 1396 | self.assertEqual(repr(ignore), "IgnoreList([])") 1397 | 1398 | def test_exclude_pattern(self): 1399 | self.ignore.exclude('*.txt') 1400 | self.assertEqual(self.ignore.filter([ 1401 | 'foo.md', 1402 | 'bar.txt', 1403 | 'subdir/bar.txt', 1404 | ]), [ 1405 | 'foo.md', 1406 | 'subdir/bar.txt', 1407 | ]) 1408 | 1409 | def test_exclude_file(self): 1410 | self.ignore.exclude('bar.txt') 1411 | self.assertEqual(self.ignore.filter([ 1412 | 'foo.md', 1413 | 'bar.txt', 1414 | 'subdir/bar.txt', 1415 | ]), [ 1416 | 'foo.md', 1417 | 'subdir/bar.txt', 1418 | ]) 1419 | 1420 | def test_exclude_doest_apply_to_directories(self): 1421 | self.ignore.exclude('subdir') 1422 | self.assertEqual(self.ignore.filter([ 1423 | 'foo.md', 1424 | 'subdir/bar.txt', 1425 | ]), [ 1426 | 'foo.md', 1427 | 'subdir/bar.txt', 1428 | ]) 1429 | 1430 | def test_global_exclude(self): 1431 | self.ignore.global_exclude('a*.txt') 1432 | self.assertEqual(self.ignore.filter([ 1433 | 'bar.txt', # make sure full filenames are matched 1434 | 'afile.txt', 1435 | 'subdir/afile.txt', 1436 | 'adir/file.txt', # make sure * doesn't match / 1437 | ]), [ 1438 | 'bar.txt', 1439 | 'adir/file.txt', 1440 | ]) 1441 | 1442 | def test_global_exclude_does_not_apply_to_directories(self): 1443 | self.ignore.global_exclude('subdir') 1444 | self.assertEqual(self.ignore.filter([ 1445 | 'bar.txt', 1446 | 'subdir/afile.txt', 1447 | ]), [ 1448 | 'bar.txt', 1449 | 'subdir/afile.txt', 1450 | ]) 1451 | 1452 | def test_recursive_exclude(self): 1453 | self.ignore.recursive_exclude('subdir', 'a*.txt') 1454 | self.assertEqual(self.ignore.filter([ 1455 | 'afile.txt', 1456 | 'subdir/afile.txt', 1457 | 'subdir/extra/afile.txt', 1458 | 'subdir/adir/file.txt', 1459 | 'other/afile.txt', 1460 | ]), [ 1461 | 'afile.txt', 1462 | 'subdir/adir/file.txt', 1463 | 'other/afile.txt', 1464 | ]) 1465 | 1466 | def test_recursive_exclude_does_not_apply_to_directories(self): 1467 | self.ignore.recursive_exclude('subdir', 'dir') 1468 | self.assertEqual(self.ignore.filter([ 1469 | 'afile.txt', 1470 | 'subdir/dir/afile.txt', 1471 | ]), [ 1472 | 'afile.txt', 1473 | 'subdir/dir/afile.txt', 1474 | ]) 1475 | 1476 | def test_recursive_exclude_can_prune(self): 1477 | self.ignore.recursive_exclude('subdir', '*') 1478 | self.assertEqual(self.ignore.filter([ 1479 | 'afile.txt', 1480 | 'subdir/afile.txt', 1481 | 'subdir/dir/afile.txt', 1482 | 'subdir/dir/dir/afile.txt', 1483 | ]), [ 1484 | 'afile.txt', 1485 | ]) 1486 | 1487 | def test_prune(self): 1488 | self.ignore.prune('subdir') 1489 | self.assertEqual(self.ignore.filter([ 1490 | 'foo.md', 1491 | 'subdir/bar.txt', 1492 | 'unrelated/subdir/baz.txt', 1493 | ]), [ 1494 | 'foo.md', 1495 | 'unrelated/subdir/baz.txt', 1496 | ]) 1497 | 1498 | def test_prune_subdir(self): 1499 | self.ignore.prune('a/b') 1500 | self.assertEqual(self.ignore.filter([ 1501 | 'foo.md', 1502 | 'a/b/bar.txt', 1503 | 'a/c/bar.txt', 1504 | ]), [ 1505 | 'foo.md', 1506 | 'a/c/bar.txt', 1507 | ]) 1508 | 1509 | def test_prune_glob(self): 1510 | self.ignore.prune('su*r') 1511 | self.assertEqual(self.ignore.filter([ 1512 | 'foo.md', 1513 | 'subdir/bar.txt', 1514 | 'unrelated/subdir/baz.txt', 1515 | ]), [ 1516 | 'foo.md', 1517 | 'unrelated/subdir/baz.txt', 1518 | ]) 1519 | 1520 | def test_prune_glob_is_not_too_greedy(self): 1521 | self.ignore.prune('su*r') 1522 | self.assertEqual(self.ignore.filter([ 1523 | 'foo.md', 1524 | # super-unrelated/subdir matches su*r if you allow * to match /, 1525 | # which fnmatch does! 1526 | 'super-unrelated/subdir/qux.txt', 1527 | ]), [ 1528 | 'foo.md', 1529 | 'super-unrelated/subdir/qux.txt', 1530 | ]) 1531 | 1532 | def test_default_excludes_pkg_info(self): 1533 | from check_manifest import IgnoreList 1534 | ignore = IgnoreList.default() 1535 | self.assertEqual(ignore.filter([ 1536 | 'PKG-INFO', 1537 | 'bar.txt', 1538 | ]), [ 1539 | 'bar.txt', 1540 | ]) 1541 | 1542 | def test_default_excludes_egg_info(self): 1543 | from check_manifest import IgnoreList 1544 | ignore = IgnoreList.default() 1545 | self.assertEqual(ignore.filter([ 1546 | 'mypackage.egg-info/PKG-INFO', 1547 | 'mypackage.egg-info/SOURCES.txt', 1548 | 'mypackage.egg-info/requires.txt', 1549 | 'bar.txt', 1550 | ]), [ 1551 | 'bar.txt', 1552 | ]) 1553 | 1554 | def test_default_excludes_egg_info_in_a_subdirectory(self): 1555 | from check_manifest import IgnoreList 1556 | ignore = IgnoreList.default() 1557 | self.assertEqual(ignore.filter([ 1558 | 'src/mypackage.egg-info/PKG-INFO', 1559 | 'src/mypackage.egg-info/SOURCES.txt', 1560 | 'src/mypackage.egg-info/requires.txt', 1561 | 'bar.txt', 1562 | ]), [ 1563 | 'bar.txt', 1564 | ]) 1565 | 1566 | 1567 | def pick_installed_vcs(): 1568 | preferred_order = [GitHelper, HgHelper, BzrHelper, SvnHelper] 1569 | force = os.getenv('FORCE_TEST_VCS') 1570 | if force: 1571 | for cls in preferred_order: 1572 | if force == cls.command: 1573 | return cls() 1574 | raise ValueError('Unsupported FORCE_TEST_VCS=%s (supported: %s)' 1575 | % (force, '/'.join(cls.command for cls in preferred_order))) 1576 | for cls in preferred_order: 1577 | vcs = cls() 1578 | if vcs.is_installed(): 1579 | return vcs 1580 | return None 1581 | 1582 | 1583 | class TestCheckManifest(unittest.TestCase): 1584 | 1585 | _vcs = pick_installed_vcs() 1586 | 1587 | def setUp(self): 1588 | if self._vcs is None: 1589 | self.fail('at least one version control system should be installed') 1590 | self.oldpwd = os.getcwd() 1591 | self.tmpdir = tempfile.mkdtemp(prefix='test-', suffix='-check-manifest') 1592 | os.chdir(self.tmpdir) 1593 | self._stdout_patcher = mock.patch('sys.stdout', StringIO()) 1594 | self._stdout_patcher.start() 1595 | self._stderr_patcher = mock.patch('sys.stderr', StringIO()) 1596 | self._stderr_patcher.start() 1597 | 1598 | def tearDown(self): 1599 | self._stderr_patcher.stop() 1600 | self._stdout_patcher.stop() 1601 | os.chdir(self.oldpwd) 1602 | rmtree(self.tmpdir) 1603 | 1604 | def _create_repo_with_code(self, add_to_vcs=True): 1605 | self._vcs._init_vcs() 1606 | with open('setup.py', 'w') as f: 1607 | f.write("from setuptools import setup\n") 1608 | f.write("setup(name='sample', py_modules=['sample'])\n") 1609 | with open('sample.py', 'w') as f: 1610 | f.write("# wow. such code. so amaze\n") 1611 | if add_to_vcs: 1612 | self._vcs._add_to_vcs(['setup.py', 'sample.py']) 1613 | 1614 | def _create_repo_with_code_in_subdir(self): 1615 | os.mkdir('subdir') 1616 | os.chdir('subdir') 1617 | self._create_repo_with_code() 1618 | # NB: when self._vcs is SvnHelper, we're actually in 1619 | # ./subdir/checkout rather than in ./subdir 1620 | subdir = os.path.basename(os.getcwd()) 1621 | os.chdir(os.pardir) 1622 | return subdir 1623 | 1624 | def _add_to_vcs(self, filename, content=''): 1625 | if os.path.sep in filename and not os.path.isdir(os.path.dirname(filename)): 1626 | os.makedirs(os.path.dirname(filename)) 1627 | with open(filename, 'w') as f: 1628 | f.write(content) 1629 | self._vcs._add_to_vcs([filename]) 1630 | 1631 | def test_not_python_project(self): 1632 | from check_manifest import Failure, check_manifest 1633 | with self.assertRaises(Failure) as cm: 1634 | check_manifest() 1635 | self.assertEqual( 1636 | str(cm.exception), 1637 | "This is not a Python project (no setup.py/pyproject.toml).") 1638 | 1639 | def test_forgot_to_git_add_anything(self): 1640 | from check_manifest import Failure, check_manifest 1641 | self._create_repo_with_code(add_to_vcs=False) 1642 | with self.assertRaises(Failure) as cm: 1643 | check_manifest() 1644 | self.assertEqual(str(cm.exception), 1645 | "There are no files added to version control!") 1646 | 1647 | def test_all_is_well(self): 1648 | from check_manifest import check_manifest 1649 | self._create_repo_with_code() 1650 | self.assertTrue(check_manifest(), sys.stderr.getvalue()) 1651 | 1652 | def test_relative_pathname(self): 1653 | from check_manifest import check_manifest 1654 | subdir = self._create_repo_with_code_in_subdir() 1655 | self.assertTrue(check_manifest(subdir), sys.stderr.getvalue()) 1656 | 1657 | def test_relative_python(self): 1658 | # https://github.com/mgedmin/check-manifest/issues/36 1659 | from check_manifest import check_manifest 1660 | subdir = self._create_repo_with_code_in_subdir() 1661 | python = os.path.relpath(sys.executable) 1662 | self.assertTrue(check_manifest(subdir, python=python), 1663 | sys.stderr.getvalue()) 1664 | 1665 | def test_python_from_path(self): 1666 | # https://github.com/mgedmin/check-manifest/issues/57 1667 | from check_manifest import check_manifest 1668 | 1669 | # We need a Python interpeter to be in PATH. 1670 | python = 'python' 1671 | if hasattr(shutil, 'which'): 1672 | for python in 'python', 'python3', os.path.basename(sys.executable): 1673 | if shutil.which(python): 1674 | break 1675 | subdir = self._create_repo_with_code_in_subdir() 1676 | self.assertTrue(check_manifest(subdir, python=python), 1677 | sys.stderr.getvalue()) 1678 | 1679 | def test_extra_ignore(self): 1680 | from check_manifest import IgnoreList, check_manifest 1681 | self._create_repo_with_code() 1682 | self._add_to_vcs('unrelated.txt') 1683 | ignore = IgnoreList().global_exclude('*.txt') 1684 | self.assertTrue(check_manifest(extra_ignore=ignore), 1685 | sys.stderr.getvalue()) 1686 | 1687 | def test_suggestions(self): 1688 | from check_manifest import check_manifest 1689 | self._create_repo_with_code() 1690 | self._add_to_vcs('unrelated.txt') 1691 | self.assertFalse(check_manifest()) 1692 | self.assertIn("missing from sdist:\n unrelated.txt", 1693 | sys.stderr.getvalue()) 1694 | self.assertIn("suggested MANIFEST.in rules:\n include *.txt", 1695 | sys.stdout.getvalue()) 1696 | 1697 | def test_suggestions_create(self): 1698 | from check_manifest import check_manifest 1699 | self._create_repo_with_code() 1700 | self._add_to_vcs('unrelated.txt') 1701 | self.assertFalse(check_manifest(create=True)) 1702 | self.assertIn("missing from sdist:\n unrelated.txt", 1703 | sys.stderr.getvalue()) 1704 | self.assertIn("suggested MANIFEST.in rules:\n include *.txt", 1705 | sys.stdout.getvalue()) 1706 | self.assertIn("creating MANIFEST.in", 1707 | sys.stdout.getvalue()) 1708 | with open('MANIFEST.in') as f: 1709 | self.assertEqual(f.read(), "include *.txt\n") 1710 | 1711 | def test_suggestions_update(self): 1712 | from check_manifest import check_manifest 1713 | self._create_repo_with_code() 1714 | self._add_to_vcs('unrelated.txt') 1715 | self._add_to_vcs('MANIFEST.in', '#tbd') 1716 | self.assertFalse(check_manifest(update=True)) 1717 | self.assertIn("missing from sdist:\n unrelated.txt", 1718 | sys.stderr.getvalue()) 1719 | self.assertIn("suggested MANIFEST.in rules:\n include *.txt", 1720 | sys.stdout.getvalue()) 1721 | self.assertIn("updating MANIFEST.in", 1722 | sys.stdout.getvalue()) 1723 | with open('MANIFEST.in') as f: 1724 | self.assertEqual( 1725 | f.read(), 1726 | "#tbd\n# added by check-manifest\ninclude *.txt\n") 1727 | 1728 | def test_suggestions_all_unknown_patterns(self): 1729 | from check_manifest import check_manifest 1730 | self._create_repo_with_code() 1731 | self._add_to_vcs('.dunno-what-to-do-with-this') 1732 | self.assertFalse(check_manifest(update=True)) 1733 | self.assertIn("missing from sdist:\n .dunno-what-to-do-with-this", 1734 | sys.stderr.getvalue()) 1735 | self.assertIn( 1736 | "don't know how to come up with rules matching any of the files, sorry!", 1737 | sys.stdout.getvalue()) 1738 | 1739 | def test_suggestions_some_unknown_patterns(self): 1740 | from check_manifest import check_manifest 1741 | self._create_repo_with_code() 1742 | self._add_to_vcs('.dunno-what-to-do-with-this') 1743 | self._add_to_vcs('unrelated.txt') 1744 | self.assertFalse(check_manifest(update=True)) 1745 | self.assertIn( 1746 | "don't know how to come up with rules matching\n .dunno-what-to-do-with-this", 1747 | sys.stdout.getvalue()) 1748 | self.assertIn("creating MANIFEST.in", 1749 | sys.stdout.getvalue()) 1750 | with open('MANIFEST.in') as f: 1751 | self.assertEqual(f.read(), "include *.txt\n") 1752 | 1753 | def test_MANIFEST_in_does_not_need_to_be_added_to_be_considered(self): 1754 | from check_manifest import check_manifest 1755 | self._create_repo_with_code() 1756 | self._add_to_vcs('unrelated.txt') 1757 | with open('MANIFEST.in', 'w') as f: 1758 | f.write("include *.txt\n") 1759 | self.assertFalse(check_manifest()) 1760 | self.assertIn("missing from VCS:\n MANIFEST.in", sys.stderr.getvalue()) 1761 | self.assertNotIn("missing from sdist", sys.stderr.getvalue()) 1762 | 1763 | def test_setup_py_does_not_need_to_be_added_to_be_considered(self): 1764 | from check_manifest import check_manifest 1765 | self._create_repo_with_code(add_to_vcs=False) 1766 | self._add_to_vcs('sample.py') 1767 | self.assertFalse(check_manifest()) 1768 | self.assertIn("missing from VCS:\n setup.py", sys.stderr.getvalue()) 1769 | self.assertNotIn("missing from sdist", sys.stderr.getvalue()) 1770 | 1771 | def test_bad_ideas(self): 1772 | from check_manifest import check_manifest 1773 | self._create_repo_with_code() 1774 | self._add_to_vcs('foo.egg-info') 1775 | self._add_to_vcs('moo.mo') 1776 | self.assertFalse(check_manifest()) 1777 | self.assertIn("you have foo.egg-info in source control!", 1778 | sys.stderr.getvalue()) 1779 | self.assertIn("this also applies to the following:\n moo.mo", 1780 | sys.stderr.getvalue()) 1781 | 1782 | def test_ignore_bad_ideas(self): 1783 | from check_manifest import IgnoreList, check_manifest 1784 | self._create_repo_with_code() 1785 | with open('setup.cfg', 'w') as f: 1786 | f.write('[check-manifest]\n' 1787 | 'ignore =\n' 1788 | ' subdir/bar.egg-info\n' 1789 | 'ignore-bad-ideas =\n' 1790 | ' subdir/bar.egg-info\n') 1791 | self._add_to_vcs('foo.egg-info') 1792 | self._add_to_vcs('moo.mo') 1793 | self._add_to_vcs(os.path.join('subdir', 'bar.egg-info')) 1794 | ignore = IgnoreList().global_exclude('*.mo') 1795 | self.assertFalse(check_manifest(extra_ignore_bad_ideas=ignore)) 1796 | self.assertIn("you have foo.egg-info in source control!", 1797 | sys.stderr.getvalue()) 1798 | self.assertNotIn("moo.mo", sys.stderr.getvalue()) 1799 | self.assertNotIn("bar.egg-info", sys.stderr.getvalue()) 1800 | 1801 | def test_missing_source_files(self): 1802 | # https://github.com/mgedmin/check-manifest/issues/32 1803 | from check_manifest import check_manifest 1804 | self._create_repo_with_code() 1805 | self._add_to_vcs('missing.py') 1806 | os.unlink('missing.py') 1807 | check_manifest() 1808 | if self._vcs.command != 'bzr': 1809 | # 'bzr ls' doesn't list files that were deleted but not 1810 | # marked for deletion. 'bzr st' does, but it doesn't list 1811 | # unmodified files. Importing bzrlib and using the API to 1812 | # get the file list we need is (a) complicated, (b) opens 1813 | # the optional dependency can of worms, and (c) not viable 1814 | # under Python 3 unless we fork off a Python 2 subprocess. 1815 | # Manually combining 'bzr ls' and 'bzr st' outputs just to 1816 | # produce a cosmetic warning message seems like overkill. 1817 | self.assertIn( 1818 | "some files listed as being under source control are missing:\n" 1819 | " missing.py", 1820 | sys.stderr.getvalue()) 1821 | --------------------------------------------------------------------------------