├── .coveragerc ├── .github ├── pull_request_template.md └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── AUTHORS ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── docs ├── Makefile ├── _static │ └── .keep ├── alternatives.rst ├── conf.py ├── consumers.rst ├── contributing.rst ├── highlighter.rst ├── images │ ├── python-tap.png │ ├── stream.gif │ └── tap.png ├── index.rst ├── make.bat ├── producers.rst ├── releases.rst ├── sample_tap.txt └── tappy.1.rst ├── pyproject.toml ├── src └── tap │ ├── __init__.py │ ├── __main__.py │ ├── adapter.py │ ├── directive.py │ ├── formatter.py │ ├── line.py │ ├── loader.py │ ├── main.py │ ├── parser.py │ ├── rules.py │ ├── runner.py │ ├── tests │ ├── __init__.py │ ├── factory.py │ ├── test_example.py │ └── testcase.py │ └── tracker.py ├── tests ├── run.py ├── test_adapter.py ├── test_directive.py ├── test_formatter.py ├── test_line.py ├── test_loader.py ├── test_main.py ├── test_parser.py ├── test_result.py ├── test_rules.py ├── test_runner.py └── test_tracker.py ├── tox.ini └── uv.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | tap 4 | */site-packages/tap 5 | 6 | [run] 7 | omit = 8 | tap/tests/* 9 | # This is tested with tox -e module in CI. 10 | src/tap/__main__.py 11 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | To accept your contribution, please ensure that the checklist below is complete. 2 | 3 | * [ ] Is your name/identity in the AUTHORS file? 4 | * [ ] Does the code change (if the PR contains code) have 100% test coverage? 5 | * [ ] Is CI passing all quality and testing checks? 6 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | python: ['3.9', '3.10', '3.11', '3.12', '3.13'] 11 | os: [macos-latest, ubuntu-latest, windows-latest] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python }} 20 | 21 | - name: Install tox and any other packages 22 | run: pip install tox 23 | 24 | - name: Run tox 25 | # Run tox using the version of Python in `PATH` 26 | run: tox -e py 27 | 28 | # Run the extra tox configurations that run integration type tests. 29 | extra: 30 | needs: build 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | 36 | - name: Setup Python 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: '3.13' 40 | 41 | - name: Install tox and any other packages 42 | run: pip install tox 43 | 44 | - name: Run tox 45 | run: tox -e with_optional,runner,module,integration,coverage 46 | 47 | - name: Combine coverage & fail if it's <100% 48 | run: | 49 | python -Im pip install --upgrade coverage[toml] 50 | 51 | python -Im coverage html --skip-covered --skip-empty 52 | 53 | # Report and write to summary. 54 | python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 55 | 56 | # Report again and fail if under 100%. 57 | python -Im coverage report --fail-under=100 58 | 59 | - name: Upload HTML report if check failed 60 | uses: actions/upload-artifact@v4 61 | with: 62 | name: html-report 63 | path: htmlcov 64 | if: ${{ failure() }} 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | coverage.xml 28 | .tox 29 | nosetests.xml 30 | htmlcov 31 | .cache 32 | .pytest_cache 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | 39 | # Dev 40 | *.swp 41 | .vscode 42 | 43 | # TAP 44 | *.tap 45 | 46 | # docs 47 | docs/_build 48 | 49 | # virtualenv 50 | include/ 51 | local/ 52 | venv/ 53 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.2 4 | hooks: 5 | # Run the linter. 6 | - id: ruff 7 | args: [ --fix, --unsafe-fixes ] 8 | # Run the formatter. 9 | - id: ruff-format 10 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | tappy was originally created by Matt Layman. 2 | 3 | Contributors 4 | ------------ 5 | 6 | * Adeodato Simó 7 | * Allison Karlitskaya 8 | * Andrew McNamara 9 | * Chris Clarke 10 | * Cody D'Ambrosio 11 | * Erik Cederstrand 12 | * Marc Abramowitz 13 | * Mark E. Hamilton 14 | * Matt Layman 15 | * meejah (https://meejah.ca) 16 | * Michael F. Lamb (http://datagrok.org) 17 | * Nicolas Caniart 18 | * Richard Bosworth 19 | * Ross Burton 20 | * Simon McVittie 21 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | As a Python project, 2 | tappy adheres 3 | to the [PSF Code of Conduct](https://www.python.org/psf/conduct/). 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Matt Layman and contributors. See AUTHORS for more details. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | uv run pytest 3 | 4 | clean: 5 | @rm -rf \ 6 | .coverage \ 7 | coverage.xml \ 8 | dist \ 9 | htmlcov \ 10 | results \ 11 | testout 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | tappy 2 | ===== 3 | 4 | [![PyPI version][pypishield]](https://pypi.python.org/pypi/tap.py) 5 | 6 | TAP logo 8 | 9 | tappy is a set of tools for working with the 10 | [Test Anything Protocol (TAP)][tap] in Python. TAP is a line based test 11 | protocol for recording test data in a standard way. 12 | 13 | Full documentation for tappy is at [Read the Docs][rtd]. The information 14 | below provides a synopsis of what tappy supplies. 15 | 16 | For the curious: tappy sounds like "happy." 17 | 18 | If you find tappy useful, please consider starring the repository to show a 19 | kindness and help others discover something valuable. Thanks! 20 | 21 | Installation 22 | ------------ 23 | 24 | tappy is available for download from [PyPI][pypi]. tappy is currently supported 25 | on Python 26 | 3.7, 27 | 3.8, 28 | 3.9, 29 | 3.10, 30 | and PyPy. 31 | It is continuously tested on Linux, OS X, and Windows. 32 | 33 | ```bash 34 | $ pip install tap.py 35 | ``` 36 | 37 | For testing with [pytest][pytest], 38 | you only need to install `pytest-tap`. 39 | 40 | ```bash 41 | $ pip install pytest-tap 42 | ``` 43 | 44 | For testing with [nose][ns], 45 | you only need to install `nose-tap`. 46 | 47 | ```bash 48 | $ pip install nose-tap 49 | ``` 50 | 51 | TAP version 13 brings support 52 | for [YAML blocks](http://testanything.org/tap-version-13-specification.html#yaml-blocks) 53 | associated with test results. 54 | To work with version 13, install the optional dependencies. 55 | Learn more about YAML support 56 | in the [TAP version 13](http://tappy.readthedocs.io/en/latest/consumers.html#tap-version-13) section. 57 | 58 | ```bash 59 | $ pip install tap.py[yaml] 60 | ``` 61 | 62 | Motivation 63 | ---------- 64 | 65 | Some projects have mixed programming environments with many 66 | programming languages and tools. Because of TAP's simplicity, 67 | it can function as a *lingua franca* for testing. 68 | When every testing tool can create TAP, 69 | a team can get a holistic view of their system. 70 | Python did not have a bridge from `unittest` to TAP so it was 71 | difficult to integrate a Python test suite into a larger TAP ecosystem. 72 | 73 | tappy is Python's bridge to TAP. 74 | 75 | ![TAP streaming demo][stream] 76 | 77 | Goals 78 | ----- 79 | 80 | 1. Provide [TAP Producers][produce] which translate Python's `unittest` into 81 | TAP. 82 | 2. Provide a [TAP Consumer][consume] which reads TAP and provides a 83 | programmatic API in Python or generates summary results. 84 | 3. Provide a command line interface for reading TAP. 85 | 86 | Producers 87 | --------- 88 | 89 | * `TAPTestRunner` - This subclass of `unittest.TextTestRunner` provides all 90 | the functionality of `TextTestRunner` and generates TAP files. 91 | * tappy for [nose][ns] - 92 | `nose-tap` provides a plugin 93 | for the **nose** testing tool. 94 | * tappy for [pytest][pytest] - 95 | `pytest-tap` provides a plugin 96 | for the **pytest** testing tool. 97 | 98 | Consumers 99 | --------- 100 | 101 | * `tappy` - A command line tool for processing TAP files. 102 | * `Loader` and `Parser` - Python APIs for handling of TAP files and data. 103 | 104 | Contributing 105 | ------------ 106 | 107 | The project welcomes contributions of all kinds. 108 | Check out the [contributing guidelines][contributing] 109 | for tips on how to get started. 110 | 111 | [tap]: http://testanything.org/ 112 | [pypishield]: https://img.shields.io/pypi/v/tap.py.svg 113 | [rtd]: http://tappy.readthedocs.io/en/latest/ 114 | [pypi]: https://pypi.python.org/pypi/tap.py 115 | [stream]: https://github.com/python-tap/tappy/blob/main/docs/images/stream.gif 116 | [produce]: http://testanything.org/producers.html 117 | [consume]: http://testanything.org/consumers.html 118 | [ns]: https://nose.readthedocs.io/en/latest/ 119 | [pytest]: http://pytest.org/latest/ 120 | [contributing]: http://tappy.readthedocs.io/en/latest/contributing.html 121 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/tappy.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/tappy.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/tappy" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/tappy" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-tap/tappy/c14fc7e9d9f4acce348fc23638561b2da9394be0/docs/_static/.keep -------------------------------------------------------------------------------- /docs/alternatives.rst: -------------------------------------------------------------------------------- 1 | Alternatives 2 | ============ 3 | 4 | tappy is not the only project that can produce TAP output for Python. 5 | While tappy is a capable TAP producer and consumer, 6 | other projects might be a better fit for you. 7 | The following comparison lists some other Python TAP tools 8 | and lists some of the biggest differences compared to tappy. 9 | 10 | pycotap 11 | ------- 12 | 13 | pycotap is a good tool for when you want TAP output, 14 | but you don't want extra dependencies. 15 | pycotap is a zero dependency TAP producer. 16 | It is so small that you could even embed it into your project. 17 | `Check out the project homepage 18 | `_. 19 | 20 | catapult 21 | -------- 22 | 23 | catapult is a TAP producer. 24 | catapult is also capable of producing TAP-Y and TAP-J 25 | which are YAML and JSON test streams 26 | that are inspired by TAP. 27 | `You can find the catapult source on GitHub 28 | `_. 29 | 30 | pytap13 31 | ------- 32 | 33 | pytap13 is a TAP consumer for TAP version 13. 34 | It parses a TAP stream 35 | and produces test instances that can be inspected. 36 | `pytap13's homepage is on Bitbucket 37 | `_. 38 | 39 | bayeux 40 | ------ 41 | 42 | bayeux is a TAP producer 43 | that is designed to work with unittest and unittest2. 44 | `bayeux is on GitLab. 45 | `_. 46 | 47 | taptaptap 48 | --------- 49 | 50 | taptaptap is a TAP producer with a procedural style 51 | similar to Perl. 52 | It also includes a ``TapWriter`` class as a TAP producer. 53 | `Visit the taptaptap homepage 54 | `_. 55 | 56 | unittest-tap-reporting 57 | ---------------------- 58 | 59 | unittest-tap-reporting is another zero dependency TAP producer. 60 | `Check it out on GitHub 61 | `_. 62 | 63 | If there are other relevant projects, 64 | please post an issue on GitHub 65 | so this comparison page can be updated accordingly. 66 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # tappy documentation build configuration file, created by 3 | # sphinx-quickstart on Tue Mar 11 20:21:22 2014. 4 | # 5 | # This file is execfile()d with the current directory set to its 6 | # containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import os 15 | import sys 16 | 17 | sys.path.append(os.path.abspath("..")) 18 | from tap import __version__ # noqa 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | # sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | "sphinx.ext.autodoc", 35 | ] 36 | 37 | # autodoc settings 38 | autodoc_member_order = "bysource" 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ["_templates"] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = ".rst" 45 | 46 | # The encoding of source files. 47 | # source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = "index" 51 | 52 | # General information about the project. 53 | project = "tappy" 54 | copyright = "Matt Layman and contributors" 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | version = __version__ 62 | # The full version, including alpha/beta/rc tags. 63 | release = __version__ 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | # language = None 68 | 69 | # There are two options for replacing |today|: either, you set today to some 70 | # non-false value, then it is used: 71 | # today = '' 72 | # Else, today_fmt is used as the format for a strftime call. 73 | # today_fmt = '%B %d, %Y' 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | exclude_patterns = ["_build"] 78 | 79 | # The reST default role (used for this markup: `text`) to use for all 80 | # documents. 81 | # default_role = None 82 | 83 | # If true, '()' will be appended to :func: etc. cross-reference text. 84 | # add_function_parentheses = True 85 | 86 | # If true, the current module name will be prepended to all description 87 | # unit titles (such as .. function::). 88 | # add_module_names = True 89 | 90 | # If true, sectionauthor and moduleauthor directives will be shown in the 91 | # output. They are ignored by default. 92 | # show_authors = False 93 | 94 | # The name of the Pygments (syntax highlighting) style to use. 95 | pygments_style = "sphinx" 96 | 97 | # A list of ignored prefixes for module index sorting. 98 | # modindex_common_prefix = [] 99 | 100 | # If true, keep warnings as "system message" paragraphs in the built documents. 101 | # keep_warnings = False 102 | 103 | 104 | # -- Options for HTML output ---------------------------------------------- 105 | 106 | # The theme to use for HTML and HTML Help pages. See the documentation for 107 | # a list of builtin themes. 108 | html_theme = "default" 109 | 110 | # Theme options are theme-specific and customize the look and feel of a theme 111 | # further. For a list of options available for each theme, see the 112 | # documentation. 113 | # html_theme_options = {} 114 | 115 | # Add any paths that contain custom themes here, relative to this directory. 116 | # html_theme_path = [] 117 | 118 | # The name for this set of Sphinx documents. If None, it defaults to 119 | # " v documentation". 120 | # html_title = None 121 | 122 | # A shorter title for the navigation bar. Default is the same as html_title. 123 | # html_short_title = None 124 | 125 | # The name of an image file (relative to this directory) to place at the top 126 | # of the sidebar. 127 | # html_logo = None 128 | 129 | # The name of an image file (within the static path) to use as favicon of the 130 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 131 | # pixels large. 132 | # html_favicon = None 133 | 134 | # Add any paths that contain custom static files (such as style sheets) here, 135 | # relative to this directory. They are copied after the builtin static files, 136 | # so a file named "default.css" will overwrite the builtin "default.css". 137 | html_static_path = ["_static"] 138 | 139 | # Add any extra paths that contain custom files (such as robots.txt or 140 | # .htaccess) here, relative to this directory. These files are copied 141 | # directly to the root of the documentation. 142 | # html_extra_path = [] 143 | 144 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 145 | # using the given strftime format. 146 | # html_last_updated_fmt = '%b %d, %Y' 147 | 148 | # If true, SmartyPants will be used to convert quotes and dashes to 149 | # typographically correct entities. 150 | # html_use_smartypants = True 151 | 152 | # Custom sidebar templates, maps document names to template names. 153 | # html_sidebars = {} 154 | 155 | # Additional templates that should be rendered to pages, maps page names to 156 | # template names. 157 | # html_additional_pages = {} 158 | 159 | # If false, no module index is generated. 160 | # html_domain_indices = True 161 | 162 | # If false, no index is generated. 163 | # html_use_index = True 164 | 165 | # If true, the index is split into individual pages for each letter. 166 | # html_split_index = False 167 | 168 | # If true, links to the reST sources are added to the pages. 169 | # html_show_sourcelink = True 170 | 171 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 172 | # html_show_sphinx = True 173 | 174 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 175 | # html_show_copyright = True 176 | 177 | # If true, an OpenSearch description file will be output, and all pages will 178 | # contain a tag referring to it. The value of this option must be the 179 | # base URL from which the finished HTML is served. 180 | # html_use_opensearch = '' 181 | 182 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 183 | # html_file_suffix = None 184 | 185 | # Output file base name for HTML help builder. 186 | htmlhelp_basename = "tappydoc" 187 | 188 | 189 | # -- Options for LaTeX output --------------------------------------------- 190 | 191 | latex_elements = { 192 | # The paper size ('letterpaper' or 'a4paper'). 193 | # 'papersize': 'letterpaper', 194 | # The font size ('10pt', '11pt' or '12pt'). 195 | # 'pointsize': '10pt', 196 | # Additional stuff for the LaTeX preamble. 197 | # 'preamble': '', 198 | } 199 | 200 | # Grouping the document tree into LaTeX files. List of tuples 201 | # (source start file, target name, title, 202 | # author, documentclass [howto, manual, or own class]). 203 | latex_documents = [ 204 | ( 205 | "index", 206 | "tappy.tex", 207 | "tappy Documentation", 208 | "Matt Layman and contributors", 209 | "manual", 210 | ), 211 | ] 212 | 213 | # The name of an image file (relative to this directory) to place at the top of 214 | # the title page. 215 | # latex_logo = None 216 | 217 | # For "manual" documents, if this is true, then toplevel headings are parts, 218 | # not chapters. 219 | # latex_use_parts = False 220 | 221 | # If true, show page references after internal links. 222 | # latex_show_pagerefs = False 223 | 224 | # If true, show URL addresses after external links. 225 | # latex_show_urls = False 226 | 227 | # Documents to append as an appendix to all manuals. 228 | # latex_appendices = [] 229 | 230 | # If false, no module index is generated. 231 | # latex_domain_indices = True 232 | 233 | 234 | # -- Options for manual page output --------------------------------------- 235 | 236 | # One entry per manual page. List of tuples 237 | # (source start file, name, description, authors, manual section). 238 | man_pages = [("tappy.1", "tappy", "a tap consumer for python", [], 1)] 239 | 240 | # If true, show URL addresses after external links. 241 | # man_show_urls = False 242 | 243 | 244 | # -- Options for Texinfo output ------------------------------------------- 245 | 246 | # Grouping the document tree into Texinfo files. List of tuples 247 | # (source start file, target name, title, author, 248 | # dir menu entry, description, category) 249 | texinfo_documents = [ 250 | ( 251 | "index", 252 | "tappy", 253 | "tappy Documentation", 254 | "Matt Layman and contributors", 255 | "tappy", 256 | "One line description of project.", 257 | "Miscellaneous", 258 | ), 259 | ] 260 | 261 | # Documents to append as an appendix to all manuals. 262 | # texinfo_appendices = [] 263 | 264 | # If false, no module index is generated. 265 | # texinfo_domain_indices = True 266 | 267 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 268 | # texinfo_show_urls = 'footnote' 269 | 270 | # If true, do not generate a @detailmenu in the "Top" node's menu. 271 | # texinfo_no_detailmenu = False 272 | -------------------------------------------------------------------------------- /docs/consumers.rst: -------------------------------------------------------------------------------- 1 | TAP Consumers 2 | ============= 3 | 4 | tappy Tool 5 | ---------- 6 | 7 | The ``tappy`` command line tool is a `TAP consumer 8 | `_. 9 | The tool accepts TAP files or directories containing TAP files 10 | and provides a standard Python ``unittest`` style summary report. 11 | Check out ``tappy -h`` for the complete list of options. 12 | You can also use the tool's shorter alias of ``tap``. 13 | 14 | .. code-block:: console 15 | 16 | $ tappy *.tap 17 | ................F.................................. 18 | ====================================================================== 19 | FAIL: 20 | - The parser extracts a bail out line. 21 | ---------------------------------------------------------------------- 22 | 23 | ---------------------------------------------------------------------- 24 | Ran 51 tests in 0.002s 25 | 26 | FAILED (failures=1) 27 | 28 | TAP Stream 29 | ~~~~~~~~~~ 30 | 31 | ``tappy`` can read a TAP stream directly STDIN. 32 | This permits any TAP producer to pipe its results to ``tappy`` 33 | without generating intermediate output files. 34 | ``tappy`` will read from STDIN 35 | when no arguments are provided 36 | or when a dash character is the only argument. 37 | 38 | Here is an example of ``nosetests`` piping to ``tappy``: 39 | 40 | .. code-block:: console 41 | 42 | $ nosetests --with-tap --tap-stream 2>&1 | tappy 43 | ...................................................................... 44 | ............................................... 45 | ---------------------------------------------------------------------- 46 | Ran 117 tests in 0.003s 47 | 48 | OK 49 | 50 | In this example, 51 | ``nosetests`` puts the TAP stream on STDERR 52 | so it must be redirected to STDOUT 53 | because the Unix pipe expects input on STDOUT. 54 | 55 | ``tappy`` can use redirected input 56 | from a shell. 57 | 58 | .. code-block:: console 59 | 60 | $ tappy < TestAdapter.tap 61 | ........ 62 | ---------------------------------------------------------------------- 63 | Ran 8 tests in 0.000s 64 | 65 | OK 66 | 67 | This final example shows ``tappy`` consuming TAP 68 | from Perl's test tool, ``prove``. 69 | The example includes the optional dash character. 70 | 71 | .. code-block:: console 72 | 73 | $ prove t/array.t -v | tappy - 74 | ............ 75 | ---------------------------------------------------------------------- 76 | Ran 12 tests in 0.001s 77 | 78 | OK 79 | 80 | API 81 | --- 82 | 83 | In addition to a command line interface, tappy enables programmatic access to 84 | TAP files for users to create their own TAP consumers. This access comes in 85 | two forms: 86 | 87 | 1. A ``Loader`` class which provides a ``load`` method to load a set of TAP 88 | files into a ``unittest.TestSuite``. The ``Loader`` can receive files or 89 | directories. 90 | 91 | .. code-block:: pycon 92 | 93 | >>> loader = Loader() 94 | >>> suite = loader.load(['foo.tap', 'bar.tap', 'baz.tap']) 95 | 96 | 2. A ``Parser`` class to provide a lower level interface. The ``Parser`` can 97 | parse a file via ``parse_file`` and return parsed lines that categorize the 98 | file contents. 99 | 100 | .. code-block:: pycon 101 | 102 | >>> parser = Parser() 103 | >>> for line in parser.parse_file('foo.tap'): 104 | ... # Do whatever you want with the processed line. 105 | ... pass 106 | 107 | The API specifics are listed below. 108 | 109 | .. autoclass:: tap.loader.Loader 110 | :members: 111 | 112 | .. autoclass:: tap.parser.Parser 113 | :members: 114 | 115 | .. _tap-version-13: 116 | 117 | TAP version 13 118 | ~~~~~~~~~~~~~~ 119 | 120 | The specification for TAP version 13 adds support for `yaml blocks `_ 121 | to provide additional information about the preceding test. In order to consume 122 | yaml blocks, ``tappy`` requires `pyyaml `_ and 123 | `more-itertools `_ to be installed. 124 | 125 | These dependencies are optional. If they are not installed, TAP output will still 126 | be consumed, but any yaml blocks will be parsed as :class:`tap.line.Unknown`. If a 127 | :class:`tap.line.Result` object has an associated yaml block, :attr:`~tap.line.Result.yaml_block` 128 | will return the block converted to a ``dict``. Otherwise, it will return ``None``. 129 | 130 | ``tappy`` provides a strict interpretation of the specification. A yaml block will 131 | only be associated with a result if it immediately follows that result. Any 132 | :class:`diagnostic ` between a :class:`result ` and a yaml 133 | block will result in the block lines being parsed as :class:`tap.line.Unknown`. 134 | 135 | Line Categories 136 | ~~~~~~~~~~~~~~~ 137 | 138 | The parser returns ``Line`` instances. Each line contains different properties 139 | depending on its category. 140 | 141 | .. autoclass:: tap.line.Result 142 | :members: 143 | 144 | .. autoclass:: tap.line.Plan 145 | :members: 146 | 147 | .. autoclass:: tap.line.Diagnostic 148 | :members: 149 | 150 | .. autoclass:: tap.line.Bail 151 | :members: 152 | 153 | .. autoclass:: tap.line.Version 154 | :members: 155 | 156 | .. autoclass:: tap.line.Unknown 157 | :members: 158 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | tappy should be easy to contribute to. If anything is unclear about how to 5 | contribute, please submit an issue on GitHub so that we can fix it! 6 | 7 | How 8 | --- 9 | 10 | Fork tappy on `GitHub `_ and 11 | `submit a Pull Request `_ 12 | when you're ready. 13 | 14 | The goal of tappy is to be a TAP-compliant producer and consumer. 15 | If you want to work on an issue 16 | that is outside of the TAP spec, 17 | please write up an issue first, 18 | so we can discuss the change. 19 | 20 | Setup 21 | ----- 22 | 23 | tappy uses [uv](https://docs.astral.sh/uv/) for development. 24 | 25 | .. code-block:: console 26 | 27 | $ git clone git@github.com:python-tap/tappy.git 28 | $ cd tappy 29 | $ # Edit some files and run the tests. 30 | $ make test 31 | 32 | The commands above show how to get a tappy clone configured. 33 | If you've executed those commands 34 | and the test suite passes, 35 | you should be ready to develop. 36 | 37 | Guidelines 38 | ---------- 39 | 40 | 1. Code uses Ruff for formatting and linting. 41 | If you have `pre-commit`, you can add ruff hooks via `pre-commit install`. 42 | These hooks will run as part of CI. 43 | Changes will not be accepted unless CI passes. 44 | 2. Make sure your change works with unit tests. 45 | 3. Document your change in the ``docs/releases.rst`` file. 46 | 4. For first time contributors, please add your name to ``AUTHORS`` 47 | so you get attribution for you effort. 48 | This is also to recognize your claim to the copyright in the project. 49 | 50 | Release checklist 51 | ----------------- 52 | 53 | These are notes for my release process, 54 | so I don't have to remember all the steps. 55 | Other contributors are free to ignore this. 56 | 57 | 1. Update ``docs/releases.rst``. 58 | 2. Update version in ``pyproject.toml`` and ``tap/__init__.py``. 59 | 3. ``rm -rf dist && uv build`` 60 | 4. ``uv publish`` 61 | 5. ``git tag -a vX.X -m "Version X.X"`` 62 | 6. ``git push --tags`` 63 | -------------------------------------------------------------------------------- /docs/highlighter.rst: -------------------------------------------------------------------------------- 1 | TAP Syntax Highlighter for Pygments 2 | =================================== 3 | 4 | `Pygments `_ contains an extension for syntax 5 | highlighting of TAP files. Any project that uses Pygments, like 6 | `Sphinx `_, can take advantage of this feature. 7 | 8 | This highlighter was initially implemented in tappy. 9 | Since the highlighter was merged into the upstream Pygments project, 10 | tappy is no longer a requirement to get TAP syntax highlighting. 11 | 12 | Below is an example usage for Sphinx. 13 | 14 | .. code-block:: rst 15 | 16 | .. code-block:: tap 17 | 18 | 1..2 19 | ok 1 - A passing test. 20 | not ok 2 - A failing test. 21 | -------------------------------------------------------------------------------- /docs/images/python-tap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-tap/tappy/c14fc7e9d9f4acce348fc23638561b2da9394be0/docs/images/python-tap.png -------------------------------------------------------------------------------- /docs/images/stream.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-tap/tappy/c14fc7e9d9f4acce348fc23638561b2da9394be0/docs/images/stream.gif -------------------------------------------------------------------------------- /docs/images/tap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-tap/tappy/c14fc7e9d9f4acce348fc23638561b2da9394be0/docs/images/tap.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | tappy - TAP tools for Python 2 | ============================ 3 | 4 | .. image:: images/tap.png 5 | 6 | tappy provides tools for working with the 7 | `Test Anything Protocol (TAP) `_ 8 | in Python. 9 | 10 | tappy generates TAP output from your ``unittest`` test cases. You 11 | can use the TAP output files with a tool like the `Jenkins TAP plugin 12 | `_ or any other TAP 13 | consumer. 14 | 15 | tappy also provides a ``tappy`` command line tool as a TAP consumer. This tool 16 | can read TAP files and display the results like a normal Python test runner. 17 | tappy provides other TAP consumers via Python APIs for programmatic access to 18 | TAP files. 19 | 20 | For the curious: tappy sounds like "happy." 21 | 22 | Installation 23 | ------------ 24 | 25 | tappy is available for download from `PyPI 26 | `_. tappy is currently supported on 27 | Python 28 | 3.7, 29 | 3.8, 30 | 3.9, 31 | 3.10, 32 | and PyPy. 33 | It is continuously tested on Linux, OS X, and Windows. 34 | 35 | .. code-block:: console 36 | 37 | $ pip install tap.py 38 | 39 | TAP version 13 brings support for YAML blocks 40 | for `YAML blocks `_ 41 | associated with test results. 42 | To work with version 13, install the optional dependencies. 43 | Learn more about YAML support in the :ref:`tap-version-13` section. 44 | 45 | .. code-block:: console 46 | 47 | $ pip install tap.py[yaml] 48 | 49 | Quickstart 50 | ---------- 51 | 52 | tappy can run like the built-in ``unittest`` discovery runner. 53 | 54 | .. code-block:: console 55 | 56 | $ python -m tap 57 | 58 | This should be enough to run a unittest-based test suite 59 | and output TAP to the console. 60 | 61 | Documentation 62 | ------------- 63 | 64 | .. toctree:: 65 | :maxdepth: 2 66 | 67 | producers 68 | consumers 69 | highlighter 70 | contributing 71 | alternatives 72 | releases 73 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\tappy.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\tappy.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/producers.rst: -------------------------------------------------------------------------------- 1 | TAP Producers 2 | ============= 3 | 4 | tappy integrates with ``unittest`` based test cases to produce TAP output. 5 | The producers come in three varieties: 6 | support with only the standard library, 7 | support for `nose `_, 8 | and support for `pytest `_. 9 | 10 | * ``TAPTestRunner`` - This subclass of ``unittest.TextTestRunner`` provides all 11 | the functionality of ``TextTestRunner`` and generates TAP files or streams. 12 | * tappy for **nose** - tappy provides a plugin (simply called ``TAP``) 13 | for the **nose** testing tool. 14 | * tappy for **pytest** - tappy provides a plugin called ``tap`` 15 | for the **pytest** testing tool. 16 | * tappy as the test runner - tappy can run like ``python -m unittest``. 17 | Run your test suite with ``python -m tap``. 18 | 19 | By default, the producers will create one TAP file for each ``TestCase`` 20 | executed by the test suite. 21 | The files will use the name of the test case class with a ``.tap`` 22 | extension. For example: 23 | 24 | .. code-block:: python 25 | 26 | class TestFoo(unittest.TestCase): 27 | 28 | def test_identity(self): 29 | """Test numeric equality as an example.""" 30 | self.assertTrue(1 == 1) 31 | 32 | The class will create a file named ``TestFoo.tap`` containing the following. 33 | 34 | .. code-block:: tap 35 | 36 | # TAP results for TestFoo 37 | ok 1 - Test numeric equality as an example. 38 | 1..1 39 | 40 | The producers also have streaming modes which bypass the default runner 41 | output and write TAP to the output stream instead of files. This is useful 42 | for piping output directly to tools that read TAP natively. 43 | 44 | .. code-block:: tap 45 | 46 | $ nosetests --with-tap --tap-stream tap.tests.test_parser 47 | # TAP results for TestParser 48 | ok 1 - test_after_hash_is_not_description (tap.tests.test_parser.TestParser) 49 | ok 2 - The parser extracts a bail out line. 50 | ok 3 - The parser extracts a diagnostic line. 51 | ok 4 - The TAP spec dictates that anything less than 13 is an error. 52 | ok 5 - test_finds_description (tap.tests.test_parser.TestParser) 53 | ok 6 - The parser extracts a not ok line. 54 | ok 7 - The parser extracts a test number. 55 | ok 8 - The parser extracts an ok line. 56 | ok 9 - The parser extracts a plan line. 57 | ok 10 - The parser extracts a plan line containing a SKIP. 58 | 1..10 59 | 60 | .. image:: images/stream.gif 61 | 62 | Examples 63 | -------- 64 | 65 | The ``TAPTestRunner`` works like the ``TextTestRunner``. To use the runner, 66 | load test cases using the ``TestLoader`` and pass the tests to the run method. 67 | The sample below is the test runner used with tappy's own tests. 68 | 69 | .. literalinclude:: ../tap/tests/run.py 70 | :lines: 3- 71 | 72 | Running tappy with **nose** is as straightforward as enabling the plugin 73 | when calling ``nosetests``. 74 | 75 | .. code-block:: console 76 | 77 | $ nosetests --with-tap 78 | ............... 79 | ---------------------------------------------------------------------- 80 | Ran 15 tests in 0.020s 81 | 82 | OK 83 | 84 | The **pytest** plugin is automatically activated for **pytest** 85 | when tappy is installed. 86 | Because it is automatically activated, 87 | **pytest** users should specify an output style. 88 | 89 | .. code-block:: console 90 | 91 | $ py.test --tap-files 92 | =========================== test session starts ============================ 93 | platform linux2 -- Python 2.7.6 -- py-1.4.30 -- pytest-2.7.2 94 | rootdir: /home/matt/tappy, inifile: 95 | plugins: tap.py 96 | collected 94 items 97 | 98 | tests/test_adapter.py ..... 99 | tests/test_directive.py ...... 100 | tests/test_line.py ...... 101 | tests/test_loader.py ...... 102 | tests/test_main.py . 103 | tests/test_nose_plugin.py ...... 104 | tests/test_parser.py ................ 105 | tests/test_pytest_plugin.py ......... 106 | tests/test_result.py ....... 107 | tests/test_rules.py ........ 108 | tests/test_runner.py ....... 109 | tests/test_tracker.py ................. 110 | 111 | ======================== 94 passed in 0.24 seconds ========================= 112 | 113 | The configuration options for each TAP tool are listed 114 | in the following sections. 115 | 116 | TAPTestRunner 117 | ------------- 118 | 119 | You can configure the ``TAPTestRunner`` from a set of class or instance 120 | methods. 121 | 122 | * ``set_stream`` - Enable streaming mode to send TAP output directly to 123 | the output stream. Use the ``set_stream`` instance method. 124 | 125 | .. code-block:: python 126 | 127 | runner = TAPTestRunner() 128 | runner.set_stream(True) 129 | 130 | * ``set_outdir`` - The ``TAPTestRunner`` gives the user the ability to 131 | set the output directory. Use the ``set_outdir`` class method. 132 | 133 | .. code-block:: python 134 | 135 | TAPTestRunner.set_outdir('/my/output/directory') 136 | 137 | * ``set_combined`` - TAP results can be directed into a single output file. 138 | Use the ``set_combined`` class method to store the results in 139 | ``testresults.tap``. 140 | 141 | .. code-block:: python 142 | 143 | TAPTestRunner.set_combined(True) 144 | 145 | * ``set_format`` - Use the ``set_format`` class method to change the 146 | format of result lines. ``{method_name}`` and ``{short_description}`` 147 | are available options. 148 | 149 | .. code-block:: python 150 | 151 | TAPTestRunner.set_format('{method_name}: {short_description}') 152 | 153 | * ``set_header`` - Turn off or on the test case header output. 154 | The default is ``True`` (ie, the header is displayed.) 155 | Use the ``set_header`` instance method. 156 | 157 | .. code-block:: python 158 | 159 | runner = TAPTestRunner() 160 | runner.set_header(False) 161 | 162 | nose TAP Plugin 163 | --------------- 164 | 165 | .. note:: 166 | 167 | To use this plugin, install it with ``pip install nose-tap``. 168 | 169 | The **nose** TAP plugin is configured from command line flags. 170 | 171 | * ``--with-tap`` - This flag is required to enable the plugin. 172 | 173 | * ``--tap-stream`` - Enable streaming mode to send TAP output directly to 174 | the output stream. 175 | 176 | * ``--tap-combined`` - Store test results in a single output file 177 | in ``testresults.tap``. 178 | 179 | * ``--tap-outdir`` - The **nose** TAP plugin also supports an optional 180 | output directory when you don't want to store the ``.tap`` files 181 | wherever you executed ``nosetests``. 182 | 183 | Use ``--tap-outdir`` followed by a directory path to store the files 184 | in a different place. The directory will be created if it does not exist. 185 | 186 | * ``--tap-format`` - Provide a different format for the result lines. 187 | ``{method_name}`` and ``{short_description}`` are available options. 188 | For example, ``'{method_name}: {short_description}'``. 189 | 190 | pytest TAP Plugin 191 | ----------------- 192 | 193 | .. note:: 194 | 195 | To use this plugin, install it with ``pip install pytest-tap``. 196 | 197 | The **pytest** TAP plugin is configured from command line flags. 198 | Since **pytest** automatically activates the TAP plugin, 199 | the plugin does nothing by default. 200 | Users must enable a TAP output mode 201 | (via ``--tap-stream|files|combined``) 202 | or the plugin will take no action. 203 | 204 | * ``--tap-stream`` - Enable streaming mode to send TAP output directly to 205 | the output stream. 206 | 207 | * ``--tap-files`` - Store test results in individual test files. 208 | One test file is created for each test case. 209 | 210 | * ``--tap-combined`` - Store test results in a single output file 211 | in ``testresults.tap``. 212 | 213 | * ``--tap-outdir`` - The **pytest** TAP plugin also supports an optional 214 | output directory when you don't want to store the ``.tap`` files 215 | wherever you executed ``py.test``. 216 | 217 | Use ``--tap-outdir`` followed by a directory path to store the files 218 | in a different place. The directory will be created if it does not exist. 219 | 220 | Python and TAP 221 | -------------- 222 | 223 | The TAP specification is open-ended 224 | on certain topics. 225 | This section clarifies how tappy interprets these topics. 226 | 227 | The specification indicates that a test line represents a "test point" 228 | without explicitly defining "test point." 229 | tappy assumes that each test line is **per test method**. 230 | TAP producers in other languages may output test lines **per assertion**, 231 | but the unit of work in the Python ecosystem is the test method 232 | (i.e. ``unittest``, nose, and pytest all report per method by default). 233 | 234 | tappy does not permit setting the plan. 235 | Instead, the plan is a count of the number of test methods executed. 236 | Python test runners execute all test methods in a suite, 237 | regardless of any errors encountered. 238 | Thus, the test method count should be an accurate measure for the plan. 239 | -------------------------------------------------------------------------------- /docs/releases.rst: -------------------------------------------------------------------------------- 1 | tappy is a set of tools for working with the `Test Anything Protocol (TAP) 2 | `_, a line based test protocol for recording test 3 | data in a standard way. 4 | 5 | Follow tappy development on `GitHub `_. 6 | Developer documentation is on 7 | `Read the Docs `_. 8 | 9 | Releases 10 | ======== 11 | 12 | Version 3.2, Released January 25, 2025 13 | -------------------------------------- 14 | 15 | * Drop support for Python 3.6 (it is end-of-life). 16 | * Drop support for Python 3.7 (it is end-of-life). 17 | * Drop support for Python 3.8 (it is end-of-life). 18 | * Add support for Python 3.11. 19 | * Add support for Python 3.12. 20 | * Add support for Python 3.13. 21 | * Add support for adding diagnostics to ok test. 22 | 23 | Version 3.1, Released December 29, 2021 24 | --------------------------------------- 25 | 26 | * Add support for Python 3.10. 27 | * Add support for Python 3.9. 28 | * Add support for Python 3.8. 29 | * Drop support for Python 3.5 (it is end-of-life). 30 | * Fix parsing of multi-line strings in YAML blocks (#111) 31 | * Remove unmaintained i18n support. 32 | 33 | Version 3.0, Released January 10, 2020 34 | -------------------------------------- 35 | 36 | * Drop support for Python 2 (it is end-of-life). 37 | * Add support for subtests. 38 | * Run a test suite with ``python -m tap``. 39 | * Discontinue use of Pipenv for managing development. 40 | 41 | Version 2.6.2, Released October 20, 2019 42 | ---------------------------------------- 43 | 44 | * Fix bug in streaming mode that would generate tap files 45 | when the plan was already set (affected pytest). 46 | 47 | Version 2.6.1, Released September 17, 2019 48 | ------------------------------------------ 49 | 50 | * Fix TAP version 13 support from more-itertools behavior change. 51 | 52 | Version 2.6, Released September 16, 2019 53 | ---------------------------------------- 54 | 55 | * Add support for Python 3.7. 56 | * Drop support for Python 3.4 (it is end-of-life). 57 | 58 | Version 2.5, Released September 15, 2018 59 | ---------------------------------------- 60 | 61 | * Add ``set_plan`` to ``Tracker`` which allows producing the ``1..N`` plan line 62 | before any tests. 63 | * Switch code style to use Black formatting. 64 | 65 | 66 | Version 2.4, Released May 29, 2018 67 | ---------------------------------- 68 | 69 | * Add support for producing TAP version 13 output 70 | to streaming and file reports 71 | by including the ``TAP version 13`` line. 72 | 73 | Version 2.3, Released May 15, 2018 74 | ---------------------------------- 75 | 76 | * Add optional method to install tappy for YAML support 77 | with ``pip install tap.py[yaml]``. 78 | * Make tappy version 13 compliant by adding support for parsing YAML blocks. 79 | * ``unittest.expectedFailure`` now uses a TODO directive to better align 80 | with the specification. 81 | 82 | Version 2.2, Released January 7, 2018 83 | ------------------------------------- 84 | 85 | * Add support for Python 3.6. 86 | * Drop support for Python 3.3 (it is end-of-life). 87 | * Use Pipenv for managing development. 88 | * Switch to pytest as the development test runner. 89 | 90 | Version 2.1, Released September 23, 2016 91 | ---------------------------------------- 92 | 93 | * Add ``Parser.parse_text`` to parse TAP 94 | provided as a string. 95 | 96 | Version 2.0, Released July 31, 2016 97 | ----------------------------------- 98 | 99 | * Remove nose plugin. 100 | The plugin moved to the ``nose-tap`` distribution. 101 | * Remove pytest plugin. 102 | The plugin moved to the ``pytest-tap`` distribution. 103 | * Remove Pygments syntax highlighting plugin. 104 | The plugin was merged upstream directly into the Pygments project 105 | and is available without tappy. 106 | * Drop support for Python 2.6. 107 | 108 | Version 1.9, Released March 28, 2016 109 | ------------------------------------ 110 | 111 | * ``TAPTestRunner`` has a ``set_header`` method 112 | to enable or disable test case header ouput in the TAP stream. 113 | * Add support for Python 3.5. 114 | * Perform continuous integration testing on OS X. 115 | * Drop support for Python 3.2. 116 | 117 | Version 1.8, Released November 30, 2015 118 | --------------------------------------- 119 | 120 | * The ``tappy`` TAP consumer can read a TAP stream 121 | directly from STDIN. 122 | * Tracebacks are included as diagnostic output 123 | for failures and errors. 124 | * The ``tappy`` TAP consumer has an alternative, shorter name 125 | of ``tap``. 126 | * The pytest plugin now defaults to no output 127 | unless provided a flag. 128 | Users dependent on the old default behavior 129 | can use ``--tap-files`` to achieve the same results. 130 | * Translated into Arabic. 131 | * Translated into Chinese. 132 | * Translated into Japanese. 133 | * Translated into Russian. 134 | * Perform continuous integration testing on Windows with AppVeyor. 135 | * Improve unit test coverage to 100%. 136 | 137 | Version 1.7, Released August 19, 2015 138 | ------------------------------------- 139 | 140 | * Provide a plugin to integrate with pytest. 141 | * Document some viable alternatives to tappy. 142 | * Translated into German. 143 | * Translated into Portuguese. 144 | 145 | Version 1.6, Released June 18, 2015 146 | ----------------------------------- 147 | 148 | * ``TAPTestRunner`` has a ``set_stream`` method to stream all TAP 149 | output directly to an output stream instead of a file. 150 | results in a single output file. 151 | * The ``nosetests`` plugin has an optional ``--tap-stream`` flag to 152 | stream all TAP output directly to an output stream instead of a file. 153 | * tappy is now internationalized. It is translated into Dutch, French, 154 | Italian, and Spanish. 155 | * tappy is available as a Python wheel package, the new Python packaging 156 | standard. 157 | 158 | Version 1.5, Released May 18, 2015 159 | ---------------------------------- 160 | 161 | * ``TAPTestRunner`` has a ``set_combined`` method to collect all 162 | results in a single output file. 163 | * The ``nosetests`` plugin has an optional ``--tap-combined`` flag to 164 | collect all results in a single output file. 165 | * ``TAPTestRunner`` has a ``set_format`` method to specify line format. 166 | * The ``nosetests`` plugin has an optional ``--tap-format`` flag to specify 167 | line format. 168 | 169 | Version 1.4, Released April 4, 2015 170 | ----------------------------------- 171 | 172 | * Update ``setup.py`` to support Debian packaging. Include man page. 173 | 174 | Version 1.3, Released January 9, 2015 175 | ------------------------------------- 176 | 177 | * The ``tappy`` command line tool is available as a TAP consumer. 178 | * The ``Parser`` and ``Loader`` are available as APIs for programmatic 179 | handling of TAP files and data. 180 | 181 | Version 1.2, Released December 21, 2014 182 | --------------------------------------- 183 | 184 | * Provide a syntax highlighter for Pygments so any project using Pygments 185 | (e.g., Sphinx) can highlight TAP output. 186 | 187 | Version 1.1, Released October 23, 2014 188 | -------------------------------------- 189 | 190 | * ``TAPTestRunner`` has a ``set_outdir`` method to specify where to store 191 | ``.tap`` files. 192 | * The ``nosetests`` plugin has an optional ``--tap-outdir`` flag to specify 193 | where to store ``.tap`` files. 194 | * tappy has backported support for Python 2.6. 195 | * tappy has support for Python 3.2, 3.3, and 3.4. 196 | * tappy has support for PyPy. 197 | 198 | Version 1.0, Released March 16, 2014 199 | ------------------------------------ 200 | 201 | * Initial release of tappy 202 | * ``TAPTestRunner`` - A test runner for ``unittest`` modules that generates 203 | TAP files. 204 | * Provides a plugin for integrating with **nose**. 205 | -------------------------------------------------------------------------------- /docs/sample_tap.txt: -------------------------------------------------------------------------------- 1 | TAP version 13 2 | 1..7 3 | 4 | # This is a full sample TAP file. 5 | It should try all the functionality of TAP. 6 | 7 | ok 1 A passing test 8 | not ok A failing test 9 | 10 | ok 3 An unexpected success # TODO That was unexpected. 11 | not ok 4 An expected failure # TODO Because it is not done yet. 12 | 13 | ok 5 A skipped test # SKIP Because. Just because. 14 | not ok 6 A skipped test # SKIP Failing or not does not matter. 15 | 16 | Bail out! Something blew up. 17 | 18 | ok 7 This should not have happened because the test supposedly bailed out. 19 | -------------------------------------------------------------------------------- /docs/tappy.1.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | tappy manual page 4 | ================= 5 | 6 | 7 | Synopsis 8 | -------- 9 | 10 | **tappy** [*options*] <*pathname*> [<*pathname*> ...] 11 | 12 | 13 | Description 14 | ----------- 15 | 16 | The :program:`tappy` command consumes the list of tap files 17 | given as *pathname* s and produces an output similar to what 18 | the regular text test-runner from python's :py:mod:`unittest` 19 | module would. If *pathname* points to a directory, 20 | :program:`tappy` will look in that directory for ``*.tap`` 21 | files to consume. 22 | 23 | If you have a tool that consumes the `unittest` regular output, 24 | but wish to use the TAP protocol to better integrate with other 25 | tools, you may use tappy to *replay* tests from .tap files, 26 | without having to actually run the tests again (which is much 27 | faster). 28 | 29 | It is also an example of how to use the tap consumer API 30 | provided by the :py:mod:`tap` module. 31 | 32 | .. warning:: 33 | 34 | :program:`tappy`'s output will differ from the standard 35 | :py:mod:`unittest` output. Indeed it cannot reproduce error 36 | and failure messages (e.g. stack traces, ...) that are not 37 | recorded in tap files. 38 | 39 | 40 | Options 41 | ------- 42 | 43 | -h, --help show a short description and option list 44 | and exit. 45 | -v, --verbose produce verbose output 46 | 47 | 48 | Author 49 | ------ 50 | 51 | The :program:`tappy` and the :py:mod:`tap` modules were written 52 | by Matt LAYMAN (https://github.com/python-tap/tappy). 53 | 54 | This manual page was written Nicolas CANIART, for the Debian project. 55 | 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tap.py" 3 | version = "3.2.1" 4 | description = "Test Anything Protocol (TAP) tools" 5 | readme = "docs/releases.rst" 6 | license = {text = "BSD"} 7 | authors = [ 8 | { name = "Matt Layman", email = "matthewlayman@gmail.com" } 9 | ] 10 | homepage = "https://github.com/python-tap/tappy" 11 | requires-python = ">=3.9" 12 | dependencies = [] 13 | keywords = ["TAP", "unittest"] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Environment :: Console", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: BSD License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Topic :: Software Development :: Testing" 26 | ] 27 | 28 | [project.optional-dependencies] 29 | yaml = ["more-itertools", "PyYAML>=5.1"] 30 | 31 | [project.scripts] 32 | tappy = "tap.main:main" 33 | tap = "tap.main:main" 34 | 35 | [dependency-groups] 36 | dev = [ 37 | "coverage>=7.6.10", 38 | "pytest>=8.3.4", 39 | "ruff>=0.9.3", 40 | "sphinx>=7.4.7", 41 | "tox>=4.24.1", 42 | # These are the optional dependencies to enable TAP version 13 support. 43 | "more-itertools>=10.6.0", 44 | "pyyaml>=6.0.2", 45 | ] 46 | 47 | [build-system] 48 | requires = ["hatchling"] 49 | build-backend = "hatchling.build" 50 | 51 | [tool.hatch.build.targets.sdist] 52 | include = [ 53 | "/src", 54 | "/docs", 55 | "/tests", 56 | ] 57 | sources = ["src"] 58 | 59 | [tool.hatch.build.targets.wheel] 60 | sources = ["src"] 61 | packages = ["tap"] 62 | 63 | [tool.pytest.ini_options] 64 | pythonpath = [".", "src"] 65 | 66 | [tool.ruff.lint] 67 | select = [ 68 | # pycodestyle 69 | "E", 70 | "W", 71 | # Pyflakes 72 | "F", 73 | # pyupgrade 74 | "UP", 75 | # flake8-bandit 76 | "S", 77 | # flake8-bugbear 78 | "B", 79 | # flake8-simplify 80 | "SIM", 81 | # isort 82 | "I", 83 | ] 84 | ignore = [ 85 | # bandit: Use of `assert` detected 86 | "S101", 87 | ] 88 | -------------------------------------------------------------------------------- /src/tap/__init__.py: -------------------------------------------------------------------------------- 1 | from .runner import TAPTestRunner 2 | 3 | __all__ = ["TAPTestRunner"] 4 | __version__ = "3.2.1" 5 | -------------------------------------------------------------------------------- /src/tap/__main__.py: -------------------------------------------------------------------------------- 1 | from tap.main import main_module 2 | 3 | main_module() 4 | -------------------------------------------------------------------------------- /src/tap/adapter.py: -------------------------------------------------------------------------------- 1 | class Adapter: 2 | """The adapter processes a TAP test line and updates a unittest result. 3 | 4 | It is an alternative to TestCase to collect TAP results. 5 | """ 6 | 7 | failureException = AssertionError 8 | 9 | def __init__(self, filename, line): 10 | self._filename = filename 11 | self._line = line 12 | 13 | def shortDescription(self): 14 | """Get the short description for verbeose results.""" 15 | return self._line.description 16 | 17 | def __call__(self, result): 18 | """Update test result with the lines in the TAP file. 19 | 20 | Provide the interface that TestCase provides to a suite or runner. 21 | """ 22 | result.startTest(self) 23 | 24 | if self._line.skip: 25 | result.addSkip(None, self._line.directive.reason) 26 | return 27 | 28 | if self._line.todo: 29 | if self._line.ok: 30 | result.addUnexpectedSuccess(self) 31 | else: 32 | result.addExpectedFailure(self, (Exception, Exception(), None)) 33 | return 34 | 35 | if self._line.ok: 36 | result.addSuccess(self) 37 | else: 38 | self.addFailure(result) 39 | 40 | def addFailure(self, result): 41 | """Add a failure to the result.""" 42 | result.addFailure(self, (Exception, Exception(), None)) 43 | # Since TAP will not provide assertion data, clean up the assertion 44 | # section so it is not so spaced out. 45 | test, err = result.failures[-1] 46 | result.failures[-1] = (test, "") 47 | 48 | def __repr__(self): 49 | return f"" 50 | -------------------------------------------------------------------------------- /src/tap/directive.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class Directive: 5 | """A representation of a result line directive.""" 6 | 7 | skip_pattern = re.compile( 8 | r"""^SKIP\S* 9 | (?P\s*) # Optional whitespace. 10 | (?P.*) # Slurp up the rest.""", 11 | re.IGNORECASE | re.VERBOSE, 12 | ) 13 | todo_pattern = re.compile( 14 | r"""^TODO\b # The directive name 15 | (?P\s*) # Immediately following must be whitespace. 16 | (?P.*) # Slurp up the rest.""", 17 | re.IGNORECASE | re.VERBOSE, 18 | ) 19 | 20 | def __init__(self, text): 21 | r"""Initialize the directive by parsing the text. 22 | 23 | The text is assumed to be everything after a '#\s*' on a result line. 24 | """ 25 | self._text = text 26 | self._skip = False 27 | self._todo = False 28 | self._reason = None 29 | 30 | match = self.skip_pattern.match(text) 31 | if match: 32 | self._skip = True 33 | self._reason = match.group("reason") 34 | 35 | match = self.todo_pattern.match(text) 36 | if match: 37 | if match.group("whitespace"): 38 | self._todo = True 39 | else: 40 | # Catch the case where the directive has no descriptive text. 41 | if match.group("reason") == "": 42 | self._todo = True 43 | self._reason = match.group("reason") 44 | 45 | @property 46 | def text(self): 47 | """Get the entire text.""" 48 | return self._text 49 | 50 | @property 51 | def skip(self): 52 | """Check if the directive is a SKIP type.""" 53 | return self._skip 54 | 55 | @property 56 | def todo(self): 57 | """Check if the directive is a TODO type.""" 58 | return self._todo 59 | 60 | @property 61 | def reason(self): 62 | """Get the reason for the directive.""" 63 | return self._reason 64 | -------------------------------------------------------------------------------- /src/tap/formatter.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | 4 | def format_exception(exception): 5 | """Format an exception as diagnostics output. 6 | 7 | exception is the tuple as expected from sys.exc_info. 8 | """ 9 | exception_lines = traceback.format_exception(*exception) 10 | # The lines returned from format_exception do not strictly contain 11 | # one line per element in the list (i.e. some elements have new 12 | # line characters in the middle). Normalize that oddity. 13 | lines = "".join(exception_lines).splitlines(True) 14 | return format_as_diagnostics(lines) 15 | 16 | 17 | def format_as_diagnostics(lines): 18 | """Format the lines as diagnostics output by prepending the diagnostic #. 19 | 20 | This function makes no assumptions about the line endings. 21 | """ 22 | return "".join(["# " + line for line in lines]) 23 | -------------------------------------------------------------------------------- /src/tap/line.py: -------------------------------------------------------------------------------- 1 | try: 2 | import yaml 3 | 4 | LOAD_YAML = True 5 | except ImportError: # pragma: no cover 6 | LOAD_YAML = False 7 | 8 | 9 | class Line: 10 | """Base type for TAP data. 11 | 12 | TAP is a line based protocol. Thus, the most primitive type is a line. 13 | """ 14 | 15 | @property 16 | def category(self): 17 | raise NotImplementedError 18 | 19 | 20 | class Result(Line): 21 | """Information about an individual test line.""" 22 | 23 | def __init__( 24 | self, 25 | ok, 26 | number=None, 27 | description="", 28 | directive=None, 29 | diagnostics=None, 30 | raw_yaml_block=None, 31 | ): 32 | self._ok = ok 33 | if number: 34 | self._number = int(number) 35 | else: 36 | # The number may be an empty string so explicitly set to None. 37 | self._number = None 38 | self._description = description 39 | self.directive = directive 40 | self.diagnostics = diagnostics 41 | self._yaml_block = raw_yaml_block 42 | 43 | @property 44 | def category(self): 45 | """:returns: ``test``""" 46 | return "test" 47 | 48 | @property 49 | def ok(self): 50 | """Get the ok status. 51 | 52 | :rtype: bool 53 | """ 54 | return self._ok 55 | 56 | @property 57 | def number(self): 58 | """Get the test number. 59 | 60 | :rtype: int 61 | """ 62 | return self._number 63 | 64 | @property 65 | def description(self): 66 | """Get the description.""" 67 | return self._description 68 | 69 | @property 70 | def skip(self): 71 | """Check if this test was skipped. 72 | 73 | :rtype: bool 74 | """ 75 | return self.directive.skip 76 | 77 | @property 78 | def todo(self): 79 | """Check if this test was a TODO. 80 | 81 | :rtype: bool 82 | """ 83 | return self.directive.todo 84 | 85 | @property 86 | def yaml_block(self): 87 | """Lazy load a yaml_block. 88 | 89 | If yaml support is not available, 90 | there is an error in parsing the yaml block, 91 | or no yaml is associated with this result, 92 | ``None`` will be returned. 93 | 94 | :rtype: dict 95 | """ 96 | if LOAD_YAML and self._yaml_block is not None: 97 | try: 98 | yaml_dict = yaml.load(self._yaml_block, Loader=yaml.SafeLoader) 99 | return yaml_dict 100 | except yaml.error.YAMLError: 101 | print("Error parsing yaml block. Check formatting.") 102 | return None 103 | 104 | def __str__(self): 105 | is_not = "" 106 | if not self.ok: 107 | is_not = "not " 108 | directive = "" 109 | if self.directive is not None and self.directive.text: 110 | directive = f" # {self.directive.text}" 111 | diagnostics = "" 112 | if self.diagnostics is not None: 113 | diagnostics = "\n" + self.diagnostics.rstrip() 114 | return f"{is_not}ok {self.number} {self.description}{directive}{diagnostics}" 115 | 116 | 117 | class Plan(Line): 118 | """A plan line to indicate how many tests to expect.""" 119 | 120 | def __init__(self, expected_tests, directive=None): 121 | self._expected_tests = expected_tests 122 | self.directive = directive 123 | 124 | @property 125 | def category(self): 126 | """:returns: ``plan``""" 127 | return "plan" 128 | 129 | @property 130 | def expected_tests(self): 131 | """Get the number of expected tests. 132 | 133 | :rtype: int 134 | """ 135 | return self._expected_tests 136 | 137 | @property 138 | def skip(self): 139 | """Check if this plan should skip the file. 140 | 141 | :rtype: bool 142 | """ 143 | return self.directive.skip 144 | 145 | 146 | class Diagnostic(Line): 147 | """A diagnostic line (i.e. anything starting with a hash).""" 148 | 149 | def __init__(self, text): 150 | self._text = text 151 | 152 | @property 153 | def category(self): 154 | """:returns: ``diagnostic``""" 155 | return "diagnostic" 156 | 157 | @property 158 | def text(self): 159 | """Get the text.""" 160 | return self._text 161 | 162 | 163 | class Bail(Line): 164 | """A bail out line (i.e. anything starting with 'Bail out!').""" 165 | 166 | def __init__(self, reason): 167 | self._reason = reason 168 | 169 | @property 170 | def category(self): 171 | """:returns: ``bail``""" 172 | return "bail" 173 | 174 | @property 175 | def reason(self): 176 | """Get the reason.""" 177 | return self._reason 178 | 179 | 180 | class Version(Line): 181 | """A version line (i.e. of the form 'TAP version 13').""" 182 | 183 | def __init__(self, version): 184 | self._version = version 185 | 186 | @property 187 | def category(self): 188 | """:returns: ``version``""" 189 | return "version" 190 | 191 | @property 192 | def version(self): 193 | """Get the version number. 194 | 195 | :rtype: int 196 | """ 197 | return self._version 198 | 199 | 200 | class Unknown(Line): 201 | """A line that represents something that is not a known TAP line. 202 | 203 | This exists for the purpose of a Null Object pattern. 204 | """ 205 | 206 | @property 207 | def category(self): 208 | """:returns: ``unknown``""" 209 | return "unknown" 210 | -------------------------------------------------------------------------------- /src/tap/loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from tap.adapter import Adapter 5 | from tap.parser import Parser 6 | from tap.rules import Rules 7 | 8 | 9 | class Loader: 10 | """Load TAP lines into unittest-able objects.""" 11 | 12 | ignored_lines = set(["diagnostic", "unknown"]) 13 | 14 | def __init__(self): 15 | self._parser = Parser() 16 | 17 | def load(self, files): 18 | """Load any files found into a suite. 19 | 20 | Any directories are walked and their files are added as TAP files. 21 | 22 | :returns: A ``unittest.TestSuite`` instance 23 | """ 24 | suite = unittest.TestSuite() 25 | for filepath in files: 26 | if os.path.isdir(filepath): 27 | self._find_tests_in_directory(filepath, suite) 28 | else: 29 | suite.addTest(self.load_suite_from_file(filepath)) 30 | return suite 31 | 32 | def load_suite_from_file(self, filename): 33 | """Load a test suite with test lines from the provided TAP file. 34 | 35 | :returns: A ``unittest.TestSuite`` instance 36 | """ 37 | suite = unittest.TestSuite() 38 | rules = Rules(filename, suite) 39 | 40 | if not os.path.exists(filename): 41 | rules.handle_file_does_not_exist() 42 | return suite 43 | 44 | line_generator = self._parser.parse_file(filename) 45 | return self._load_lines(filename, line_generator, suite, rules) 46 | 47 | def load_suite_from_stdin(self): 48 | """Load a test suite with test lines from the TAP stream on STDIN. 49 | 50 | :returns: A ``unittest.TestSuite`` instance 51 | """ 52 | suite = unittest.TestSuite() 53 | rules = Rules("stream", suite) 54 | line_generator = self._parser.parse_stdin() 55 | return self._load_lines("stream", line_generator, suite, rules) 56 | 57 | def _find_tests_in_directory(self, directory, suite): 58 | """Find test files in the directory and add them to the suite.""" 59 | for dirpath, _dirnames, filenames in os.walk(directory): 60 | for filename in filenames: 61 | filepath = os.path.join(dirpath, filename) 62 | suite.addTest(self.load_suite_from_file(filepath)) 63 | 64 | def _load_lines(self, filename, line_generator, suite, rules): 65 | """Load a suite with lines produced by the line generator.""" 66 | line_counter = 0 67 | for line in line_generator: 68 | line_counter += 1 69 | 70 | if line.category in self.ignored_lines: 71 | continue 72 | 73 | if line.category == "test": 74 | suite.addTest(Adapter(filename, line)) 75 | rules.saw_test() 76 | elif line.category == "plan": 77 | if line.skip: 78 | rules.handle_skipping_plan(line) 79 | return suite 80 | rules.saw_plan(line, line_counter) 81 | elif line.category == "bail": 82 | rules.handle_bail(line) 83 | return suite 84 | elif line.category == "version": 85 | rules.saw_version_at(line_counter) 86 | 87 | rules.check(line_counter) 88 | return suite 89 | -------------------------------------------------------------------------------- /src/tap/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | import unittest 4 | 5 | from tap.loader import Loader 6 | from tap.runner import TAPTestRunner 7 | 8 | 9 | def main(argv=sys.argv, stream=sys.stderr): 10 | """Entry point for ``tappy`` command.""" 11 | args = parse_args(argv) 12 | suite = build_suite(args) 13 | runner = unittest.TextTestRunner(verbosity=args.verbose, stream=stream) 14 | result = runner.run(suite) 15 | 16 | return get_status(result) 17 | 18 | 19 | def build_suite(args): 20 | """Build a test suite by loading TAP files or a TAP stream.""" 21 | loader = Loader() 22 | if len(args.files) == 0 or args.files[0] == "-": 23 | suite = loader.load_suite_from_stdin() 24 | else: 25 | suite = loader.load(args.files) 26 | return suite 27 | 28 | 29 | def parse_args(argv): 30 | description = "A TAP consumer for Python" 31 | epilog = ( 32 | "When no files are given or a dash (-) is used for the file name, " 33 | "tappy will read a TAP stream from STDIN." 34 | ) 35 | parser = argparse.ArgumentParser(description=description, epilog=epilog) 36 | parser.add_argument( 37 | "files", 38 | metavar="FILE", 39 | nargs="*", 40 | help=( 41 | "A file containing TAP output. Any directories listed will be " 42 | "scanned for files to include as TAP files." 43 | ), 44 | ) 45 | parser.add_argument( 46 | "-v", 47 | "--verbose", 48 | action="store_const", 49 | default=1, 50 | const=2, 51 | help="use verbose messages", 52 | ) 53 | 54 | # argparse expects the executable to be removed from argv. 55 | args = parser.parse_args(argv[1:]) 56 | 57 | # When no files are provided, the user wants to use a TAP stream on STDIN. 58 | # But they probably didn't mean it if there is no pipe connected. 59 | # In that case, print the help and exit. 60 | if not args.files and sys.stdin.isatty(): 61 | sys.exit(parser.print_help()) 62 | 63 | return args 64 | 65 | 66 | def get_status(result): 67 | """Get a return status from the result.""" 68 | if result.wasSuccessful(): 69 | return 0 70 | else: 71 | return 1 72 | 73 | 74 | def main_module(): 75 | """Entry point for running as ``python -m tap``.""" 76 | runner = TAPTestRunner() 77 | runner.set_stream(True) 78 | unittest.main(module=None, testRunner=runner) 79 | -------------------------------------------------------------------------------- /src/tap/parser.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import re 3 | import sys 4 | from io import StringIO 5 | 6 | from tap.directive import Directive 7 | from tap.line import Bail, Diagnostic, Plan, Result, Unknown, Version 8 | 9 | try: 10 | import yaml # noqa 11 | from more_itertools import peekable 12 | 13 | ENABLE_VERSION_13 = True 14 | except ImportError: # pragma: no cover 15 | ENABLE_VERSION_13 = False 16 | 17 | 18 | class Parser: 19 | """A parser for TAP files and lines.""" 20 | 21 | # ok and not ok share most of the same characteristics. 22 | result_base = r""" 23 | \s* # Optional whitespace. 24 | (?P\d*) # Optional test number. 25 | \s* # Optional whitespace. 26 | (?P[^#]*) # Optional description before #. 27 | \#? # Optional directive marker. 28 | \s* # Optional whitespace. 29 | (?P.*) # Optional directive text. 30 | """ 31 | ok = re.compile(r"^ok" + result_base, re.VERBOSE) 32 | not_ok = re.compile(r"^not\ ok" + result_base, re.VERBOSE) 33 | plan = re.compile( 34 | r""" 35 | ^1..(?P\d+) # Match the plan details. 36 | [^#]* # Consume any non-hash character to confirm only 37 | # directives appear with the plan details. 38 | \#? # Optional directive marker. 39 | \s* # Optional whitespace. 40 | (?P.*) # Optional directive text. 41 | """, 42 | re.VERBOSE, 43 | ) 44 | diagnostic = re.compile(r"^#") 45 | bail = re.compile( 46 | r""" 47 | ^Bail\ out! 48 | \s* # Optional whitespace. 49 | (?P.*) # Optional reason. 50 | """, 51 | re.VERBOSE, 52 | ) 53 | version = re.compile(r"^TAP version (?P\d+)$") 54 | 55 | yaml_block_start = re.compile(r"^(?P\s+)-") 56 | yaml_block_end = re.compile(r"^\s+\.\.\.") 57 | 58 | TAP_MINIMUM_DECLARED_VERSION = 13 59 | 60 | def parse_file(self, filename): 61 | """Parse a TAP file to an iterable of tap.line.Line objects. 62 | 63 | This is a generator method that will yield an object for each 64 | parsed line. The file given by `filename` is assumed to exist. 65 | """ 66 | return self.parse(open(filename)) 67 | 68 | def parse_stdin(self): 69 | """Parse a TAP stream from standard input. 70 | 71 | Note: this has the side effect of closing the standard input 72 | filehandle after parsing. 73 | """ 74 | return self.parse(sys.stdin) 75 | 76 | def parse_text(self, text): 77 | """Parse a string containing one or more lines of TAP output.""" 78 | return self.parse(StringIO(text)) 79 | 80 | def parse(self, fh): 81 | """Generate tap.line.Line objects, given a file-like object `fh`. 82 | 83 | `fh` may be any object that implements both the iterator and 84 | context management protocol (i.e. it can be used in both a 85 | "with" statement and a "for...in" statement.) 86 | 87 | Trailing whitespace and newline characters will be automatically 88 | stripped from the input lines. 89 | """ 90 | with fh: 91 | try: 92 | first_line = next(fh) 93 | except StopIteration: 94 | return 95 | first_parsed = self.parse_line(first_line.rstrip()) 96 | fh_new = itertools.chain([first_line], fh) 97 | if first_parsed.category == "version" and first_parsed.version >= 13: 98 | if ENABLE_VERSION_13: 99 | fh_new = peekable(itertools.chain([first_line], fh)) 100 | else: # pragma no cover 101 | print( 102 | """ 103 | WARNING: Optional imports not found, TAP 13 output will be 104 | ignored. To parse yaml, see requirements in docs: 105 | https://tappy.readthedocs.io/en/latest/consumers.html#tap-version-13""" 106 | ) 107 | 108 | for line in fh_new: 109 | yield self.parse_line(line.rstrip(), fh_new) 110 | 111 | def parse_line(self, text, fh=None): 112 | """Parse a line into whatever TAP category it belongs.""" 113 | match = self.ok.match(text) 114 | if match: 115 | return self._parse_result(True, match, fh) 116 | 117 | match = self.not_ok.match(text) 118 | if match: 119 | return self._parse_result(False, match, fh) 120 | 121 | if self.diagnostic.match(text): 122 | return Diagnostic(text) 123 | 124 | match = self.plan.match(text) 125 | if match: 126 | return self._parse_plan(match) 127 | 128 | match = self.bail.match(text) 129 | if match: 130 | return Bail(match.group("reason")) 131 | 132 | match = self.version.match(text) 133 | if match: 134 | return self._parse_version(match) 135 | 136 | return Unknown() 137 | 138 | def _parse_plan(self, match): 139 | """Parse a matching plan line.""" 140 | expected_tests = int(match.group("expected")) 141 | directive = Directive(match.group("directive")) 142 | 143 | # Only SKIP directives are allowed in the plan. 144 | if directive.text and not directive.skip: 145 | return Unknown() 146 | 147 | return Plan(expected_tests, directive) 148 | 149 | def _parse_result(self, ok, match, fh=None): 150 | """Parse a matching result line into a result instance.""" 151 | peek_match = None 152 | try: 153 | if fh is not None and ENABLE_VERSION_13 and isinstance(fh, peekable): 154 | peek_match = self.yaml_block_start.match(fh.peek()) 155 | except StopIteration: 156 | pass 157 | if peek_match is None: 158 | return Result( 159 | ok, 160 | number=match.group("number"), 161 | description=match.group("description").strip(), 162 | directive=Directive(match.group("directive")), 163 | ) 164 | indent = peek_match.group("indent") 165 | concat_yaml = self._extract_yaml_block(indent, fh) 166 | return Result( 167 | ok, 168 | number=match.group("number"), 169 | description=match.group("description").strip(), 170 | directive=Directive(match.group("directive")), 171 | raw_yaml_block=concat_yaml, 172 | ) 173 | 174 | def _extract_yaml_block(self, indent, fh): 175 | """Extract a raw yaml block from a file handler""" 176 | raw_yaml = [] 177 | indent_match = re.compile(rf"^{indent}") 178 | try: 179 | next(fh) 180 | while indent_match.match(fh.peek()): 181 | raw_yaml.append(next(fh).replace(indent, "", 1)) 182 | # check for the end and stop adding yaml if encountered 183 | if self.yaml_block_end.match(fh.peek()): 184 | next(fh) 185 | break 186 | except StopIteration: 187 | pass 188 | return "".join(raw_yaml) 189 | 190 | def _parse_version(self, match): 191 | version = int(match.group("version")) 192 | if version < self.TAP_MINIMUM_DECLARED_VERSION: 193 | raise ValueError( 194 | "It is an error to explicitly specify any version lower than 13." 195 | ) 196 | return Version(version) 197 | -------------------------------------------------------------------------------- /src/tap/rules.py: -------------------------------------------------------------------------------- 1 | from tap.adapter import Adapter 2 | from tap.directive import Directive 3 | from tap.line import Result 4 | 5 | 6 | class Rules: 7 | def __init__(self, filename, suite): 8 | self._filename = filename 9 | self._suite = suite 10 | self._lines_seen = {"plan": [], "test": 0, "version": []} 11 | 12 | def check(self, final_line_count): 13 | """Check the status of all provided data and update the suite.""" 14 | if self._lines_seen["version"]: 15 | self._process_version_lines() 16 | self._process_plan_lines(final_line_count) 17 | 18 | def _process_version_lines(self): 19 | """Process version line rules.""" 20 | if len(self._lines_seen["version"]) > 1: 21 | self._add_error("Multiple version lines appeared.") 22 | elif self._lines_seen["version"][0] != 1: 23 | self._add_error("The version must be on the first line.") 24 | 25 | def _process_plan_lines(self, final_line_count): 26 | """Process plan line rules.""" 27 | if not self._lines_seen["plan"]: 28 | self._add_error("Missing a plan.") 29 | return 30 | 31 | if len(self._lines_seen["plan"]) > 1: 32 | self._add_error("Only one plan line is permitted per file.") 33 | return 34 | 35 | plan, at_line = self._lines_seen["plan"][0] 36 | if not self._plan_on_valid_line(at_line, final_line_count): 37 | self._add_error("A plan must appear at the beginning or end of the file.") 38 | return 39 | 40 | if plan.expected_tests != self._lines_seen["test"]: 41 | self._add_error( 42 | "Expected {expected_count} tests but only {seen_count} ran.".format( 43 | expected_count=plan.expected_tests, 44 | seen_count=self._lines_seen["test"], 45 | ) 46 | ) 47 | 48 | def _plan_on_valid_line(self, at_line, final_line_count): 49 | """Check if a plan is on a valid line.""" 50 | # Put the common cases first. 51 | if at_line == 1 or at_line == final_line_count: 52 | return True 53 | 54 | # The plan may only appear on line 2 if the version is at line 1. 55 | after_version = ( 56 | self._lines_seen["version"] 57 | and self._lines_seen["version"][0] == 1 58 | and at_line == 2 59 | ) 60 | return bool(after_version) 61 | 62 | def handle_bail(self, bail): 63 | """Handle a bail line.""" 64 | self._add_error(f"Bailed: {bail.reason}") 65 | 66 | def handle_file_does_not_exist(self): 67 | """Handle a test file that does not exist.""" 68 | self._add_error(f"{self._filename} does not exist.") 69 | 70 | def handle_skipping_plan(self, skip_plan): 71 | """Handle a plan that contains a SKIP directive.""" 72 | skip_line = Result(True, None, skip_plan.directive.text, Directive("SKIP")) 73 | self._suite.addTest(Adapter(self._filename, skip_line)) 74 | 75 | def saw_plan(self, plan, at_line): 76 | """Record when a plan line was seen.""" 77 | self._lines_seen["plan"].append((plan, at_line)) 78 | 79 | def saw_test(self): 80 | """Record when a test line was seen.""" 81 | self._lines_seen["test"] += 1 82 | 83 | def saw_version_at(self, line_counter): 84 | """Record when a version line was seen.""" 85 | self._lines_seen["version"].append(line_counter) 86 | 87 | def _add_error(self, message): 88 | """Add an error test to the suite.""" 89 | error_line = Result(False, None, message, Directive("")) 90 | self._suite.addTest(Adapter(self._filename, error_line)) 91 | -------------------------------------------------------------------------------- /src/tap/runner.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from unittest import TextTestResult, TextTestRunner 4 | from unittest.runner import _WritelnDecorator 5 | 6 | from tap import formatter 7 | from tap.tracker import Tracker 8 | 9 | 10 | class TAPTestResult(TextTestResult): 11 | FORMAT = None 12 | 13 | def __init__(self, stream, descriptions, verbosity): 14 | super().__init__(stream, descriptions, verbosity) 15 | 16 | def addSubTest(self, test, subtest, err): 17 | super().addSubTest(test, subtest, err) 18 | if err is not None: 19 | diagnostics = formatter.format_exception(err) 20 | self.tracker.add_not_ok( 21 | self._cls_name(test), 22 | self._description(subtest), 23 | diagnostics=diagnostics, 24 | ) 25 | else: 26 | self.tracker.add_ok(self._cls_name(test), self._description(subtest)) 27 | 28 | def stopTestRun(self): # pragma: no cover 29 | """Once the test run is complete, generate each of the TAP files.""" 30 | super().stopTestRun() 31 | self.tracker.generate_tap_reports() 32 | 33 | def addError(self, test, err): 34 | super().addError(test, err) 35 | diagnostics = formatter.format_exception(err) 36 | self.tracker.add_not_ok( 37 | self._cls_name(test), self._description(test), diagnostics=diagnostics 38 | ) 39 | 40 | def addFailure(self, test, err): 41 | super().addFailure(test, err) 42 | diagnostics = formatter.format_exception(err) 43 | self.tracker.add_not_ok( 44 | self._cls_name(test), self._description(test), diagnostics=diagnostics 45 | ) 46 | 47 | def addSuccess(self, test): 48 | super().addSuccess(test) 49 | self.tracker.add_ok(self._cls_name(test), self._description(test)) 50 | 51 | def addSkip(self, test, reason): 52 | super().addSkip(test, reason) 53 | self.tracker.add_skip(self._cls_name(test), self._description(test), reason) 54 | 55 | def addExpectedFailure(self, test, err): 56 | super().addExpectedFailure(test, err) 57 | diagnostics = formatter.format_exception(err) 58 | self.tracker.add_not_ok( 59 | self._cls_name(test), 60 | self._description(test), 61 | "TODO {}".format("(expected failure)"), 62 | diagnostics=diagnostics, 63 | ) 64 | 65 | def addUnexpectedSuccess(self, test): 66 | super().addUnexpectedSuccess(test) 67 | self.tracker.add_ok( 68 | self._cls_name(test), 69 | self._description(test), 70 | "TODO {}".format("(unexpected success)"), 71 | ) 72 | 73 | def _cls_name(self, test): 74 | return test.__class__.__name__ 75 | 76 | def _description(self, test): 77 | if self.FORMAT: 78 | try: 79 | return self.FORMAT.format( 80 | method_name=str(test), 81 | short_description=test.shortDescription() or "", 82 | ) 83 | except KeyError: 84 | sys.exit( 85 | f"Bad format string: {self.FORMAT}\n" 86 | "Replacement options are: {short_description} and " 87 | "{method_name}" 88 | ) 89 | 90 | return test.shortDescription() or str(test) 91 | 92 | 93 | # TODO: 2016-7-30 mblayman - Since the 2.6 signature is no longer relevant, 94 | # check the possibility of removing the module level scope. 95 | 96 | # Module level state stinks, but this is the only way to keep compatibility 97 | # with Python 2.6. The best place for the tracker is as an instance variable 98 | # on the runner, but __init__ is so different that it is not easy to create 99 | # a runner that satisfies every supported Python version. 100 | _tracker = Tracker() 101 | 102 | 103 | class TAPTestRunner(TextTestRunner): 104 | """A test runner that will behave exactly like TextTestRunner and will 105 | additionally generate TAP files for each test case""" 106 | 107 | resultclass = TAPTestResult 108 | 109 | def set_stream(self, streaming): 110 | """Set the streaming boolean option to stream TAP directly to stdout. 111 | 112 | The test runner default output will be suppressed in favor of TAP. 113 | """ 114 | self.stream = _WritelnDecorator(open(os.devnull, "w")) # noqa: SIM115 115 | _tracker.streaming = streaming 116 | _tracker.stream = sys.stdout 117 | 118 | def _makeResult(self): # pragma: no cover 119 | result = self.resultclass(self.stream, self.descriptions, self.verbosity) 120 | result.tracker = _tracker 121 | return result 122 | 123 | @classmethod 124 | def set_outdir(cls, outdir): 125 | """Set the output directory so that TAP files are written to the 126 | specified outdir location. 127 | """ 128 | # Blame the lack of unittest extensibility for this hacky method. 129 | _tracker.outdir = outdir 130 | 131 | @classmethod 132 | def set_combined(cls, combined): 133 | """Set the tracker to use a single output file.""" 134 | _tracker.combined = combined 135 | 136 | @classmethod 137 | def set_header(cls, header): 138 | """Set the header display flag.""" 139 | _tracker.header = header 140 | 141 | @classmethod 142 | def set_format(cls, fmt): 143 | """Set the format of each test line. 144 | 145 | The format string can use: 146 | * {method_name}: The test method name 147 | * {short_description}: The test's docstring short description 148 | """ 149 | TAPTestResult.FORMAT = fmt 150 | -------------------------------------------------------------------------------- /src/tap/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for tappy""" 2 | 3 | from tap.tests.testcase import TestCase # NOQA 4 | -------------------------------------------------------------------------------- /src/tap/tests/factory.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import tempfile 3 | from unittest.runner import TextTestResult 4 | 5 | from tap.directive import Directive 6 | from tap.line import Bail, Plan, Result 7 | 8 | 9 | class Factory: 10 | """A factory to produce commonly needed objects""" 11 | 12 | def make_ok(self, directive_text=""): 13 | return Result(True, 1, "This is a description.", Directive(directive_text)) 14 | 15 | def make_not_ok(self, directive_text=""): 16 | return Result(False, 1, "This is a description.", Directive(directive_text)) 17 | 18 | def make_bail(self, reason="Because it is busted."): 19 | return Bail(reason) 20 | 21 | def make_plan(self, expected_tests=99, directive_text=""): 22 | return Plan(expected_tests, Directive(directive_text)) 23 | 24 | def make_test_result(self): 25 | stream = tempfile.TemporaryFile(mode="w") # noqa: SIM115 26 | return TextTestResult(stream, None, 1) 27 | 28 | def make_exc(self): 29 | """Make a traceback tuple. 30 | 31 | Doing this intentionally is not straight forward. 32 | """ 33 | try: 34 | raise ValueError("boom") 35 | except ValueError: 36 | return sys.exc_info() 37 | -------------------------------------------------------------------------------- /src/tap/tests/test_example.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class TestCase(unittest.TestCase): 5 | def test_it(self): 6 | """This example exists for the test suite to have an in-package test.""" 7 | self.assertTrue(True) 8 | -------------------------------------------------------------------------------- /src/tap/tests/testcase.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tap.tests.factory import Factory 4 | 5 | 6 | class TestCase(unittest.TestCase): 7 | def __init__(self, methodName="runTest"): 8 | super().__init__(methodName) 9 | self.factory = Factory() 10 | -------------------------------------------------------------------------------- /src/tap/tracker.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from tap.directive import Directive 4 | from tap.line import Result 5 | 6 | try: 7 | import more_itertools # noqa 8 | import yaml # noqa 9 | 10 | ENABLE_VERSION_13 = True 11 | except ImportError: # pragma: no cover 12 | ENABLE_VERSION_13 = False 13 | 14 | 15 | class Tracker: 16 | def __init__( 17 | self, 18 | outdir=None, 19 | combined=False, 20 | streaming=False, 21 | stream=None, 22 | header=True, 23 | plan=None, 24 | ): 25 | self.outdir = outdir 26 | 27 | # Combine all the test results into one file. 28 | self.combined = combined 29 | self.combined_line_number = 0 30 | # Test case ordering is important for the combined results 31 | # because of how numbers are assigned. The test cases 32 | # must be tracked in order so that reporting can sequence 33 | # the line numbers properly. 34 | self.combined_test_cases_seen = [] 35 | 36 | # Stream output directly to a stream instead of file output. 37 | self.streaming = streaming 38 | self.stream = stream 39 | # The total number of tests we expect (or None if we don't know yet). 40 | self.plan = plan 41 | self._plan_written = False 42 | 43 | # Display the test case header unless told not to. 44 | self.header = header 45 | 46 | # Internal state for tracking each test case. 47 | self._test_cases = {} 48 | 49 | self._sanitized_table = str.maketrans(" \\/\n", "----") 50 | 51 | if self.streaming: 52 | self._write_tap_version(self.stream) 53 | if self.plan is not None: 54 | self._write_plan(self.stream) 55 | 56 | def _get_outdir(self): 57 | return self._outdir 58 | 59 | def _set_outdir(self, outdir): 60 | self._outdir = outdir 61 | if outdir and not os.path.exists(outdir): 62 | os.makedirs(outdir) 63 | 64 | outdir = property(_get_outdir, _set_outdir) 65 | 66 | def _track(self, class_name): 67 | """Keep track of which test cases have executed.""" 68 | if self._test_cases.get(class_name) is None: 69 | if self.streaming and self.header: 70 | self._write_test_case_header(class_name, self.stream) 71 | 72 | self._test_cases[class_name] = [] 73 | if self.combined: 74 | self.combined_test_cases_seen.append(class_name) 75 | 76 | def add_ok(self, class_name, description, directive="", diagnostics=None): 77 | result = Result( 78 | ok=True, 79 | number=self._get_next_line_number(class_name), 80 | description=description, 81 | diagnostics=diagnostics, 82 | directive=Directive(directive), 83 | ) 84 | self._add_line(class_name, result) 85 | 86 | def add_not_ok(self, class_name, description, directive="", diagnostics=None): 87 | result = Result( 88 | ok=False, 89 | number=self._get_next_line_number(class_name), 90 | description=description, 91 | diagnostics=diagnostics, 92 | directive=Directive(directive), 93 | ) 94 | self._add_line(class_name, result) 95 | 96 | def add_skip(self, class_name, description, reason): 97 | directive = f"SKIP {reason}" 98 | result = Result( 99 | ok=True, 100 | number=self._get_next_line_number(class_name), 101 | description=description, 102 | directive=Directive(directive), 103 | ) 104 | self._add_line(class_name, result) 105 | 106 | def _add_line(self, class_name, result): 107 | self._track(class_name) 108 | if self.streaming: 109 | print(result, file=self.stream) 110 | self._test_cases[class_name].append(result) 111 | 112 | def _get_next_line_number(self, class_name): 113 | if self.combined or self.streaming: 114 | # This has an obvious side effect. Oh well. 115 | self.combined_line_number += 1 116 | return self.combined_line_number 117 | else: 118 | try: 119 | return len(self._test_cases[class_name]) + 1 120 | except KeyError: 121 | # A result is created before the call to _track so the test 122 | # case may not be tracked yet. In that case, the line is 1. 123 | return 1 124 | 125 | def set_plan(self, total): 126 | """Notify the tracker how many total tests there will be.""" 127 | self.plan = total 128 | if self.streaming: 129 | # This will only write the plan if we haven't written it 130 | # already but we want to check if we already wrote a 131 | # test out (in which case we can't just write the plan out 132 | # right here). 133 | if not self.combined_test_cases_seen: 134 | self._write_plan(self.stream) 135 | elif not self.combined: 136 | raise ValueError( 137 | "set_plan can only be used with combined or streaming output" 138 | ) 139 | 140 | def generate_tap_reports(self): 141 | """Generate TAP reports. 142 | 143 | The results are either combined into a single output file or 144 | the output file name is generated from the test case. 145 | """ 146 | if self.streaming: 147 | # We're streaming but set_plan wasn't called, so we can only 148 | # know the plan now (at the end). 149 | if not self._plan_written: 150 | print(f"1..{self.combined_line_number}", file=self.stream) 151 | self._plan_written = True 152 | return 153 | 154 | if self.combined: 155 | combined_file = "testresults.tap" 156 | if self.outdir: 157 | combined_file = os.path.join(self.outdir, combined_file) 158 | with open(combined_file, "w") as out_file: 159 | self._write_tap_version(out_file) 160 | if self.plan is not None: 161 | print(f"1..{self.plan}", file=out_file) 162 | for test_case in self.combined_test_cases_seen: 163 | self.generate_tap_report( 164 | test_case, self._test_cases[test_case], out_file 165 | ) 166 | if self.plan is None: 167 | print(f"1..{self.combined_line_number}", file=out_file) 168 | else: 169 | for test_case, tap_lines in self._test_cases.items(): 170 | with open(self._get_tap_file_path(test_case), "w") as out_file: 171 | self._write_tap_version(out_file) 172 | self.generate_tap_report(test_case, tap_lines, out_file) 173 | 174 | def generate_tap_report(self, test_case, tap_lines, out_file): 175 | self._write_test_case_header(test_case, out_file) 176 | 177 | for tap_line in tap_lines: 178 | print(tap_line, file=out_file) 179 | 180 | # For combined results, the plan is only output once after 181 | # all the test cases complete. 182 | if not self.combined: 183 | print(f"1..{len(tap_lines)}", file=out_file) 184 | 185 | def _write_tap_version(self, filename): 186 | """Write a Version 13 TAP row. 187 | 188 | ``filename`` can be a filename or a stream. 189 | """ 190 | if ENABLE_VERSION_13: 191 | print("TAP version 13", file=filename) 192 | 193 | def _write_plan(self, stream): 194 | """Write the plan line to the stream. 195 | 196 | If we have a plan and have not yet written it out, write it to 197 | the given stream. 198 | """ 199 | if self.plan is not None: 200 | if not self._plan_written: 201 | print(f"1..{self.plan}", file=stream) 202 | self._plan_written = True 203 | 204 | def _write_test_case_header(self, test_case, stream): 205 | print(f"# TAP results for {test_case}", file=stream) 206 | 207 | def _get_tap_file_path(self, test_case): 208 | """Get the TAP output file path for the test case.""" 209 | sanitized_test_case = test_case.translate(self._sanitized_table) 210 | tap_file = sanitized_test_case + ".tap" 211 | if self.outdir: 212 | return os.path.join(self.outdir, tap_file) 213 | return tap_file 214 | -------------------------------------------------------------------------------- /tests/run.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | 5 | from tap import TAPTestRunner 6 | 7 | if __name__ == "__main__": 8 | tests_dir = os.path.dirname(os.path.abspath(__file__)) 9 | loader = unittest.TestLoader() 10 | tests = loader.discover(tests_dir) 11 | runner = TAPTestRunner() 12 | runner.set_outdir("testout") 13 | runner.set_format("Hi: {method_name} - {short_description}") 14 | result = runner.run(tests) 15 | status = 0 if result.wasSuccessful() else 1 16 | sys.exit(status) 17 | -------------------------------------------------------------------------------- /tests/test_adapter.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from tap.adapter import Adapter 4 | from tap.tests import TestCase 5 | 6 | 7 | class TestAdapter(TestCase): 8 | """Tests for tap.adapter.Adapter""" 9 | 10 | def test_adapter_has_filename(self): 11 | """The adapter has a TAP filename.""" 12 | tap_filename = "fake.tap" 13 | adapter = Adapter(tap_filename, None) 14 | 15 | self.assertEqual(tap_filename, adapter._filename) 16 | 17 | def test_handles_ok_test_line(self): 18 | """Add a success for an ok test line.""" 19 | ok_line = self.factory.make_ok() 20 | adapter = Adapter("fake.tap", ok_line) 21 | result = mock.Mock() 22 | 23 | adapter(result) 24 | 25 | self.assertTrue(result.addSuccess.called) 26 | 27 | def test_handles_skip_test_line(self): 28 | """Add a skip when a test line contains a skip directive.""" 29 | skip_line = self.factory.make_ok(directive_text="SKIP This is the reason.") 30 | adapter = Adapter("fake.tap", skip_line) 31 | result = self.factory.make_test_result() 32 | 33 | adapter(result) 34 | 35 | self.assertEqual(1, len(result.skipped)) 36 | self.assertEqual("This is the reason.", result.skipped[0][1]) 37 | 38 | def test_handles_ok_todo_test_line(self): 39 | """Add an unexpected success for an ok todo test line.""" 40 | todo_line = self.factory.make_ok(directive_text="TODO An incomplete test") 41 | adapter = Adapter("fake.tap", todo_line) 42 | result = self.factory.make_test_result() 43 | 44 | adapter(result) 45 | 46 | self.assertEqual(1, len(result.unexpectedSuccesses)) 47 | 48 | def test_handles_not_ok_todo_test_line(self): 49 | """Add an expected failure for a not ok todo test line.""" 50 | todo_line = self.factory.make_not_ok(directive_text="TODO An incomplete test") 51 | adapter = Adapter("fake.tap", todo_line) 52 | result = self.factory.make_test_result() 53 | 54 | adapter(result) 55 | 56 | self.assertEqual(1, len(result.expectedFailures)) 57 | -------------------------------------------------------------------------------- /tests/test_directive.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tap.directive import Directive 4 | 5 | 6 | class TestDirective(unittest.TestCase): 7 | """Tests for tap.directive.Directive""" 8 | 9 | def test_finds_todo(self): 10 | text = "ToDo This is something to do." 11 | directive = Directive(text) 12 | 13 | self.assertTrue(directive.todo) 14 | 15 | def test_finds_simplest_todo(self): 16 | text = "TODO" 17 | directive = Directive(text) 18 | 19 | self.assertTrue(directive.todo) 20 | 21 | def test_todo_has_boundary(self): 22 | """TAP spec indicates TODO directives must be on a boundary.""" 23 | text = "TODO: Not a TODO directive because of an immediate colon." 24 | directive = Directive(text) 25 | 26 | self.assertFalse(directive.todo) 27 | 28 | def test_finds_skip(self): 29 | text = "Skipping This is something to skip." 30 | directive = Directive(text) 31 | 32 | self.assertTrue(directive.skip) 33 | 34 | def test_finds_simplest_skip(self): 35 | text = "SKIP" 36 | directive = Directive(text) 37 | 38 | self.assertTrue(directive.skip) 39 | 40 | def test_skip_at_beginning(self): 41 | """Only match SKIP directives at the beginning.""" 42 | text = "This is not something to skip." 43 | directive = Directive(text) 44 | 45 | self.assertFalse(directive.skip) 46 | -------------------------------------------------------------------------------- /tests/test_formatter.py: -------------------------------------------------------------------------------- 1 | from tap.formatter import format_as_diagnostics, format_exception 2 | from tap.tests import TestCase 3 | 4 | 5 | class TestFormatter(TestCase): 6 | def test_formats_as_diagnostics(self): 7 | data = ["foo\n", "bar\n"] 8 | expected_diagnostics = "# foo\n# bar\n" 9 | diagnostics = format_as_diagnostics(data) 10 | self.assertEqual(expected_diagnostics, diagnostics) 11 | 12 | def test_format_exception_as_diagnostics(self): 13 | exc = self.factory.make_exc() 14 | diagnostics = format_exception(exc) 15 | self.assertTrue(diagnostics.startswith("# ")) 16 | self.assertTrue("boom" in diagnostics) 17 | -------------------------------------------------------------------------------- /tests/test_line.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tap.directive import Directive 4 | from tap.line import Line, Result 5 | 6 | 7 | class TestLine(unittest.TestCase): 8 | """Tests for tap.line.Line""" 9 | 10 | def test_line_requires_category(self): 11 | line = Line() 12 | with self.assertRaises(NotImplementedError): 13 | _ = line.category 14 | 15 | 16 | class TestResult(unittest.TestCase): 17 | """Tests for tap.line.Result""" 18 | 19 | def test_category(self): 20 | result = Result(True) 21 | self.assertEqual("test", result.category) 22 | 23 | def test_ok(self): 24 | result = Result(True) 25 | self.assertTrue(result.ok) 26 | 27 | def test_str_ok(self): 28 | result = Result(True, 42, "passing") 29 | self.assertEqual("ok 42 passing", str(result)) 30 | 31 | def test_str_not_ok(self): 32 | result = Result(False, 43, "failing") 33 | self.assertEqual("not ok 43 failing", str(result)) 34 | 35 | def test_str_directive(self): 36 | directive = Directive("SKIP a reason") 37 | result = Result(True, 44, "passing", directive) 38 | self.assertEqual("ok 44 passing # SKIP a reason", str(result)) 39 | 40 | def test_str_diagnostics(self): 41 | result = Result(False, 43, "failing", diagnostics="# more info") 42 | self.assertEqual("not ok 43 failing\n# more info", str(result)) 43 | -------------------------------------------------------------------------------- /tests/test_loader.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | import tempfile 4 | import unittest 5 | from io import StringIO 6 | from unittest import mock 7 | 8 | from tap.loader import Loader 9 | from tap.tests import TestCase 10 | 11 | 12 | class TestLoader(TestCase): 13 | """Tests for tap.loader.Loader""" 14 | 15 | def test_handles_file(self): 16 | """The loader handles a file.""" 17 | sample = inspect.cleandoc( 18 | """TAP version 13 19 | 1..2 20 | # This is a diagnostic. 21 | ok 1 A passing test 22 | not ok 2 A failing test 23 | This is an unknown line. 24 | Bail out! This test would abort. 25 | """ 26 | ) 27 | with tempfile.NamedTemporaryFile(delete=False) as temp: 28 | temp.write(sample.encode("utf-8")) 29 | loader = Loader() 30 | 31 | suite = loader.load_suite_from_file(temp.name) 32 | 33 | # The bail line counts as a failed test. 34 | self.assertEqual(3, len(suite._tests)) 35 | 36 | def test_file_does_not_exist(self): 37 | """The loader records a failure when a file does not exist.""" 38 | loader = Loader() 39 | 40 | suite = loader.load_suite_from_file("phony.tap") 41 | 42 | self.assertEqual(1, len(suite._tests)) 43 | self.assertEqual( 44 | "{filename} does not exist.".format(filename="phony.tap"), 45 | suite._tests[0]._line.description, 46 | ) 47 | 48 | def test_handles_directory(self): 49 | directory = tempfile.mkdtemp() 50 | sub_directory = os.path.join(directory, "sub") 51 | os.mkdir(sub_directory) 52 | with open(os.path.join(directory, "a_file.tap"), "w") as f: 53 | f.write("ok A passing test") 54 | with open(os.path.join(sub_directory, "another_file.tap"), "w") as f: 55 | f.write("not ok A failing test") 56 | loader = Loader() 57 | 58 | suite = loader.load([directory]) 59 | 60 | self.assertEqual(2, len(suite._tests)) 61 | 62 | def test_errors_with_multiple_version_lines(self): 63 | sample = inspect.cleandoc( 64 | """TAP version 13 65 | TAP version 13 66 | 1..0 67 | """ 68 | ) 69 | with tempfile.NamedTemporaryFile(delete=False) as temp: 70 | temp.write(sample.encode("utf-8")) 71 | loader = Loader() 72 | 73 | suite = loader.load_suite_from_file(temp.name) 74 | 75 | self.assertEqual(1, len(suite._tests)) 76 | self.assertEqual( 77 | "Multiple version lines appeared.", suite._tests[0]._line.description 78 | ) 79 | 80 | def test_errors_with_version_not_on_first_line(self): 81 | sample = inspect.cleandoc( 82 | """# Something that doesn't belong. 83 | TAP version 13 84 | 1..0 85 | """ 86 | ) 87 | with tempfile.NamedTemporaryFile(delete=False) as temp: 88 | temp.write(sample.encode("utf-8")) 89 | loader = Loader() 90 | 91 | suite = loader.load_suite_from_file(temp.name) 92 | 93 | self.assertEqual(1, len(suite._tests)) 94 | self.assertEqual( 95 | "The version must be on the first line.", 96 | suite._tests[0]._line.description, 97 | ) 98 | 99 | def test_skip_plan_aborts_loading(self): 100 | sample = inspect.cleandoc( 101 | """1..0 # Skipping this test file. 102 | ok This should not get processed. 103 | """ 104 | ) 105 | with tempfile.NamedTemporaryFile(delete=False) as temp: 106 | temp.write(sample.encode("utf-8")) 107 | loader = Loader() 108 | 109 | suite = loader.load_suite_from_file(temp.name) 110 | 111 | self.assertEqual(1, len(suite._tests)) 112 | self.assertEqual("Skipping this test file.", suite._tests[0]._line.description) 113 | 114 | @mock.patch("tap.parser.sys.stdin", StringIO("")) 115 | def test_loads_from_stream(self): 116 | loader = Loader() 117 | suite = loader.load_suite_from_stdin() 118 | self.assertTrue(isinstance(suite, unittest.TestSuite)) 119 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | from unittest import mock 4 | 5 | from tap.loader import Loader 6 | from tap.main import build_suite, get_status, main, main_module, parse_args 7 | from tap.tests import TestCase 8 | 9 | 10 | class TestMain(TestCase): 11 | """Tests for tap.main""" 12 | 13 | def test_exits_with_error(self): 14 | """The main function returns an error status if there were failures.""" 15 | argv = ["/bin/fake", "fake.tap"] 16 | stream = open(os.devnull, "w") # noqa: SIM115 17 | 18 | status = main(argv, stream=stream) 19 | 20 | self.assertEqual(1, status) 21 | 22 | def test_get_successful_status(self): 23 | result = mock.Mock() 24 | result.wasSuccessful.return_value = True 25 | self.assertEqual(0, get_status(result)) 26 | 27 | @mock.patch.object(Loader, "load_suite_from_stdin") 28 | def test_build_suite_from_stdin(self, load_suite_from_stdin): 29 | args = mock.Mock() 30 | args.files = [] 31 | expected_suite = mock.Mock() 32 | load_suite_from_stdin.return_value = expected_suite 33 | suite = build_suite(args) 34 | self.assertEqual(expected_suite, suite) 35 | 36 | @mock.patch.object(Loader, "load_suite_from_stdin") 37 | def test_build_suite_from_stdin_dash(self, load_suite_from_stdin): 38 | argv = ["/bin/fake", "-"] 39 | args = parse_args(argv) 40 | expected_suite = mock.Mock() 41 | load_suite_from_stdin.return_value = expected_suite 42 | suite = build_suite(args) 43 | self.assertEqual(expected_suite, suite) 44 | 45 | @mock.patch("tap.main.sys.stdin") 46 | @mock.patch("tap.main.sys.exit") 47 | @mock.patch.object(argparse.ArgumentParser, "print_help") 48 | def test_when_no_pipe_to_stdin(self, print_help, sys_exit, mock_stdin): 49 | argv = ["/bin/fake"] 50 | mock_stdin.isatty = mock.Mock(return_value=True) 51 | parse_args(argv) 52 | self.assertTrue(print_help.called) 53 | self.assertTrue(sys_exit.called) 54 | 55 | 56 | class TestMainModule(TestCase): 57 | @mock.patch("tap.main.unittest") 58 | def test_main_set_to_stream(self, mock_unittest): 59 | main_module() 60 | 61 | assert mock_unittest.main.called 62 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import sys 3 | import tempfile 4 | import unittest 5 | from contextlib import contextmanager 6 | from io import StringIO 7 | from unittest import mock 8 | 9 | from tap.parser import Parser 10 | 11 | try: 12 | import yaml 13 | from more_itertools import peekable # noqa 14 | 15 | have_yaml = True 16 | except ImportError: 17 | have_yaml = False 18 | 19 | 20 | @contextmanager 21 | def captured_output(): 22 | new_out, new_err = StringIO(), StringIO() 23 | old_out, old_err = sys.stdout, sys.stderr 24 | try: 25 | sys.stdout, sys.stderr = new_out, new_err 26 | yield sys.stdout, sys.stderr 27 | finally: 28 | sys.stdout, sys.stderr = old_out, old_err 29 | 30 | 31 | class TestParser(unittest.TestCase): 32 | """Tests for tap.parser.Parser""" 33 | 34 | def test_finds_ok(self): 35 | """The parser extracts an ok line.""" 36 | parser = Parser() 37 | 38 | line = parser.parse_line("ok - This is a passing test line.") 39 | 40 | self.assertEqual("test", line.category) 41 | self.assertTrue(line.ok) 42 | self.assertTrue(line.number is None) 43 | 44 | def test_finds_number(self): 45 | """The parser extracts a test number.""" 46 | parser = Parser() 47 | 48 | line = parser.parse_line("ok 42 is the magic number.") 49 | 50 | self.assertEqual("test", line.category) 51 | self.assertEqual(42, line.number) 52 | 53 | def test_finds_description(self): 54 | parser = Parser() 55 | 56 | line = parser.parse_line("ok 42 A passing test.") 57 | 58 | self.assertEqual("test", line.category) 59 | self.assertEqual("A passing test.", line.description) 60 | 61 | def test_after_hash_is_not_description(self): 62 | parser = Parser() 63 | 64 | line = parser.parse_line("ok A description # Not part of description.") 65 | 66 | self.assertEqual("test", line.category) 67 | self.assertEqual("A description", line.description) 68 | 69 | def test_finds_todo(self): 70 | parser = Parser() 71 | 72 | line = parser.parse_line("ok A description # TODO Not done") 73 | 74 | self.assertEqual("test", line.category) 75 | self.assertTrue(line.todo) 76 | 77 | def test_finds_skip(self): 78 | parser = Parser() 79 | 80 | line = parser.parse_line("ok A description # SKIP for now") 81 | 82 | self.assertEqual("test", line.category) 83 | self.assertTrue(line.skip) 84 | 85 | def test_finds_not_ok(self): 86 | """The parser extracts a not ok line.""" 87 | parser = Parser() 88 | 89 | line = parser.parse_line("not ok - This is a failing test line.") 90 | 91 | self.assertEqual("test", line.category) 92 | self.assertFalse(line.ok) 93 | self.assertTrue(line.number is None) 94 | self.assertEqual("", line.directive.text) 95 | 96 | def test_finds_directive(self): 97 | """The parser extracts a directive""" 98 | parser = Parser() 99 | test_line = "not ok - This line fails # TODO not implemented" 100 | 101 | line = parser.parse_line(test_line) 102 | directive = line.directive 103 | 104 | self.assertEqual("test", line.category) 105 | self.assertEqual("TODO not implemented", directive.text) 106 | self.assertFalse(directive.skip) 107 | self.assertTrue(directive.todo) 108 | self.assertEqual("not implemented", directive.reason) 109 | 110 | def test_unrecognizable_line(self): 111 | """The parser returns an unrecognizable line.""" 112 | parser = Parser() 113 | 114 | line = parser.parse_line("This is not a valid TAP line. # srsly") 115 | 116 | self.assertEqual("unknown", line.category) 117 | 118 | def test_diagnostic_line(self): 119 | """The parser extracts a diagnostic line.""" 120 | text = "# An example diagnostic line" 121 | parser = Parser() 122 | 123 | line = parser.parse_line(text) 124 | 125 | self.assertEqual("diagnostic", line.category) 126 | self.assertEqual(text, line.text) 127 | 128 | def test_bail_out_line(self): 129 | """The parser extracts a bail out line.""" 130 | parser = Parser() 131 | 132 | line = parser.parse_line("Bail out! This is the reason to bail.") 133 | 134 | self.assertEqual("bail", line.category) 135 | self.assertEqual("This is the reason to bail.", line.reason) 136 | 137 | def test_finds_version(self): 138 | """The parser extracts a version line.""" 139 | parser = Parser() 140 | 141 | line = parser.parse_line("TAP version 13") 142 | 143 | self.assertEqual("version", line.category) 144 | self.assertEqual(13, line.version) 145 | 146 | def test_errors_on_old_version(self): 147 | """The TAP spec dictates that anything less than 13 is an error.""" 148 | parser = Parser() 149 | 150 | with self.assertRaises(ValueError): 151 | parser.parse_line("TAP version 12") 152 | 153 | def test_finds_plan(self): 154 | """The parser extracts a plan line.""" 155 | parser = Parser() 156 | 157 | line = parser.parse_line("1..42") 158 | 159 | self.assertEqual("plan", line.category) 160 | self.assertEqual(42, line.expected_tests) 161 | 162 | def test_finds_plan_with_skip(self): 163 | """The parser extracts a plan line containing a SKIP.""" 164 | parser = Parser() 165 | 166 | line = parser.parse_line("1..42 # Skipping this test file.") 167 | 168 | self.assertEqual("plan", line.category) 169 | self.assertTrue(line.skip) 170 | 171 | def test_ignores_plan_with_any_non_skip_directive(self): 172 | """The parser only recognizes SKIP directives in plans.""" 173 | parser = Parser() 174 | 175 | line = parser.parse_line("1..42 # TODO will not work.") 176 | 177 | self.assertEqual("unknown", line.category) 178 | 179 | def test_parses_text(self): 180 | sample = inspect.cleandoc( 181 | """1..2 182 | ok 1 A passing test 183 | not ok 2 A failing test""" 184 | ) 185 | parser = Parser() 186 | lines = [] 187 | 188 | for line in parser.parse_text(sample): 189 | lines.append(line) 190 | 191 | self.assertEqual(3, len(lines)) 192 | self.assertEqual("plan", lines[0].category) 193 | self.assertEqual("test", lines[1].category) 194 | self.assertTrue(lines[1].ok) 195 | self.assertEqual("test", lines[2].category) 196 | self.assertFalse(lines[2].ok) 197 | 198 | def test_parses_file(self): 199 | sample = inspect.cleandoc( 200 | """1..2 201 | ok 1 A passing test 202 | not ok 2 A failing test""" 203 | ) 204 | temp = tempfile.NamedTemporaryFile(delete=False) # noqa: SIM115 205 | temp.write(sample.encode("utf-8")) 206 | temp.close() 207 | parser = Parser() 208 | lines = [] 209 | 210 | for line in parser.parse_file(temp.name): 211 | lines.append(line) 212 | 213 | self.assertEqual(3, len(lines)) 214 | self.assertEqual("plan", lines[0].category) 215 | self.assertEqual("test", lines[1].category) 216 | self.assertTrue(lines[1].ok) 217 | self.assertIsNone(lines[1].yaml_block) 218 | self.assertEqual("test", lines[2].category) 219 | self.assertFalse(lines[2].ok) 220 | 221 | def test_parses_yaml(self): 222 | sample = inspect.cleandoc( 223 | """TAP version 13 224 | 1..2 225 | ok 1 A passing test 226 | --- 227 | test: sample yaml 228 | ... 229 | not ok 2 A failing test""" 230 | ) 231 | parser = Parser() 232 | lines = [] 233 | 234 | for line in parser.parse_text(sample): 235 | lines.append(line) 236 | 237 | if have_yaml: 238 | converted_yaml = yaml.safe_load("""test: sample yaml""") 239 | self.assertEqual(4, len(lines)) 240 | self.assertEqual(13, lines[0].version) 241 | self.assertEqual(converted_yaml, lines[2].yaml_block) 242 | self.assertEqual("test", lines[3].category) 243 | self.assertIsNone(lines[3].yaml_block) 244 | else: 245 | self.assertEqual(7, len(lines)) 246 | self.assertEqual(13, lines[0].version) 247 | for line_index in list(range(3, 6)): 248 | self.assertEqual("unknown", lines[line_index].category) 249 | self.assertEqual("test", lines[6].category) 250 | 251 | def test_parses_mixed(self): 252 | # Test that we can parse both a version 13 and earlier version files 253 | # using the same parser. Make sure that parsing works regardless of 254 | # the order of the incoming documents. 255 | sample_version_13 = inspect.cleandoc( 256 | """TAP version 13 257 | 1..2 258 | ok 1 A passing version 13 test 259 | --- 260 | test: sample yaml 261 | ... 262 | not ok 2 A failing version 13 test""" 263 | ) 264 | sample_pre_13 = inspect.cleandoc( 265 | """1..2 266 | ok 1 A passing pre-13 test 267 | not ok 2 A failing pre-13 test""" 268 | ) 269 | 270 | parser = Parser() 271 | lines = [] 272 | lines.extend(parser.parse_text(sample_version_13)) 273 | lines.extend(parser.parse_text(sample_pre_13)) 274 | if have_yaml: 275 | self.assertEqual(13, lines[0].version) 276 | self.assertEqual("A passing version 13 test", lines[2].description) 277 | self.assertEqual("A failing version 13 test", lines[3].description) 278 | self.assertEqual("A passing pre-13 test", lines[5].description) 279 | self.assertEqual("A failing pre-13 test", lines[6].description) 280 | else: 281 | self.assertEqual(13, lines[0].version) 282 | self.assertEqual("A passing version 13 test", lines[2].description) 283 | self.assertEqual("A failing version 13 test", lines[6].description) 284 | self.assertEqual("A passing pre-13 test", lines[8].description) 285 | self.assertEqual("A failing pre-13 test", lines[9].description) 286 | 287 | # Test parsing documents in reverse order 288 | parser = Parser() 289 | lines = [] 290 | lines.extend(parser.parse_text(sample_pre_13)) 291 | lines.extend(parser.parse_text(sample_version_13)) 292 | if have_yaml: 293 | self.assertEqual("A passing pre-13 test", lines[1].description) 294 | self.assertEqual("A failing pre-13 test", lines[2].description) 295 | self.assertEqual(13, lines[3].version) 296 | self.assertEqual("A passing version 13 test", lines[5].description) 297 | self.assertEqual("A failing version 13 test", lines[6].description) 298 | else: 299 | self.assertEqual("A passing pre-13 test", lines[1].description) 300 | self.assertEqual("A failing pre-13 test", lines[2].description) 301 | self.assertEqual(13, lines[3].version) 302 | self.assertEqual("A passing version 13 test", lines[5].description) 303 | self.assertEqual("A failing version 13 test", lines[9].description) 304 | 305 | def test_parses_yaml_no_end(self): 306 | sample = inspect.cleandoc( 307 | """TAP version 13 308 | 1..2 309 | ok 1 A passing test 310 | --- 311 | test: sample yaml 312 | not ok 2 A failing test""" 313 | ) 314 | parser = Parser() 315 | lines = [] 316 | 317 | for line in parser.parse_text(sample): 318 | lines.append(line) 319 | 320 | if have_yaml: 321 | converted_yaml = yaml.safe_load("""test: sample yaml""") 322 | self.assertEqual(4, len(lines)) 323 | self.assertEqual(13, lines[0].version) 324 | self.assertEqual(converted_yaml, lines[2].yaml_block) 325 | self.assertEqual("test", lines[3].category) 326 | self.assertIsNone(lines[3].yaml_block) 327 | else: 328 | self.assertEqual(6, len(lines)) 329 | self.assertEqual(13, lines[0].version) 330 | for line_index in list(range(3, 5)): 331 | self.assertEqual("unknown", lines[line_index].category) 332 | self.assertEqual("test", lines[5].category) 333 | 334 | def test_parses_yaml_more_complex(self): 335 | sample = inspect.cleandoc( 336 | """TAP version 13 337 | 1..2 338 | ok 1 A passing test 339 | --- 340 | message: test 341 | severity: fail 342 | data: 343 | got: 344 | - foo 345 | expect: 346 | - bar 347 | output: |- 348 | a multiline string 349 | must be handled properly 350 | even with | pipes 351 | | here > and: there 352 | last_nl: |+ 353 | there's a newline here -> 354 | """ 355 | ) 356 | parser = Parser() 357 | lines = [] 358 | 359 | for line in parser.parse_text(sample): 360 | lines.append(line) 361 | 362 | if have_yaml: 363 | converted_yaml = yaml.safe_load( 364 | r''' 365 | message: test 366 | severity: fail 367 | data: 368 | got: 369 | - foo 370 | expect: 371 | - bar 372 | output: "a multiline string\nmust be handled properly\neven with | pipes\n| here > and: there" 373 | last_nl: "there's a newline here ->\n"''' # noqa 374 | ) 375 | self.assertEqual(3, len(lines)) 376 | self.assertEqual(13, lines[0].version) 377 | self.assertEqual(converted_yaml, lines[2].yaml_block) 378 | else: 379 | self.assertEqual(19, len(lines)) 380 | self.assertEqual(13, lines[0].version) 381 | for line_index in list(range(3, 11)): 382 | self.assertEqual("unknown", lines[line_index].category) 383 | 384 | def test_parses_yaml_no_association(self): 385 | sample = inspect.cleandoc( 386 | """TAP version 13 387 | 1..2 388 | ok 1 A passing test 389 | # Diagnostic line 390 | --- 391 | test: sample yaml 392 | ... 393 | not ok 2 A failing test""" 394 | ) 395 | parser = Parser() 396 | lines = [] 397 | 398 | for line in parser.parse_text(sample): 399 | lines.append(line) 400 | 401 | self.assertEqual(8, len(lines)) 402 | self.assertEqual(13, lines[0].version) 403 | self.assertIsNone(lines[2].yaml_block) 404 | self.assertEqual("diagnostic", lines[3].category) 405 | for line_index in list(range(4, 7)): 406 | self.assertEqual("unknown", lines[line_index].category) 407 | self.assertEqual("test", lines[7].category) 408 | 409 | def test_parses_yaml_no_start(self): 410 | sample = inspect.cleandoc( 411 | """TAP version 13 412 | 1..2 413 | ok 1 A passing test 414 | test: sample yaml 415 | ... 416 | not ok 2 A failing test""" 417 | ) 418 | parser = Parser() 419 | lines = [] 420 | 421 | for line in parser.parse_text(sample): 422 | lines.append(line) 423 | 424 | self.assertEqual(6, len(lines)) 425 | self.assertEqual(13, lines[0].version) 426 | self.assertIsNone(lines[2].yaml_block) 427 | for line_index in list(range(3, 5)): 428 | self.assertEqual("unknown", lines[line_index].category) 429 | self.assertEqual("test", lines[5].category) 430 | 431 | def test_malformed_yaml(self): 432 | self.maxDiff = None 433 | sample = inspect.cleandoc( 434 | """TAP version 13 435 | 1..2 436 | ok 1 A passing test 437 | --- 438 | test: sample yaml 439 | \tfail: tabs are not allowed! 440 | ... 441 | not ok 2 A failing test""" 442 | ) 443 | yaml_err = inspect.cleandoc( 444 | """ 445 | WARNING: Optional imports not found, TAP 13 output will be 446 | ignored. To parse yaml, see requirements in docs: 447 | https://tappy.readthedocs.io/en/latest/consumers.html#tap-version-13""" 448 | ) 449 | parser = Parser() 450 | lines = [] 451 | 452 | with captured_output() as (parse_out, _): 453 | for line in parser.parse_text(sample): 454 | lines.append(line) 455 | 456 | if have_yaml: 457 | self.assertEqual(4, len(lines)) 458 | self.assertEqual(13, lines[0].version) 459 | with captured_output() as (out, _): 460 | self.assertIsNone(lines[2].yaml_block) 461 | self.assertEqual( 462 | "Error parsing yaml block. Check formatting.", out.getvalue().strip() 463 | ) 464 | self.assertEqual("test", lines[3].category) 465 | self.assertIsNone(lines[3].yaml_block) 466 | else: 467 | self.assertEqual(8, len(lines)) 468 | self.assertEqual(13, lines[0].version) 469 | for line_index in list(range(3, 7)): 470 | self.assertEqual("unknown", lines[line_index].category) 471 | self.assertEqual("test", lines[7].category) 472 | self.assertEqual(yaml_err, parse_out.getvalue().strip()) 473 | 474 | def test_parse_empty_file(self): 475 | temp = tempfile.NamedTemporaryFile(delete=False) # noqa: SIM115 476 | temp.close() 477 | parser = Parser() 478 | lines = [] 479 | 480 | for line in parser.parse_file(temp.name): 481 | lines.append(line) 482 | 483 | self.assertEqual(0, len(lines)) 484 | 485 | @mock.patch( 486 | "tap.parser.sys.stdin", 487 | StringIO( 488 | """1..2 489 | ok 1 A passing test 490 | not ok 2 A failing test""" 491 | ), 492 | ) 493 | def test_parses_stdin(self): 494 | parser = Parser() 495 | lines = [] 496 | 497 | for line in parser.parse_stdin(): 498 | lines.append(line) 499 | 500 | self.assertEqual(3, len(lines)) 501 | self.assertEqual("plan", lines[0].category) 502 | self.assertEqual("test", lines[1].category) 503 | self.assertTrue(lines[1].ok) 504 | self.assertEqual("test", lines[2].category) 505 | self.assertFalse(lines[2].ok) 506 | -------------------------------------------------------------------------------- /tests/test_result.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import unittest 4 | import unittest.case 5 | 6 | from tap.runner import TAPTestResult 7 | from tap.tests import TestCase 8 | from tap.tracker import Tracker 9 | 10 | 11 | class FakeTestCase(unittest.TestCase): 12 | def runTest(self): 13 | pass 14 | 15 | @contextlib.contextmanager 16 | def subTest(self, *args, **kwargs): 17 | try: 18 | self._subtest = unittest.case._SubTest(self, object(), {}) 19 | yield 20 | finally: 21 | self._subtest = None 22 | 23 | def __call__(self, result): 24 | pass 25 | 26 | 27 | class TestTAPTestResult(TestCase): 28 | @classmethod 29 | def _make_one(cls): 30 | # Yep, the stream is not being closed. 31 | stream = open(os.devnull, "w") # noqa: SIM115 32 | result = TAPTestResult(stream, False, 0) 33 | result.tracker = Tracker() 34 | return result 35 | 36 | def test_adds_error(self): 37 | result = self._make_one() 38 | # Python 3 does some extra testing in unittest on exceptions so fake 39 | # the cause as if it were raised. 40 | ex = Exception() 41 | ex.__cause__ = None 42 | result.addError(FakeTestCase(), (None, ex, None)) 43 | self.assertEqual(len(result.tracker._test_cases["FakeTestCase"]), 1) 44 | 45 | def test_adds_failure(self): 46 | result = self._make_one() 47 | # Python 3 does some extra testing in unittest on exceptions so fake 48 | # the cause as if it were raised. 49 | ex = Exception() 50 | ex.__cause__ = None 51 | result.addFailure(FakeTestCase(), (None, ex, None)) 52 | self.assertEqual(len(result.tracker._test_cases["FakeTestCase"]), 1) 53 | 54 | def test_adds_success(self): 55 | result = self._make_one() 56 | result.addSuccess(FakeTestCase()) 57 | self.assertEqual(len(result.tracker._test_cases["FakeTestCase"]), 1) 58 | 59 | def test_adds_skip(self): 60 | result = self._make_one() 61 | result.addSkip(FakeTestCase(), "a reason") 62 | self.assertEqual(len(result.tracker._test_cases["FakeTestCase"]), 1) 63 | 64 | def test_adds_expected_failure(self): 65 | exc = self.factory.make_exc() 66 | result = self._make_one() 67 | result.addExpectedFailure(FakeTestCase(), exc) 68 | line = result.tracker._test_cases["FakeTestCase"][0] 69 | self.assertFalse(line.ok) 70 | self.assertEqual(line.directive.text, "TODO {}".format("(expected failure)")) 71 | 72 | def test_adds_unexpected_success(self): 73 | result = self._make_one() 74 | result.addUnexpectedSuccess(FakeTestCase()) 75 | line = result.tracker._test_cases["FakeTestCase"][0] 76 | self.assertTrue(line.ok) 77 | self.assertEqual(line.directive.text, "TODO {}".format("(unexpected success)")) 78 | 79 | def test_adds_subtest_success(self): 80 | """Test that the runner handles subtest success results.""" 81 | result = self._make_one() 82 | test = FakeTestCase() 83 | with test.subTest(): 84 | result.addSubTest(test, test._subtest, None) 85 | line = result.tracker._test_cases["FakeTestCase"][0] 86 | self.assertTrue(line.ok) 87 | 88 | def test_adds_subtest_failure(self): 89 | """Test that the runner handles subtest failure results.""" 90 | result = self._make_one() 91 | # Python 3 does some extra testing in unittest on exceptions so fake 92 | # the cause as if it were raised. 93 | ex = Exception() 94 | ex.__cause__ = None 95 | test = FakeTestCase() 96 | with test.subTest(): 97 | result.addSubTest(test, test._subtest, (ex.__class__, ex, None)) 98 | line = result.tracker._test_cases["FakeTestCase"][0] 99 | self.assertFalse(line.ok) 100 | -------------------------------------------------------------------------------- /tests/test_rules.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tap.rules import Rules 4 | from tap.tests import TestCase 5 | 6 | 7 | class TestRules(TestCase): 8 | """Tests for tap.rules.Rules""" 9 | 10 | def _make_one(self): 11 | self.suite = unittest.TestSuite() 12 | return Rules("foobar.tap", self.suite) 13 | 14 | def test_handles_skipping_plan(self): 15 | skip_plan = self.factory.make_plan(directive_text="Skip on Mondays.") 16 | rules = self._make_one() 17 | 18 | rules.handle_skipping_plan(skip_plan) 19 | 20 | self.assertEqual(1, len(self.suite._tests)) 21 | self.assertEqual("Skip on Mondays.", self.suite._tests[0]._line.description) 22 | 23 | def test_tracks_plan_line(self): 24 | plan = self.factory.make_plan() 25 | rules = self._make_one() 26 | 27 | rules.saw_plan(plan, 28) 28 | 29 | self.assertEqual(rules._lines_seen["plan"][0][0], plan) 30 | self.assertEqual(rules._lines_seen["plan"][0][1], 28) 31 | 32 | def test_errors_plan_not_at_end(self): 33 | plan = self.factory.make_plan() 34 | rules = self._make_one() 35 | rules.saw_plan(plan, 41) 36 | 37 | rules.check(42) 38 | 39 | self.assertEqual( 40 | "A plan must appear at the beginning or end of the file.", 41 | self.suite._tests[0]._line.description, 42 | ) 43 | 44 | def test_requires_plan(self): 45 | rules = self._make_one() 46 | 47 | rules.check(42) 48 | 49 | self.assertEqual("Missing a plan.", self.suite._tests[0]._line.description) 50 | 51 | def test_only_one_plan(self): 52 | plan = self.factory.make_plan() 53 | rules = self._make_one() 54 | rules.saw_plan(plan, 41) 55 | rules.saw_plan(plan, 42) 56 | 57 | rules.check(42) 58 | 59 | self.assertEqual( 60 | "Only one plan line is permitted per file.", 61 | self.suite._tests[0]._line.description, 62 | ) 63 | 64 | def test_plan_line_two(self): 65 | """A plan may appear on line 2 when line 1 is a version line.""" 66 | rules = self._make_one() 67 | rules.saw_version_at(1) 68 | 69 | valid = rules._plan_on_valid_line(at_line=2, final_line_count=42) 70 | 71 | self.assertTrue(valid) 72 | 73 | def test_errors_when_expected_tests_differs_from_actual(self): 74 | plan = self.factory.make_plan(expected_tests=42) 75 | rules = self._make_one() 76 | rules.saw_plan(plan, 1) 77 | rules.saw_test() 78 | 79 | rules.check(2) 80 | 81 | self.assertEqual( 82 | f"Expected {42} tests but only {1} ran.", 83 | self.suite._tests[0]._line.description, 84 | ) 85 | 86 | def test_errors_on_bail(self): 87 | bail = self.factory.make_bail(reason="Missing something important.") 88 | rules = self._make_one() 89 | 90 | rules.handle_bail(bail) 91 | 92 | self.assertEqual(1, len(self.suite._tests)) 93 | self.assertEqual( 94 | "Bailed: {reason}".format(reason="Missing something important."), 95 | self.suite._tests[0]._line.description, 96 | ) 97 | -------------------------------------------------------------------------------- /tests/test_runner.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import tempfile 4 | import unittest 5 | from unittest import mock 6 | 7 | from tap import TAPTestRunner 8 | from tap.runner import TAPTestResult, _tracker 9 | 10 | 11 | class TestTAPTestRunner(unittest.TestCase): 12 | def test_has_tap_test_result(self): 13 | runner = TAPTestRunner() 14 | self.assertEqual(runner.resultclass, TAPTestResult) 15 | 16 | def test_runner_uses_outdir(self): 17 | """Test that the test runner sets the outdir so that TAP 18 | files will be written to that location. 19 | 20 | Setting class attributes to get the right behavior is a dirty hack, but 21 | the unittest classes aren't very extensible. 22 | """ 23 | # Save the previous outdir in case **this** execution was using it. 24 | previous_outdir = _tracker.outdir 25 | outdir = tempfile.mkdtemp() 26 | 27 | TAPTestRunner.set_outdir(outdir) 28 | 29 | self.assertEqual(outdir, _tracker.outdir) 30 | 31 | _tracker.outdir = previous_outdir 32 | 33 | def test_runner_uses_format(self): 34 | """Test that format is set on TAPTestResult FORMAT.""" 35 | # Save the previous format in case **this** execution was using it. 36 | previous_format = TAPTestResult.FORMAT 37 | fmt = "{method_name}: {short_description}" 38 | 39 | TAPTestRunner.set_format(fmt) 40 | 41 | self.assertEqual(fmt, TAPTestResult.FORMAT) 42 | 43 | TAPTestResult.FORMAT = previous_format 44 | 45 | def test_runner_uses_combined(self): 46 | """Test that output is combined.""" 47 | # Save previous combined in case **this** execution was using it. 48 | previous_combined = _tracker.combined 49 | 50 | TAPTestRunner.set_combined(True) 51 | 52 | self.assertTrue(_tracker.combined) 53 | 54 | _tracker.combined = previous_combined 55 | 56 | @mock.patch("sys.exit") 57 | def test_bad_format_string(self, fake_exit): 58 | """A bad format string exits the runner.""" 59 | previous_format = TAPTestResult.FORMAT 60 | bad_format = "Not gonna work {sort_desc}" 61 | TAPTestRunner.set_format(bad_format) 62 | result = TAPTestResult(None, True, 1) 63 | test = mock.Mock() 64 | 65 | result._description(test) 66 | 67 | self.assertTrue(fake_exit.called) 68 | 69 | TAPTestResult.FORMAT = previous_format 70 | 71 | def test_runner_sets_tracker_for_streaming(self): 72 | """The tracker is set for streaming mode.""" 73 | previous_streaming = _tracker.streaming 74 | previous_stream = _tracker.stream 75 | runner = TAPTestRunner() 76 | 77 | runner.set_stream(True) 78 | 79 | self.assertTrue(_tracker.streaming) 80 | self.assertTrue(_tracker.stream, sys.stdout) 81 | 82 | _tracker.streaming = previous_streaming 83 | _tracker.stream = previous_stream 84 | 85 | def test_runner_stream_to_devnull_for_streaming(self): 86 | previous_streaming = _tracker.streaming 87 | previous_stream = _tracker.stream 88 | runner = TAPTestRunner() 89 | 90 | runner.set_stream(True) 91 | 92 | self.assertTrue(runner.stream.stream.name, os.devnull) 93 | 94 | _tracker.streaming = previous_streaming 95 | _tracker.stream = previous_stream 96 | 97 | def test_runner_uses_header(self): 98 | """Test that the case header can be turned off.""" 99 | # Save previous header in case **this** execution was using it. 100 | previous_header = _tracker.header 101 | 102 | TAPTestRunner.set_header(False) 103 | self.assertFalse(_tracker.header) 104 | 105 | TAPTestRunner.set_header(True) 106 | self.assertTrue(_tracker.header) 107 | 108 | _tracker.header = previous_header 109 | -------------------------------------------------------------------------------- /tests/test_tracker.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | import tempfile 4 | from io import StringIO 5 | from unittest import mock 6 | 7 | from tap.tests import TestCase 8 | from tap.tracker import Tracker 9 | 10 | 11 | class TestTracker(TestCase): 12 | def _make_header(self, test_case): 13 | return f"# TAP results for {test_case}" 14 | 15 | def test_has_test_cases(self): 16 | tracker = Tracker() 17 | self.assertEqual(tracker._test_cases, {}) 18 | 19 | def test_tracks_class(self): 20 | tracker = Tracker() 21 | tracker._track("FakeTestClass") 22 | self.assertEqual(tracker._test_cases.get("FakeTestClass"), []) 23 | 24 | def test_adds_ok(self): 25 | tracker = Tracker() 26 | tracker.add_ok("FakeTestCase", "a description") 27 | line = tracker._test_cases["FakeTestCase"][0] 28 | self.assertTrue(line.ok) 29 | self.assertEqual(line.description, "a description") 30 | 31 | def test_adds_not_ok(self): 32 | tracker = Tracker() 33 | tracker.add_not_ok("FakeTestCase", "a description") 34 | line = tracker._test_cases["FakeTestCase"][0] 35 | self.assertFalse(line.ok) 36 | self.assertEqual(line.description, "a description") 37 | 38 | def test_adds_skip(self): 39 | tracker = Tracker() 40 | tracker.add_skip("FakeTestCase", "a description", "a reason") 41 | line = tracker._test_cases["FakeTestCase"][0] 42 | self.assertTrue(line.ok) 43 | self.assertEqual(line.description, "a description") 44 | self.assertEqual(line.directive.text, "SKIP a reason") 45 | 46 | def test_generates_tap_reports_in_new_outdir(self): 47 | tempdir = tempfile.mkdtemp() 48 | outdir = os.path.join(tempdir, "non", "existent", "path") 49 | tracker = Tracker(outdir=outdir) 50 | tracker.add_ok("FakeTestCase", "I should be in the specified dir.") 51 | 52 | tracker.generate_tap_reports() 53 | 54 | tap_file = os.path.join(outdir, "FakeTestCase.tap") 55 | self.assertTrue(os.path.exists(tap_file)) 56 | 57 | def test_generates_tap_reports_in_existing_outdir(self): 58 | outdir = tempfile.mkdtemp() 59 | tracker = Tracker(outdir=outdir) 60 | tracker.add_ok("FakeTestCase", "I should be in the specified dir.") 61 | 62 | tracker.generate_tap_reports() 63 | 64 | tap_file = os.path.join(outdir, "FakeTestCase.tap") 65 | self.assertTrue(os.path.exists(tap_file)) 66 | 67 | def test_results_not_combined_by_default(self): 68 | tracker = Tracker() 69 | self.assertFalse(tracker.combined) 70 | 71 | def test_individual_report_has_no_plan_when_combined(self): 72 | outdir = tempfile.mkdtemp() 73 | tracker = Tracker(outdir=outdir, combined=True) 74 | tracker.add_ok("FakeTestCase", "Look ma, no plan!") 75 | out_file = StringIO() 76 | 77 | tracker.generate_tap_report( 78 | "FakeTestCase", tracker._test_cases["FakeTestCase"], out_file 79 | ) 80 | 81 | report = out_file.getvalue() 82 | self.assertTrue("Look ma" in report) 83 | self.assertFalse("1.." in report) 84 | 85 | @mock.patch("tap.tracker.ENABLE_VERSION_13", False) 86 | def test_combined_results_in_one_file_tap_version_12(self): 87 | outdir = tempfile.mkdtemp() 88 | tracker = Tracker(outdir=outdir, combined=True) 89 | tracker.add_ok("FakeTestCase", "YESSS!") 90 | tracker.add_ok("DifferentFakeTestCase", "GOAAL!") 91 | 92 | tracker.generate_tap_reports() 93 | 94 | self.assertFalse(os.path.exists(os.path.join(outdir, "FakeTestCase.tap"))) 95 | self.assertFalse( 96 | os.path.exists(os.path.join(outdir, "DifferentFakeTestCase.tap")) 97 | ) 98 | with open(os.path.join(outdir, "testresults.tap")) as f: 99 | report = f.read() 100 | expected = inspect.cleandoc( 101 | """{header_1} 102 | ok 1 YESSS! 103 | {header_2} 104 | ok 2 GOAAL! 105 | 1..2 106 | """.format( 107 | header_1=self._make_header("FakeTestCase"), 108 | header_2=self._make_header("DifferentFakeTestCase"), 109 | ) 110 | ) 111 | self.assertEqual(report.strip(), expected) 112 | 113 | @mock.patch("tap.tracker.ENABLE_VERSION_13", True) 114 | def test_combined_results_in_one_file_tap_version_13(self): 115 | outdir = tempfile.mkdtemp() 116 | tracker = Tracker(outdir=outdir, combined=True) 117 | tracker.add_ok("FakeTestCase", "YESSS!") 118 | tracker.add_ok("DifferentFakeTestCase", "GOAAL!") 119 | 120 | tracker.generate_tap_reports() 121 | 122 | self.assertFalse(os.path.exists(os.path.join(outdir, "FakeTestCase.tap"))) 123 | self.assertFalse( 124 | os.path.exists(os.path.join(outdir, "DifferentFakeTestCase.tap")) 125 | ) 126 | with open(os.path.join(outdir, "testresults.tap")) as f: 127 | report = f.read() 128 | expected = inspect.cleandoc( 129 | """ 130 | TAP version 13 131 | {header_1} 132 | ok 1 YESSS! 133 | {header_2} 134 | ok 2 GOAAL! 135 | 1..2 136 | """.format( 137 | header_1=self._make_header("FakeTestCase"), 138 | header_2=self._make_header("DifferentFakeTestCase"), 139 | ) 140 | ) 141 | self.assertEqual(report.strip(), expected) 142 | 143 | def test_tracker_does_not_stream_by_default(self): 144 | tracker = Tracker() 145 | self.assertFalse(tracker.streaming) 146 | 147 | def test_tracker_has_stream(self): 148 | tracker = Tracker() 149 | self.assertTrue(tracker.stream is None) 150 | 151 | @mock.patch("tap.tracker.ENABLE_VERSION_13", False) 152 | def test_add_ok_writes_to_stream_while_streaming(self): 153 | stream = StringIO() 154 | tracker = Tracker(streaming=True, stream=stream) 155 | 156 | tracker.add_ok("FakeTestCase", "YESSS!") 157 | tracker.add_ok("AnotherTestCase", "Sure.") 158 | 159 | expected = inspect.cleandoc( 160 | """{header_1} 161 | ok 1 YESSS! 162 | {header_2} 163 | ok 2 Sure. 164 | """.format( 165 | header_1=self._make_header("FakeTestCase"), 166 | header_2=self._make_header("AnotherTestCase"), 167 | ) 168 | ) 169 | self.assertEqual(stream.getvalue().strip(), expected) 170 | 171 | @mock.patch("tap.tracker.ENABLE_VERSION_13", False) 172 | def test_add_not_ok_writes_to_stream_while_streaming(self): 173 | stream = StringIO() 174 | tracker = Tracker(streaming=True, stream=stream) 175 | 176 | tracker.add_not_ok("FakeTestCase", "YESSS!") 177 | 178 | expected = inspect.cleandoc( 179 | """{header} 180 | not ok 1 YESSS! 181 | """.format(header=self._make_header("FakeTestCase")) 182 | ) 183 | self.assertEqual(stream.getvalue().strip(), expected) 184 | 185 | @mock.patch("tap.tracker.ENABLE_VERSION_13", False) 186 | def test_add_skip_writes_to_stream_while_streaming(self): 187 | stream = StringIO() 188 | tracker = Tracker(streaming=True, stream=stream) 189 | 190 | tracker.add_skip("FakeTestCase", "YESSS!", "a reason") 191 | 192 | expected = inspect.cleandoc( 193 | """{header} 194 | ok 1 YESSS! # SKIP a reason 195 | """.format(header=self._make_header("FakeTestCase")) 196 | ) 197 | self.assertEqual(stream.getvalue().strip(), expected) 198 | 199 | def test_streaming_does_not_write_files(self): 200 | outdir = tempfile.mkdtemp() 201 | stream = StringIO() 202 | tracker = Tracker(outdir=outdir, streaming=True, stream=stream) 203 | tracker.add_ok("FakeTestCase", "YESSS!") 204 | 205 | tracker.generate_tap_reports() 206 | 207 | self.assertFalse(os.path.exists(os.path.join(outdir, "FakeTestCase.tap"))) 208 | 209 | @mock.patch("tap.tracker.ENABLE_VERSION_13", False) 210 | def test_streaming_writes_plan(self): 211 | stream = StringIO() 212 | tracker = Tracker(streaming=True, stream=stream) 213 | tracker.combined_line_number = 42 214 | 215 | tracker.generate_tap_reports() 216 | 217 | self.assertEqual(stream.getvalue(), "1..42\n") 218 | 219 | @mock.patch("tap.tracker.ENABLE_VERSION_13", False) 220 | def test_write_plan_first_streaming(self): 221 | outdir = tempfile.mkdtemp() 222 | stream = StringIO() 223 | tracker = Tracker(outdir=outdir, streaming=True, stream=stream) 224 | tracker.set_plan(123) 225 | tracker.add_ok("FakeTestCase", "YESSS!") 226 | 227 | tracker.generate_tap_reports() 228 | 229 | self.assertEqual( 230 | stream.getvalue(), 231 | "1..123\n{header}\nok 1 YESSS!\n".format( 232 | header=self._make_header("FakeTestCase") 233 | ), 234 | ) 235 | self.assertFalse(os.path.exists(os.path.join(outdir, "FakeTestCase.tap"))) 236 | 237 | @mock.patch("tap.tracker.ENABLE_VERSION_13", False) 238 | def test_write_plan_immediate_streaming(self): 239 | stream = StringIO() 240 | Tracker(streaming=True, stream=stream, plan=123) 241 | self.assertEqual(stream.getvalue(), "1..123\n") 242 | 243 | @mock.patch("tap.tracker.ENABLE_VERSION_13", False) 244 | def test_write_plan_first_combined(self): 245 | outdir = tempfile.mkdtemp() 246 | tracker = Tracker(streaming=False, outdir=outdir, combined=True) 247 | tracker.set_plan(123) 248 | tracker.generate_tap_reports() 249 | with open(os.path.join(outdir, "testresults.tap")) as f: 250 | lines = f.readlines() 251 | self.assertEqual(lines[0], "1..123\n") 252 | 253 | @mock.patch("tap.tracker.ENABLE_VERSION_13", False) 254 | def test_write_plan_first_not_combined(self): 255 | outdir = tempfile.mkdtemp() 256 | tracker = Tracker(streaming=False, outdir=outdir, combined=False) 257 | with self.assertRaises(ValueError): 258 | tracker.set_plan(123) 259 | 260 | @mock.patch("tap.tracker.ENABLE_VERSION_13", True) 261 | def test_streaming_writes_tap_version_13(self): 262 | stream = StringIO() 263 | tracker = Tracker(streaming=True, stream=stream) 264 | 265 | tracker.add_skip("FakeTestCase", "YESSS!", "a reason") 266 | 267 | expected = inspect.cleandoc( 268 | """ 269 | TAP version 13 270 | {header} 271 | ok 1 YESSS! # SKIP a reason 272 | """.format(header=self._make_header("FakeTestCase")) 273 | ) 274 | self.assertEqual(stream.getvalue().strip(), expected) 275 | 276 | def test_get_default_tap_file_path(self): 277 | tracker = Tracker() 278 | file_path = tracker._get_tap_file_path("foo") 279 | self.assertEqual("foo.tap", file_path) 280 | 281 | def test_sanitizes_tap_file_path(self): 282 | tracker = Tracker() 283 | file_path = tracker._get_tap_file_path("an awful \\ testcase / name\n") 284 | self.assertEqual("an-awful---testcase---name-.tap", file_path) 285 | 286 | def test_adds_ok_with_diagnostics(self): 287 | tracker = Tracker() 288 | tracker.add_ok("FakeTestCase", "a description", diagnostics="# more info\n") 289 | line = tracker._test_cases["FakeTestCase"][0] 290 | self.assertEqual("# more info\n", line.diagnostics) 291 | 292 | def test_adds_not_ok_with_diagnostics(self): 293 | tracker = Tracker() 294 | tracker.add_not_ok("FakeTestCase", "a description", diagnostics="# more info\n") 295 | line = tracker._test_cases["FakeTestCase"][0] 296 | self.assertEqual("# more info\n", line.diagnostics) 297 | 298 | def test_header_displayed_by_default(self): 299 | tracker = Tracker() 300 | self.assertTrue(tracker.header) 301 | 302 | def test_header_set_by_init(self): 303 | tracker = Tracker(header=False) 304 | self.assertFalse(tracker.header) 305 | 306 | @mock.patch("tap.tracker.ENABLE_VERSION_13", False) 307 | def test_does_not_write_header(self): 308 | stream = StringIO() 309 | tracker = Tracker(streaming=True, stream=stream, header=False) 310 | 311 | tracker.add_skip("FakeTestCase", "YESSS!", "a reason") 312 | 313 | expected = inspect.cleandoc( 314 | """ 315 | ok 1 YESSS! # SKIP a reason 316 | """ 317 | ) 318 | self.assertEqual(stream.getvalue().strip(), expected) 319 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | deps = 3 | pytest 4 | commands = pytest 5 | 6 | [testenv:with_optional] 7 | deps = 8 | pyyaml 9 | more-itertools 10 | commands = python tests/run.py 11 | 12 | [testenv:runner] 13 | commands = python tests/run.py 14 | 15 | [testenv:module] 16 | commands = python -m tap 17 | changedir = src 18 | 19 | [testenv:integration] 20 | deps = 21 | pytest 22 | pytest-tap 23 | commands = 24 | pytest --tap-files --tap-outdir=results {envsitepackagesdir}/tap 25 | tappy results 26 | 27 | [testenv:coverage] 28 | setenv = 29 | CI = true 30 | deps = 31 | pytest 32 | pytest-cov 33 | pyyaml 34 | more-itertools 35 | commands = 36 | pytest --cov=tap --cov-report xml --cov-report term 37 | --------------------------------------------------------------------------------