├── .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 |
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 |
--------------------------------------------------------------------------------