├── .editorconfig ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .isort.cfg ├── AUTHORS.txt ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── NOTICE.txt ├── README.rst ├── django_coverage_plugin ├── __init__.py └── plugin.py ├── howto.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── banner.py ├── conftest.py ├── plugin_test.py ├── test_engines.py ├── test_extends.py ├── test_flow.py ├── test_helpers.py ├── test_html.py ├── test_i18n.py ├── test_settings.py ├── test_simple.py └── test_source.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | # This file is for unifying the coding style for different editors and IDEs. 5 | # More information at http://EditorConfig.org 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | indent_size = 4 13 | indent_style = space 14 | insert_final_newline = true 15 | max_line_length = 80 16 | trim_trailing_whitespace = true 17 | 18 | [*.py] 19 | max_line_length = 100 20 | 21 | [*.yml] 22 | indent_size = 2 23 | 24 | [*.rst] 25 | max_line_length = 79 26 | 27 | [Makefile] 28 | indent_style = tab 29 | indent_size = 8 30 | 31 | [*,cover] 32 | trim_trailing_whitespace = false 33 | 34 | [*.diff] 35 | trim_trailing_whitespace = false 36 | 37 | [.git/*] 38 | trim_trailing_whitespace = false 39 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt 3 | 4 | name: "Tests" 5 | 6 | on: 7 | push: 8 | pull_request: 9 | workflow_dispatch: 10 | schedule: 11 | # Since we test against the tip of Django development, run the tests once a 12 | # week, Sundays at 6:00 UTC. 13 | - cron: "0 6 * * 0" 14 | 15 | permissions: 16 | contents: read 17 | 18 | concurrency: 19 | group: "${{ github.workflow }}-${{ github.ref }}" 20 | cancel-in-progress: true 21 | 22 | defaults: 23 | run: 24 | shell: bash 25 | 26 | env: 27 | PIP_DISABLE_PIP_VERSION_CHECK: 1 28 | FORCE_COLOR: 1 # Get colored pytest output 29 | 30 | jobs: 31 | tests: 32 | name: "${{ matrix.python-version }} on ${{ matrix.os }}" 33 | runs-on: "${{ matrix.os }}-latest" 34 | 35 | strategy: 36 | matrix: 37 | os: 38 | - ubuntu 39 | - macos 40 | - windows 41 | python-version: 42 | # When changing this list, be sure to check the [gh-actions] list in 43 | # tox.ini so that tox will run properly. 44 | - "3.9" 45 | - "3.10" 46 | - "3.11" 47 | - "3.12" 48 | - "3.13" 49 | fail-fast: false 50 | 51 | steps: 52 | - name: "Check out the repo" 53 | uses: "actions/checkout@v4" 54 | with: 55 | persist-credentials: false 56 | 57 | - name: "Set up Python" 58 | uses: "actions/setup-python@v5" 59 | with: 60 | python-version: "${{ matrix.python-version }}" 61 | allow-prereleases: true 62 | 63 | - name: "Install dependencies" 64 | run: | 65 | set -xe 66 | python -VV 67 | python -m site 68 | python -m pip install -r requirements.txt 69 | python -m pip install tox-gh-actions 70 | 71 | - name: "Run tox for ${{ matrix.python-version }}" 72 | run: | 73 | # GitHub Actions on Windows sets TEMP to a shortname, and HOME to a 74 | # longname. Eventually, filename comparisons fail because of the 75 | # difference. Fix $TEMP. 76 | echo $TEMP 77 | if [ "$RUNNER_OS" == "Windows" ]; then 78 | export TMP=$HOME\\AppData\\Local\\Temp 79 | export TEMP=$HOME\\AppData\\Local\\Temp 80 | fi 81 | echo $TEMP 82 | python -m tox 83 | 84 | checks: 85 | name: "Quality checks" 86 | runs-on: "ubuntu-latest" 87 | 88 | steps: 89 | - name: "Check out the repo" 90 | uses: "actions/checkout@v4" 91 | with: 92 | persist-credentials: false 93 | 94 | - name: "Set up Python" 95 | uses: "actions/setup-python@v5" 96 | with: 97 | python-version: "3.9" 98 | 99 | - name: "Install dependencies" 100 | run: | 101 | set -xe 102 | python -VV 103 | python -m site 104 | python -m pip install -r requirements.txt 105 | 106 | - name: "Run check" 107 | run: | 108 | python -m tox -e check 109 | 110 | - name: "Run pkgcheck" 111 | run: | 112 | python -m tox -e pkgcheck 113 | 114 | - name: "Run doc" 115 | run: | 116 | python -m tox -e doc 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .coverage 3 | htmlcov 4 | .tox 5 | MANIFEST 6 | *.egg-info 7 | build 8 | dist 9 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | combine_as_imports=True 3 | force_grid_wrap=0 4 | include_trailing_comma=True 5 | line_length=79 6 | multi_line_output=3 7 | known_third_party = coverage,django,setuptools,unittest_mixins 8 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | The Django coverage plugin was written by Ned Batchelder, with other 2 | contributions by: 3 | 4 | Andrew Pinkham 5 | Aron Griffis 6 | Bruno Alla 7 | Emil Madsen 8 | Federico Bond 9 | Jessamyn Smith 10 | Pamela McA'Nulty 11 | Simon Charette 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache 2.0 License 2 | # - http://www.apache.org/licenses/LICENSE-2.0 3 | # - https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 4 | 5 | exclude .isort.cfg 6 | exclude howto.txt 7 | exclude Makefile 8 | exclude requirements.txt 9 | exclude tox.ini 10 | exclude .editorconfig 11 | include AUTHORS.txt 12 | include LICENSE.txt 13 | include NOTICE.txt 14 | include README.rst 15 | prune tests 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | # Makefile for django_coverage_plugin 5 | 6 | help: ## Show this help. 7 | @echo "Available targets:" 8 | @grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf " %-26s%s\n", $$1, $$2}' 9 | 10 | test: ## Run all the tests. 11 | tox -q -- -q 12 | 13 | clean: ## Remove non-source files. 14 | -rm -rf *.egg-info 15 | -rm -rf build dist 16 | -rm -f *.pyc */*.pyc */*/*.pyc */*/*/*.pyc */*/*/*/*.pyc */*/*/*/*/*.pyc 17 | -rm -f *.pyo */*.pyo */*/*.pyo */*/*/*.pyo */*/*/*/*.pyo */*/*/*/*/*.pyo 18 | -rm -f *.bak */*.bak */*/*.bak */*/*/*.bak */*/*/*/*.bak */*/*/*/*/*.bak 19 | -rm -rf __pycache__ */__pycache__ */*/__pycache__ */*/*/__pycache__ */*/*/*/__pycache__ */*/*/*/*/__pycache__ 20 | -rm -f MANIFEST 21 | -rm -f .coverage .coverage.* coverage.xml 22 | -rm -f setuptools-*.egg distribute-*.egg distribute-*.tar.gz 23 | 24 | sterile: clean ## Remove all non-controlled content, even if expensive. 25 | -rm -rf .tox* 26 | 27 | kit: ## Make the source distribution. 28 | python -m build 29 | python -m twine check dist/* 30 | 31 | kit_upload: ## Upload the built distributions to PyPI. 32 | python -m twine upload --verbose dist/* 33 | 34 | tag: ## Make a git tag with the version number. 35 | git tag -s -m "Version v$$(python setup.py --version)" v$$(python setup.py --version) 36 | git push --all 37 | 38 | ghrelease: ## Make a GitHub release for the latest version. 39 | python -m scriv github-release 40 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2015-2022 Ned Batchelder. All rights reserved. 2 | 3 | Except where noted otherwise, this software is licensed under the Apache 4 | License, Version 2.0 (the "License"); you may not use this work except in 5 | compliance with the License. You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Django Template Coverage.py Plugin 3 | ================================== 4 | 5 | A `coverage.py`_ plugin to measure test coverage of Django templates. 6 | 7 | .. start-badges 8 | 9 | | |status| |kit| |license| 10 | | |versions| |djversions| 11 | 12 | .. |status| image:: https://img.shields.io/pypi/status/django_coverage_plugin.svg 13 | :target: https://pypi.python.org/pypi/django_coverage_plugin 14 | :alt: Package stability 15 | .. |kit| image:: https://badge.fury.io/py/django_coverage_plugin.svg 16 | :target: https://pypi.python.org/pypi/django_coverage_plugin 17 | :alt: Latest PyPI Version 18 | .. |license| image:: https://img.shields.io/pypi/l/django_coverage_plugin.svg 19 | :target: https://pypi.python.org/pypi/django_coverage_plugin 20 | :alt: Apache 2.0 License 21 | .. |versions| image:: https://img.shields.io/pypi/pyversions/django_coverage_plugin.svg 22 | :target: https://pypi.python.org/pypi/django_coverage_plugin 23 | :alt: Supported Python Versions 24 | .. |djversions| image:: https://img.shields.io/badge/Django-1.8%20%7C%201.11%20%7C%202.2%20%7C%203.2%20%7C%204.1-44b78b.svg 25 | :target: https://pypi.python.org/pypi/django_coverage_plugin 26 | :alt: Supported Django Versions 27 | 28 | ------------------ 29 | 30 | .. end-badges 31 | 32 | Supported on: 33 | 34 | - Python: 3.9 through 3.13. 35 | 36 | - Django: 2.x, 3.x, 4.x and 5.x. 37 | 38 | - Coverage.py: 6.x or higher. 39 | 40 | The plugin is pip-installable:: 41 | 42 | $ python3 -m pip install django_coverage_plugin 43 | 44 | To run it, add this setting to your ``.coveragerc`` file:: 45 | 46 | [run] 47 | plugins = django_coverage_plugin 48 | 49 | Then run your tests under `coverage.py`_. 50 | 51 | You will see your templates listed in your coverage report along with 52 | your Python modules. Please use `coverage.py`_ v4.4 or greater to allow 53 | the plugin to identify untested templates. 54 | 55 | If you get a :code:`django.core.exceptions.ImproperlyConfigured` error, 56 | you need to set the :code:`DJANGO_SETTINGS_MODULE` environment variable. 57 | 58 | Template coverage only works if your Django templates have debugging enabled. 59 | If you get :code:`django_coverage_plugin.plugin.DjangoTemplatePluginException: 60 | Template debugging must be enabled in settings`, or if no templates get 61 | measured, make sure you have :code:`TEMPLATES.OPTIONS.debug` set to True in 62 | your settings file: 63 | 64 | .. code-block:: python 65 | 66 | TEMPLATES = [ 67 | { 68 | ... 69 | 'OPTIONS': { 70 | 'debug': True, 71 | }, 72 | }, 73 | ] 74 | 75 | 76 | Configuration 77 | ~~~~~~~~~~~~~ 78 | 79 | The Django template plugin uses some existing settings from your 80 | ``.coveragerc`` file. The ``source=``, ``include=``, and ``omit=`` options 81 | control what template files are included in the report. 82 | 83 | The plugin can find unused template and include them in your results. By 84 | default, it will look for files in your templates directory with an extension 85 | of ``.html``, ``.htm``, or ``.txt``. You can configure it to look for a different set of 86 | extensions if you like:: 87 | 88 | [run] 89 | plugins = django_coverage_plugin 90 | 91 | [django_coverage_plugin] 92 | template_extensions = html, txt, tex, email 93 | 94 | If you use ``pyproject.toml`` for tool configuration use:: 95 | 96 | [tool.coverage.run] 97 | plugins = [ 98 | 'django_coverage_plugin', 99 | ] 100 | 101 | [tool.coverage.django_coverage_plugin] 102 | template_extensions = 'html, txt, tex, email' 103 | 104 | Caveats 105 | ~~~~~~~ 106 | 107 | Coverage.py can't tell whether a ``{% blocktrans %}`` tag used the 108 | singular or plural text, so both are marked as used if the tag is used. 109 | 110 | 111 | What the? How? 112 | ~~~~~~~~~~~~~~ 113 | 114 | The technique used to measure the coverage is the same that Dmitry 115 | Trofimov used in `dtcov`_, but integrated into coverage.py as a plugin, 116 | and made more performant. I'd love to see how well it works in a real 117 | production project. If you want to help me with it, feel free to drop me 118 | an email. 119 | 120 | The coverage.py plugin mechanism is designed to be generally useful for 121 | hooking into the collection and reporting phases of coverage.py, 122 | specifically to support non-Python files. If you have non-Python files 123 | you'd like to support in coverage.py, let's talk. 124 | 125 | 126 | Tests 127 | ~~~~~ 128 | 129 | To run the tests:: 130 | 131 | $ python3 -m pip install -r requirements.txt 132 | $ tox 133 | 134 | 135 | History 136 | ~~~~~~~ 137 | 138 | .. scriv-insert-here 139 | 140 | v3.1.0 — 2023-07-10 141 | ------------------- 142 | 143 | Dropped support for Python 3.7 and Django 1.x. Declared support for Python 144 | 3.12. 145 | 146 | 147 | v3.0.0 — 2022-12-06 148 | ------------------- 149 | 150 | Dropped support for Python 2.7, Python 3.6, and Django 1.8. 151 | 152 | 153 | v2.0.4 — 2022-10-31 154 | ------------------- 155 | 156 | Declare our support for Python 3.11 and Django 4.1. 157 | 158 | 159 | v2.0.3 — 2022-05-04 160 | ------------------- 161 | 162 | Add support for Django 4.0. 163 | 164 | 165 | v2.0.2 — 2021-11-11 166 | ------------------- 167 | 168 | If a non-UTF8 file was found when looking for templates, it would fail when 169 | reading during the reporting phase, ending execution. This failure is now 170 | raised in a way that can be ignored with a .coveragerc setting of ``[report] 171 | ignore_errors=True`` (`issue 78`_). 172 | 173 | When using ``source=.``, an existing coverage HTML report directory would be 174 | found and believed to be unmeasured HTML template files. This is now fixed. 175 | 176 | .. _issue 78: https://github.com/nedbat/django_coverage_plugin/issues/78 177 | 178 | 179 | v2.0.1 — 2021-10-06 180 | ------------------- 181 | 182 | Test and claim our support on Python 3.10. 183 | 184 | v2.0.0 — 2021-06-08 185 | ------------------- 186 | 187 | Drop support for Python 3.4 and 3.5. 188 | 189 | A setting is available: ``template_extensions`` lets you set the file 190 | extensions that will be considered when looking for unused templates 191 | (requested in `issue 60`_). 192 | 193 | Fix an issue on Windows where file names were being compared 194 | case-sensitively, causing templates to be missed (`issue 46`_). 195 | 196 | Fix an issue (`issue 63`_) where tag libraries can't be found if imported 197 | during test collection. Thanks to Daniel Izquierdo for the fix. 198 | 199 | .. _issue 46: https://github.com/nedbat/django_coverage_plugin/issues/46 200 | .. _issue 60: https://github.com/nedbat/django_coverage_plugin/issues/60 201 | .. _issue 63: https://github.com/nedbat/django_coverage_plugin/issues/63 202 | 203 | v1.8.0 — 2020-01-23 204 | ------------------- 205 | 206 | Add support for: 207 | 208 | - Coverage 5 209 | 210 | v1.7.0 — 2020-01-16 211 | ------------------- 212 | 213 | Add support for: 214 | 215 | - Python 3.7 & 3.8 216 | - Django 2.2 & 3.0 217 | 218 | v1.6.0 — 2018-09-04 219 | ------------------- 220 | 221 | Add support for Django 2.1. 222 | 223 | 224 | v1.5.2 — 2017-10-18 225 | ------------------- 226 | 227 | Validates support for Django version 2.0b1. Improves discovery of 228 | template files. 229 | 230 | 231 | v1.5.1a — 2017-04-05 232 | -------------------- 233 | 234 | Validates support for Django version 1.11. Testing for new package 235 | maintainer Pamela McA'Nulty 236 | 237 | 238 | v1.5.0 — 2017-02-23 239 | ------------------- 240 | 241 | Removes support for Django versions below 1.8. Validates support for 242 | Django version 1.11b1 243 | 244 | 245 | v1.4.2 — 2017-02-06 246 | ------------------- 247 | 248 | Fixes another instance of `issue 32`_, which was the result of an 249 | initialization order problem. 250 | 251 | .. _issue 32: https://github.com/nedbat/django_coverage_plugin/issues/32 252 | 253 | 254 | v1.4.1 — 2017-01-25 255 | ------------------- 256 | 257 | Fixes `issue 32`_, which was the result of an initialization order 258 | problem. 259 | 260 | 261 | v1.4 — 2017-01-16 262 | ----------------- 263 | 264 | Django 1.10.5 is now supported. 265 | 266 | Checking settings configuration is deferred so that settings.py is 267 | included in coverage reporting. Fixes `issue 28`_. 268 | 269 | Only the ``django.template.backends.django.DjangoTemplates`` template 270 | engine is supported, and it must be configured with 271 | ``['OPTIONS']['debug'] = True``. Fixes `issue 27`_. 272 | 273 | .. _issue 28: https://github.com/nedbat/django_coverage_plugin/issues/28 274 | .. _issue 27: https://github.com/nedbat/django_coverage_plugin/issues/27 275 | 276 | 277 | 278 | v1.3.1 — 2016-06-02 279 | ------------------- 280 | 281 | Settings are read slightly differently, so as to not interfere with 282 | programs that don't need settings. Fixes `issue 18`_. 283 | 284 | .. _issue 18: https://github.com/nedbat/django_coverage_plugin/issues/18 285 | 286 | 287 | 288 | v1.3 — 2016-04-03 289 | ----------------- 290 | 291 | Multiple template engines are allowed. Thanks, Simon Charette. 292 | 293 | 294 | 295 | v1.2.2 — 2016-02-01 296 | ------------------- 297 | 298 | No change in code, but Django 1.9.2 is now supported. 299 | 300 | 301 | 302 | v1.2.1 — 2016-01-28 303 | ------------------- 304 | 305 | The template debug settings are checked properly for people still using 306 | ``TEMPLATE_DEBUG`` in newer versions of Django. 307 | 308 | 309 | 310 | v1.2 — 2016-01-16 311 | ----------------- 312 | 313 | Check if template debugging is enabled in the settings, and raise a 314 | visible warning if not. This prevents mysterious failures of the 315 | plugin, and fixes `issue 17`_. 316 | 317 | Potential Django 1.9 support is included, but the patch to Django hasn't 318 | been applied yet. 319 | 320 | .. _issue 17: https://github.com/nedbat/django_coverage_plugin/issues/17 321 | 322 | 323 | 324 | v1.1 — 2015-11-12 325 | ----------------- 326 | 327 | Explicitly configure settings if need be to get things to work. 328 | 329 | 330 | 331 | v1.0 — 2015-09-20 332 | ----------------- 333 | 334 | First version :) 335 | 336 | .. _coverage.py: http://nedbatchelder.com/code/coverage 337 | .. _dtcov: https://github.com/traff/dtcov 338 | -------------------------------------------------------------------------------- /django_coverage_plugin/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Django Template Coverage Plugin""" 5 | 6 | from .plugin import DjangoTemplatePluginException # noqa 7 | from .plugin import DjangoTemplatePlugin 8 | 9 | 10 | def coverage_init(reg, options): 11 | plugin = DjangoTemplatePlugin(options) 12 | reg.add_file_tracer(plugin) 13 | reg.add_configurer(plugin) 14 | -------------------------------------------------------------------------------- /django_coverage_plugin/plugin.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """The Django template coverage plugin.""" 5 | 6 | import os.path 7 | import re 8 | 9 | try: 10 | from coverage.exceptions import NoSource 11 | except ImportError: 12 | # for coverage 5.x 13 | from coverage.misc import NoSource 14 | import coverage.plugin 15 | import django 16 | import django.template 17 | from django.template.base import Lexer, NodeList, Template, TextNode 18 | from django.template.defaulttags import VerbatimNode 19 | from django.templatetags.i18n import BlockTranslateNode 20 | 21 | try: 22 | from django.template.base import TokenType 23 | 24 | def _token_name(token_type): 25 | token_type.name.capitalize() 26 | 27 | except ImportError: 28 | # Django <2.1 uses separate constants for token types 29 | from django.template.base import ( 30 | TOKEN_BLOCK, 31 | TOKEN_MAPPING, 32 | TOKEN_TEXT, 33 | TOKEN_VAR, 34 | ) 35 | 36 | class TokenType: 37 | TEXT = TOKEN_TEXT 38 | VAR = TOKEN_VAR 39 | BLOCK = TOKEN_BLOCK 40 | 41 | def _token_name(token_type): 42 | return TOKEN_MAPPING[token_type] 43 | 44 | 45 | class DjangoTemplatePluginException(Exception): 46 | """Used for any errors from the plugin itself.""" 47 | pass 48 | 49 | 50 | # For debugging the plugin itself. 51 | SHOW_PARSING = False 52 | SHOW_TRACING = False 53 | 54 | 55 | def check_debug(): 56 | """Check that Django's template debugging is enabled. 57 | 58 | Django's built-in "template debugging" records information the plugin needs 59 | to do its work. Check that the setting is correct, and raise an exception 60 | if it is not. 61 | 62 | Returns True if the debug check was performed, False otherwise 63 | """ 64 | from django.conf import settings 65 | 66 | if not settings.configured: 67 | return False 68 | 69 | # I _think_ this check is all that's needed and the 3 "hasattr" checks 70 | # below can be removed, but it's not clear how to verify that 71 | from django.apps import apps 72 | if not apps.ready: 73 | return False 74 | 75 | # django.template.backends.django gets loaded lazily, so return false 76 | # until they've been loaded 77 | if not hasattr(django.template, "backends"): 78 | return False 79 | if not hasattr(django.template.backends, "django"): 80 | return False 81 | if not hasattr(django.template.backends.django, "DjangoTemplates"): 82 | raise DjangoTemplatePluginException("Can't use non-Django templates.") 83 | if not django.template.engines._engines: 84 | return False 85 | 86 | for engine in django.template.engines.all(): 87 | if not isinstance(engine, django.template.backends.django.DjangoTemplates): 88 | raise DjangoTemplatePluginException( 89 | "Can't use non-Django templates." 90 | ) 91 | if not engine.engine.debug: 92 | raise DjangoTemplatePluginException( 93 | "Template debugging must be enabled in settings." 94 | ) 95 | 96 | return True 97 | 98 | 99 | if django.VERSION < (2, 0): 100 | raise RuntimeError("Django Coverage Plugin requires Django 2.x or higher") 101 | 102 | 103 | # Since we are grabbing at internal details, we have to adapt as they 104 | # change over versions. 105 | def filename_for_frame(frame): 106 | try: 107 | return frame.f_locals["self"].origin.name 108 | except (KeyError, AttributeError): 109 | return None 110 | 111 | 112 | def position_for_node(node): 113 | try: 114 | return node.token.position 115 | except AttributeError: 116 | return None 117 | 118 | 119 | def position_for_token(token): 120 | return token.position 121 | 122 | 123 | def read_template_source(filename): 124 | """Read the source of a Django template, returning the Unicode text.""" 125 | # Import this late to be sure we don't trigger settings machinery too 126 | # early. 127 | from django.conf import settings 128 | 129 | if not settings.configured: 130 | settings.configure() 131 | 132 | with open(filename, "rb") as f: 133 | # The FILE_CHARSET setting will be removed in 3.1: 134 | # https://docs.djangoproject.com/en/3.0/ref/settings/#file-charset 135 | if django.VERSION >= (3, 1): 136 | charset = 'utf-8' 137 | else: 138 | charset = settings.FILE_CHARSET 139 | text = f.read().decode(charset) 140 | 141 | return text 142 | 143 | 144 | class DjangoTemplatePlugin( 145 | coverage.plugin.CoveragePlugin, 146 | coverage.plugin.FileTracer, 147 | ): 148 | 149 | def __init__(self, options): 150 | extensions = options.get("template_extensions", "html,htm,txt") 151 | self.extensions = [e.strip() for e in extensions.split(",")] 152 | 153 | self.debug_checked = False 154 | 155 | self.django_template_dir = os.path.normcase(os.path.realpath( 156 | os.path.dirname(django.template.__file__) 157 | )) 158 | 159 | self.source_map = {} 160 | 161 | # --- CoveragePlugin methods 162 | 163 | def sys_info(self): 164 | return [ 165 | ("django_template_dir", self.django_template_dir), 166 | ("environment", sorted( 167 | ("{} = {}".format(k, v)) 168 | for k, v in os.environ.items() 169 | if "DJANGO" in k 170 | )), 171 | ] 172 | 173 | def configure(self, config): 174 | self.html_report_dir = os.path.abspath(config.get_option("html:directory")) 175 | 176 | def file_tracer(self, filename): 177 | if os.path.normcase(filename).startswith(self.django_template_dir): 178 | if not self.debug_checked: 179 | # Keep calling check_debug until it returns True, which it 180 | # will only do after settings have been configured 181 | self.debug_checked = check_debug() 182 | 183 | return self 184 | return None 185 | 186 | def file_reporter(self, filename): 187 | return FileReporter(filename) 188 | 189 | def find_executable_files(self, src_dir): 190 | # We're only interested in files that look like reasonable HTML 191 | # files: Must end with one of our extensions, and must not have 192 | # funny characters that probably mean they are editor junk. 193 | rx = r"^[^.#~!$@%^&*()+=,]+\.(" + "|".join(self.extensions) + r")$" 194 | 195 | for (dirpath, dirnames, filenames) in os.walk(src_dir): 196 | if dirpath == self.html_report_dir: 197 | # Don't confuse the HTML report with HTML templates. 198 | continue 199 | for filename in filenames: 200 | if re.search(rx, filename): 201 | yield os.path.join(dirpath, filename) 202 | 203 | # --- FileTracer methods 204 | 205 | def has_dynamic_source_filename(self): 206 | return True 207 | 208 | # "render" is the public method, but "render_annotated" is an internal 209 | # method sometimes implemented directly on nodes. 210 | RENDER_METHODS = {"render", "render_annotated"} 211 | 212 | def dynamic_source_filename(self, filename, frame): 213 | if frame.f_code.co_name not in self.RENDER_METHODS: 214 | return None 215 | 216 | if 0: 217 | dump_frame(frame, label="dynamic_source_filename") 218 | filename = filename_for_frame(frame) 219 | if filename is not None: 220 | if filename.startswith("<"): 221 | # String templates have a filename of "", and 222 | # can't be reported on later, so ignore them. 223 | return None 224 | return filename 225 | return None 226 | 227 | def line_number_range(self, frame): 228 | assert frame.f_code.co_name in self.RENDER_METHODS 229 | if 0: 230 | dump_frame(frame, label="line_number_range") 231 | 232 | render_self = frame.f_locals['self'] 233 | if isinstance(render_self, (NodeList, Template)): 234 | return -1, -1 235 | 236 | position = position_for_node(render_self) 237 | if position is None: 238 | return -1, -1 239 | 240 | if SHOW_TRACING: 241 | print(f"{render_self!r}: {position}") 242 | s_start, s_end = position 243 | if isinstance(render_self, TextNode): 244 | first_line = render_self.s.splitlines(True)[0] 245 | if first_line.isspace(): 246 | s_start += len(first_line) 247 | elif VerbatimNode and isinstance(render_self, VerbatimNode): 248 | # VerbatimNode doesn't track source the same way. s_end only points 249 | # to the end of the {% verbatim %} opening tag, not the entire 250 | # content. Adjust it to cover all of it. 251 | s_end += len(render_self.content) 252 | elif isinstance(render_self, BlockTranslateNode): 253 | # BlockTranslateNode has a list of text and variable tokens. 254 | # Get the end of the contents by looking at the last token, 255 | # and use its endpoint. 256 | last_tokens = render_self.plural or render_self.singular 257 | s_end = position_for_token(last_tokens[-1])[1] 258 | 259 | filename = filename_for_frame(frame) 260 | line_map = self.get_line_map(filename) 261 | start = get_line_number(line_map, s_start) 262 | end = get_line_number(line_map, s_end-1) 263 | if start < 0 or end < 0: 264 | start, end = -1, -1 265 | if SHOW_TRACING: 266 | print("line_number_range({}) -> {}".format( 267 | filename, (start, end) 268 | )) 269 | return start, end 270 | 271 | # --- FileTracer helpers 272 | 273 | def get_line_map(self, filename): 274 | """The line map for `filename`. 275 | 276 | A line map is a list of character offsets, indicating where each line 277 | in the text begins. For example, a line map like this:: 278 | 279 | [13, 19, 30] 280 | 281 | means that line 2 starts at character 13, line 3 starts at 19, etc. 282 | Line 1 always starts at character 0. 283 | 284 | """ 285 | if filename not in self.source_map: 286 | template_source = read_template_source(filename) 287 | if 0: # change to see the template text 288 | for i in range(0, len(template_source), 10): 289 | print("%3d: %r" % (i, template_source[i:i+10])) 290 | self.source_map[filename] = make_line_map(template_source) 291 | return self.source_map[filename] 292 | 293 | 294 | class FileReporter(coverage.plugin.FileReporter): 295 | def __init__(self, filename): 296 | super().__init__(filename) 297 | # TODO: html filenames are absolute. 298 | 299 | self._source = None 300 | 301 | def source(self): 302 | if self._source is None: 303 | try: 304 | self._source = read_template_source(self.filename) 305 | except (OSError, UnicodeError) as exc: 306 | raise NoSource(f"Couldn't read {self.filename}: {exc}") 307 | return self._source 308 | 309 | def lines(self): 310 | source_lines = set() 311 | 312 | if SHOW_PARSING: 313 | print(f"-------------- {self.filename}") 314 | 315 | lexer = Lexer(self.source()) 316 | tokens = lexer.tokenize() 317 | 318 | # Are we inside a comment? 319 | comment = False 320 | # Is this a template that extends another template? 321 | extends = False 322 | # Are we inside a block? 323 | inblock = False 324 | 325 | for token in tokens: 326 | if SHOW_PARSING: 327 | print( 328 | "%10s %2d: %r" % ( 329 | _token_name(token.token_type), 330 | token.lineno, 331 | token.contents, 332 | ) 333 | ) 334 | if token.token_type == TokenType.BLOCK: 335 | if token.contents == "endcomment": 336 | comment = False 337 | continue 338 | 339 | if comment: 340 | continue 341 | 342 | if token.token_type == TokenType.BLOCK: 343 | if token.contents.startswith("endblock"): 344 | inblock = False 345 | elif token.contents.startswith("block"): 346 | inblock = True 347 | if extends: 348 | continue 349 | 350 | if extends and not inblock: 351 | # In an inheriting template, ignore all tags outside of 352 | # blocks. 353 | continue 354 | 355 | if token.contents == "comment": 356 | comment = True 357 | if token.contents.startswith("end"): 358 | continue 359 | elif token.contents in ("else", "empty"): 360 | continue 361 | elif token.contents.startswith("elif"): 362 | # NOTE: I don't like this, I want to be able to trace elif 363 | # nodes, but the Django template engine doesn't track them 364 | # in a way that we can get useful information from them. 365 | continue 366 | elif token.contents.startswith("extends"): 367 | extends = True 368 | 369 | source_lines.add(token.lineno) 370 | 371 | elif token.token_type == TokenType.VAR: 372 | source_lines.add(token.lineno) 373 | 374 | elif token.token_type == TokenType.TEXT: 375 | if extends and not inblock: 376 | continue 377 | # Text nodes often start with newlines, but we don't want to 378 | # consider that first line to be part of the text. 379 | lineno = token.lineno 380 | lines = token.contents.splitlines(True) 381 | num_lines = len(lines) 382 | if lines[0].isspace(): 383 | lineno += 1 384 | num_lines -= 1 385 | source_lines.update(range(lineno, lineno+num_lines)) 386 | 387 | if SHOW_PARSING: 388 | print(f"\t\t\tNow source_lines is: {source_lines!r}") 389 | 390 | return source_lines 391 | 392 | 393 | def running_sum(seq): 394 | total = 0 395 | for num in seq: 396 | total += num 397 | yield total 398 | 399 | 400 | def make_line_map(text): 401 | line_lengths = [len(line) for line in text.splitlines(True)] 402 | line_map = list(running_sum(line_lengths)) 403 | return line_map 404 | 405 | 406 | def get_line_number(line_map, offset): 407 | """Find a line number, given a line map and a character offset.""" 408 | for lineno, line_offset in enumerate(line_map, start=1): 409 | if line_offset > offset: 410 | return lineno 411 | return -1 412 | 413 | 414 | def dump_frame(frame, label=""): 415 | """Dump interesting information about this frame.""" 416 | locals = dict(frame.f_locals) 417 | self = locals.get('self', None) 418 | context = locals.get('context', None) 419 | if "__builtins__" in locals: 420 | del locals["__builtins__"] 421 | 422 | if label: 423 | label = " ( %s ) " % label 424 | print("-- frame --%s---------------------" % label) 425 | print("{}:{}:{}".format( 426 | os.path.basename(frame.f_code.co_filename), 427 | frame.f_lineno, 428 | type(self), 429 | )) 430 | print(locals) 431 | if self: 432 | print("self:", self.__dict__) 433 | if context: 434 | print("context:", context.__dict__) 435 | print("\\--") 436 | -------------------------------------------------------------------------------- /howto.txt: -------------------------------------------------------------------------------- 1 | * Release checklist 2 | 3 | - Version number in setup.py 4 | - Classifiers in setup.py 5 | https://pypi.python.org/pypi?%3Aaction=list_classifiers 6 | eg: 7 | Development Status :: 3 - Alpha 8 | Development Status :: 5 - Production/Stable 9 | - Copyright date in NOTICE.txt 10 | - Update README.rst with latest changes 11 | - Kits: 12 | $ make clean kit 13 | $ make kit_upload 14 | $ make tag 15 | $ make ghrelease 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # To run tests, we just need tox. 2 | tox >= 1.8 3 | build 4 | scriv 5 | twine 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | [tool:pytest] 5 | # How come these warnings are suppressed successfully here, but not in conftest.py?? 6 | filterwarnings = 7 | # ignore all DeprecationWarnings... 8 | ignore::DeprecationWarning 9 | # ...but show them if they are from our code. 10 | default::DeprecationWarning:django_coverage_plugin 11 | 12 | [scriv] 13 | fragment_directory = scriv.d 14 | output_file = README.rst 15 | rst_header_chars = -. 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Setup for Django Coverage Plugin 3 | 4 | Licensed under the Apache 2.0 License 5 | - http://www.apache.org/licenses/LICENSE-2.0 6 | - https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 7 | 8 | """ 9 | 10 | import re 11 | from os.path import dirname, join 12 | 13 | from setuptools import setup 14 | 15 | 16 | def read(*names, **kwargs): 17 | """Read and return contents of file 18 | 19 | Parameter: encoding kwarg may be set 20 | """ 21 | with open( 22 | join(dirname(__file__), *names), 23 | encoding=kwargs.get('encoding', 'utf8') 24 | ) as f: 25 | return f.read() 26 | 27 | 28 | classifiers = """\ 29 | Environment :: Console 30 | Intended Audience :: Developers 31 | License :: OSI Approved :: Apache Software License 32 | Operating System :: OS Independent 33 | Programming Language :: Python :: 3.9 34 | Programming Language :: Python :: 3.10 35 | Programming Language :: Python :: 3.11 36 | Programming Language :: Python :: 3.12 37 | Programming Language :: Python :: 3.13 38 | Programming Language :: Python :: Implementation :: CPython 39 | Programming Language :: Python :: Implementation :: PyPy 40 | Topic :: Software Development :: Quality Assurance 41 | Topic :: Software Development :: Testing 42 | Development Status :: 5 - Production/Stable 43 | Framework :: Django 44 | Framework :: Django :: 2.2 45 | Framework :: Django :: 3.2 46 | Framework :: Django :: 4.2 47 | Framework :: Django :: 5.1 48 | """ 49 | 50 | setup( 51 | name='django_coverage_plugin', 52 | version='3.1.0', 53 | description='Django template coverage.py plugin', 54 | long_description=( 55 | re.sub( 56 | '(?ms)^.. start-badges.*^.. end-badges', 57 | '', 58 | read('README.rst'), 59 | ) 60 | ), 61 | long_description_content_type='text/x-rst', 62 | author='Ned Batchelder', 63 | author_email='ned@nedbatchelder.com', 64 | url='https://github.com/nedbat/django_coverage_plugin', 65 | packages=['django_coverage_plugin'], 66 | install_requires=[ 67 | 'coverage', 68 | ], 69 | license='Apache-2.0', 70 | classifiers=classifiers.splitlines(), 71 | ) 72 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """The tests for the Django Coverage Plugin.""" 5 | 6 | # Define URLs here so we can use ROOT_URLCONF="tests" 7 | 8 | try: 9 | from django.urls import re_path 10 | except ImportError: 11 | from django.conf.urls import url as re_path 12 | 13 | 14 | def index(request): 15 | """A bogus view to use in the urls below.""" 16 | pass 17 | 18 | 19 | urlpatterns = [ 20 | re_path(r'^home$', index, name='index'), 21 | ] 22 | -------------------------------------------------------------------------------- /tests/banner.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """For printing the versions from tox.ini.""" 5 | 6 | import platform 7 | 8 | import coverage 9 | import django 10 | 11 | print( 12 | "{} {}; Django {}; Coverage {}".format( 13 | platform.python_implementation(), 14 | platform.python_version(), 15 | django.get_version(), 16 | coverage.__version__, 17 | ) 18 | ) 19 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """ 5 | Pytest auto configuration. 6 | 7 | This module is run automatically by pytest, to define and enable fixtures. 8 | """ 9 | 10 | import re 11 | import warnings 12 | 13 | import django.utils.deprecation 14 | import pytest 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def set_warnings(): 19 | """Configure warnings to show while running tests.""" 20 | warnings.simplefilter("default") 21 | warnings.simplefilter("once", DeprecationWarning) 22 | 23 | # Warnings to suppress: 24 | # How come these warnings are successfully suppressed here, but not in setup.cfg?? 25 | 26 | # We know we do tricky things with Django settings, don't warn us about it. 27 | warnings.filterwarnings( 28 | "ignore", 29 | category=UserWarning, 30 | message=r"Overriding setting DATABASES can lead to unexpected behavior.", 31 | ) 32 | 33 | # Django has warnings like RemovedInDjango40Warning. We use features that are going to be 34 | # deprecated, so we don't need to see those warnings. But the specific warning classes change 35 | # in every release. Find them and ignore them. 36 | for name, obj in vars(django.utils.deprecation).items(): 37 | if re.match(r"RemovedInDjango\d+Warning", name): 38 | warnings.filterwarnings("ignore", category=obj) 39 | -------------------------------------------------------------------------------- /tests/plugin_test.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Base classes and helpers for testing the plugin.""" 5 | 6 | import contextlib 7 | import os 8 | import os.path 9 | import re 10 | import unittest 11 | 12 | import coverage 13 | import django 14 | from django.conf import settings 15 | from django.template import Context, Template # noqa 16 | from django.template.backends.django import DjangoTemplates # noqa 17 | from django.template.loader import get_template # noqa 18 | from django.test import TestCase # noqa 19 | from unittest_mixins import StdStreamCapturingMixin, TempDirMixin 20 | 21 | from django_coverage_plugin.plugin import DjangoTemplatePlugin 22 | 23 | 24 | def get_test_settings(): 25 | """Create a dict full of default Django settings for the tests.""" 26 | the_settings = { 27 | 'CACHES': { 28 | 'default': { 29 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 30 | }, 31 | }, 32 | 'DATABASES': { 33 | 'default': { 34 | 'ENGINE': 'django.db.backends.sqlite3', 35 | 'NAME': ':memory:', 36 | } 37 | }, 38 | 'ROOT_URLCONF': 'tests', 39 | } 40 | 41 | the_settings.update({ 42 | 'TEMPLATES': [ 43 | { 44 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 45 | 'DIRS': ['templates'], # where the tests put things. 46 | 'OPTIONS': { 47 | 'debug': True, 48 | 'loaders': [ 49 | 'django.template.loaders.filesystem.Loader', 50 | ] 51 | }, 52 | }, 53 | ], 54 | }) 55 | 56 | return the_settings 57 | 58 | 59 | settings.configure(**get_test_settings()) 60 | 61 | if hasattr(django, "setup"): 62 | django.setup() 63 | 64 | 65 | class DjangoPluginTestCase(StdStreamCapturingMixin, TempDirMixin, TestCase): 66 | """A base class for all our tests.""" 67 | 68 | def setUp(self): 69 | super().setUp() 70 | self.template_directory = "templates" 71 | 72 | def _path(self, name=None): 73 | return f"{self.template_directory}/{name or self.template_file}" 74 | 75 | def make_template(self, text, name=None): 76 | """Make a template with `text`. 77 | 78 | If `name` isn't provided, make a name from the test method name. 79 | The template is implicitly available for the other methods to use. 80 | 81 | """ 82 | if name is not None: 83 | self.template_file = name 84 | else: 85 | self.template_file = self.id().rpartition(".")[2] + ".html" 86 | template_path = self._path(self.template_file) 87 | return os.path.abspath(self.make_file(template_path, text)) 88 | 89 | def run_django_coverage( 90 | self, name=None, text=None, context=None, options=None, using=None 91 | ): 92 | """Run a template under coverage. 93 | 94 | The data context is `context` if provided, else {}. 95 | If `text` is provided, make a string template to run. Otherwise, 96 | if `name` is provided, run that template, otherwise use the last 97 | template made by `make_template`. 98 | 99 | If `options` is provided, they are kwargs for the Coverage 100 | constructor, which default to source=["."]. 101 | 102 | Returns: 103 | str: the text produced by the template. 104 | 105 | """ 106 | use_real_context = False 107 | 108 | if options is None: 109 | options = {'source': ["."]} 110 | 111 | if using: 112 | from django.template import engines 113 | engine = engines[using] 114 | if text is not None: 115 | tem = engine.from_string(text) 116 | else: 117 | tem = engine.get_template(name or self.template_file) 118 | elif text is not None: 119 | tem = Template(text) 120 | use_real_context = True 121 | else: 122 | tem = get_template(name or self.template_file) 123 | 124 | ctx = context or {} 125 | if use_real_context: 126 | # Before 1.8, render() wanted a Context. After, it wants a dict. 127 | ctx = Context(ctx) 128 | 129 | self.cov = coverage.Coverage(**options) 130 | self.append_config("run:plugins", "django_coverage_plugin") 131 | if 0: 132 | self.append_config("run:debug", "trace") 133 | self.cov.start() 134 | text = tem.render(ctx) 135 | self.cov.stop() 136 | self.cov.save() 137 | # Warning! Accessing secret internals! 138 | if hasattr(self.cov, 'plugins'): 139 | plugins = self.cov.plugins 140 | else: 141 | plugins = self.cov._plugins 142 | for pl in plugins: 143 | if isinstance(pl, DjangoTemplatePlugin): 144 | if not pl._coverage_enabled: 145 | raise PluginDisabled() 146 | return text 147 | 148 | def append_config(self, option, value): 149 | """Append to a configuration option.""" 150 | val = self.cov.config.get_option(option) 151 | val.append(value) 152 | self.cov.config.set_option(option, val) 153 | 154 | def get_line_data(self, name=None): 155 | """Get the executed-line data for a template. 156 | 157 | Returns: 158 | list: the line numbers of lines executed in the template. 159 | 160 | """ 161 | path = self._path(name) 162 | line_data = self.cov.data.line_data()[os.path.realpath(path)] 163 | return line_data 164 | 165 | def get_analysis(self, name=None): 166 | """Get the coverage analysis for a template. 167 | 168 | Returns: 169 | list, list: the line numbers of executable lines, and the line 170 | numbers of missed lines. 171 | 172 | """ 173 | path = self._path(name) 174 | analysis = self.cov.analysis2(os.path.abspath(path)) 175 | _, executable, _, missing, _ = analysis 176 | return executable, missing 177 | 178 | def assert_measured_files(self, *template_files): 179 | """Assert that the measured files are `template_files`. 180 | 181 | The names in `template_files` are the base names of files 182 | in the templates directory. 183 | """ 184 | measured = {os.path.relpath(f) for f in self.cov.get_data().measured_files()} 185 | expected = {os.path.join("templates", f) for f in template_files} 186 | self.assertEqual(measured, expected) 187 | 188 | def assert_analysis(self, executable, missing=None, name=None): 189 | """Assert that the analysis for `name` is right.""" 190 | actual_executable, actual_missing = self.get_analysis(name) 191 | self.assertEqual( 192 | executable, 193 | actual_executable, 194 | "Executable lines aren't as expected: {!r} != {!r}".format( 195 | executable, actual_executable, 196 | ), 197 | ) 198 | self.assertEqual( 199 | missing or [], 200 | actual_missing, 201 | "Missing lines aren't as expected: {!r} != {!r}".format( 202 | missing, actual_missing, 203 | ), 204 | ) 205 | 206 | def get_html_report(self, name=None): 207 | """Get the html report for a template. 208 | 209 | Returns: 210 | float: the total percentage covered. 211 | 212 | """ 213 | path = self._path(name) 214 | html_coverage = self.cov.html_report(os.path.abspath(path)) 215 | return html_coverage 216 | 217 | def get_xml_report(self, name=None): 218 | """Get the xml report for a template. 219 | 220 | Returns: 221 | float: the total percentage covered. 222 | 223 | """ 224 | path = self._path(name) 225 | xml_coverage = self.cov.xml_report(os.path.abspath(path)) 226 | return xml_coverage 227 | 228 | @contextlib.contextmanager 229 | def assert_coverage_warnings(self, *msgs, min_cov=None): 230 | """Assert that coverage.py warnings are raised that contain all msgs. 231 | 232 | If coverage version isn't at least min_cov, then no warnings are expected. 233 | 234 | """ 235 | # Coverage.py 6.0 made the warnings real warnings, so we have to adapt 236 | # how we test the warnings based on the version. 237 | if min_cov is not None and coverage.version_info < min_cov: 238 | # Don't check for warnings on lower versions of coverage 239 | yield 240 | return 241 | elif coverage.version_info >= (6, 0): 242 | import coverage.exceptions as cov_exc 243 | ctxmgr = self.assertWarns(cov_exc.CoverageWarning) 244 | else: 245 | ctxmgr = contextlib.nullcontext() 246 | with ctxmgr as cw: 247 | yield 248 | 249 | if cw is not None: 250 | warn_text = "\n".join(str(w.message) for w in cw.warnings) 251 | else: 252 | warn_text = self.stderr() 253 | for msg in msgs: 254 | self.assertIn(msg, warn_text) 255 | 256 | @contextlib.contextmanager 257 | def assert_plugin_disabled(self, msg): 258 | """Assert that our plugin was disabled during an operation.""" 259 | # self.run_django_coverage will raise PluginDisabled if the plugin 260 | # was disabled. 261 | msgs = [ 262 | "Disabling plug-in 'django_coverage_plugin.DjangoTemplatePlugin' due to an exception:", 263 | "DjangoTemplatePluginException: " + msg, 264 | ] 265 | with self.assert_coverage_warnings(*msgs): 266 | with self.assertRaises(PluginDisabled): 267 | yield 268 | 269 | @contextlib.contextmanager 270 | def assert_no_data(self, min_cov=None): 271 | """Assert that coverage warns no data was collected.""" 272 | warn_msg = "No data was collected. (no-data-collected)" 273 | with self.assert_coverage_warnings(warn_msg, min_cov=min_cov): 274 | yield 275 | 276 | 277 | def squashed(s): 278 | """Remove all of the whitespace from s.""" 279 | return re.sub(r"\s", "", s) 280 | 281 | 282 | def django_start_at(*needed_version): 283 | """A decorator for tests to require a minimum version of Django. 284 | 285 | @django_start_at(1, 10) # Don't run the test on 1.10 or lower. 286 | def test_thing(self): 287 | ... 288 | 289 | """ 290 | if django.VERSION >= needed_version: 291 | return lambda func: func 292 | else: 293 | return unittest.skip("Django version must be newer") 294 | 295 | 296 | def django_stop_before(*needed_version): 297 | """A decorator for tests to require a maximum version of Django. 298 | 299 | @django_stop_before(1, 10) # Don't run the test on 1.10 or higher. 300 | def test_thing(self): 301 | ... 302 | 303 | """ 304 | if django.VERSION < needed_version: 305 | return lambda func: func 306 | else: 307 | return unittest.skip("Django version must be older") 308 | 309 | 310 | class PluginDisabled(Exception): 311 | """Raised if we find that our plugin has been disabled.""" 312 | pass 313 | -------------------------------------------------------------------------------- /tests/test_engines.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Tests of multiple engines for django_coverage_plugin.""" 5 | 6 | from django.test import modify_settings 7 | 8 | from .plugin_test import DjangoPluginTestCase 9 | 10 | 11 | class MultipleEngineTests(DjangoPluginTestCase): 12 | def setUp(self): 13 | super().setUp() 14 | 15 | engine = { 16 | 'NAME': 'other', 17 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 18 | 'DIRS': ['templates2'], # where the tests put things. 19 | 'OPTIONS': { 20 | 'debug': True, 21 | }, 22 | } 23 | modified_settings = modify_settings(TEMPLATES={'append': [engine]}) 24 | modified_settings.enable() 25 | self.addCleanup(modified_settings.disable) 26 | 27 | self.template_directory = 'templates2' 28 | 29 | def test_file_template(self): 30 | self.make_template('Hello') 31 | text = self.run_django_coverage(using='other') 32 | self.assertEqual(text, 'Hello') 33 | self.assert_analysis([1]) 34 | 35 | def test_string_template(self): 36 | with self.assert_no_data(): 37 | text = self.run_django_coverage(text='Hello', using='other') 38 | self.assertEqual(text, 'Hello') 39 | 40 | def test_third_engine_not_debug(self): 41 | engine3 = { 42 | 'NAME': 'notdebug', 43 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 44 | 'DIRS': ['templates3'], # where the tests put things. 45 | } 46 | modified_settings = modify_settings(TEMPLATES={'append': [engine3]}) 47 | modified_settings.enable() 48 | self.addCleanup(modified_settings.disable) 49 | 50 | self.make_template('Hello') 51 | with self.assert_plugin_disabled("Template debugging must be enabled in settings."): 52 | self.run_django_coverage() 53 | -------------------------------------------------------------------------------- /tests/test_extends.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Tests of template inheritance for django_coverage_plugin.""" 5 | 6 | from .plugin_test import DjangoPluginTestCase 7 | 8 | 9 | class BlockTest(DjangoPluginTestCase): 10 | 11 | def test_empty_block(self): 12 | self.make_template("""\ 13 | {% block somewhere %}{% endblock %} 14 | """) 15 | 16 | text = self.run_django_coverage() 17 | self.assertEqual(text.strip(), '') 18 | self.assert_analysis([1]) 19 | 20 | def test_empty_block_with_text_inside(self): 21 | self.make_template("""\ 22 | {% block somewhere %}Hello{% endblock %} 23 | """) 24 | 25 | text = self.run_django_coverage() 26 | self.assertEqual(text.strip(), 'Hello') 27 | self.assert_analysis([1]) 28 | 29 | def test_empty_block_with_text_outside(self): 30 | self.make_template("""\ 31 | {% block somewhere %}{% endblock %} 32 | Hello 33 | """) 34 | 35 | text = self.run_django_coverage() 36 | self.assertEqual(text.strip(), 'Hello') 37 | self.assert_analysis([1, 2]) 38 | 39 | def test_two_empty_blocks(self): 40 | self.make_template("""\ 41 | {% block somewhere %}{% endblock %} 42 | X 43 | {% block elsewhere %}{% endblock %} 44 | Y 45 | """) 46 | 47 | text = self.run_django_coverage() 48 | self.assertEqual(text.strip(), 'X\n\nY') 49 | self.assert_analysis([1, 2, 3, 4]) 50 | 51 | def test_inheriting(self): 52 | self.make_template(name="base.html", text="""\ 53 | Hello 54 | {% block second_line %}second{% endblock %} 55 | Goodbye 56 | """) 57 | 58 | self.make_template(name="specific.html", text="""\ 59 | PROLOG 60 | {% extends "base.html" %} 61 | THIS DOESN'T APPEAR 62 | {% block second_line %} 63 | SECOND 64 | {% endblock %} 65 | 66 | THIS WON'T EITHER 67 | """) 68 | 69 | text = self.run_django_coverage(name="specific.html") 70 | self.assertEqual(text, "PROLOG\nHello\n\nSECOND\n\nGoodbye\n") 71 | self.assert_analysis([1, 2, 3], name="base.html") 72 | self.assert_analysis([1, 2, 5], name="specific.html") 73 | 74 | def test_inheriting_with_unused_blocks(self): 75 | self.make_template(name="base.html", text="""\ 76 | Hello 77 | {% block second_line %}second{% endblock %} 78 | Goodbye 79 | """) 80 | 81 | self.make_template(name="specific.html", text="""\ 82 | {% extends "base.html" %} 83 | 84 | {% block second_line %} 85 | SECOND 86 | {% endblock %} 87 | 88 | {% block sir_does_not_appear_in_this_movie %} 89 | I was bit by a moose once 90 | {% endblock %} 91 | """) 92 | 93 | text = self.run_django_coverage(name="specific.html") 94 | self.assertEqual(text, "Hello\n\nSECOND\n\nGoodbye\n") 95 | self.assert_analysis([1, 2, 3], name="base.html") 96 | self.assert_analysis([1, 4, 8], [8], name="specific.html") 97 | 98 | 99 | class LoadTest(DjangoPluginTestCase): 100 | def test_load(self): 101 | self.make_template(name="load.html", text="""\ 102 | {% load i18n %} 103 | 104 | FIRST 105 | SECOND 106 | """) 107 | 108 | text = self.run_django_coverage(name="load.html") 109 | self.assertEqual(text, "\n\nFIRST\nSECOND\n") 110 | self.assert_analysis([1, 2, 3, 4], name="load.html") 111 | 112 | def test_load_with_extends(self): 113 | self.make_template(name="base.html", text="""\ 114 | Hello 115 | {% block second_line %}second{% endblock %} 116 | Goodbye 117 | """) 118 | 119 | self.make_template(name="specific.html", text="""\ 120 | {% extends "base.html" %} 121 | {% load i18n %} 122 | {% block second_line %} 123 | SPECIFIC 124 | {% endblock %} 125 | """) 126 | 127 | text = self.run_django_coverage(name="specific.html") 128 | self.assertEqual(text, "Hello\n\nSPECIFIC\n\nGoodbye\n") 129 | self.assert_analysis([1, 4], name="specific.html") 130 | 131 | 132 | class IncludeTest(DjangoPluginTestCase): 133 | def test_include(self): 134 | self.make_template(name="outer.html", text="""\ 135 | First 136 | {% include "nested.html" %} 137 | Last 138 | """) 139 | 140 | self.make_template(name="nested.html", text="""\ 141 | Inside 142 | Job 143 | """) 144 | 145 | text = self.run_django_coverage(name="outer.html") 146 | self.assertEqual(text, "First\nInside\nJob\n\nLast\n") 147 | self.assert_analysis([1, 2, 3], name="outer.html") 148 | self.assert_analysis([1, 2], name="nested.html") 149 | -------------------------------------------------------------------------------- /tests/test_flow.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Tests of control-flow structures for django_coverage_plugin.""" 5 | 6 | import textwrap 7 | 8 | from .plugin_test import DjangoPluginTestCase, django_stop_before, squashed 9 | 10 | 11 | class IfTest(DjangoPluginTestCase): 12 | 13 | def test_if(self): 14 | self.make_template("""\ 15 | {% if foo %} 16 | Hello 17 | {% endif %} 18 | """) 19 | 20 | text = self.run_django_coverage(context={'foo': True}) 21 | self.assertEqual(text.strip(), 'Hello') 22 | self.assert_analysis([1, 2]) 23 | 24 | text = self.run_django_coverage(context={'foo': False}) 25 | self.assertEqual(text.strip(), '') 26 | self.assert_analysis([1, 2], [2]) 27 | 28 | def test_if_else(self): 29 | self.make_template("""\ 30 | {% if foo %} 31 | Hello 32 | {% else %} 33 | Goodbye 34 | {% endif %} 35 | """) 36 | 37 | text = self.run_django_coverage(context={'foo': True}) 38 | self.assertEqual(text.strip(), 'Hello') 39 | self.assert_analysis([1, 2, 4], [4]) 40 | 41 | text = self.run_django_coverage(context={'foo': False}) 42 | self.assertEqual(text.strip(), 'Goodbye') 43 | self.assert_analysis([1, 2, 4], [2]) 44 | 45 | def test_if_elif_else(self): 46 | self.make_template("""\ 47 | {% if foo %} 48 | Hello 49 | {% elif bar %} 50 | Aloha 51 | {% else %} 52 | Goodbye 53 | {% endif %} 54 | """) 55 | 56 | text = self.run_django_coverage(context={'foo': True, 'bar': False}) 57 | self.assertEqual(text.strip(), 'Hello') 58 | self.assert_analysis([1, 2, 4, 6], [4, 6]) 59 | 60 | text = self.run_django_coverage(context={'foo': False, 'bar': True}) 61 | self.assertEqual(text.strip(), 'Aloha') 62 | self.assert_analysis([1, 2, 4, 6], [2, 6]) 63 | 64 | text = self.run_django_coverage(context={'foo': False, 'bar': False}) 65 | self.assertEqual(text.strip(), 'Goodbye') 66 | self.assert_analysis([1, 2, 4, 6], [2, 4]) 67 | 68 | 69 | class LoopTest(DjangoPluginTestCase): 70 | def test_loop(self): 71 | self.make_template("""\ 72 | Before 73 | {% for item in items %} 74 | {% cycle "-" "+" %}{{ item }} 75 | {% endfor %} 76 | After 77 | """) 78 | 79 | text = self.run_django_coverage(context={'items': "ABC"}) 80 | self.assertEqual(text, "Before\n\n-A\n\n+B\n\n-C\n\nAfter\n") 81 | self.assert_analysis([1, 2, 3, 5]) 82 | 83 | text = self.run_django_coverage(context={'items': ""}) 84 | self.assertEqual(text, "Before\n\nAfter\n") 85 | self.assert_analysis([1, 2, 3, 5], [3]) 86 | 87 | def test_loop_with_empty_clause(self): 88 | self.make_template("""\ 89 | Before 90 | {% for item in items %} 91 | -{{ item }} 92 | {% empty %} 93 | NONE 94 | {% endfor %} 95 | After 96 | """) 97 | 98 | text = self.run_django_coverage(context={'items': "ABC"}) 99 | self.assertEqual(text, "Before\n\n-A\n\n-B\n\n-C\n\nAfter\n") 100 | self.assert_analysis([1, 2, 3, 5, 7], [5]) 101 | 102 | text = self.run_django_coverage(context={'items': ""}) 103 | self.assertEqual(text, "Before\n\nNONE\n\nAfter\n") 104 | self.assert_analysis([1, 2, 3, 5, 7], [3]) 105 | 106 | def test_regroup(self): 107 | self.make_template("""\ 108 | {% spaceless %} 109 | {% regroup cities by country as country_list %} 110 | 121 | {% endspaceless %} 122 | """) 123 | text = self.run_django_coverage(context={ 124 | 'cities': [ 125 | {'name': 'Mumbai', 'population': '19', 'country': 'India'}, 126 | {'name': 'Calcutta', 'population': '15', 'country': 'India'}, 127 | {'name': 'New York', 'population': '20', 'country': 'USA'}, 128 | {'name': 'Chicago', 'population': '7', 'country': 'USA'}, 129 | {'name': 'Tokyo', 'population': '33', 'country': 'Japan'}, 130 | ], 131 | }) 132 | self.assertEqual(text, textwrap.dedent("""\ 133 | 137 | """)) 138 | self.assert_analysis([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13]) 139 | 140 | 141 | class IfChangedTest(DjangoPluginTestCase): 142 | 143 | def test_ifchanged(self): 144 | self.make_template("""\ 145 | {% for a,b in items %} 146 | {% ifchanged %} 147 | {{ a }} 148 | {% endifchanged %} 149 | {{ b }} 150 | {% endfor %} 151 | """) 152 | 153 | text = self.run_django_coverage(context={ 154 | 'items': [("A", "X"), ("A", "Y"), ("B", "Z"), ("B", "W")], 155 | }) 156 | self.assertEqual(squashed(text), 'AXYBZW') 157 | self.assert_analysis([1, 2, 3, 4, 5]) 158 | 159 | def test_ifchanged_variable(self): 160 | self.make_template("""\ 161 | {% for a,b in items %} 162 | {% ifchanged a %} 163 | {{ a }} 164 | {% endifchanged %} 165 | {{ b }} 166 | {% endfor %} 167 | """) 168 | 169 | text = self.run_django_coverage(context={ 170 | 'items': [("A", "X"), ("A", "Y"), ("B", "Z"), ("B", "W")], 171 | }) 172 | self.assertEqual(squashed(text), 'AXYBZW') 173 | self.assert_analysis([1, 2, 3, 4, 5]) 174 | 175 | 176 | @django_stop_before(4, 0) 177 | class IfEqualTest(DjangoPluginTestCase): 178 | 179 | def test_ifequal(self): 180 | self.make_template("""\ 181 | {% for i,x in items %} 182 | {% ifequal x "X" %} 183 | X 184 | {% endifequal %} 185 | {{ i }} 186 | {% endfor %} 187 | """) 188 | 189 | text = self.run_django_coverage(context={ 190 | 'items': [(0, 'A'), (1, 'X'), (2, 'X'), (3, 'B')], 191 | }) 192 | self.assertEqual(squashed(text), '0X1X23') 193 | self.assert_analysis([1, 2, 3, 4, 5]) 194 | 195 | def test_ifnotequal(self): 196 | self.make_template("""\ 197 | {% for i,x in items %} 198 | {% ifnotequal x "X" %} 199 | X 200 | {% endifnotequal %} 201 | {{ i }} 202 | {% endfor %} 203 | """) 204 | 205 | text = self.run_django_coverage(context={ 206 | 'items': [(0, 'A'), (1, 'X'), (2, 'X'), (3, 'B')], 207 | }) 208 | self.assertEqual(squashed(text), 'X012X3') 209 | self.assert_analysis([1, 2, 3, 4, 5]) 210 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Test helpers for the django coverage plugin.""" 5 | 6 | import unittest 7 | 8 | from django_coverage_plugin.plugin import get_line_number, make_line_map 9 | 10 | 11 | class HelperTest(unittest.TestCase): 12 | def test_line_maps(self): 13 | line_map = make_line_map("Hello\nWorld\n") 14 | # character positions: 012345 6789ab 15 | self.assertEqual(get_line_number(line_map, 0), 1) 16 | self.assertEqual(get_line_number(line_map, 1), 1) 17 | self.assertEqual(get_line_number(line_map, 5), 1) 18 | self.assertEqual(get_line_number(line_map, 6), 2) 19 | self.assertEqual(get_line_number(line_map, 7), 2) 20 | self.assertEqual(get_line_number(line_map, 11), 2) 21 | self.assertEqual(get_line_number(line_map, 12), -1) 22 | -------------------------------------------------------------------------------- /tests/test_html.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Tests of HTML reporting for django_coverage_plugin.""" 5 | 6 | import glob 7 | 8 | from .plugin_test import DjangoPluginTestCase 9 | 10 | 11 | class HtmlTest(DjangoPluginTestCase): 12 | 13 | def test_simple(self): 14 | self.make_template("""\ 15 | Simple © 2015 16 | """) 17 | 18 | self.run_django_coverage() 19 | self.cov.html_report() 20 | html_file = glob.glob("htmlcov/*_test_simple_html.html")[0] 21 | with open(html_file) as fhtml: 22 | html = fhtml.read() 23 | self.assertIn('Simple © 2015', html) 24 | -------------------------------------------------------------------------------- /tests/test_i18n.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Tests of i18n tags for django_coverage_plugin.""" 5 | 6 | from .plugin_test import DjangoPluginTestCase 7 | 8 | 9 | class I18nTest(DjangoPluginTestCase): 10 | 11 | def test_trans(self): 12 | self.make_template("""\ 13 | {% load i18n %} 14 | Hello 15 | {% trans "World" %} 16 | done. 17 | """) 18 | text = self.run_django_coverage() 19 | self.assertEqual(text, "\nHello\nWorld\ndone.\n") 20 | self.assert_analysis([1, 2, 3, 4]) 21 | 22 | def test_blocktrans(self): 23 | self.make_template("""\ 24 | {% load i18n %} 25 | Hello 26 | {% blocktrans with where="world" %} 27 | to {{ where }}. 28 | {% endblocktrans %} 29 | bye. 30 | """) 31 | text = self.run_django_coverage() 32 | self.assertEqual(text, "\nHello\n\nto world.\n\nbye.\n") 33 | self.assert_analysis([1, 2, 3, 4, 6]) 34 | 35 | def test_blocktrans_plural(self): 36 | self.make_template("""\ 37 | {% load i18n %} 38 | {% blocktrans count counter=cats|length %} 39 | There is one cat. 40 | {% plural %} 41 | There are {{ counter }} cats. 42 | {% endblocktrans %} 43 | bye. 44 | """) 45 | # It doesn't make a difference whether you use the plural or not, we 46 | # can't tell, so the singluar and plural are always marked as used. 47 | text = self.run_django_coverage(context={'cats': ['snowy']}) 48 | self.assertEqual(text, "\n\nThere is one cat.\n\nbye.\n") 49 | self.assert_analysis([1, 2, 3, 4, 5, 7]) 50 | 51 | text = self.run_django_coverage(context={'cats': ['snowy', 'coaly']}) 52 | self.assertEqual(text, "\n\nThere are 2 cats.\n\nbye.\n") 53 | self.assert_analysis([1, 2, 3, 4, 5, 7]) 54 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Settings tests for django_coverage_plugin.""" 5 | 6 | from django.test.utils import override_settings 7 | 8 | from .plugin_test import DjangoPluginTestCase, get_test_settings 9 | 10 | # Make settings overrides for tests below. 11 | NON_DJANGO_BACKEND = 'django.template.backends.dummy.TemplateStrings' 12 | 13 | DEBUG_FALSE_OVERRIDES = get_test_settings() 14 | DEBUG_FALSE_OVERRIDES['TEMPLATES'][0]['OPTIONS']['debug'] = False 15 | 16 | NO_OPTIONS_OVERRIDES = get_test_settings() 17 | del NO_OPTIONS_OVERRIDES['TEMPLATES'][0]['OPTIONS'] 18 | 19 | OTHER_ENGINE_OVERRIDES = get_test_settings() 20 | OTHER_ENGINE_OVERRIDES['TEMPLATES'][0]['BACKEND'] = NON_DJANGO_BACKEND 21 | OTHER_ENGINE_OVERRIDES['TEMPLATES'][0]['OPTIONS'] = {} 22 | 23 | 24 | class SettingsTest(DjangoPluginTestCase): 25 | """Tests of detecting that the settings need to be right for the plugin to work.""" 26 | 27 | @override_settings(**DEBUG_FALSE_OVERRIDES) 28 | def test_debug_false(self): 29 | self.make_template('Hello') 30 | with self.assert_plugin_disabled("Template debugging must be enabled in settings."): 31 | self.run_django_coverage() 32 | 33 | @override_settings(**NO_OPTIONS_OVERRIDES) 34 | def test_no_options(self): 35 | self.make_template('Hello') 36 | with self.assert_plugin_disabled("Template debugging must be enabled in settings."): 37 | self.run_django_coverage() 38 | 39 | @override_settings(**OTHER_ENGINE_OVERRIDES) 40 | def test_other_engine(self): 41 | self.make_template('Hello') 42 | with self.assert_plugin_disabled("Can't use non-Django templates."): 43 | self.run_django_coverage() 44 | -------------------------------------------------------------------------------- /tests/test_simple.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Simple tests for django_coverage_plugin.""" 5 | 6 | from .plugin_test import DjangoPluginTestCase 7 | 8 | # 200 Unicode chars: snowman + poo. 9 | UNIUNI = "\u26C4\U0001F4A9"*100 10 | if isinstance(UNIUNI, str): 11 | UNISTR = UNIUNI 12 | else: 13 | UNISTR = UNIUNI.encode("utf8") 14 | 15 | 16 | class SimpleTemplateTest(DjangoPluginTestCase): 17 | 18 | def test_one_line(self): 19 | self.make_template('Hello') 20 | text = self.run_django_coverage() 21 | self.assertEqual(text, 'Hello') 22 | self.assert_analysis([1]) 23 | 24 | def test_plain_text(self): 25 | self.make_template('Hello\nWorld\n\nGoodbye') 26 | text = self.run_django_coverage() 27 | self.assertEqual(text, 'Hello\nWorld\n\nGoodbye') 28 | self.assert_analysis([1, 2, 3, 4]) 29 | 30 | def test_variable(self): 31 | self.make_template("""\ 32 | Hello, {{name}} 33 | """) 34 | text = self.run_django_coverage(context={'name': 'John'}) 35 | self.assertEqual(text, "Hello, John\n") 36 | self.assert_analysis([1]) 37 | 38 | def test_variable_on_second_line(self): 39 | self.make_template("""\ 40 | Hello, 41 | {{name}} 42 | """) 43 | text = self.run_django_coverage(context={'name': 'John'}) 44 | self.assertEqual(text, "Hello,\nJohn\n") 45 | self.assert_analysis([1, 2]) 46 | 47 | def test_lone_variable(self): 48 | self.make_template("""\ 49 | {{name}} 50 | """) 51 | text = self.run_django_coverage(context={'name': 'John'}) 52 | self.assertEqual(text, "John\n") 53 | self.assert_analysis([1]) 54 | 55 | def test_long_text(self): 56 | self.make_template("line\n"*50) 57 | text = self.run_django_coverage() 58 | self.assertEqual(text, "line\n"*50) 59 | self.assert_analysis(list(range(1, 51))) 60 | 61 | def test_non_ascii(self): 62 | self.make_template("""\ 63 | υηιcσɗє ιѕ тяιcку 64 | {{more}}! 65 | """) 66 | text = self.run_django_coverage(context={'more': 'ɘboɔinU'}) 67 | self.assertEqual(text, 'υηιcσɗє ιѕ тяιcку\nɘboɔinU!\n') 68 | self.assert_analysis([1, 2]) 69 | self.assertEqual(self.get_html_report(), 100) 70 | self.assertEqual(self.get_xml_report(), 100) 71 | 72 | 73 | class CommentTest(DjangoPluginTestCase): 74 | 75 | def test_simple(self): 76 | self.make_template("""\ 77 | First 78 | {% comment %} 79 | ignore this 80 | {% endcomment %} 81 | Last 82 | """) 83 | text = self.run_django_coverage() 84 | self.assertEqual(text, "First\n\nLast\n") 85 | self.assert_analysis([1, 2, 5]) 86 | 87 | def test_with_stuff_inside(self): 88 | self.make_template("""\ 89 | First 90 | {% comment %} 91 | {% if foo %} 92 | {{ foo }} 93 | {% endif %} 94 | {% endcomment %} 95 | Last 96 | """) 97 | text = self.run_django_coverage() 98 | self.assertEqual(text, "First\n\nLast\n") 99 | self.assert_analysis([1, 2, 7]) 100 | 101 | def test_inline_comment(self): 102 | self.make_template("""\ 103 | First 104 | {# disregard all of this #} 105 | Last 106 | """) 107 | text = self.run_django_coverage() 108 | self.assertEqual(text, "First\n\nLast\n") 109 | self.assert_analysis([1, 3]) 110 | 111 | 112 | class OtherTest(DjangoPluginTestCase): 113 | """Tests of miscellaneous tags.""" 114 | 115 | def test_autoescape(self): 116 | self.make_template("""\ 117 | First 118 | {% autoescape on %} 119 | {{ body }} 120 | {% endautoescape %} 121 | {% autoescape off %} 122 | {{ body }} 123 | {% endautoescape %} 124 | Last 125 | """) 126 | text = self.run_django_coverage(context={'body': ''}) 127 | self.assertEqual(text, "First\n\n<Hello>\n\n\n\n\nLast\n") 128 | self.assert_analysis([1, 2, 3, 5, 6, 8]) 129 | 130 | def test_filter(self): 131 | self.make_template("""\ 132 | First 133 | {% filter force_escape|lower %} 134 | LOOK: 1 < 2 135 | {% endfilter %} 136 | Last 137 | """) 138 | text = self.run_django_coverage() 139 | self.assertEqual(text, "First\n\n look: 1 < 2\n\nLast\n") 140 | self.assert_analysis([1, 2, 3, 5]) 141 | 142 | def test_firstof(self): 143 | self.make_template("""\ 144 | {% firstof var1 var2 var3 "xyzzy" %} 145 | {% firstof var2 var3 "plugh" %} 146 | {% firstof var3 "quux" %} 147 | """) 148 | text = self.run_django_coverage(context={'var1': 'A'}) 149 | self.assertEqual(text, "A\nplugh\nquux\n") 150 | self.assert_analysis([1, 2, 3]) 151 | 152 | text = self.run_django_coverage(context={'var2': 'B'}) 153 | self.assertEqual(text, "B\nB\nquux\n") 154 | self.assert_analysis([1, 2, 3]) 155 | 156 | def test_lorem(self): 157 | self.make_template("""\ 158 | First 159 | {% lorem 3 w %} 160 | Last 161 | """) 162 | text = self.run_django_coverage() 163 | self.assertEqual(text, "First\nlorem ipsum dolor\nLast\n") 164 | self.assert_analysis([1, 2, 3]) 165 | 166 | def test_now(self): 167 | self.make_template("""\ 168 | Now: 169 | {% now "\\n\\o\\w" %} 170 | . 171 | """) 172 | text = self.run_django_coverage() 173 | self.assertEqual(text, "Now:\nnow\n.\n") 174 | self.assert_analysis([1, 2, 3]) 175 | 176 | def test_now_as(self): 177 | self.make_template("""\ 178 | {% now "\\n\\o\\w" as right_now %} 179 | Now it's {{ right_now }}. 180 | """) 181 | text = self.run_django_coverage() 182 | self.assertEqual(text, "\nNow it's now.\n") 183 | self.assert_analysis([1, 2]) 184 | 185 | def test_templatetag(self): 186 | self.make_template("""\ 187 | {% templatetag openblock %} 188 | url 'entry_list' 189 | {% templatetag closeblock %} 190 | """) 191 | text = self.run_django_coverage() 192 | self.assertEqual(text, "{%\nurl 'entry_list'\n%}\n") 193 | self.assert_analysis([1, 2, 3]) 194 | 195 | def test_url(self): 196 | self.make_template("""\ 197 | {% url 'index' %} 198 | nice. 199 | """) 200 | text = self.run_django_coverage() 201 | self.assertEqual(text, "/home\nnice.\n") 202 | self.assert_analysis([1, 2]) 203 | 204 | def test_verbatim(self): 205 | self.make_template("""\ 206 | 1 207 | {% verbatim %} 208 | {{if dying}}Alive.{{/if}} 209 | second. 210 | {%third%}.UNISTR 211 | {% endverbatim %} 212 | 7 213 | """.replace("UNISTR", UNISTR)) 214 | text = self.run_django_coverage() 215 | self.assertEqual( 216 | text, 217 | "1\n\n{{if dying}}Alive.{{/if}}\nsecond.\n" 218 | "{%third%}.UNIUNI\n\n7\n".replace("UNIUNI", UNIUNI) 219 | ) 220 | self.assert_analysis([1, 2, 3, 4, 5, 7]) 221 | 222 | def test_widthratio(self): 223 | self.make_template("""\ 224 | {% widthratio 175 200 100 %} 225 | == 88 226 | """) 227 | text = self.run_django_coverage() 228 | self.assertEqual(text, "88\n== 88\n") 229 | self.assert_analysis([1, 2]) 230 | 231 | def test_with(self): 232 | self.make_template("""\ 233 | {% with alpha=1 beta=2 %} 234 | alpha = {{ alpha }}, beta = {{ beta }}. 235 | {% endwith %} 236 | """) 237 | text = self.run_django_coverage() 238 | self.assertEqual(text, "\nalpha = 1, beta = 2.\n\n") 239 | self.assert_analysis([1, 2]) 240 | 241 | 242 | class StringTemplateTest(DjangoPluginTestCase): 243 | 244 | run_in_temp_dir = False 245 | 246 | def test_string_template(self): 247 | # I don't understand why coverage 6 warns about no data, 248 | # but coverage 5 does not. 249 | with self.assert_no_data(min_cov=(6, 0)): 250 | text = self.run_django_coverage( 251 | text="Hello, {{name}}!", 252 | context={'name': 'World'}, 253 | options={}, 254 | ) 255 | self.assertEqual(text, "Hello, World!") 256 | 257 | 258 | class BranchTest(DjangoPluginTestCase): 259 | 260 | def test_with_branch_enabled(self): 261 | self.make_template('Hello\nWorld\n\nGoodbye') 262 | text = self.run_django_coverage( 263 | options={'source': ["."], 'branch': True} 264 | ) 265 | self.assertEqual(text, 'Hello\nWorld\n\nGoodbye') 266 | self.assert_analysis([1, 2, 3, 4]) 267 | -------------------------------------------------------------------------------- /tests/test_source.py: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | """Tests of template inheritance for django_coverage_plugin.""" 5 | 6 | import os 7 | 8 | try: 9 | from coverage.exceptions import NoSource 10 | except ImportError: 11 | # for coverage 5.x 12 | from coverage.misc import NoSource 13 | 14 | from .plugin_test import DjangoPluginTestCase 15 | 16 | 17 | class FindSourceTest(DjangoPluginTestCase): 18 | 19 | def test_finding_source(self): 20 | # This is a template that is rendered. 21 | self.make_template(name="main.html", text="Hello") 22 | # These are templates that aren't rendered, but are considered renderable. 23 | self.make_template(name="unused.html", text="Not used") 24 | self.make_template(name="unused.htm", text="Not used") 25 | self.make_template(name="unused.txt", text="Not used") 26 | # These are things left behind by an editor. 27 | self.make_template(name="~unused.html", text="junk") 28 | self.make_template(name="unused=.html", text="junk") 29 | self.make_template(name="unused.html,", text="junk") 30 | # This is some other file format we don't recognize. 31 | self.make_template(name="phd.tex", text="Too complicated to read") 32 | 33 | text = self.run_django_coverage(name="main.html") 34 | self.assertEqual(text, "Hello") 35 | 36 | # The rendered file has data, and was measured. 37 | self.assert_analysis([1], name="main.html") 38 | # The unrendered files have data, and were not measured. 39 | self.assert_analysis([1], name="unused.html", missing=[1]) 40 | self.assert_analysis([1], name="unused.htm", missing=[1]) 41 | self.assert_analysis([1], name="unused.txt", missing=[1]) 42 | # The editor leave-behinds are not in the measured files. 43 | self.assert_measured_files("main.html", "unused.html", "unused.htm", "unused.txt") 44 | 45 | def test_customized_extensions(self): 46 | self.make_file(".coveragerc", """\ 47 | [run] 48 | plugins = django_coverage_plugin 49 | [django_coverage_plugin] 50 | template_extensions = html, tex 51 | """) 52 | # This is a template that is rendered. 53 | self.make_template(name="main.html", text="Hello") 54 | # These are templates that aren't rendered, but are considered renderable. 55 | self.make_template(name="unused.html", text="Not used") 56 | self.make_template(name="phd.tex", text="Too complicated to read") 57 | # These are things left behind by an editor. 58 | self.make_template(name="~unused.html", text="junk") 59 | self.make_template(name="unused=.html", text="junk") 60 | self.make_template(name="unused.html,", text="junk") 61 | # This is some other file format we don't recognize. 62 | self.make_template(name="unused.htm", text="Not used") 63 | self.make_template(name="unused.txt", text="Not used") 64 | 65 | text = self.run_django_coverage(name="main.html") 66 | self.assertEqual(text, "Hello") 67 | 68 | # The rendered file has data, and was measured. 69 | self.assert_analysis([1], name="main.html") 70 | # The unrendered files have data, and were not measured. 71 | self.assert_analysis([1], name="unused.html", missing=[1]) 72 | self.assert_analysis([1], name="phd.tex", missing=[1]) 73 | # The editor leave-behinds are not in the measured files. 74 | self.assert_measured_files("main.html", "unused.html", "phd.tex") 75 | 76 | def test_non_utf8_error(self): 77 | # A non-UTF8 text file will raise an error. 78 | self.make_file(".coveragerc", """\ 79 | [run] 80 | plugins = django_coverage_plugin 81 | source = . 82 | """) 83 | # This is a template that is rendered. 84 | self.make_template(name="main.html", text="Hello") 85 | # Extra file containing a word encoded in CP-1252 86 | self.make_file(self._path("static/changelog.txt"), bytes=b"sh\xf6n") 87 | 88 | text = self.run_django_coverage(name="main.html") 89 | self.assertEqual(text, "Hello") 90 | 91 | self.assert_measured_files("main.html", f"static{os.sep}changelog.txt") 92 | self.assert_analysis([1], name="main.html") 93 | with self.assertRaisesRegex(NoSource, r"changelog.txt.*invalid start byte"): 94 | self.cov.html_report() 95 | 96 | def test_non_utf8_omitted(self): 97 | # If we omit the directory with the non-UTF8 file, all is well. 98 | self.make_file(".coveragerc", """\ 99 | [run] 100 | plugins = django_coverage_plugin 101 | source = . 102 | [report] 103 | omit = */static/* 104 | """) 105 | # This is a template that is rendered. 106 | self.make_template(name="main.html", text="Hello") 107 | # Extra file containing a word encoded in CP-1252 108 | self.make_file(self._path("static/changelog.txt"), bytes=b"sh\xf6n") 109 | 110 | text = self.run_django_coverage(name="main.html") 111 | self.assertEqual(text, "Hello") 112 | 113 | self.assert_measured_files("main.html", f"static{os.sep}changelog.txt") 114 | self.assert_analysis([1], name="main.html") 115 | self.cov.html_report() 116 | 117 | def test_non_utf8_ignored(self): 118 | # If we ignore reporting errors, a non-UTF8 text file is fine. 119 | self.make_file(".coveragerc", """\ 120 | [run] 121 | plugins = django_coverage_plugin 122 | source = . 123 | [report] 124 | ignore_errors = True 125 | """) 126 | # This is a template that is rendered. 127 | self.make_template(name="main.html", text="Hello") 128 | # Extra file containing a word encoded in CP-1252 129 | self.make_file(self._path("static/changelog.txt"), bytes=b"sh\xf6n") 130 | 131 | text = self.run_django_coverage(name="main.html") 132 | self.assertEqual(text, "Hello") 133 | 134 | self.assert_measured_files("main.html", f"static{os.sep}changelog.txt") 135 | self.assert_analysis([1], name="main.html") 136 | warn_msg = ( 137 | "'utf-8' codec can't decode byte 0xf6 in position 2: " + 138 | "invalid start byte (couldnt-parse)" 139 | ) 140 | with self.assert_coverage_warnings(warn_msg, min_cov=(6, 0)): 141 | self.cov.html_report() 142 | 143 | def test_htmlcov_isnt_measured(self): 144 | # We used to find the HTML report and think it was template files. 145 | self.make_file(".coveragerc", """\ 146 | [run] 147 | plugins = django_coverage_plugin 148 | source = . 149 | """) 150 | self.make_template(name="main.html", text="Hello") 151 | text = self.run_django_coverage(name="main.html") 152 | self.assertEqual(text, "Hello") 153 | 154 | self.assert_measured_files("main.html") 155 | self.cov.html_report() 156 | 157 | # Run coverage again with an HTML report on disk. 158 | text = self.run_django_coverage(name="main.html") 159 | self.assert_measured_files("main.html") 160 | 161 | def test_custom_html_report_isnt_measured(self): 162 | # We used to find the HTML report and think it was template files. 163 | self.make_file(".coveragerc", """\ 164 | [run] 165 | plugins = django_coverage_plugin 166 | source = . 167 | [html] 168 | directory = my_html_report 169 | """) 170 | self.make_template(name="main.html", text="Hello") 171 | text = self.run_django_coverage(name="main.html") 172 | self.assertEqual(text, "Hello") 173 | 174 | self.assert_measured_files("main.html") 175 | self.cov.html_report() 176 | 177 | # Run coverage again with an HTML report on disk. 178 | text = self.run_django_coverage(name="main.html") 179 | self.assert_measured_files("main.html") 180 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2 | # For details: https://github.com/nedbat/django_coverage_plugin/blob/master/NOTICE.txt 3 | 4 | # tox configuration for django_coverage_plugin. 5 | # 6 | # To run all the tests: 7 | # 8 | # $ tox 9 | # 10 | # To run one test: 11 | # 12 | # $ tox -- tests.test_extends.SsiTest.test_ssi_parsed 13 | # 14 | 15 | [tox] 16 | # When changing this, also update the classifiers in setup.py: 17 | envlist = 18 | py39-django{22,32,42}-cov{6,7,tip}, 19 | py310-django{32,42,51}-cov{6,7,tip}, 20 | py311-django{42,51}-cov{6,7,tip}, 21 | py312-django{51,tip}-cov{7,tip}, 22 | py313-django{51,tip}-cov{7,tip}, 23 | check,pkgcheck,doc 24 | 25 | [testenv] 26 | deps = 27 | cov6: coverage>=6.0,<7.0 28 | cov7: coverage>=7.0,<8.0 29 | covtip: git+https://github.com/nedbat/coveragepy.git 30 | django22: Django>=2.2,<3.0 31 | django32: Django>=3.2,<4.0 32 | django42: Django>=4.2,<5.0 33 | django51: Django>=5.1,<6.0 34 | djangotip: git+https://github.com/django/django.git 35 | pytest 36 | unittest-mixins==1.6 37 | 38 | commands = 39 | python -c "import tests.banner" 40 | python -m pytest {posargs} 41 | 42 | usedevelop = True 43 | 44 | passenv = * 45 | 46 | [testenv:check] 47 | deps = 48 | flake8 49 | isort 50 | 51 | commands = 52 | flake8 --max-line-length=100 setup.py django_coverage_plugin tests setup.py 53 | isort --check-only --diff django_coverage_plugin tests setup.py 54 | 55 | [testenv:pkgcheck] 56 | skip_install = true 57 | deps = 58 | build 59 | docutils 60 | check-manifest 61 | readme-renderer 62 | twine 63 | 64 | commands = 65 | python -m build --config-setting=--quiet 66 | twine check dist/* 67 | check-manifest {toxinidir} 68 | 69 | [testenv:doc] 70 | deps = 71 | sphinx 72 | 73 | commands = 74 | rst2html --strict README.rst /tmp/django_coverage_plugin_README.html 75 | 76 | [gh-actions] 77 | python = 78 | 3.9: py39 79 | 3.10: py310 80 | 3.11: py311 81 | 3.12: py312 82 | 3.13: py313 83 | --------------------------------------------------------------------------------