From b27491262294f0438027f1efae7c6def83112edd Mon Sep 17 00:00:00 2001 From: Michael Howitz Date: Fri, 23 Dec 2022 09:35:23 +0100 Subject: [PATCH] Add script to drop Python 2.7 up to 3.6 support. (#179) * Add script to drop Python 2.7 up to 3.6 support. * Drop support for `--without-legacy-python` as it is the default now. * replace macos-latest with macos-11 to work around test failures Co-authored-by: Maurits van Rees Co-authored-by: Jens Vagelpohl --- .github/workflows/pre-commit.yml | 6 +- config/README.rst | 38 +++++++-- config/buildout-recipe/coveragerc.j2 | 3 - config/buildout-recipe/tox.ini.j2 | 3 - config/c-code/manylinux-install.sh.j2 | 4 - config/c-code/tests-strategy.j2 | 10 +-- config/c-code/tox.ini.j2 | 4 - config/config-package.py | 44 +++-------- config/default/appveyor.yml.j2 | 2 - config/default/setup.cfg.j2 | 4 - config/default/tests.yml.j2 | 18 ++--- config/default/tox-coverage-config.j2 | 3 - config/default/tox-envlist.j2 | 4 - config/drop-legacy-python.py | 108 ++++++++++++++++++++++++++ config/meta-cfg-to-toml.py | 2 - config/pure-python/tox.ini.j2 | 3 - config/requirements.txt | 4 +- config/shared/call.py | 10 ++- config/shared/git.py | 32 ++++++++ config/zope-product/tox.ini.j2 | 4 - 20 files changed, 202 insertions(+), 104 deletions(-) create mode 100644 config/drop-legacy-python.py create mode 100644 config/shared/git.py diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index bd303aa..0f1b979 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: | ~/.cache/pip @@ -27,7 +27,7 @@ jobs: restore-keys: | lint-v1- - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.x' diff --git a/config/README.rst b/config/README.rst index 6834243..866c742 100644 --- a/config/README.rst +++ b/config/README.rst @@ -169,10 +169,6 @@ The following options are only needed one time as their values re stored in a final release thus it is not yet generally supported by the zopefoundation packages. ---without-legacy-python - The package does not support Python versions which reached their end-of-life. - (Currently this is a no-op as there are no supported legacy versions). - --with-docs Enable building the documentation using Sphinx. @@ -202,7 +198,6 @@ updated. Example: [python] with-appveyor = false - with-legacy-python = true with-pypy = false with-docs = true with-sphinx-doctests = false @@ -394,9 +389,6 @@ with-macos with-windows Run the tests also on Windows on GitHub Actions: true/false, default: false -with-legacy-python - Run the tests even on legacy versions (currently none): true/false - with-pypy Does the package support PyPy: true/false @@ -749,3 +741,33 @@ Usage To run the script just call it:: $ bin/python re-enable-actions.py + +Dropping support for legacy Python versions +------------------------------------------- + +To drop support for Python 2.7 up to 3.6 several steps have to be done as +documented at https://zope.dev/developer/python2.html#how-to-drop-support. +There is a script to ease this process. + +Preparation ++++++++++++ + +* The package to remove legacy python support from has to have a ``.meta.toml`` + file aka it must be under control of the ``config-package.py`` script. + +Usage ++++++ + +To run the script call:: + + $ bin/python drop-legacy-python.py + +Additional optional parameters, see above at ``config-package.py`` for a +descriptions of them: + +* ``--branch`` + +You can call the script interactively by passing the argument +``--interactive``, this will let the various scripts prompt for information and +prevent automatic commits and pushes. That way all changes can be viewed before +committing them. diff --git a/config/buildout-recipe/coveragerc.j2 b/config/buildout-recipe/coveragerc.j2 index b96a86d..fe607fd 100644 --- a/config/buildout-recipe/coveragerc.j2 +++ b/config/buildout-recipe/coveragerc.j2 @@ -1,8 +1,5 @@ [run] source = %(coverage_run_source)s -{% if with_legacy_python %} -plugins = coverage_python_version -{% endif %} branch = true parallel = true {% for line in run_additional_config %} diff --git a/config/buildout-recipe/tox.ini.j2 b/config/buildout-recipe/tox.ini.j2 index 3c7dfde..d70f92a 100644 --- a/config/buildout-recipe/tox.ini.j2 +++ b/config/buildout-recipe/tox.ini.j2 @@ -18,9 +18,6 @@ setenv = {% endif %} deps = coverage -{% if with_legacy_python %} - coverage-python-version -{% endif %} {% for line in testenv_deps %} %(line)s {% endfor %} diff --git a/config/c-code/manylinux-install.sh.j2 b/config/c-code/manylinux-install.sh.j2 index 9ea393a..f55056a 100644 --- a/config/c-code/manylinux-install.sh.j2 +++ b/config/c-code/manylinux-install.sh.j2 @@ -29,8 +29,6 @@ yum -y install libffi-devel tox_env_map() { case $1 in -{% if with_legacy_python %} -{% endif %} {% if with_future_python %} *"cp312"*) echo 'py312';; {% endif %} @@ -46,8 +44,6 @@ tox_env_map() { # Compile wheels for PYBIN in /opt/python/*/bin; do if \ -{% if with_legacy_python %} -{% endif %} {% if with_future_python %} [[ "${PYBIN}" == *"cp312"* ]] || \ {% endif %} diff --git a/config/c-code/tests-strategy.j2 b/config/c-code/tests-strategy.j2 index f0f693c..e267a23 100644 --- a/config/c-code/tests-strategy.j2 +++ b/config/c-code/tests-strategy.j2 @@ -2,10 +2,6 @@ fail-fast: false matrix: python-version: -{% if with_legacy_python %} -{% endif %} -{% if with_pypy and with_legacy_python %} -{% endif %} {% if with_pypy %} - "pypy-3.7" {% endif %} @@ -17,16 +13,14 @@ {% if with_future_python %} - "%(future_python_version)s" {% endif %} - os: [ubuntu-20.04, macos-latest] + os: [ubuntu-20.04, macos-11] {% if with_pypy or gha_additional_exclude %} exclude: {% endif %} {% if with_pypy %} - - os: macos-latest + - os: macos-11 python-version: "pypy-3.7" {% endif %} -{% if with_legacy_python %} -{% endif %} {% for line in gha_additional_exclude %} %(line)s {% endfor %} diff --git a/config/c-code/tox.ini.j2 b/config/c-code/tox.ini.j2 index 6c892dd..b13a639 100644 --- a/config/c-code/tox.ini.j2 +++ b/config/c-code/tox.ini.j2 @@ -6,8 +6,6 @@ minversion = 3.18 envlist = lint -{% if with_legacy_python %} -{% endif %} py37,py37-pure py38,py38-pure py39,py39-pure @@ -16,8 +14,6 @@ envlist = {% if with_future_python %} py312,py312-pure {% endif %} -{% if with_pypy and with_legacy_python %} -{% endif %} {% if with_pypy %} pypy3 {% endif %} diff --git a/config/config-package.py b/config/config-package.py index 2698392..c2a39c1 100755 --- a/config/config-package.py +++ b/config/config-package.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 from shared.call import abort from shared.call import call +from shared.git import get_branch_name +from shared.git import get_commit_id +from shared.git import git_branch from shared.path import change_dir from shared.toml_encoder import TomlArraySeparatorEncoderWithNewline import argparse @@ -101,13 +104,6 @@ def copy_with_meta( default=False, help='Activate support for a future non-final Python version if not' ' already configured in .meta.toml.') -parser.add_argument( - '--without-legacy-python', - dest='with_legacy_python', - action='store_false', - default=None, - help='Disable support for Python versions which reached their end-of-life.' - ' (currently no versions) if not already configured in .meta.toml.') parser.add_argument( '--with-docs', # people (me) use --with-sphinx and accidentally get --with-sphinx-doctests @@ -194,8 +190,7 @@ def copy_with_meta( lstrip_blocks=True, ) -meta_cfg['meta']['commit-id'] = call( - 'git', 'log', '-n1', '--format=format:%H', capture_output=True).stdout +meta_cfg['meta']['commit-id'] = get_commit_id() with_appveyor = meta_cfg['python'].get( 'with-appveyor', False) or args.with_appveyor meta_cfg['python']['with-appveyor'] = with_appveyor @@ -210,17 +205,15 @@ def copy_with_meta( with_future_python = (meta_cfg['python'].get('with-future-python', False) or args.with_future_python) meta_cfg['python']['with-future-python'] = with_future_python -if args.with_legacy_python is None: - with_legacy_python = meta_cfg['python'].get('with-legacy-python', True) -else: - with_legacy_python = args.with_legacy_python -meta_cfg['python']['with-legacy-python'] = with_legacy_python with_docs = meta_cfg['python'].get('with-docs', False) or args.with_docs meta_cfg['python']['with-docs'] = with_docs with_sphinx_doctests = meta_cfg['python'].get( 'with-sphinx-doctests', False) or args.with_sphinx_doctests meta_cfg['python']['with-sphinx-doctests'] = with_sphinx_doctests - +try: + del meta_cfg['python']['with-legacy-python'] +except KeyError: + pass if with_sphinx_doctests and not with_docs: print("The package is configured without sphinx docs, but with sphinx" @@ -260,7 +253,6 @@ def copy_with_meta( isort_known_first_party=isort_known_first_party, isort_known_local_folder=isort_known_local_folder, with_docs=with_docs, with_sphinx_doctests=with_sphinx_doctests, - with_legacy_python=with_legacy_python, zest_releaser_options=zest_releaser_options, ) @@ -291,7 +283,6 @@ def copy_with_meta( 'coveragerc.j2', path / '.coveragerc', config_type, coverage_run_source=coverage_run_source, run_additional_config=coverage_run_additional_config, - with_legacy_python=with_legacy_python, ) add_coveragerc = True elif (path / '.coveragerc').exists(): @@ -315,7 +306,6 @@ def copy_with_meta( package_name=path.name, setup=manylinux_install_setup, aarch64_tests=manylinux_aarch64_tests, - with_legacy_python=with_legacy_python, with_future_python=with_future_python, ) (path / '.manylinux-install.sh').chmod(0o755) @@ -374,7 +364,6 @@ def copy_with_meta( testenv_setenv=testenv_setenv, use_flake8=use_flake8, with_docs=with_docs, - with_legacy_python=with_legacy_python, with_future_python=with_future_python, with_pypy=with_pypy, with_sphinx_doctests=with_sphinx_doctests, @@ -405,7 +394,6 @@ def copy_with_meta( steps_before_checkout=gha_steps_before_checkout, with_docs=with_docs, with_sphinx_doctests=with_sphinx_doctests, - with_legacy_python=with_legacy_python, with_future_python=with_future_python, future_python_version=FUTURE_PYTHON_VERSION, with_pypy=with_pypy, @@ -442,7 +430,6 @@ def copy_with_meta( appveyor_replacement = meta_cfg['appveyor'].get('replacement', []) copy_with_meta( 'appveyor.yml.j2', path / 'appveyor.yml', config_type, - with_legacy_python=with_legacy_python, with_future_python=with_future_python, global_env_vars=appveyor_global_env_vars, additional_matrix=appveyor_additional_matrix, @@ -453,9 +440,7 @@ def copy_with_meta( ) -branch_name = ( - args.branch_name - or f"config-with-{config_type}-template-{meta_cfg['meta']['commit-id']}") +branch_name = get_branch_name(args.branch_name, config_type) with change_dir(path) as cwd: if pathlib.Path('bootstrap.py').exists(): call('git', 'rm', 'bootstrap.py') @@ -482,15 +467,8 @@ def copy_with_meta( tox_path = shutil.which('tox') or (pathlib.Path(cwd) / 'bin' / 'tox') call(tox_path, '-p', 'auto') - branches = call( - 'git', 'branch', '--format', '%(refname:short)', - capture_output=True).stdout.splitlines() - if branch_name in branches: - call('git', 'checkout', branch_name) - updating = True - else: - call('git', 'checkout', '-b', branch_name) - updating = False + updating = git_branch(branch_name) + if not fail_under: print('In .meta.toml in section [coverage] the option "fail-under" is' ' 0. Please enter a valid minimum coverage and rerun.') diff --git a/config/default/appveyor.yml.j2 b/config/default/appveyor.yml.j2 index 38b6524..5c32846 100644 --- a/config/default/appveyor.yml.j2 +++ b/config/default/appveyor.yml.j2 @@ -9,8 +9,6 @@ environment: {% endfor %} matrix: -{% if with_legacy_python %} -{% endif %} - python: 37-x64 - python: 38-x64 - python: 39-x64 diff --git a/config/default/setup.cfg.j2 b/config/default/setup.cfg.j2 index 99d4e30..dbf65b2 100644 --- a/config/default/setup.cfg.j2 +++ b/config/default/setup.cfg.j2 @@ -1,9 +1,5 @@ [bdist_wheel] -{% if with_legacy_python %} -universal = 1 -{% else %} universal = 0 -{% endif %} {% if zest_releaser_options %} [zest.releaser] diff --git a/config/default/tests.yml.j2 b/config/default/tests.yml.j2 index 5457237..3ea1c16 100644 --- a/config/default/tests.yml.j2 +++ b/config/default/tests.yml.j2 @@ -26,13 +26,11 @@ jobs: - ["windows", "windows-latest"] {% endif %} {% if with_macos %} - - ["macos", "macos-latest"] + - ["macos", "macos-11"] {% endif %} config: # [Python version, tox env] - ["3.9", "lint"] -{% if with_legacy_python %} -{% endif %} - ["3.7", "py37"] - ["3.8", "py38"] - ["3.9", "py39"] @@ -41,8 +39,6 @@ jobs: {% if with_future_python %} - ["%(future_python_version)s", "py312"] {% endif %} -{% if with_pypy and with_legacy_python %} -{% endif %} {% if with_pypy %} - ["pypy-3.7", "pypy3"] {% endif %} @@ -64,14 +60,14 @@ jobs: - { os: ["windows", "windows-latest"], config: ["3.9", "coverage"] } {% endif %} {% if with_macos %} - - { os: ["macos", "macos-latest"], config: ["3.9", "lint"] } + - { os: ["macos", "macos-11"], config: ["3.9", "lint"] } {% if with_docs %} - - { os: ["macos", "macos-latest"], config: ["3.9", "docs"] } + - { os: ["macos", "macos-11"], config: ["3.9", "docs"] } {% endif %} - - { os: ["macos", "macos-latest"], config: ["3.9", "coverage"] } + - { os: ["macos", "macos-11"], config: ["3.9", "coverage"] } # macOS/Python 3.11 is set up for universal2 architecture # which causes build and package selection issues. - - { os: ["macos", "macos-latest"], config: ["3.11", "py311"] } + - { os: ["macos", "macos-11"], config: ["3.11", "py311"] } {% endif %} {% for line in gha_additional_exclude %} %(line)s @@ -120,11 +116,7 @@ jobs: - name: Coverage if: matrix.config[1] == 'coverage' run: | -{% if with_legacy_python %} - pip install coveralls coverage-python-version -{% else %} pip install coveralls -{% endif %} coveralls --service=github env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/config/default/tox-coverage-config.j2 b/config/default/tox-coverage-config.j2 index 6944b48..66f0b6c 100644 --- a/config/default/tox-coverage-config.j2 +++ b/config/default/tox-coverage-config.j2 @@ -1,9 +1,6 @@ [coverage:run] branch = True -{% if with_legacy_python %} -plugins = coverage_python_version -{% endif %} source = %(coverage_run_source)s {% for line in coverage_run_additional_config %} %(line)s diff --git a/config/default/tox-envlist.j2 b/config/default/tox-envlist.j2 index b3aa980..460cfef 100644 --- a/config/default/tox-envlist.j2 +++ b/config/default/tox-envlist.j2 @@ -2,8 +2,6 @@ minversion = 3.18 envlist = lint -{% if with_legacy_python %} -{% endif %} py37 py38 py39 @@ -12,8 +10,6 @@ envlist = {% if with_future_python %} py312 {% endif %} -{% if with_pypy and with_legacy_python %} -{% endif %} {% if with_pypy %} pypy3 {% endif %} diff --git a/config/drop-legacy-python.py b/config/drop-legacy-python.py new file mode 100644 index 0000000..aec2cc6 --- /dev/null +++ b/config/drop-legacy-python.py @@ -0,0 +1,108 @@ +#!/usr/bin/env', 'python3 +from shared.call import call +from shared.call import wait_for_accept +from shared.git import get_branch_name +from shared.git import git_branch +from shared.path import change_dir +import argparse +import collections +import os +import pathlib +import shutil +import sys +import toml + + +parser = argparse.ArgumentParser( + description='Drop support of Python 2.7 up to 3.6 from a package.') +parser.add_argument( + 'path', type=pathlib.Path, help='path to the repository to be configured') +parser.add_argument( + '--branch', + dest='branch_name', + default=None, + help='Define a git branch name to be used for the changes. If not given' + ' it is constructed automatically and includes the configuration' + ' type') +parser.add_argument( + '--interactive', + dest='interactive', + action='store_true', + default=False, + help='Run interactively: Scripts will prompt for input and changes will ' + 'not be committed and pushed automatically.') + + +args = parser.parse_args() +path = args.path.absolute() + +if not (path / '.git').exists(): + raise ValueError('`path` does not point to a git clone of a repository!') +if not (path / '.meta.toml').exists(): + raise ValueError('The repository `path` points to has no .meta.toml!') + +with change_dir(path) as cwd_str: + cwd = pathlib.Path(cwd_str) + bin_dir = cwd / 'bin' + meta_cfg = collections.defaultdict(dict, **toml.load('.meta.toml')) + config_type = meta_cfg['meta']['template'] + branch_name = get_branch_name(args.branch_name, config_type) + updating = git_branch(branch_name) + + if not args.interactive: + call(bin_dir / 'bumpversion', '--breaking', '--no-input') + call(bin_dir / 'addchangelogentry', + 'Drop support for Python 2.7, 3.5, 3.6.', '--no-input') + else: + call(bin_dir / 'bumpversion', '--breaking') + call(bin_dir / 'addchangelogentry', + 'Drop support for Python 2.7, 3.5, 3.6.') + call(bin_dir / 'check-python-versions', + '--drop=2.7,3.5,3.6', '--only=setup.py') + print('Remove legacy Python specific settings from .meta.toml') + call(os.environ['EDITOR'], '.meta.toml') + + config_package_args = [ + sys.executable, + 'config-package.py', + path, + f'--branch={branch_name}', + '--no-push', + ] + if args.interactive: + config_package_args.append('--no-commit') + call(*config_package_args, cwd=cwd_str) + print('Remove `six` from the list of dependencies and other Py 2 things.') + call(os.environ['EDITOR'], 'setup.py') + src = path.resolve() / 'src' + call('find', src, '-name', '*.py', '-exec', + bin_dir / 'pyupgrade', '--py3-plus', '--py37-plus', '{}', ';') + call(bin_dir / 'pyupgrade', '--py3-plus', '--py37-plus', 'setup.py', + allowed_return_codes=(0, 1)) + + excludes = ('--exclude-dir', '__pycache__', '--exclude-dir', '*.egg-info', + '--exclude', '*.pyc', '--exclude', '*.so') + print( + 'Replace all remaining `six` mentions or continue if none are listed.') + call('grep', '-rn', 'six', src, *excludes, allowed_return_codes=(0, 1)) + wait_for_accept() + print('Replace any remaining code that may support legacy Python 2:') + call('egrep', '-rn', + '2.7|3.5|3.6|sys.version|PY2|PY3|Py2|Py3|Python 2|Python 3' + '|__unicode__|ImportError', src, *excludes, + allowed_return_codes=(0, 1)) + wait_for_accept() + tox_path = shutil.which('tox') or (cwd / 'bin' / 'tox') + call(tox_path, '-p', 'auto') + if not args.interactive: + print('Adding, committing and pushing all changes ...') + call('git', 'add', '.') + call('git', 'commit', '-m', 'Drop support for Python 2.7 up to 3.6.') + call('git', 'push', '--set-upstream', 'origin', branch_name) + if updating: + print('Updated the previously created PR.') + else: + print('If everything went fine up to here:') + print('Create a PR, using the URL shown above.') + else: + print('Applied all changes. Please check and commit manually.') diff --git a/config/meta-cfg-to-toml.py b/config/meta-cfg-to-toml.py index 7a50a87..1955b32 100644 --- a/config/meta-cfg-to-toml.py +++ b/config/meta-cfg-to-toml.py @@ -41,8 +41,6 @@ dest['meta']['commit-id'] = src['commit-id'] dest['python']['with-pypy'] = src.getboolean('with-pypy', False) -dest['python']['with-legacy-python'] = src.getboolean( - 'with-legacy-python', True) dest['python']['with-docs'] = src.getboolean('with-docs', False) dest['python']['with-sphinx-doctests'] = src.getboolean( 'with-sphinx-doctests', False) diff --git a/config/pure-python/tox.ini.j2 b/config/pure-python/tox.ini.j2 index 2742bef..cfa9f04 100644 --- a/config/pure-python/tox.ini.j2 +++ b/config/pure-python/tox.ini.j2 @@ -10,9 +10,6 @@ allowlist_externals = mkdir deps = coverage -{% if with_legacy_python %} - coverage-python-version -{% endif %} {% for line in testenv_deps %} %(line)s {% endfor %} diff --git a/config/requirements.txt b/config/requirements.txt index 34829fa..0d4dadf 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -1,4 +1,6 @@ -Jinja2==3.1.2 check-python-versions==0.20.0 +Jinja2==3.1.2 +pyupgrade==3.3.1 toml==0.10.2 tox==4.0.14 +zest.releaser==7.2.0 diff --git a/config/shared/call.py b/config/shared/call.py index 26bda5a..0b18ae7 100644 --- a/config/shared/call.py +++ b/config/shared/call.py @@ -10,13 +10,19 @@ def abort(exitcode): sys.exit(exitcode) -def call(*args, capture_output=False, cwd=None): +def wait_for_accept(): + """Wait until the user has hit enter.""" + print('Proceed by hitting ') + input() + + +def call(*args, capture_output=False, cwd=None, allowed_return_codes=(0, )): """Call `args` as a subprocess. If it fails exit the process. """ result = subprocess.run( args, capture_output=capture_output, text=True, cwd=cwd) - if result.returncode != 0: + if result.returncode not in allowed_return_codes: abort(result.returncode) return result diff --git a/config/shared/git.py b/config/shared/git.py new file mode 100644 index 0000000..e395998 --- /dev/null +++ b/config/shared/git.py @@ -0,0 +1,32 @@ +from .call import call + + +def get_commit_id(): + """Return the first 8 digits of the commit id of this repository.""" + return call( + 'git', 'rev-parse', '--short=8', 'HEAD', + capture_output=True).stdout.strip() + + +def get_branch_name(override, config_type): + """Get the default branch name but prefer override if not empty.""" + return ( + override + or f"config-with-{config_type}-template-{get_commit_id()}") + + +def git_branch(branch_name) -> bool: + """Switch to existing or create new branch. + + Return `True` if updating. + """ + branches = call( + 'git', 'branch', '--format', '%(refname:short)', + capture_output=True).stdout.splitlines() + if branch_name in branches: + call('git', 'checkout', branch_name) + updating = True + else: + call('git', 'checkout', '-b', branch_name) + updating = False + return updating diff --git a/config/zope-product/tox.ini.j2 b/config/zope-product/tox.ini.j2 index 2d3061c..e54b7d0 100644 --- a/config/zope-product/tox.ini.j2 +++ b/config/zope-product/tox.ini.j2 @@ -46,7 +46,6 @@ allowlist_externals = commands = {% if use_flake8 %} isort --check-only --diff {toxinidir}/src {toxinidir}/setup.py%(isort_additional_sources)s - - flake8 {toxinidir}/src {toxinidir}/setup.py%(flake8_additional_sources)s flake8 {toxinidir}/src {toxinidir}/setup.py%(flake8_additional_sources)s {% endif %} check-manifest @@ -86,9 +85,6 @@ setenv = deps = {[testenv]deps} coverage -{% if with_legacy_python %} - coverage-python-version -{% endif %} commands = mkdir -p {toxinidir}/parts/htmlcov {% if coverage_command %}