diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6e7851e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,38 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt + +# This file is for unifying the coding style for different editors and IDEs. +# More information at http://EditorConfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 80 +trim_trailing_whitespace = true + +[*.py] +max_line_length = 100 + +[*.yml] +indent_size = 2 + +[*.rst] +max_line_length = 79 + +[Makefile] +indent_style = tab +indent_size = 8 + +[*,cover] +trim_trailing_whitespace = false + +[*.diff] +trim_trailing_whitespace = false + +[.git/*] +trim_trailing_whitespace = false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4deb5b5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,116 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt + +name: "Tests" + +on: + push: + pull_request: + workflow_dispatch: + schedule: + # Since we test against the tip of Django development, run the tests once a + # week, Sundays at 6:00 UTC. + - cron: "0 6 * * 0" + +permissions: + contents: read + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +defaults: + run: + shell: bash + +env: + PIP_DISABLE_PIP_VERSION_CHECK: 1 + FORCE_COLOR: 1 # Get colored pytest output + +jobs: + tests: + name: "${{ matrix.python-version }} on ${{ matrix.os }}" + runs-on: "${{ matrix.os }}-latest" + + strategy: + matrix: + os: + - ubuntu + - macos + - windows + python-version: + # When changing this list, be sure to check the [gh-actions] list in + # tox.ini so that tox will run properly. + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + fail-fast: false + + steps: + - name: "Check out the repo" + uses: "actions/checkout@v4" + with: + persist-credentials: false + + - name: "Set up Python" + uses: "actions/setup-python@v5" + with: + python-version: "${{ matrix.python-version }}" + allow-prereleases: true + + - name: "Install dependencies" + run: | + set -xe + python -VV + python -m site + python -m pip install -r requirements.txt + python -m pip install tox-gh-actions + + - name: "Run tox for ${{ matrix.python-version }}" + run: | + # GitHub Actions on Windows sets TEMP to a shortname, and HOME to a + # longname. Eventually, filename comparisons fail because of the + # difference. Fix $TEMP. + echo $TEMP + if [ "$RUNNER_OS" == "Windows" ]; then + export TMP=$HOME\\AppData\\Local\\Temp + export TEMP=$HOME\\AppData\\Local\\Temp + fi + echo $TEMP + python -m tox + + checks: + name: "Quality checks" + runs-on: "ubuntu-latest" + + steps: + - name: "Check out the repo" + uses: "actions/checkout@v4" + with: + persist-credentials: false + + - name: "Set up Python" + uses: "actions/setup-python@v5" + with: + python-version: "3.10" + + - name: "Install dependencies" + run: | + set -xe + python -VV + python -m site + python -m pip install -r requirements.txt + + - name: "Run check" + run: | + python -m tox -e check + + - name: "Run pkgcheck" + run: | + python -m tox -e pkgcheck + + - name: "Run doc" + run: | + python -m tox -e doc diff --git a/.isort.cfg b/.isort.cfg index ed14f2e..cd31999 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -4,4 +4,4 @@ force_grid_wrap=0 include_trailing_comma=True line_length=79 multi_line_output=3 -known_third_party = coverage,django,setuptools,six,unittest_mixins +known_third_party = coverage,django,setuptools,unittest_mixins diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9db7c78..0000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -sudo: false -language: python -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "3.8" -install: pip install tox-travis -script: tox diff --git a/HISTORY.rst b/HISTORY.rst deleted file mode 100644 index 7470bd0..0000000 --- a/HISTORY.rst +++ /dev/null @@ -1,138 +0,0 @@ -======= -History -======= - -v1.8.0 --- 2020-01-23 ---------------------- - -Add support for: - -- Coverage 5 - -v1.7.0 --- 2020-01-16 ---------------------- - -Add support for: - -- Python 3.7 & 3.8 -- Django 2.2 & 3.0 - -v1.6.0 --- 2018-09-04 ---------------------- - -Add support for Django 2.1. - - -v1.5.2 --- 2017-10-18 ---------------------- - -Validates support for Django version 2.0b1. Improves discovery of -template files. - - -v1.5.1a --- 2017-04-05 ----------------------- - -Validates support for Django version 1.11. Testing for new package -maintainer Pamela McA'Nulty - - -v1.5.0 --- 2017-02-23 ---------------------- - -Removes support for Django versions below 1.8. Validates support for -Django version 1.11b1 - - -v1.4.2 --- 2017-02-06 ---------------------- - -Fixes another instance of `issue 32`_, which was the result of an -initialization order problem. - - - -v1.4.1 --- 2017-01-25 ---------------------- - -Fixes `issue 32`_, which was the result of an initialization order -problem. - -.. _issue 32: https://github.com/nedbat/django_coverage_plugin/issues/32 - - - -v1.4 --- 2017-01-16 -------------------- - -Django 1.10.5 is now supported. - -Checking settings configuration is deferred so that settings.py is -included in coverage reporting. Fixes `issue 28`_. - -Only the ``django.template.backends.django.DjangoTemplates`` template -engine is supported, and it must be configured with -``['OPTIONS']['debug'] = True``. Fixes `issue 27`_. - -.. _issue 28: https://github.com/nedbat/django_coverage_plugin/issues/28 -.. _issue 27: https://github.com/nedbat/django_coverage_plugin/issues/27 - - - -v1.3.1 --- 2016-06-02 ---------------------- - -Settings are read slightly differently, so as to not interfere with -programs that don't need settings. Fixes `issue 18`_. - -.. _issue 18: https://github.com/nedbat/django_coverage_plugin/issues/18 - - - -v1.3 --- 2016-04-03 -------------------- - -Multiple template engines are allowed. Thanks, Simon Charette. - - - -v1.2.2 --- 2016-02-01 ---------------------- - -No change in code, but Django 1.9.2 is now supported. - - - -v1.2.1 --- 2016-01-28 ---------------------- - -The template debug settings are checked properly for people still using -``TEMPLATE_DEBUG`` in newer versions of Django. - - - -v1.2 --- 2016-01-16 -------------------- - -Check if template debugging is enabled in the settings, and raise a -visible warning if not. This prevents mysterious failures of the -plugin, and fixes `issue 17`_. - -Potential Django 1.9 support is included, but the patch to Django hasn't -been applied yet. - -.. _issue 17: https://github.com/nedbat/django_coverage_plugin/issues/17 - - - -v1.1 --- 2015-11-12 -------------------- - -Explicitly configure settings if need be to get things to work. - - - -v1.0 --- 2015-09-20 -------------------- - -First version :) diff --git a/MANIFEST.in b/MANIFEST.in index 200b65c..b220a56 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,15 +1,12 @@ # Licensed under the Apache 2.0 License # - http://www.apache.org/licenses/LICENSE-2.0 -# - https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# - https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt -exclude .isort.cfg -exclude howto.txt -exclude Makefile -exclude requirements.txt -exclude tox.ini -include AUTHORS.txt -include HISTORY.rst -include LICENSE.txt -include NOTICE.txt -include README.rst -prune tests +include *.txt +include .editorconfig +include .isort.cfg +include Makefile +include tox.ini + +recursive-include .github * +recursive-include tests *.py diff --git a/Makefile b/Makefile index 2aa9a8e..8d302f0 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,18 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt # Makefile for django_coverage_plugin -default: - @echo "* No default action *" +.PHONY: help test clean sterile dist pypi test_pypi tag ghrelease -test: - tox +help: ## Show this help. + @echo "Available targets:" + @grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf " %-26s%s\n", $$1, $$2}' -clean: +test: ## Run all the tests. + tox -q -- -q + +clean: ## Remove non-source files. -rm -rf *.egg-info -rm -rf build dist -rm -f *.pyc */*.pyc */*/*.pyc */*/*/*.pyc */*/*/*/*.pyc */*/*/*/*/*.pyc @@ -20,13 +23,26 @@ clean: -rm -f .coverage .coverage.* coverage.xml -rm -f setuptools-*.egg distribute-*.egg distribute-*.tar.gz -sterile: clean +sterile: clean ## Remove all non-controlled content, even if expensive. -rm -rf .tox* -SDIST_CMD = python setup.py sdist --formats=gztar +dist: ## Make the source distribution. + python -m build + python -m twine check dist/* + +pypi: ## Upload the built distributions to PyPI. + python -m twine upload --verbose dist/* + +test_pypi: ## Upload the distributions to test PyPI. + python -m twine upload --verbose --repository testpypi --password $$TWINE_TEST_PASSWORD dist/* + +_install_e: + python -m pip install -q -e . -kit: - $(SDIST_CMD) +tag: _install_e ## Make a git tag with the version number. + @export VER="$$(python -c "import django_coverage_plugin as me; print(me.__version__)")" && \ + git tag -s -m "Version v$$VER" v$$VER + git push --all -kit_upload: - twine upload dist/* +ghrelease: ## Make a GitHub release for the latest version. + python -m scriv github-release diff --git a/NOTICE.txt b/NOTICE.txt index 03e0879..7803be8 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,4 +1,4 @@ -Copyright 2015-2020 Ned Batchelder. All rights reserved. +Copyright 2015-2025 Ned Batchelder. All rights reserved. Except where noted otherwise, this software is licensed under the Apache License, Version 2.0 (the "License"); you may not use this work except in diff --git a/README.rst b/README.rst index 0b68a8e..d6ef68f 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,12 @@ -.. start-badges +================================== +Django Template Coverage.py Plugin +================================== + +A `coverage.py`_ plugin to measure test coverage of Django templates. -|status| |kit| |license| |versions| |djversions| +| |status| |kit| |license| +| |versions| |djversions| +| |sponsor| |bluesky-nedbat| |mastodon-nedbat| .. |status| image:: https://img.shields.io/pypi/status/django_coverage_plugin.svg :target: https://pypi.python.org/pypi/django_coverage_plugin @@ -14,51 +20,92 @@ .. |versions| image:: https://img.shields.io/pypi/pyversions/django_coverage_plugin.svg :target: https://pypi.python.org/pypi/django_coverage_plugin :alt: Supported Python Versions -.. |djversions| image:: https://img.shields.io/badge/Django-1.8%20%7C%201.11%20%7C%202.0%20%7C%202.1%20%7C%202.2%20%7C%203.0-44b78b.svg +.. the Django badge says: `3.2 | 4.2 | 5.2` +.. |djversions| image:: https://img.shields.io/badge/Django-3.2%20%7C%204.2%20%7C%205.2-44b78b.svg :target: https://pypi.python.org/pypi/django_coverage_plugin :alt: Supported Django Versions +.. |sponsor| image:: https://img.shields.io/badge/%E2%9D%A4-Sponsor%20me-brightgreen?style=flat&logo=GitHub + :target: https://github.com/sponsors/nedbat + :alt: Sponsor me on GitHub +.. |bluesky-nedbat| image:: https://img.shields.io/badge/dynamic/json?style=flat&color=96a3b0&labelColor=3686f7&logo=icloud&logoColor=white&label=@nedbat&url=https%3A%2F%2Fpublic.api.bsky.app%2Fxrpc%2Fapp.bsky.actor.getProfile%3Factor=nedbat.com&query=followersCount + :target: https://bsky.app/profile/nedbat.com + :alt: nedbat on Bluesky +.. |mastodon-nedbat| image:: https://img.shields.io/badge/dynamic/json?style=flat&labelColor=450657&logo=mastodon&logoColor=ffffff&label=@nedbat&query=followers_count&url=https%3A%2F%2Fhachyderm.io%2Fapi%2Fv1%2Faccounts%2Flookup%3Facct=nedbat + :target: https://hachyderm.io/@nedbat + :alt: nedbat on Mastodon -.. end-badges -================================== -Django Template Coverage.py Plugin -================================== -A `coverage.py`_ plugin to measure test coverage of Django templates. +Supported on: -Supported Python versions: 2.7, 3.4, 3.5, 3.6, 3.7 and 3.8. +- Python: 3.10 through 3.14. -Supported Django versions: 1.8, 1.11, 2.0, 2.1, 2.2 and 3.0. +- Django: 3.2 through 5.2. -Supported coverage.py version 4.x or 5.x. +- Coverage.py: 6.x or higher. -The plugin is pip installable:: +The plugin is pip-installable:: - $ pip install django_coverage_plugin + $ python3 -m pip install django_coverage_plugin To run it, add this setting to your ``.coveragerc`` file:: [run] - plugins = - django_coverage_plugin + plugins = django_coverage_plugin Then run your tests under `coverage.py`_. You will see your templates listed in your coverage report along with -your Python modules. Please use `coverage.py`_ v4.4 or greater to allow -the plugin to identify untested templates. +your Python modules. If you get a :code:`django.core.exceptions.ImproperlyConfigured` error, you need to set the :code:`DJANGO_SETTINGS_MODULE` environment variable. +Template coverage only works if your Django templates have debugging enabled. +If you get :code:`django_coverage_plugin.plugin.DjangoTemplatePluginException: +Template debugging must be enabled in settings`, or if no templates get +measured, make sure you have :code:`TEMPLATES.OPTIONS.debug` set to True in +your settings file: + +.. code-block:: python + + TEMPLATES = [ + { + ... + 'OPTIONS': { + 'debug': True, + }, + }, + ] + Configuration ~~~~~~~~~~~~~ The Django template plugin uses some existing settings from your -.coveragerc file. The ``source=``, ``include=``, and ``omit=`` options +``.coveragerc`` file. The ``source=``, ``include=``, and ``omit=`` options control what template files are included in the report. +The plugin can find unused template and include them in your results. By +default, it will look for files in your templates directory with an extension +of ``.html``, ``.htm``, or ``.txt``. You can configure it to look for a different set of +extensions if you like:: + + [run] + plugins = django_coverage_plugin + + [django_coverage_plugin] + template_extensions = html, txt, tex, email + +If you use ``pyproject.toml`` for tool configuration use:: + + [tool.coverage.run] + plugins = [ + 'django_coverage_plugin', + ] + + [tool.coverage.django_coverage_plugin] + template_extensions = 'html, txt, tex, email' Caveats ~~~~~~~ @@ -87,8 +134,222 @@ Tests To run the tests:: - $ pip install -r requirements.txt + $ python3 -m pip install -r requirements.txt $ tox + +History +~~~~~~~ + +.. scriv-insert-here + +v3.2.0 — 2025-10-05 +------------------- + +Drop Python 3.9 and Django 2.2. Add Python 3.14. + + +v3.1.1 — 2025-06-15 +------------------- + +Support changes: dropped Python 3.8, added Python 3.13. Added Django 5.2. + + +v3.1.0 — 2023-07-10 +------------------- + +Dropped support for Python 3.7 and Django 1.x. Declared support for Python +3.12. + + +v3.0.0 — 2022-12-06 +------------------- + +Dropped support for Python 2.7, Python 3.6, and Django 1.8. + + +v2.0.4 — 2022-10-31 +------------------- + +Declare our support for Python 3.11 and Django 4.1. + + +v2.0.3 — 2022-05-04 +------------------- + +Add support for Django 4.0. + + +v2.0.2 — 2021-11-11 +------------------- + +If a non-UTF8 file was found when looking for templates, it would fail when +reading during the reporting phase, ending execution. This failure is now +raised in a way that can be ignored with a .coveragerc setting of ``[report] +ignore_errors=True`` (`issue 78`_). + +When using ``source=.``, an existing coverage HTML report directory would be +found and believed to be unmeasured HTML template files. This is now fixed. + +.. _issue 78: https://github.com/coveragepy/django_coverage_plugin/issues/78 + + +v2.0.1 — 2021-10-06 +------------------- + +Test and claim our support on Python 3.10. + +v2.0.0 — 2021-06-08 +------------------- + +Drop support for Python 3.4 and 3.5. + +A setting is available: ``template_extensions`` lets you set the file +extensions that will be considered when looking for unused templates +(requested in `issue 60`_). + +Fix an issue on Windows where file names were being compared +case-sensitively, causing templates to be missed (`issue 46`_). + +Fix an issue (`issue 63`_) where tag libraries can't be found if imported +during test collection. Thanks to Daniel Izquierdo for the fix. + +.. _issue 46: https://github.com/coveragepy/django_coverage_plugin/issues/46 +.. _issue 60: https://github.com/coveragepy/django_coverage_plugin/issues/60 +.. _issue 63: https://github.com/coveragepy/django_coverage_plugin/issues/63 + +v1.8.0 — 2020-01-23 +------------------- + +Add support for: + +- Coverage 5 + +v1.7.0 — 2020-01-16 +------------------- + +Add support for: + +- Python 3.7 & 3.8 +- Django 2.2 & 3.0 + +v1.6.0 — 2018-09-04 +------------------- + +Add support for Django 2.1. + + +v1.5.2 — 2017-10-18 +------------------- + +Validates support for Django version 2.0b1. Improves discovery of +template files. + + +v1.5.1a — 2017-04-05 +-------------------- + +Validates support for Django version 1.11. Testing for new package +maintainer Pamela McA'Nulty + + +v1.5.0 — 2017-02-23 +------------------- + +Removes support for Django versions below 1.8. Validates support for +Django version 1.11b1 + + +v1.4.2 — 2017-02-06 +------------------- + +Fixes another instance of `issue 32`_, which was the result of an +initialization order problem. + +.. _issue 32: https://github.com/coveragepy/django_coverage_plugin/issues/32 + + +v1.4.1 — 2017-01-25 +------------------- + +Fixes `issue 32`_, which was the result of an initialization order +problem. + + +v1.4 — 2017-01-16 +----------------- + +Django 1.10.5 is now supported. + +Checking settings configuration is deferred so that settings.py is +included in coverage reporting. Fixes `issue 28`_. + +Only the ``django.template.backends.django.DjangoTemplates`` template +engine is supported, and it must be configured with +``['OPTIONS']['debug'] = True``. Fixes `issue 27`_. + +.. _issue 28: https://github.com/coveragepy/django_coverage_plugin/issues/28 +.. _issue 27: https://github.com/coveragepy/django_coverage_plugin/issues/27 + + + +v1.3.1 — 2016-06-02 +------------------- + +Settings are read slightly differently, so as to not interfere with +programs that don't need settings. Fixes `issue 18`_. + +.. _issue 18: https://github.com/coveragepy/django_coverage_plugin/issues/18 + + + +v1.3 — 2016-04-03 +----------------- + +Multiple template engines are allowed. Thanks, Simon Charette. + + + +v1.2.2 — 2016-02-01 +------------------- + +No change in code, but Django 1.9.2 is now supported. + + + +v1.2.1 — 2016-01-28 +------------------- + +The template debug settings are checked properly for people still using +``TEMPLATE_DEBUG`` in newer versions of Django. + + + +v1.2 — 2016-01-16 +----------------- + +Check if template debugging is enabled in the settings, and raise a +visible warning if not. This prevents mysterious failures of the +plugin, and fixes `issue 17`_. + +Potential Django 1.9 support is included, but the patch to Django hasn't +been applied yet. + +.. _issue 17: https://github.com/coveragepy/django_coverage_plugin/issues/17 + + + +v1.1 — 2015-11-12 +----------------- + +Explicitly configure settings if need be to get things to work. + + + +v1.0 — 2015-09-20 +----------------- + +First version :) + .. _coverage.py: http://nedbatchelder.com/code/coverage .. _dtcov: https://github.com/traff/dtcov diff --git a/django_coverage_plugin/__init__.py b/django_coverage_plugin/__init__.py index 5150b20..f346811 100644 --- a/django_coverage_plugin/__init__.py +++ b/django_coverage_plugin/__init__.py @@ -1,11 +1,15 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """Django Template Coverage Plugin""" +__version__ = "3.2.0" + +from .plugin import DjangoTemplatePluginException # noqa from .plugin import DjangoTemplatePlugin -from .plugin import DjangoTemplatePluginException # noqa def coverage_init(reg, options): - reg.add_file_tracer(DjangoTemplatePlugin()) + plugin = DjangoTemplatePlugin(options) + reg.add_file_tracer(plugin) + reg.add_configurer(plugin) diff --git a/django_coverage_plugin/plugin.py b/django_coverage_plugin/plugin.py index e777f4f..13e9c41 100644 --- a/django_coverage_plugin/plugin.py +++ b/django_coverage_plugin/plugin.py @@ -1,40 +1,18 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """The Django template coverage plugin.""" -from __future__ import print_function - import os.path import re import coverage.plugin import django import django.template -from django.template.base import Lexer, NodeList, Template, TextNode +from coverage.exceptions import NoSource +from django.template.base import Lexer, NodeList, Template, TextNode, TokenType from django.template.defaulttags import VerbatimNode from django.templatetags.i18n import BlockTranslateNode -from six.moves import range - -try: - from django.template.base import TokenType - - def _token_name(token_type): - token_type.name.capitalize() - -except ImportError: - # Django <2.1 uses separate constants for token types - from django.template.base import ( - TOKEN_BLOCK, TOKEN_MAPPING, TOKEN_TEXT, TOKEN_VAR - ) - - class TokenType: - TEXT = TOKEN_TEXT - VAR = TOKEN_VAR - BLOCK = TOKEN_BLOCK - - def _token_name(token_type): - return TOKEN_MAPPING[token_type] class DjangoTemplatePluginException(Exception): @@ -75,6 +53,8 @@ def check_debug(): return False if not hasattr(django.template.backends.django, "DjangoTemplates"): raise DjangoTemplatePluginException("Can't use non-Django templates.") + if not django.template.engines._engines: + return False for engine in django.template.engines.all(): if not isinstance(engine, django.template.backends.django.DjangoTemplates): @@ -89,39 +69,28 @@ def check_debug(): return True -if django.VERSION < (1, 8): - raise RuntimeError("Django Coverage Plugin requires Django 1.8 or higher") +if django.VERSION < (3, 0): + raise RuntimeError("Django Coverage Plugin requires Django 3.x or higher") -if django.VERSION >= (1, 9): - # Since we are grabbing at internal details, we have to adapt as they - # change over versions. - def filename_for_frame(frame): - try: - return frame.f_locals["self"].origin.name - except (KeyError, AttributeError): - return None +# Since we are grabbing at internal details, we have to adapt as they +# change over versions. +def filename_for_frame(frame): + try: + return frame.f_locals["self"].origin.name + except (KeyError, AttributeError): + return None - def position_for_node(node): - try: - return node.token.position - except AttributeError: - return None - def position_for_token(token): - return token.position -else: - def filename_for_frame(frame): - try: - return frame.f_locals["self"].source[0].name - except (KeyError, AttributeError, IndexError): - return None +def position_for_node(node): + try: + return node.token.position + except AttributeError: + return None - def position_for_node(node): - return node.source[1] - def position_for_token(token): - return token.source[1] +def position_for_token(token): + return token.position def read_template_source(filename): @@ -133,14 +102,8 @@ def read_template_source(filename): if not settings.configured: settings.configure() - with open(filename, "rb") as f: - # The FILE_CHARSET setting will be removed in 3.1: - # https://docs.djangoproject.com/en/3.0/ref/settings/#file-charset - if django.VERSION >= (3, 1): - charset = 'utf-8' - else: - charset = settings.FILE_CHARSET - text = f.read().decode(charset) + with open(filename, "r", encoding="utf-8") as f: + text = f.read() return text @@ -150,12 +113,15 @@ class DjangoTemplatePlugin( coverage.plugin.FileTracer, ): - def __init__(self): + def __init__(self, options): + extensions = options.get("template_extensions", "html,htm,txt") + self.extensions = [e.strip() for e in extensions.split(",")] + self.debug_checked = False - self.django_template_dir = os.path.realpath( + self.django_template_dir = os.path.normcase(os.path.realpath( os.path.dirname(django.template.__file__) - ) + )) self.source_map = {} @@ -165,14 +131,17 @@ def sys_info(self): return [ ("django_template_dir", self.django_template_dir), ("environment", sorted( - ("%s = %s" % (k, v)) + ("{} = {}".format(k, v)) for k, v in os.environ.items() if "DJANGO" in k )), ] + def configure(self, config): + self.html_report_dir = os.path.abspath(config.get_option("html:directory")) + def file_tracer(self, filename): - if filename.startswith(self.django_template_dir): + if os.path.normcase(filename).startswith(self.django_template_dir): if not self.debug_checked: # Keep calling check_debug until it returns True, which it # will only do after settings have been configured @@ -185,12 +154,17 @@ def file_reporter(self, filename): return FileReporter(filename) def find_executable_files(self, src_dir): + # We're only interested in files that look like reasonable HTML + # files: Must end with one of our extensions, and must not have + # funny characters that probably mean they are editor junk. + rx = r"^[^.#~!$@%^&*()+=,]+\.(" + "|".join(self.extensions) + r")$" + for (dirpath, dirnames, filenames) in os.walk(src_dir): + if dirpath == self.html_report_dir: + # Don't confuse the HTML report with HTML templates. + continue for filename in filenames: - # We're only interested in files that look like reasonable HTML - # files: Must end with .htm or .html, and must not have certain - # funny characters that probably mean they are editor junk. - if re.match(r"^[^.#~!$@%^&*()+=,]+\.html?$", filename): + if re.search(rx, filename): yield os.path.join(dirpath, filename) # --- FileTracer methods @@ -198,8 +172,12 @@ def find_executable_files(self, src_dir): def has_dynamic_source_filename(self): return True + # "render" is the public method, but "render_annotated" is an internal + # method sometimes implemented directly on nodes. + RENDER_METHODS = {"render", "render_annotated"} + def dynamic_source_filename(self, filename, frame): - if frame.f_code.co_name != 'render': + if frame.f_code.co_name not in self.RENDER_METHODS: return None if 0: @@ -214,7 +192,7 @@ def dynamic_source_filename(self, filename, frame): return None def line_number_range(self, frame): - assert frame.f_code.co_name == 'render' + assert frame.f_code.co_name in self.RENDER_METHODS if 0: dump_frame(frame, label="line_number_range") @@ -227,7 +205,7 @@ def line_number_range(self, frame): return -1, -1 if SHOW_TRACING: - print("{!r}: {}".format(render_self, position)) + print(f"{render_self!r}: {position}") s_start, s_end = position if isinstance(render_self, TextNode): first_line = render_self.s.splitlines(True)[0] @@ -282,26 +260,26 @@ def get_line_map(self, filename): class FileReporter(coverage.plugin.FileReporter): def __init__(self, filename): - super(FileReporter, self).__init__(filename) + super().__init__(filename) # TODO: html filenames are absolute. self._source = None def source(self): if self._source is None: - self._source = read_template_source(self.filename) + try: + self._source = read_template_source(self.filename) + except (OSError, UnicodeError) as exc: + raise NoSource(f"Couldn't read {self.filename}: {exc}") return self._source def lines(self): source_lines = set() if SHOW_PARSING: - print("-------------- {}".format(self.filename)) + print(f"-------------- {self.filename}") - if django.VERSION >= (1, 9): - lexer = Lexer(self.source()) - else: - lexer = Lexer(self.source(), self.filename) + lexer = Lexer(self.source()) tokens = lexer.tokenize() # Are we inside a comment? @@ -315,7 +293,7 @@ def lines(self): if SHOW_PARSING: print( "%10s %2d: %r" % ( - _token_name(token.token_type), + token.token_type.capitalize(), token.lineno, token.contents, ) @@ -337,7 +315,7 @@ def lines(self): continue if extends and not inblock: - # In an inheriting tempalte, ignore all tags outside of + # In an inheriting template, ignore all tags outside of # blocks. continue @@ -374,7 +352,7 @@ def lines(self): source_lines.update(range(lineno, lineno+num_lines)) if SHOW_PARSING: - print("\t\t\tNow source_lines is: {!r}".format(source_lines)) + print(f"\t\t\tNow source_lines is: {source_lines!r}") return source_lines @@ -387,7 +365,7 @@ def running_sum(seq): def make_line_map(text): - line_lengths = [len(l) for l in text.splitlines(True)] + line_lengths = [len(line) for line in text.splitlines(True)] line_map = list(running_sum(line_lengths)) return line_map diff --git a/howto.txt b/howto.txt index d29a877..f0f4bd7 100644 --- a/howto.txt +++ b/howto.txt @@ -1,15 +1,16 @@ * Release checklist -- Version number in setup.py -- Classifiers in setup.py +- Version number in __init__.py +- Classifiers in pyproject.toml https://pypi.python.org/pypi?%3Aaction=list_classifiers eg: Development Status :: 3 - Alpha Development Status :: 5 - Production/Stable - Copyright date in NOTICE.txt - Update README.rst with latest changes -- Update HISTORY.rst with latest changes - Kits: - $ make kit - $ make kit_upload -- tag git + $ make clean dist + $ make test_pypi + $ make pypi + $ make tag + $ make ghrelease diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0c7292f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt + +[build-system] +requires = ["setuptools>=77.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "django_coverage_plugin" +description = "Django template coverage.py plugin" +readme = "README.rst" +authors = [ + {name = "Ned Batchelder", email = "ned@nedbatchelder.com"}, +] +license = "Apache-2.0" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Framework :: Django", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.2", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", +] +requires-python = ">= 3.10" +dependencies = [ + "coverage", + "Django", +] +dynamic = ["version"] + +[project.urls] +"Homepage" = "https://github.com/coveragepy/django_coverage_plugin" +"Bug Tracker" = "https://github.com/coveragepy/django_coverage_plugin/issues" +"Source" = "https://github.com/coveragepy/django_coverage_plugin" + +[tool.setuptools.dynamic] +version = {attr = "django_coverage_plugin.__version__"} + +[tool.setuptools.packages.find] +include = ["django_coverage_plugin*"] + +[tool.pytest.ini_options] +# How come these warnings are suppressed successfully here, but not in conftest.py?? +filterwarnings = [ + # ignore all DeprecationWarnings... + "ignore::DeprecationWarning", + # ...but show them if they are from our code. + "default::DeprecationWarning:django_coverage_plugin", +] + +[tool.scriv] +fragment_directory = "scriv.d" +output_file = "README.rst" +rst_header_chars = "-." diff --git a/requirements.txt b/requirements.txt index 1b68e9e..fe664e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ -# To run tests, we just need tox. -tox >= 1.8 +# To run tests, we just need tox: +tox + +# For releases: +build +scriv twine diff --git a/setup.py b/setup.py deleted file mode 100644 index 2529896..0000000 --- a/setup.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python -# -*- encoding: utf-8 -*- -"""Setup for Django Coverage Plugin - -Licensed under the Apache 2.0 License -- http://www.apache.org/licenses/LICENSE-2.0 -- https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt - -""" -from __future__ import absolute_import, print_function - -import io -import re -from os.path import dirname, join - -from setuptools import setup - - -def read(*names, **kwargs): - """Read and return contents of file - - Parameter: encoding kwarg may be set - """ - return io.open( - join(dirname(__file__), *names), - encoding=kwargs.get('encoding', 'utf8') - ).read() - - -classifiers = """\ -Environment :: Console -Intended Audience :: Developers -License :: OSI Approved :: Apache Software License -Operating System :: OS Independent -Programming Language :: Python :: 2.7 -Programming Language :: Python :: 3.4 -Programming Language :: Python :: 3.5 -Programming Language :: Python :: 3.6 -Programming Language :: Python :: 3.7 -Programming Language :: Python :: 3.8 -Programming Language :: Python :: Implementation :: CPython -Programming Language :: Python :: Implementation :: PyPy -Topic :: Software Development :: Quality Assurance -Topic :: Software Development :: Testing -Development Status :: 5 - Production/Stable -Framework :: Django -Framework :: Django :: 1.8 -Framework :: Django :: 1.11 -Framework :: Django :: 2.0 -Framework :: Django :: 2.1 -Framework :: Django :: 2.2 -Framework :: Django :: 3.0 -""" - -setup( - name='django_coverage_plugin', - version='1.8.0', - description='Django template coverage.py plugin', - long_description=( - re.compile( - '^.. start-badges.*^.. end-badges', - re.M | re.S, - ).sub('', read('README.rst')) - ), - author='Ned Batchelder', - author_email='ned@nedbatchelder.com', - url='https://github.com/nedbat/django_coverage_plugin', - packages=['django_coverage_plugin'], - install_requires=[ - 'coverage', - 'six >= 1.4.0', - ], - license='Apache 2.0', - classifiers=classifiers.splitlines(), -) diff --git a/tests/__init__.py b/tests/__init__.py index 038b3ad..d4318d5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,11 +1,14 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """The tests for the Django Coverage Plugin.""" # Define URLs here so we can use ROOT_URLCONF="tests" -from django.conf.urls import url +try: + from django.urls import re_path +except ImportError: + from django.conf.urls import url as re_path def index(request): @@ -14,5 +17,5 @@ def index(request): urlpatterns = [ - url(r'^home$', index, name='index'), + re_path(r'^home$', index, name='index'), ] diff --git a/tests/banner.py b/tests/banner.py index 2febc6e..8200b15 100644 --- a/tests/banner.py +++ b/tests/banner.py @@ -1,18 +1,18 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """For printing the versions from tox.ini.""" -from __future__ import print_function - import platform +import coverage import django print( - "{} {}; Django {}".format( + "{} {}; Django {}; Coverage {}".format( platform.python_implementation(), platform.python_version(), - django.get_version() + django.get_version(), + coverage.__version__, ) ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5c8f6d7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt + +""" +Pytest auto configuration. + +This module is run automatically by pytest, to define and enable fixtures. +""" + +import re +import warnings + +import django.utils.deprecation +import pytest + + +@pytest.fixture(autouse=True) +def set_warnings(): + """Configure warnings to show while running tests.""" + warnings.simplefilter("default") + warnings.simplefilter("once", DeprecationWarning) + + # Warnings to suppress: + # How come these warnings are successfully suppressed here, but not in setup.cfg?? + + # We know we do tricky things with Django settings, don't warn us about it. + warnings.filterwarnings( + "ignore", + category=UserWarning, + message=r"Overriding setting DATABASES can lead to unexpected behavior.", + ) + + # Django has warnings like RemovedInDjango40Warning. We use features that are going to be + # deprecated, so we don't need to see those warnings. But the specific warning classes change + # in every release. Find them and ignore them. + for name, obj in vars(django.utils.deprecation).items(): + if re.match(r"RemovedInDjango\d+Warning", name): + warnings.filterwarnings("ignore", category=obj) diff --git a/tests/plugin_test.py b/tests/plugin_test.py index e8ab006..f103454 100644 --- a/tests/plugin_test.py +++ b/tests/plugin_test.py @@ -1,5 +1,5 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """Base classes and helpers for testing the plugin.""" @@ -21,7 +21,7 @@ from django_coverage_plugin.plugin import DjangoTemplatePlugin -def test_settings(): +def get_test_settings(): """Create a dict full of default Django settings for the tests.""" the_settings = { 'CACHES': { @@ -45,19 +45,18 @@ def test_settings(): 'DIRS': ['templates'], # where the tests put things. 'OPTIONS': { 'debug': True, + 'loaders': [ + 'django.template.loaders.filesystem.Loader', + ] }, }, ], }) - if django.VERSION < (1, 10): - # for {% ssi %} - the_settings['TEMPLATES'][0]['OPTIONS']['allowed_include_roots'] = ['/'] - return the_settings -settings.configure(**test_settings()) +settings.configure(**get_test_settings()) if hasattr(django, "setup"): django.setup() @@ -67,11 +66,11 @@ class DjangoPluginTestCase(StdStreamCapturingMixin, TempDirMixin, TestCase): """A base class for all our tests.""" def setUp(self): - super(DjangoPluginTestCase, self).setUp() + super().setUp() self.template_directory = "templates" def _path(self, name=None): - return "{}/{}".format(self.template_directory, name or self.template_file) + return f"{self.template_directory}/{name or self.template_file}" def make_template(self, text, name=None): """Make a template with `text`. @@ -176,9 +175,15 @@ def get_analysis(self, name=None): _, executable, _, missing, _ = analysis return executable, missing - def measured_files(self): - """Get the list of measured files, in relative form.""" - return [os.path.relpath(f) for f in self.cov.get_data().measured_files()] + def assert_measured_files(self, *template_files): + """Assert that the measured files are `template_files`. + + The names in `template_files` are the base names of files + in the templates directory. + """ + measured = {os.path.relpath(f) for f in self.cov.get_data().measured_files()} + expected = {os.path.join("templates", f) for f in template_files} + self.assertEqual(measured, expected) def assert_analysis(self, executable, missing=None, name=None): """Assert that the analysis for `name` is right.""" @@ -186,14 +191,14 @@ def assert_analysis(self, executable, missing=None, name=None): self.assertEqual( executable, actual_executable, - "Executable lines aren't as expected: %r != %r" % ( + "Executable lines aren't as expected: {!r} != {!r}".format( executable, actual_executable, ), ) self.assertEqual( missing or [], actual_missing, - "Missing lines aren't as expected: %r != %r" % ( + "Missing lines aren't as expected: {!r} != {!r}".format( missing, actual_missing, ), ) @@ -220,24 +225,53 @@ def get_xml_report(self, name=None): xml_coverage = self.cov.xml_report(os.path.abspath(path)) return xml_coverage + @contextlib.contextmanager + def assert_coverage_warnings(self, *msgs, min_cov=None): + """Assert that coverage.py warnings are raised that contain all msgs. + + If coverage version isn't at least min_cov, then no warnings are expected. + + """ + # Coverage.py 6.0 made the warnings real warnings, so we have to adapt + # how we test the warnings based on the version. + if min_cov is not None and coverage.version_info < min_cov: + # Don't check for warnings on lower versions of coverage + yield + return + elif coverage.version_info >= (6, 0): + import coverage.exceptions as cov_exc + ctxmgr = self.assertWarns(cov_exc.CoverageWarning) + else: + ctxmgr = contextlib.nullcontext() + with ctxmgr as cw: + yield + + if cw is not None: + warn_text = "\n".join(str(w.message) for w in cw.warnings) + else: + warn_text = self.stderr() + for msg in msgs: + self.assertIn(msg, warn_text) + @contextlib.contextmanager def assert_plugin_disabled(self, msg): """Assert that our plugin was disabled during an operation.""" # self.run_django_coverage will raise PluginDisabled if the plugin # was disabled. - with self.assertRaises(PluginDisabled): - yield - stderr = self.stderr() - self.assertIn( - "Coverage.py warning: " - "Disabling plug-in 'django_coverage_plugin.DjangoTemplatePlugin' " - "due to an exception:", - stderr - ) - self.assertIn( + msgs = [ + "Disabling plug-in 'django_coverage_plugin.DjangoTemplatePlugin' due to an exception:", "DjangoTemplatePluginException: " + msg, - stderr - ) + ] + with self.assert_coverage_warnings(*msgs): + with self.assertRaises(PluginDisabled): + yield + + @contextlib.contextmanager + def assert_no_data(self, min_cov=None): + """Assert that coverage warns no data was collected.""" + warn_msg = "No data was collected. (no-data-collected)" + with self.assert_coverage_warnings(warn_msg, min_cov=min_cov): + yield def squashed(s): diff --git a/tests/test_engines.py b/tests/test_engines.py index 76c713f..3bdbe32 100644 --- a/tests/test_engines.py +++ b/tests/test_engines.py @@ -1,5 +1,5 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """Tests of multiple engines for django_coverage_plugin.""" @@ -10,7 +10,7 @@ class MultipleEngineTests(DjangoPluginTestCase): def setUp(self): - super(MultipleEngineTests, self).setUp() + super().setUp() engine = { 'NAME': 'other', @@ -33,7 +33,8 @@ def test_file_template(self): self.assert_analysis([1]) def test_string_template(self): - text = self.run_django_coverage(text='Hello', using='other') + with self.assert_no_data(): + text = self.run_django_coverage(text='Hello', using='other') self.assertEqual(text, 'Hello') def test_third_engine_not_debug(self): diff --git a/tests/test_extends.py b/tests/test_extends.py index cfa3af3..3f7a987 100644 --- a/tests/test_extends.py +++ b/tests/test_extends.py @@ -1,9 +1,9 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """Tests of template inheritance for django_coverage_plugin.""" -from .plugin_test import DjangoPluginTestCase, django_stop_before +from .plugin_test import DjangoPluginTestCase class BlockTest(DjangoPluginTestCase): @@ -146,49 +146,3 @@ def test_include(self): self.assertEqual(text, "First\nInside\nJob\n\nLast\n") self.assert_analysis([1, 2, 3], name="outer.html") self.assert_analysis([1, 2], name="nested.html") - - -# {% ssi %} is in earlier Djangos than 1.9, but doesn't trace properly. -@django_stop_before(1, 10) -class SsiTest(DjangoPluginTestCase): - """Test {% ssi %}, which does not trace the included file.""" - - def test_ssi_unparsed(self): - nested = self.make_template(name="nested.html", text="""\ - Inside {{ a }} - Job - """) - - self.make_template(name="outer.html", text="""\ - First - {% ssi "NESTED" %} - Last - """.replace("NESTED", nested)) - - text = self.run_django_coverage(name="outer.html", context={'a': 17}) - self.assertEqual(text, "First\nInside {{ a }}\nJob\n\nLast\n") - self.assert_analysis([1, 2, 3], name="outer.html") - self.assertEqual( - set(self.measured_files()), - set(["templates/outer.html", "templates/nested.html"]) - ) - - def test_ssi_parsed(self): - nested = self.make_template(name="nested.html", text="""\ - Inside {{ a }} - Job - """) - - self.make_template(name="outer.html", text="""\ - First - {% ssi "NESTED" parsed %} - Last - """.replace("NESTED", nested)) - - text = self.run_django_coverage(name="outer.html", context={'a': 17}) - self.assertEqual(text, "First\nInside 17\nJob\n\nLast\n") - self.assert_analysis([1, 2, 3], name="outer.html") - self.assertEqual( - set(self.measured_files()), - set(["templates/outer.html", "templates/nested.html"]) - ) diff --git a/tests/test_flow.py b/tests/test_flow.py index 172e0e4..db83fa8 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -1,11 +1,11 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """Tests of control-flow structures for django_coverage_plugin.""" import textwrap -from .plugin_test import DjangoPluginTestCase, squashed +from .plugin_test import DjangoPluginTestCase, django_stop_before, squashed class IfTest(DjangoPluginTestCase): @@ -173,6 +173,7 @@ def test_ifchanged_variable(self): self.assert_analysis([1, 2, 3, 4, 5]) +@django_stop_before(4, 0) class IfEqualTest(DjangoPluginTestCase): def test_ifequal(self): diff --git a/tests/test_helpers.py b/tests/test_helpers.py index d0e919c..467d879 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,5 +1,5 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """Test helpers for the django coverage plugin.""" diff --git a/tests/test_html.py b/tests/test_html.py index 56099b1..b71537b 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,9 +1,10 @@ -# coding: utf8 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """Tests of HTML reporting for django_coverage_plugin.""" +import glob + from .plugin_test import DjangoPluginTestCase @@ -16,6 +17,7 @@ def test_simple(self): self.run_django_coverage() self.cov.html_report() - with open("htmlcov/templates_test_simple_html.html") as fhtml: + html_file = glob.glob("htmlcov/*_test_simple_html.html")[0] + with open(html_file) as fhtml: html = fhtml.read() self.assertIn('Simple © 2015', html) diff --git a/tests/test_i18n.py b/tests/test_i18n.py index e68b920..40affec 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -1,5 +1,5 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """Tests of i18n tags for django_coverage_plugin.""" diff --git a/tests/test_settings.py b/tests/test_settings.py index c3ef4d5..5314051 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,22 +1,22 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """Settings tests for django_coverage_plugin.""" from django.test.utils import override_settings -from .plugin_test import DjangoPluginTestCase, test_settings +from .plugin_test import DjangoPluginTestCase, get_test_settings # Make settings overrides for tests below. NON_DJANGO_BACKEND = 'django.template.backends.dummy.TemplateStrings' -DEBUG_FALSE_OVERRIDES = test_settings() +DEBUG_FALSE_OVERRIDES = get_test_settings() DEBUG_FALSE_OVERRIDES['TEMPLATES'][0]['OPTIONS']['debug'] = False -NO_OPTIONS_OVERRIDES = test_settings() +NO_OPTIONS_OVERRIDES = get_test_settings() del NO_OPTIONS_OVERRIDES['TEMPLATES'][0]['OPTIONS'] -OTHER_ENGINE_OVERRIDES = test_settings() +OTHER_ENGINE_OVERRIDES = get_test_settings() OTHER_ENGINE_OVERRIDES['TEMPLATES'][0]['BACKEND'] = NON_DJANGO_BACKEND OTHER_ENGINE_OVERRIDES['TEMPLATES'][0]['OPTIONS'] = {} diff --git a/tests/test_simple.py b/tests/test_simple.py index 7ca9188..c4e326d 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -1,13 +1,12 @@ -# coding: utf8 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt """Simple tests for django_coverage_plugin.""" from .plugin_test import DjangoPluginTestCase # 200 Unicode chars: snowman + poo. -UNIUNI = u"\u26C4\U0001F4A9"*100 +UNIUNI = "\u26C4\U0001F4A9"*100 if isinstance(UNIUNI, str): UNISTR = UNIUNI else: @@ -64,8 +63,8 @@ def test_non_ascii(self): υηιcσɗє ιѕ тяιcку {{more}}! """) - text = self.run_django_coverage(context={'more': u'ɘboɔinU'}) - self.assertEqual(text, u'υηιcσɗє ιѕ тяιcку\nɘboɔinU!\n') + text = self.run_django_coverage(context={'more': 'ɘboɔinU'}) + self.assertEqual(text, 'υηιcσɗє ιѕ тяιcку\nɘboɔinU!\n') self.assert_analysis([1, 2]) self.assertEqual(self.get_html_report(), 100) self.assertEqual(self.get_xml_report(), 100) @@ -215,8 +214,8 @@ def test_verbatim(self): text = self.run_django_coverage() self.assertEqual( text, - u"1\n\n{{if dying}}Alive.{{/if}}\nsecond.\n" - u"{%third%}.UNIUNI\n\n7\n".replace(u"UNIUNI", UNIUNI) + "1\n\n{{if dying}}Alive.{{/if}}\nsecond.\n" + "{%third%}.UNIUNI\n\n7\n".replace("UNIUNI", UNIUNI) ) self.assert_analysis([1, 2, 3, 4, 5, 7]) @@ -245,11 +244,14 @@ class StringTemplateTest(DjangoPluginTestCase): run_in_temp_dir = False def test_string_template(self): - text = self.run_django_coverage( - text="Hello, {{name}}!", - context={'name': 'World'}, - options={}, - ) + # I don't understand why coverage 6 warns about no data, + # but coverage 5 does not. + with self.assert_no_data(min_cov=(6, 0)): + text = self.run_django_coverage( + text="Hello, {{name}}!", + context={'name': 'World'}, + options={}, + ) self.assertEqual(text, "Hello, World!") diff --git a/tests/test_source.py b/tests/test_source.py new file mode 100644 index 0000000..e44c7ea --- /dev/null +++ b/tests/test_source.py @@ -0,0 +1,179 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt + +"""Tests of template inheritance for django_coverage_plugin.""" + +import os + +try: + from coverage.exceptions import NoSource +except ImportError: + # for coverage 5.x + from coverage.misc import NoSource + +from .plugin_test import DjangoPluginTestCase + + +class FindSourceTest(DjangoPluginTestCase): + + def test_finding_source(self): + # This is a template that is rendered. + self.make_template(name="main.html", text="Hello") + # These are templates that aren't rendered, but are considered renderable. + self.make_template(name="unused.html", text="Not used") + self.make_template(name="unused.htm", text="Not used") + self.make_template(name="unused.txt", text="Not used") + # These are things left behind by an editor. + self.make_template(name="~unused.html", text="junk") + self.make_template(name="unused=.html", text="junk") + self.make_template(name="unused.html,", text="junk") + # This is some other file format we don't recognize. + self.make_template(name="phd.tex", text="Too complicated to read") + + text = self.run_django_coverage(name="main.html") + self.assertEqual(text, "Hello") + + # The rendered file has data, and was measured. + self.assert_analysis([1], name="main.html") + # The unrendered files have data, and were not measured. + self.assert_analysis([1], name="unused.html", missing=[1]) + self.assert_analysis([1], name="unused.htm", missing=[1]) + self.assert_analysis([1], name="unused.txt", missing=[1]) + # The editor leave-behinds are not in the measured files. + self.assert_measured_files("main.html", "unused.html", "unused.htm", "unused.txt") + + def test_customized_extensions(self): + self.make_file(".coveragerc", """\ + [run] + plugins = django_coverage_plugin + [django_coverage_plugin] + template_extensions = html, tex + """) + # This is a template that is rendered. + self.make_template(name="main.html", text="Hello") + # These are templates that aren't rendered, but are considered renderable. + self.make_template(name="unused.html", text="Not used") + self.make_template(name="phd.tex", text="Too complicated to read") + # These are things left behind by an editor. + self.make_template(name="~unused.html", text="junk") + self.make_template(name="unused=.html", text="junk") + self.make_template(name="unused.html,", text="junk") + # This is some other file format we don't recognize. + self.make_template(name="unused.htm", text="Not used") + self.make_template(name="unused.txt", text="Not used") + + text = self.run_django_coverage(name="main.html") + self.assertEqual(text, "Hello") + + # The rendered file has data, and was measured. + self.assert_analysis([1], name="main.html") + # The unrendered files have data, and were not measured. + self.assert_analysis([1], name="unused.html", missing=[1]) + self.assert_analysis([1], name="phd.tex", missing=[1]) + # The editor leave-behinds are not in the measured files. + self.assert_measured_files("main.html", "unused.html", "phd.tex") + + def test_non_utf8_error(self): + # A non-UTF8 text file will raise an error. + self.make_file(".coveragerc", """\ + [run] + plugins = django_coverage_plugin + source = . + """) + # This is a template that is rendered. + self.make_template(name="main.html", text="Hello") + # Extra file containing a word encoded in CP-1252 + self.make_file(self._path("static/changelog.txt"), bytes=b"sh\xf6n") + + text = self.run_django_coverage(name="main.html") + self.assertEqual(text, "Hello") + + self.assert_measured_files("main.html", f"static{os.sep}changelog.txt") + self.assert_analysis([1], name="main.html") + with self.assertRaisesRegex(NoSource, r"changelog.txt.*invalid start byte"): + self.cov.html_report() + + def test_non_utf8_omitted(self): + # If we omit the directory with the non-UTF8 file, all is well. + self.make_file(".coveragerc", """\ + [run] + plugins = django_coverage_plugin + source = . + [report] + omit = */static/* + """) + # This is a template that is rendered. + self.make_template(name="main.html", text="Hello") + # Extra file containing a word encoded in CP-1252 + self.make_file(self._path("static/changelog.txt"), bytes=b"sh\xf6n") + + text = self.run_django_coverage(name="main.html") + self.assertEqual(text, "Hello") + + self.assert_measured_files("main.html", f"static{os.sep}changelog.txt") + self.assert_analysis([1], name="main.html") + self.cov.html_report() + + def test_non_utf8_ignored(self): + # If we ignore reporting errors, a non-UTF8 text file is fine. + self.make_file(".coveragerc", """\ + [run] + plugins = django_coverage_plugin + source = . + [report] + ignore_errors = True + """) + # This is a template that is rendered. + self.make_template(name="main.html", text="Hello") + # Extra file containing a word encoded in CP-1252 + self.make_file(self._path("static/changelog.txt"), bytes=b"sh\xf6n") + + text = self.run_django_coverage(name="main.html") + self.assertEqual(text, "Hello") + + self.assert_measured_files("main.html", f"static{os.sep}changelog.txt") + self.assert_analysis([1], name="main.html") + warn_msg = ( + "'utf-8' codec can't decode byte 0xf6 in position 2: " + + "invalid start byte (couldnt-parse)" + ) + with self.assert_coverage_warnings(warn_msg, min_cov=(6, 0)): + self.cov.html_report() + + def test_htmlcov_isnt_measured(self): + # We used to find the HTML report and think it was template files. + self.make_file(".coveragerc", """\ + [run] + plugins = django_coverage_plugin + source = . + """) + self.make_template(name="main.html", text="Hello") + text = self.run_django_coverage(name="main.html") + self.assertEqual(text, "Hello") + + self.assert_measured_files("main.html") + self.cov.html_report() + + # Run coverage again with an HTML report on disk. + text = self.run_django_coverage(name="main.html") + self.assert_measured_files("main.html") + + def test_custom_html_report_isnt_measured(self): + # We used to find the HTML report and think it was template files. + self.make_file(".coveragerc", """\ + [run] + plugins = django_coverage_plugin + source = . + [html] + directory = my_html_report + """) + self.make_template(name="main.html", text="Hello") + text = self.run_django_coverage(name="main.html") + self.assertEqual(text, "Hello") + + self.assert_measured_files("main.html") + self.cov.html_report() + + # Run coverage again with an HTML report on disk. + text = self.run_django_coverage(name="main.html") + self.assert_measured_files("main.html") diff --git a/tox.ini b/tox.ini index 7af5133..1aae1d5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt +# For details: https://github.com/coveragepy/django_coverage_plugin/blob/main/NOTICE.txt # tox configuration for django_coverage_plugin. # @@ -13,54 +13,61 @@ # [tox] +# When changing this, also update the classifiers in setup.py: envlist = - py27-django{18,19,110,111}, - py34-django{18,19,110,111,20}, - py35-django{18,19,110,111,20,21,22}, - py36-django{18,19,110,111,20,21,22,30,tip}, - py37-django{20,21,22,30,tip}, - py38-django{22,30,tip}, + py310-django{32,42,52}-cov{6,7,tip}, + py311-django{42,52}-cov{6,7,tip}, + py312-django{52,tip}-cov{7,tip}, + py313-django{52,tip}-cov{7,tip}, + py314-django{52,tip}-cov{7,tip}, check,pkgcheck,doc [testenv] deps = + cov6: coverage>=6.0,<7.0 + cov7: coverage>=7.0,<8.0 + covtip: git+https://github.com/nedbat/coveragepy.git + django32: Django>=3.2,<4.0 + django42: Django>=4.2,<5.0 + django52: Django>=5.2,<6.0 + djangotip: git+https://github.com/django/django.git + pytest unittest-mixins==1.6 - django18: Django>=1.8,<1.9 - django19: Django>=1.9,<1.10 - django110: Django>=1.10,<1.11 - django111: Django>=1.11,<2.0 - django20: Django>=2.0,<2.1 - django21: Django>=2.1,<2.2 - django22: Django>=2.2,<3.0 - django30: Django>=3.0,<3.1 - djangotip: https://github.com/django/django/archive/master.tar.gz commands = python -c "import tests.banner" - python -m unittest {posargs:discover -b} + python -m pytest {posargs} usedevelop = True passenv = * +setenv = + # In later versions of Python, the default coverage.py core is sysmon, + # which doesn't support plugins like us. Force ctrace instead. + py3{12,13,14}: COVERAGE_CORE=ctrace + [testenv:check] deps = flake8 isort commands = - flake8 --max-line-length=100 setup.py django_coverage_plugin tests setup.py - isort --verbose --check-only --diff --recursive django_coverage_plugin tests setup.py + flake8 --max-line-length=100 django_coverage_plugin tests + isort --check-only --diff django_coverage_plugin tests [testenv:pkgcheck] skip_install = true deps = + build docutils check-manifest readme-renderer + twine commands = - python setup.py check --strict --metadata --restructuredtext + python -m build --config-setting=--quiet + twine check dist/* check-manifest {toxinidir} [testenv:doc] @@ -68,4 +75,12 @@ deps = sphinx commands = - rst2html.py --strict README.rst /tmp/django_coverage_plugin_README.html + rst2html --strict README.rst /tmp/django_coverage_plugin_README.html + +[gh-actions] +python = + 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313 + 3.14: py314