diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..072c25e --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,5 @@ +To accept your contribution, please ensure that the checklist below is complete. + +* [ ] Is your name/identity in the AUTHORS file? +* [ ] Does the code change (if the PR contains code) have 100% test coverage? +* [ ] Is CI passing all quality and testing checks? diff --git a/.gitignore b/.gitignore index 39ec5ab..7755710 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ pip-log.txt nosetests.xml htmlcov .cache +.pytest_cache # Translations .transifex.ini @@ -38,8 +39,9 @@ htmlcov .project .pydevproject -# Vim +# Dev *.swp +.vscode # TAP *.tap diff --git a/.travis.yml b/.travis.yml index 3e01a1f..45292de 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,8 @@ sudo: false language: python +dist: xenial matrix: include: - - os: linux - python: 2.7 - env: TOX_ENV=py27 - - os: linux - python: 3.4 - env: TOX_ENV=py34 - os: linux python: 3.5 env: TOX_ENV=py35 @@ -15,55 +10,36 @@ matrix: python: 3.6 env: TOX_ENV=py36 - os: linux - python: pypy - env: TOX_ENV=pypy + python: 3.7 + env: TOX_ENV=py37 + - os: linux + python: 3.8 + env: TOX_ENV=py38 + - os: linux + python: pypy3.5 + env: TOX_ENV=pypy3 - os: osx + osx_image: xcode11.2 language: generic - env: TOX_ENV=py27 - before_install: - - brew upgrade python - - python3 -m venv venv - - source venv/bin/activate + env: TOX_ENV=py37 + - os: linux + python: 3.8 + env: TOX_ENV=with_optional - os: linux - python: 2.7 + python: 3.8 env: TOX_ENV=runner - os: linux - python: 2.7 - env: TOX_ENV=flake8 + python: 3.8 + env: TOX_ENV=module + - os: linux + python: 3.8 + env: TOX_ENV=lint - os: linux - python: 2.7 + python: 3.8 env: TOX_ENV=integration - os: linux - python: 2.7 + python: 3.8 env: TOX_ENV=coverage - # Stop testing the languages until someone steps up to support them. - # - os: linux - # python: 2.7 - # env: TOX_ENV=language_ar - # - os: linux - # python: 2.7 - # env: TOX_ENV=language_de - # - os: linux - # python: 2.7 - # env: TOX_ENV=language_es - # - os: linux - # python: 2.7 - # env: TOX_ENV=language_fr - # - os: linux - # python: 2.7 - # env: TOX_ENV=language_it - # - os: linux - # python: 2.7 - # env: TOX_ENV=language_ja - # - os: linux - # python: 2.7 - # env: TOX_ENV=language_nl - # - os: linux - # python: 2.7 - # env: TOX_ENV=language_pt - # - os: linux - # python: 2.7 - # env: TOX_ENV=language_ru install: - - pip install Babel tox + - pip3 install tox script: tox -e $TOX_ENV diff --git a/AUTHORS b/AUTHORS index addfcbc..be1b390 100644 --- a/AUTHORS +++ b/AUTHORS @@ -3,10 +3,18 @@ tappy was originally created by Matt Layman. Contributors ------------ +* Adeodato Simó * Andrew McNamara * Chris Clarke +* Erik Cederstrand * Marc Abramowitz * Mark E. Hamilton * Matt Layman +* meejah (https://meejah.ca) * Michael F. Lamb (http://datagrok.org) +* Mikael Barfred +* Nicolai Søborg * Nicolas Caniart +* Richard Bosworth +* Ross Burton +* Simon McVittie diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..3b76411 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +As a Python project, +tappy adheres +to the [PSF Code of Conduct](https://www.python.org/psf/conduct/). diff --git a/LICENSE b/LICENSE index 443a83f..852b81e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2018, Matt Layman and contributors. See AUTHORS for more details. +Copyright (c) 2019, Matt Layman and contributors. See AUTHORS for more details. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/MANIFEST.in b/MANIFEST.in index 8a23171..56c4599 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,6 @@ include AUTHORS include LICENSE include README.md -include Pipfile -include Pipfile.lock recursive-include docs * recursive-include tap/locale * prune docs/_build diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 0b7bf1d..0000000 --- a/Pipfile +++ /dev/null @@ -1,22 +0,0 @@ -[[source]] - -url = "https://pypi.python.org/simple" -verify_ssl = true -name = "pypi" - - -[dev-packages] - -Babel = "*" -coverage = "*" -"flake8" = "*" -mock = "*" -requests = "*" -Sphinx = "*" -tox = "*" -twine = "*" -pytest = "*" - - -[packages] - diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 85019cc..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,349 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "f8c87d38088c4cd0c495b52eeeb100c09fa7d526e11f4ed07d203b6391bd7bed" - }, - "host-environment-markers": { - "implementation_name": "cpython", - "implementation_version": "3.6.2", - "os_name": "posix", - "platform_machine": "x86_64", - "platform_python_implementation": "CPython", - "platform_release": "15.6.0", - "platform_system": "Darwin", - "platform_version": "Darwin Kernel Version 15.6.0: Mon Nov 13 21:58:35 PST 2017; root:xnu-3248.72.11~1/RELEASE_X86_64", - "python_full_version": "3.6.2", - "python_version": "3.6", - "sys_platform": "darwin" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.python.org/simple", - "verify_ssl": true - } - ] - }, - "default": {}, - "develop": { - "alabaster": { - "hashes": [ - "sha256:2eef172f44e8d301d25aff8068fddd65f767a3f04b5f15b0f4922f113aa1c732", - "sha256:37cdcb9e9954ed60912ebc1ca12a9d12178c26637abdf124e3cde2341c257fe0" - ], - "version": "==0.7.10" - }, - "attrs": { - "hashes": [ - "sha256:a17a9573a6f475c99b551c0e0a812707ddda1ec9653bed04c13841404ed6f450", - "sha256:1c7960ccfd6a005cd9f7ba884e6316b5e430a3f1a6c37c5f87d8b43f83b54ec9" - ], - "version": "==17.4.0" - }, - "babel": { - "hashes": [ - "sha256:f20b2acd44f587988ff185d8949c3e208b4b3d5d20fcab7d91fe481ffa435528", - "sha256:6007daf714d0cd5524bbe436e2d42b3c20e68da66289559341e48d2cd6d25811" - ], - "version": "==2.5.1" - }, - "certifi": { - "hashes": [ - "sha256:244be0d93b71e93fc0a0a479862051414d0e00e16435707e5bf5000f92e04694", - "sha256:5ec74291ca1136b40f0379e1128ff80e866597e4e2c1e755739a913bbc3613c0" - ], - "version": "==2017.11.5" - }, - "chardet": { - "hashes": [ - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691", - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae" - ], - "version": "==3.0.4" - }, - "configparser": { - "hashes": [ - "sha256:5308b47021bc2340965c371f0f058cc6971a04502638d4244225c49d80db273a" - ], - "markers": "python_version < '3.2'", - "version": "==3.5.0" - }, - "coverage": { - "hashes": [ - "sha256:d1ee76f560c3c3e8faada866a07a32485445e16ed2206ac8378bd90dadffb9f0", - "sha256:007eeef7e23f9473622f7d94a3e029a45d55a92a1f083f0f3512f5ab9a669b05", - "sha256:17307429935f96c986a1b1674f78079528833410750321d22b5fb35d1883828e", - "sha256:845fddf89dca1e94abe168760a38271abfc2e31863fbb4ada7f9a99337d7c3dc", - "sha256:3f4d0b3403d3e110d2588c275540649b1841725f5a11a7162620224155d00ba2", - "sha256:4c4f368ffe1c2e7602359c2c50233269f3abe1c48ca6b288dcd0fb1d1c679733", - "sha256:f8c55dd0f56d3d618dfacf129e010cbe5d5f94b6951c1b2f13ab1a2f79c284da", - "sha256:cdd92dd9471e624cd1d8c1a2703d25f114b59b736b0f1f659a98414e535ffb3d", - "sha256:2ad357d12971e77360034c1596011a03f50c0f9e1ecd12e081342b8d1aee2236", - "sha256:e9a0e1caed2a52f15c96507ab78a48f346c05681a49c5b003172f8073da6aa6b", - "sha256:eea9135432428d3ca7ee9be86af27cb8e56243f73764a9b6c3e0bda1394916be", - "sha256:700d7579995044dc724847560b78ac786f0ca292867447afda7727a6fbaa082e", - "sha256:66f393e10dd866be267deb3feca39babba08ae13763e0fc7a1063cbe1f8e49f6", - "sha256:5ff16548492e8a12e65ff3d55857ccd818584ed587a6c2898a9ebbe09a880674", - "sha256:d00e29b78ff610d300b2c37049a41234d48ea4f2d2581759ebcf67caaf731c31", - "sha256:87d942863fe74b1c3be83a045996addf1639218c2cb89c5da18c06c0fe3917ea", - "sha256:358d635b1fc22a425444d52f26287ae5aea9e96e254ff3c59c407426f44574f4", - "sha256:81912cfe276e0069dca99e1e4e6be7b06b5fc8342641c6b472cb2fed7de7ae18", - "sha256:079248312838c4c8f3494934ab7382a42d42d5f365f0cf7516f938dbb3f53f3f", - "sha256:b0059630ca5c6b297690a6bf57bf2fdac1395c24b7935fd73ee64190276b743b", - "sha256:493082f104b5ca920e97a485913de254cbe351900deed72d4264571c73464cd0", - "sha256:e3ba9b14607c23623cf38f90b23f5bed4a3be87cbfa96e2e9f4eabb975d1e98b", - "sha256:82cbd3317320aa63c65555aa4894bf33a13fb3a77f079059eb5935eea415938d", - "sha256:9721f1b7275d3112dc7ccf63f0553c769f09b5c25a26ee45872c7f5c09edf6c1", - "sha256:bd4800e32b4c8d99c3a2c943f1ac430cbf80658d884123d19639bcde90dad44a", - "sha256:f29841e865590af72c4b90d7b5b8e93fd560f5dea436c1d5ee8053788f9285de", - "sha256:f3a5c6d054c531536a83521c00e5d4004f1e126e2e2556ce399bef4180fbe540", - "sha256:dd707a21332615108b736ef0b8513d3edaf12d2a7d5fc26cd04a169a8ae9b526", - "sha256:2e1a5c6adebb93c3b175103c2f855eda957283c10cf937d791d81bef8872d6ca", - "sha256:f87f522bde5540d8a4b11df80058281ac38c44b13ce29ced1e294963dd51a8f8", - "sha256:a7cfaebd8f24c2b537fa6a271229b051cdac9c1734bb6f939ccfc7c055689baa", - "sha256:309d91bd7a35063ec7a0e4d75645488bfab3f0b66373e7722f23da7f5b0f34cc", - "sha256:0388c12539372bb92d6dde68b4627f0300d948965bbb7fc104924d715fdc0965", - "sha256:ab3508df9a92c1d3362343d235420d08e2662969b83134f8a97dc1451cbe5e84", - "sha256:43a155eb76025c61fc20c3d03b89ca28efa6f5be572ab6110b2fb68eda96bfea", - "sha256:f98b461cb59f117887aa634a66022c0bd394278245ed51189f63a036516e32de", - "sha256:b6cebae1502ce5b87d7c6f532fa90ab345cfbda62b95aeea4e431e164d498a3d", - "sha256:a4497faa4f1c0fc365ba05eaecfb6b5d24e3c8c72e95938f9524e29dadb15e76", - "sha256:2b4d7f03a8a6632598cbc5df15bbca9f778c43db7cf1a838f4fa2c8599a8691a", - "sha256:1afccd7e27cac1b9617be8c769f6d8a6d363699c9b86820f40c74cfb3328921c" - ], - "version": "==4.4.2" - }, - "docutils": { - "hashes": [ - "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6", - "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", - "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274" - ], - "version": "==0.14" - }, - "enum34": { - "hashes": [ - "sha256:6bd0f6ad48ec2aa117d3d141940d484deccda84d4fcd884f5c3d93c23ecd8c79", - "sha256:644837f692e5f550741432dd3f223bbb9852018674981b1664e5dc339387588a", - "sha256:8ad8c4783bf61ded74527bffb48ed9b54166685e4230386a9ed9b1279e2df5b1", - "sha256:2d81cbbe0e73112bdfe6ef8576f2238f2ba27dd0d55752a776c41d38b7da2850" - ], - "markers": "python_version < '3.4'", - "version": "==1.1.6" - }, - "flake8": { - "hashes": [ - "sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37", - "sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0" - ], - "version": "==3.5.0" - }, - "funcsigs": { - "hashes": [ - "sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca", - "sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50" - ], - "markers": "python_version < '3.3'", - "version": "==1.0.2" - }, - "idna": { - "hashes": [ - "sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4", - "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f" - ], - "version": "==2.6" - }, - "imagesize": { - "hashes": [ - "sha256:6ebdc9e0ad188f9d1b2cdd9bc59cbe42bf931875e829e7a595e6b3abdc05cdfb", - "sha256:0ab2c62b87987e3252f89d30b7cedbec12a01af9274af9ffa48108f2c13c6062" - ], - "version": "==0.7.1" - }, - "jinja2": { - "hashes": [ - "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", - "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" - ], - "version": "==2.10" - }, - "markupsafe": { - "hashes": [ - "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" - ], - "version": "==1.0" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, - "mock": { - "hashes": [ - "sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1", - "sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba" - ], - "version": "==2.0.0" - }, - "pbr": { - "hashes": [ - "sha256:60c25b7dfd054ef9bb0ae327af949dd4676aa09ac3a9471cdc871d8a9213f9ac", - "sha256:05f61c71aaefc02d8e37c0a3eeb9815ff526ea28b3b76324769e6158d7f95be1" - ], - "version": "==3.1.1" - }, - "pkginfo": { - "hashes": [ - "sha256:31a49103180ae1518b65d3f4ce09c784e2bc54e338197668b4fb7dc539521024", - "sha256:bb1a6aeabfc898f5df124e7e00303a5b3ec9a489535f346bfbddb081af93f89e" - ], - "version": "==1.4.1" - }, - "pluggy": { - "hashes": [ - "sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff" - ], - "version": "==0.6.0" - }, - "py": { - "hashes": [ - "sha256:8cca5c229d225f8c1e3085be4fcf306090b00850fefad892f9d96c7b6e2f310f", - "sha256:ca18943e28235417756316bfada6cd96b23ce60dd532642690dcfdaba988a76d" - ], - "version": "==1.5.2" - }, - "pycodestyle": { - "hashes": [ - "sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9", - "sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766" - ], - "version": "==2.3.1" - }, - "pyflakes": { - "hashes": [ - "sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f", - "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805" - ], - "version": "==1.6.0" - }, - "pygments": { - "hashes": [ - "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d", - "sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc" - ], - "version": "==2.2.0" - }, - "pytest": { - "hashes": [ - "sha256:b84878865558194630c6147f44bdaef27222a9f153bbd4a08908b16bf285e0b1", - "sha256:53548280ede7818f4dc2ad96608b9f08ae2cc2ca3874f2ceb6f97e3583f25bc4" - ], - "version": "==3.3.2" - }, - "pytz": { - "hashes": [ - "sha256:80af0f3008046b9975242012a985f04c5df1f01eed4ec1633d56cc47a75a6a48", - "sha256:feb2365914948b8620347784b6b6da356f31c9d03560259070b2f30cff3d469d", - "sha256:59707844a9825589878236ff2f4e0dc9958511b7ffaae94dc615da07d4a68d33", - "sha256:d0ef5ef55ed3d37854320d4926b04a4cb42a2e88f71da9ddfdacfde8e364f027", - "sha256:c41c62827ce9cafacd6f2f7018e4f83a6f1986e87bfd000b8cfbd4ab5da95f1a", - "sha256:8cc90340159b5d7ced6f2ba77694d946fc975b09f1a51d93f3ce3bb399396f94", - "sha256:dd2e4ca6ce3785c8dd342d1853dd9052b19290d5bf66060846e5dc6b8d6667f7", - "sha256:699d18a2a56f19ee5698ab1123bbcc1d269d061996aeb1eda6d89248d3542b82", - "sha256:fae4cffc040921b8a2d60c6cf0b5d662c1190fe54d718271db4eb17d44a185b7" - ], - "version": "==2017.3" - }, - "requests": { - "hashes": [ - "sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b", - "sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e" - ], - "version": "==2.18.4" - }, - "requests-toolbelt": { - "hashes": [ - "sha256:42c9c170abc2cacb78b8ab23ac957945c7716249206f90874651971a4acff237", - "sha256:f6a531936c6fa4c6cfce1b9c10d5c4f498d16528d2a54a22ca00011205a187b5" - ], - "version": "==0.8.0" - }, - "six": { - "hashes": [ - "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb", - "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9" - ], - "version": "==1.11.0" - }, - "snowballstemmer": { - "hashes": [ - "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89", - "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128" - ], - "version": "==1.2.1" - }, - "sphinx": { - "hashes": [ - "sha256:fdf77f4f30d84a314c797d67fe7d1b46665e6c48a25699d7bf0610e05a2221d4", - "sha256:c6de5dbdbb7a0d7d2757f4389cc00e8f6eb3c49e1772378967a12cfcf2cfe098" - ], - "version": "==1.6.5" - }, - "sphinxcontrib-websupport": { - "hashes": [ - "sha256:f4932e95869599b89bf4f80fc3989132d83c9faa5bf633e7b5e0c25dffb75da2", - "sha256:7a85961326aa3a400cd4ad3c816d70ed6f7c740acd7ce5d78cd0a67825072eb9" - ], - "version": "==1.0.1" - }, - "tox": { - "hashes": [ - "sha256:8af30fd835a11f3ff8e95176ccba5a4e60779df4d96a9dfefa1a1704af263225", - "sha256:752f5ec561c6c08c5ecb167d3b20f4f4ffc158c0ab78855701a75f5cef05f4b8" - ], - "version": "==2.9.1" - }, - "tqdm": { - "hashes": [ - "sha256:4c041f8019f7be65b8028ddde9a836f7ccc51c4637f1ff2ba9b5813d38d19d5a", - "sha256:df32e6f127dc0ccbc675eadb33f749abbcb8f174c5cb9ec49c0cdb73aa737377" - ], - "version": "==4.19.5" - }, - "twine": { - "hashes": [ - "sha256:d3ce5c480c22ccfb761cd358526e862b32546d2fe4bc93d46b5cf04ea3cc46ca", - "sha256:caa45b7987fc96321258cd7668e3be2ff34064f5c66d2d975b641adca659c1ab" - ], - "version": "==1.9.1" - }, - "typing": { - "hashes": [ - "sha256:349b1f9c109c84b53ac79ac1d822eaa68fc91d63b321bd9392df15098f746f53", - "sha256:63a8255fe7c6269916baa440eb9b6a67139b0b97a01af632e7bd2842e1e02f15", - "sha256:d514bd84b284dd3e844f0305ac07511f097e325171f6cc4a20878d11ad771849" - ], - "markers": "python_version < '3.5'", - "version": "==3.6.2" - }, - "urllib3": { - "hashes": [ - "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b", - "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f" - ], - "version": "==1.22" - }, - "virtualenv": { - "hashes": [ - "sha256:39d88b533b422825d644087a21e78c45cf5af0ef7a99a1fc9fbb7b481e5c85b0", - "sha256:02f8102c2436bb03b3ee6dede1919d1dac8a427541652e5ec95171ec8adbc93a" - ], - "markers": "python_version != '3.2'", - "version": "==15.1.0" - } - } -} diff --git a/README.md b/README.md index 74e189a..daaea28 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,10 @@ Installation tappy is available for download from [PyPI][pypi]. tappy is currently supported on Python -2.7, -3.4, 3.5, 3.6, +3.7, +3.8, and PyPy. It is continuously tested on Linux, OS X, and Windows. @@ -53,6 +53,17 @@ you only need to install `nose-tap`. $ pip install nose-tap ``` +TAP version 13 brings support +for [YAML blocks](http://testanything.org/tap-version-13-specification.html#yaml-blocks) +associated with test results. +To work with version 13, install the optional dependencies. +Learn more about YAML support +in the [TAP version 13](http://tappy.readthedocs.io/en/latest/consumers.html#tap-version-13) section. + +```bash +$ pip install tap.py[yaml] +``` + Motivation ---------- diff --git a/appveyor.yml b/appveyor.yml index dbe0150..bd73777 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,10 +1,10 @@ environment: - PYTHON: "C:\\Python34" - PATH: "C:\\Python34;C:\\Python34\\Scripts;%PATH%" + PYTHON: "C:\\Python36" + PATH: "C:\\Python36;C:\\Python36\\Scripts;%PATH%" install: - python -m ensurepip - - pip install Babel tox + - pip install tox build_script: - python --version test_script: - - tox -e py34 + - tox -e windows diff --git a/docs/conf.py b/docs/conf.py index ec1f6d2..23e156a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,7 +52,7 @@ # General information about the project. project = u'tappy' -copyright = u'2018, Matt Layman and contributors' +copyright = u'2019, Matt Layman and contributors' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/docs/consumers.rst b/docs/consumers.rst index 91389ad..6f8d645 100644 --- a/docs/consumers.rst +++ b/docs/consumers.rst @@ -112,6 +112,26 @@ The API specifics are listed below. .. autoclass:: tap.parser.Parser :members: +.. _tap-version-13: + +TAP version 13 +~~~~~~~~~~~~~~ + +The specification for TAP version 13 adds support for `yaml blocks `_ +to provide additional information about the preceding test. In order to consume +yaml blocks, ``tappy`` requires `pyyaml `_ and +`more-itertools `_ to be installed. + +These dependencies are optional. If they are not installed, TAP output will still +be consumed, but any yaml blocks will be parsed as :class:`tap.line.Unknown`. If a +:class:`tap.line.Result` object has an associated yaml block, :attr:`~tap.line.Result.yaml_block` +will return the block converted to a ``dict``. Otherwise, it will return ``None``. + +``tappy`` provides a strict interpretation of the specification. A yaml block will +only be associated with a result if it immediately follows that result. Any +:class:`diagnostic ` between a :class:`result ` and a yaml +block will result in the block lines being parsed as :class:`tap.line.Unknown`. + Line Categories ~~~~~~~~~~~~~~~ diff --git a/docs/contributing.rst b/docs/contributing.rst index d9fb2d2..e0c47f8 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -14,20 +14,15 @@ when you're ready. Setup ----- -tappy uses Pipenv -to manage development. -The following instructions assume that Pipenv is installed. -See the `Pipenv install instructions `_ -for more details. - -After installing Pipenv: +tappy uses the built-in `venv` module. .. code-block:: console $ git clone git@github.com:python-tap/tappy.git $ cd tappy - $ pipenv install --dev --ignore-pipfile - $ pipenv shell + $ python3 -m venv venv + $ source venv/bin/activate + $ pip install -r requirements-dev.txt $ # Edit some files and run the tests. $ pytest @@ -39,6 +34,9 @@ you should be ready to develop. Guidelines ---------- -1. Code should follow PEP 8 style. Please run it through ``pep8`` to check. -2. Please try to conform with any conventions seen in the code for consistency. -3. Make sure your change works against master! (Bonus points for unit tests.) +1. Code uses Black style. Please run it through ``black tap`` to autoformat. +2. Make sure your change works against master! (Bonus points for unit tests.) +3. Document your change in the ``docs/releases.rst`` file. +4. For first time contributors, please add your name to ``AUTHORS`` + so you get attribution for you effort. + This is also to recognize your claim to the copyright in the project. diff --git a/docs/index.rst b/docs/index.rst index deb8818..79da86a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,13 +24,40 @@ Installation tappy is available for download from `PyPI `_. tappy is currently supported on -Python 2.7, 3.4, 3.5, 3.6, and PyPy. +Python +3.5, +3.6, +3.7, +3.8, +and PyPy. It is continuously tested on Linux, OS X, and Windows. .. code-block:: console $ pip install tap.py +TAP version 13 brings support for YAML blocks +for `YAML blocks `_ +associated with test results. +To work with version 13, install the optional dependencies. +Learn more about YAML support in the :ref:`tap-version-13` section. + +.. code-block:: console + + $ pip install tap.py[yaml] + +Quickstart +---------- + +tappy can run like the built-in ``unittest`` discovery runner. + +.. code-block:: console + + $ python -m tap + +This should be enough to run a unittest-based test suite +and output TAP to the console. + Documentation ------------- diff --git a/docs/producers.rst b/docs/producers.rst index e44f225..ad5434f 100644 --- a/docs/producers.rst +++ b/docs/producers.rst @@ -13,6 +13,8 @@ and support for `pytest `_. for the **nose** testing tool. * tappy for **pytest** - tappy provides a plugin called ``tap`` for the **pytest** testing tool. +* tappy as the test runner - tappy can run like ``python -m unittest``. + Run your test suite with ``python -m tap``. By default, the producers will create one TAP file for each ``TestCase`` executed by the test suite. diff --git a/docs/releases.rst b/docs/releases.rst index 323874b..7f708c7 100644 --- a/docs/releases.rst +++ b/docs/releases.rst @@ -1,6 +1,61 @@ Releases ======== +Version 3.1, To Be Released +--------------------------- + +* Add support for Python 3.8. +* Fix parsing of multi-line strings in YAML blocks (#111) + +Version 3.0, Released January 10, 2020 +-------------------------------------- + +* Drop support for Python 2 (it is end-of-life). +* Add support for subtests. +* Run a test suite with ``python -m tap``. +* Discontinue use of Pipenv for managing development. + +Version 2.6.2, Released October 20, 2019 +---------------------------------------- + +* Fix bug in streaming mode that would generate tap files + when the plan was already set (affected pytest). + +Version 2.6.1, Released September 17, 2019 +------------------------------------------ + +* Fix TAP version 13 support from more-itertools behavior change. + +Version 2.6, Released September 16, 2019 +---------------------------------------- + +* Add support for Python 3.7. +* Drop support for Python 3.4 (it is end-of-life). + +Version 2.5, Released September 15, 2018 +---------------------------------------- + +* Add ``set_plan`` to ``Tracker`` which allows producing the ``1..N`` plan line + before any tests. +* Switch code style to use Black formatting. + + +Version 2.4, Released May 29, 2018 +---------------------------------- + +* Add support for producing TAP version 13 output + to streaming and file reports + by including the ``TAP version 13`` line. + +Version 2.3, Released May 15, 2018 +---------------------------------- + +* Add optional method to install tappy for YAML support + with ``pip install tap.py[yaml]``. +* Make tappy version 13 compliant by adding support for parsing YAML blocks. +* ``unittest.expectedFailure`` now uses a TODO directive to better align + with the specification. + Version 2.2, Released January 7, 2018 ------------------------------------- diff --git a/docs/tappy.1.rst b/docs/tappy.1.rst index 0ee254d..6d59377 100644 --- a/docs/tappy.1.rst +++ b/docs/tappy.1.rst @@ -17,7 +17,7 @@ The :program:`tappy` command consumes the list of tap files given as *pathname* s and produces an output similar to what the regular text test-runner from python's :py:mod:`unittest` module would. If *pathname* points to a directory, -:program:`tappy` will look in that directory of ``*.tap`` +:program:`tappy` will look in that directory for ``*.tap`` files to consume. If you have a tool that consumes the `unittest` regular output, diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7d020b6 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,14 @@ +Babel +black +coverage +flake8 +pytest +requests +Sphinx +tox +twine +wheel + +# These are the optional dependencies to enable TAP version 13 support. +more-itertools +pyyaml diff --git a/setup.cfg b/setup.cfg index 24fa24b..21dc8f9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,5 +10,8 @@ input_dirs = tap output_file = tap/locale/tappy.pot copyright_holder = Matt Layman +[flake8] +max-line-length = 88 + [metadata] license-file = LICENSE diff --git a/setup.py b/setup.py index c28cd52..172e31e 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors """ tappy is a set of tools for working with the `Test Anything Protocol (TAP) `_, a line based test protocol for recording test @@ -9,82 +9,74 @@ `Read the Docs `_. """ -from setuptools import find_packages, setup -from setuptools.command.build_py import build_py -from setuptools.command.sdist import sdist +from setuptools import find_packages, setup, Command import tap -class BuildPy(build_py): - """Custom ``build_py`` command to always build mo files for wheels.""" - - def run(self): - self.run_command('compile_catalog') - # build_py is an old style class so super cannot be used. - build_py.run(self) +class ReleaseCommand(Command): + description = "generate distribution release artifacts" + user_options = [] + def initialize_options(self): + """Initialize options. + This method overrides a required abstract method. + """ -class Sdist(sdist): - """Custom ``sdist`` command to ensure that mo files are always created.""" + def finalize_options(self): + """Finalize options. + This method overrides a required abstract method. + """ def run(self): - self.run_command('compile_catalog') - # sdist is an old style class so super cannot be used. - sdist.run(self) + """Generate the distribution release artifacts. + The custom command is used to ensure that compiling + po to mo is not skipped. + """ + self.run_command("compile_catalog") + self.run_command("sdist") + self.run_command("bdist_wheel") # The docs import setup.py for the version so only call setup when not behaving # as a module. -if __name__ == '__main__': - with open('docs/releases.rst', 'r') as f: +if __name__ == "__main__": + with open("docs/releases.rst", "r") as f: releases = f.read() - long_description = __doc__ + '\n\n' + releases + long_description = __doc__ + "\n\n" + releases setup( - name='tap.py', + name="tap.py", version=tap.__version__, - url='https://github.com/python-tap/tappy', - license='BSD', - author='Matt Layman', - author_email='matthewlayman@gmail.com', - description='Test Anything Protocol (TAP) tools', + url="https://github.com/python-tap/tappy", + license="BSD", + author="Matt Layman", + author_email="matthewlayman@gmail.com", + description="Test Anything Protocol (TAP) tools", long_description=long_description, packages=find_packages(), entry_points={ - 'console_scripts': [ - 'tappy = tap.main:main', - 'tap = tap.main:main', - ], + "console_scripts": ["tappy = tap.main:main", "tap = tap.main:main"] }, include_package_data=True, zip_safe=False, - platforms='any', + platforms="any", install_requires=[], + extras_require={"yaml": ["more-itertools", "PyYAML>=5.1"]}, classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Topic :: Software Development :: Testing', + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Testing", ], - keywords=[ - 'TAP', - 'unittest', - ], - cmdclass={ - 'build_py': BuildPy, - 'sdist': Sdist, - }, - test_suite='tap.tests', - tests_require=[ - 'mock' - ] + keywords=["TAP", "unittest"], + cmdclass={"release": ReleaseCommand}, ) diff --git a/tap/__init__.py b/tap/__init__.py index e90a1e8..613a61a 100644 --- a/tap/__init__.py +++ b/tap/__init__.py @@ -1,6 +1,6 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors from .runner import TAPTestRunner -__all__ = ['TAPTestRunner'] -__version__ = '2.2' +__all__ = ["TAPTestRunner"] +__version__ = "3.0" diff --git a/tap/__main__.py b/tap/__main__.py new file mode 100644 index 0000000..5fa9af8 --- /dev/null +++ b/tap/__main__.py @@ -0,0 +1,3 @@ +from tap.main import main_module + +main_module() diff --git a/tap/adapter.py b/tap/adapter.py index 7ea5532..7f76279 100644 --- a/tap/adapter.py +++ b/tap/adapter.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors class Adapter(object): @@ -6,6 +6,7 @@ class Adapter(object): It is an alternative to TestCase to collect TAP results. """ + failureException = AssertionError def __init__(self, filename, line): @@ -45,7 +46,7 @@ def addFailure(self, result): # Since TAP will not provide assertion data, clean up the assertion # section so it is not so spaced out. test, err = result.failures[-1] - result.failures[-1] = (test, '') + result.failures[-1] = (test, "") def __repr__(self): - return ''.format(filename=self._filename) + return "".format(filename=self._filename) diff --git a/tap/directive.py b/tap/directive.py index 50e7303..c114309 100644 --- a/tap/directive.py +++ b/tap/directive.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import re @@ -10,15 +10,17 @@ class Directive(object): r"""^SKIP\S* (?P\s*) # Optional whitespace. (?P.*) # Slurp up the rest.""", - re.IGNORECASE | re.VERBOSE) + re.IGNORECASE | re.VERBOSE, + ) todo_pattern = re.compile( r"""^TODO\b # The directive name (?P\s*) # Immediately following must be whitespace. (?P.*) # Slurp up the rest.""", - re.IGNORECASE | re.VERBOSE) + re.IGNORECASE | re.VERBOSE, + ) def __init__(self, text): - """Initialize the directive by parsing the text. + r"""Initialize the directive by parsing the text. The text is assumed to be everything after a '#\s*' on a result line. """ @@ -30,17 +32,17 @@ def __init__(self, text): match = self.skip_pattern.match(text) if match: self._skip = True - self._reason = match.group('reason') + self._reason = match.group("reason") match = self.todo_pattern.match(text) if match: - if match.group('whitespace'): + if match.group("whitespace"): self._todo = True else: # Catch the case where the directive has no descriptive text. - if match.group('reason') == '': + if match.group("reason") == "": self._todo = True - self._reason = match.group('reason') + self._reason = match.group("reason") @property def text(self): diff --git a/tap/formatter.py b/tap/formatter.py index f42b150..71e4d59 100644 --- a/tap/formatter.py +++ b/tap/formatter.py @@ -10,7 +10,7 @@ def format_exception(exception): # The lines returned from format_exception do not strictly contain # one line per element in the list (i.e. some elements have new # line characters in the middle). Normalize that oddity. - lines = ''.join(exception_lines).splitlines(True) + lines = "".join(exception_lines).splitlines(True) return format_as_diagnostics(lines) @@ -19,4 +19,4 @@ def format_as_diagnostics(lines): This function makes no assumptions about the line endings. """ - return ''.join(['# ' + line for line in lines]) + return "".join(["# " + line for line in lines]) diff --git a/tap/i18n.py b/tap/i18n.py index bff8366..ad106ca 100644 --- a/tap/i18n.py +++ b/tap/i18n.py @@ -1,8 +1,8 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import gettext import os -localedir = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'locale') -translate = gettext.translation('tappy', localedir, fallback=True) +localedir = os.path.join(os.path.abspath(os.path.dirname(__file__)), "locale") +translate = gettext.translation("tappy", localedir, fallback=True) _ = translate.gettext diff --git a/tap/line.py b/tap/line.py index b38fa31..fc8f8e2 100644 --- a/tap/line.py +++ b/tap/line.py @@ -1,4 +1,10 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors +try: + import yaml + + LOAD_YAML = True +except ImportError: # pragma: no cover + LOAD_YAML = False class Line(object): @@ -6,6 +12,7 @@ class Line(object): TAP is a line based protocol. Thus, the most primitive type is a line. """ + @property def category(self): raise NotImplementedError @@ -15,8 +22,14 @@ class Result(Line): """Information about an individual test line.""" def __init__( - self, ok, number=None, description='', directive=None, - diagnostics=None): + self, + ok, + number=None, + description="", + directive=None, + diagnostics=None, + raw_yaml_block=None, + ): self._ok = ok if number: self._number = int(number) @@ -26,11 +39,12 @@ def __init__( self._description = description self.directive = directive self.diagnostics = diagnostics + self._yaml_block = raw_yaml_block @property def category(self): """:returns: ``test``""" - return 'test' + return "test" @property def ok(self): @@ -69,18 +83,38 @@ def todo(self): """ return self.directive.todo + @property + def yaml_block(self): + """Lazy load a yaml_block. + + If yaml support is not available, + there is an error in parsing the yaml block, + or no yaml is associated with this result, + ``None`` will be returned. + + :rtype: dict + """ + if LOAD_YAML and self._yaml_block is not None: + try: + yaml_dict = yaml.load(self._yaml_block, Loader=yaml.SafeLoader) + return yaml_dict + except yaml.error.YAMLError: + print("Error parsing yaml block. Check formatting.") + return None + def __str__(self): - is_not = '' + is_not = "" if not self.ok: - is_not = 'not ' - directive = '' + is_not = "not " + directive = "" if self.directive is not None and self.directive.text: - directive = ' # {0}'.format(self.directive.text) - diagnostics = '' + directive = " # {0}".format(self.directive.text) + diagnostics = "" if self.diagnostics is not None: - diagnostics = '\n' + self.diagnostics.rstrip() + diagnostics = "\n" + self.diagnostics.rstrip() return "{0}ok {1} {2}{3}{4}".format( - is_not, self.number, self.description, directive, diagnostics) + is_not, self.number, self.description, directive, diagnostics + ) class Plan(Line): @@ -93,7 +127,7 @@ def __init__(self, expected_tests, directive=None): @property def category(self): """:returns: ``plan``""" - return 'plan' + return "plan" @property def expected_tests(self): @@ -121,7 +155,7 @@ def __init__(self, text): @property def category(self): """:returns: ``diagnostic``""" - return 'diagnostic' + return "diagnostic" @property def text(self): @@ -138,7 +172,7 @@ def __init__(self, reason): @property def category(self): """:returns: ``bail``""" - return 'bail' + return "bail" @property def reason(self): @@ -155,7 +189,7 @@ def __init__(self, version): @property def category(self): """:returns: ``version``""" - return 'version' + return "version" @property def version(self): @@ -171,7 +205,8 @@ class Unknown(Line): This exists for the purpose of a Null Object pattern. """ + @property def category(self): """:returns: ``unknown``""" - return 'unknown' + return "unknown" diff --git a/tap/loader.py b/tap/loader.py index 83cdd50..b07b567 100644 --- a/tap/loader.py +++ b/tap/loader.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import os import unittest @@ -11,7 +11,7 @@ class Loader(object): """Load TAP lines into unittest-able objects.""" - ignored_lines = set(['diagnostic', 'unknown']) + ignored_lines = set(["diagnostic", "unknown"]) def __init__(self): self._parser = Parser() @@ -52,9 +52,9 @@ def load_suite_from_stdin(self): :returns: A ``unittest.TestSuite`` instance """ suite = unittest.TestSuite() - rules = Rules('stream', suite) + rules = Rules("stream", suite) line_generator = self._parser.parse_stdin() - return self._load_lines('stream', line_generator, suite, rules) + return self._load_lines("stream", line_generator, suite, rules) def _find_tests_in_directory(self, directory, suite): """Find test files in the directory and add them to the suite.""" @@ -72,18 +72,18 @@ def _load_lines(self, filename, line_generator, suite, rules): if line.category in self.ignored_lines: continue - if line.category == 'test': + if line.category == "test": suite.addTest(Adapter(filename, line)) rules.saw_test() - elif line.category == 'plan': + elif line.category == "plan": if line.skip: rules.handle_skipping_plan(line) return suite rules.saw_plan(line, line_counter) - elif line.category == 'bail': + elif line.category == "bail": rules.handle_bail(line) return suite - elif line.category == 'version': + elif line.category == "version": rules.saw_version_at(line_counter) rules.check(line_counter) diff --git a/tap/main.py b/tap/main.py index fb69712..af7650b 100644 --- a/tap/main.py +++ b/tap/main.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import argparse import sys @@ -6,6 +6,7 @@ from tap.i18n import _ from tap.loader import Loader +from tap.runner import TAPTestRunner def main(argv=sys.argv, stream=sys.stderr): @@ -21,7 +22,7 @@ def main(argv=sys.argv, stream=sys.stderr): def build_suite(args): """Build a test suite by loading TAP files or a TAP stream.""" loader = Loader() - if len(args.files) == 0 or args.files[0] == '-': + if len(args.files) == 0 or args.files[0] == "-": suite = loader.load_suite_from_stdin() else: suite = loader.load(args.files) @@ -29,18 +30,29 @@ def build_suite(args): def parse_args(argv): - description = _('A TAP consumer for Python') + description = _("A TAP consumer for Python") epilog = _( - 'When no files are given or a dash (-) is used for the file name, ' - 'tappy will read a TAP stream from STDIN.') + "When no files are given or a dash (-) is used for the file name, " + "tappy will read a TAP stream from STDIN." + ) parser = argparse.ArgumentParser(description=description, epilog=epilog) parser.add_argument( - 'files', metavar='FILE', nargs='*', help=_( - 'A file containing TAP output. Any directories listed will be ' - 'scanned for files to include as TAP files.')) + "files", + metavar="FILE", + nargs="*", + help=_( + "A file containing TAP output. Any directories listed will be " + "scanned for files to include as TAP files." + ), + ) parser.add_argument( - '-v', '--verbose', action='store_const', default=1, const=2, - help=_('use verbose messages')) + "-v", + "--verbose", + action="store_const", + default=1, + const=2, + help=_("use verbose messages"), + ) # argparse expects the executable to be removed from argv. args = parser.parse_args(argv[1:]) @@ -60,3 +72,10 @@ def get_status(result): return 0 else: return 1 + + +def main_module(): + """Entry point for running as ``python -m tap``.""" + runner = TAPTestRunner() + runner.set_stream(True) + unittest.main(module=None, testRunner=runner) diff --git a/tap/parser.py b/tap/parser.py index e28872b..2f28bd8 100644 --- a/tap/parser.py +++ b/tap/parser.py @@ -1,6 +1,7 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors from io import StringIO +import itertools import re import sys @@ -8,6 +9,14 @@ from tap.i18n import _ from tap.line import Bail, Diagnostic, Plan, Result, Unknown, Version +try: + from more_itertools import peekable + import yaml # noqa + + ENABLE_VERSION_13 = True +except ImportError: # pragma: no cover + ENABLE_VERSION_13 = False + class Parser(object): """A parser for TAP files and lines.""" @@ -22,23 +31,32 @@ class Parser(object): \s* # Optional whitespace. (?P.*) # Optional directive text. """ - ok = re.compile(r'^ok' + result_base, re.VERBOSE) - not_ok = re.compile(r'^not\ ok' + result_base, re.VERBOSE) - plan = re.compile(r""" + ok = re.compile(r"^ok" + result_base, re.VERBOSE) + not_ok = re.compile(r"^not\ ok" + result_base, re.VERBOSE) + plan = re.compile( + r""" ^1..(?P\d+) # Match the plan details. [^#]* # Consume any non-hash character to confirm only # directives appear with the plan details. \#? # Optional directive marker. \s* # Optional whitespace. (?P.*) # Optional directive text. - """, re.VERBOSE) - diagnostic = re.compile(r'^#') - bail = re.compile(r""" + """, + re.VERBOSE, + ) + diagnostic = re.compile(r"^#") + bail = re.compile( + r""" ^Bail\ out! \s* # Optional whitespace. (?P.*) # Optional reason. - """, re.VERBOSE) - version = re.compile(r'^TAP version (?P\d+)$') + """, + re.VERBOSE, + ) + version = re.compile(r"^TAP version (?P\d+)$") + + yaml_block_start = re.compile(r"^(?P\s+)-") + yaml_block_end = re.compile(r"^\s+\.\.\.") TAP_MINIMUM_DECLARED_VERSION = 13 @@ -48,7 +66,7 @@ def parse_file(self, filename): This is a generator method that will yield an object for each parsed line. The file given by `filename` is assumed to exist. """ - return self.parse(open(filename, 'r')) + return self.parse(open(filename, "r")) def parse_stdin(self): """Parse a TAP stream from standard input. @@ -73,18 +91,35 @@ def parse(self, fh): stripped from the input lines. """ with fh: - for line in fh: - yield self.parse_line(line.rstrip()) - - def parse_line(self, text): + try: + first_line = next(fh) + except StopIteration: + return + first_parsed = self.parse_line(first_line.rstrip()) + fh_new = itertools.chain([first_line], fh) + if first_parsed.category == "version" and first_parsed.version >= 13: + if ENABLE_VERSION_13: + fh_new = peekable(itertools.chain([first_line], fh)) + else: # pragma no cover + print( + """ +WARNING: Optional imports not found, TAP 13 output will be + ignored. To parse yaml, see requirements in docs: + https://tappy.readthedocs.io/en/latest/consumers.html#tap-version-13""" + ) + + for line in fh_new: + yield self.parse_line(line.rstrip(), fh_new) + + def parse_line(self, text, fh=None): """Parse a line into whatever TAP category it belongs.""" match = self.ok.match(text) if match: - return self._parse_result(True, match) + return self._parse_result(True, match, fh) match = self.not_ok.match(text) if match: - return self._parse_result(False, match) + return self._parse_result(False, match, fh) if self.diagnostic.match(text): return Diagnostic(text) @@ -95,7 +130,7 @@ def parse_line(self, text): match = self.bail.match(text) if match: - return Bail(match.group('reason')) + return Bail(match.group("reason")) match = self.version.match(text) if match: @@ -105,8 +140,8 @@ def parse_line(self, text): def _parse_plan(self, match): """Parse a matching plan line.""" - expected_tests = int(match.group('expected')) - directive = Directive(match.group('directive')) + expected_tests = int(match.group("expected")) + directive = Directive(match.group("directive")) # Only SKIP directives are allowed in the plan. if directive.text and not directive.skip: @@ -114,15 +149,52 @@ def _parse_plan(self, match): return Plan(expected_tests, directive) - def _parse_result(self, ok, match): + def _parse_result(self, ok, match, fh=None): """Parse a matching result line into a result instance.""" + peek_match = None + try: + if fh is not None and ENABLE_VERSION_13 and isinstance(fh, peekable): + peek_match = self.yaml_block_start.match(fh.peek()) + except StopIteration: + pass + if peek_match is None: + return Result( + ok, + number=match.group("number"), + description=match.group("description").strip(), + directive=Directive(match.group("directive")), + ) + indent = peek_match.group("indent") + concat_yaml = self._extract_yaml_block(indent, fh) return Result( - ok, match.group('number'), match.group('description').strip(), - Directive(match.group('directive'))) + ok, + number=match.group("number"), + description=match.group("description").strip(), + directive=Directive(match.group("directive")), + raw_yaml_block=concat_yaml, + ) + + def _extract_yaml_block(self, indent, fh): + """Extract a raw yaml block from a file handler""" + raw_yaml = [] + indent_match = re.compile(r"^{}".format(indent)) + try: + next(fh) + while indent_match.match(fh.peek()): + yaml_line = next(fh).replace(indent, "", 1) + raw_yaml.append(yaml_line.rstrip("\n")) + # check for the end and stop adding yaml if encountered + if self.yaml_block_end.match(fh.peek()): + next(fh) + break + except StopIteration: + pass + return "\n".join(raw_yaml) def _parse_version(self, match): - version = int(match.group('version')) + version = int(match.group("version")) if version < self.TAP_MINIMUM_DECLARED_VERSION: - raise ValueError(_('It is an error to explicitly specify ' - 'any version lower than 13.')) + raise ValueError( + _("It is an error to explicitly specify any version lower than 13.") + ) return Version(version) diff --git a/tap/rules.py b/tap/rules.py index 23a16f0..bd4df23 100644 --- a/tap/rules.py +++ b/tap/rules.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors from tap.adapter import Adapter from tap.directive import Directive @@ -7,47 +7,48 @@ class Rules(object): - def __init__(self, filename, suite): self._filename = filename self._suite = suite - self._lines_seen = {'plan': [], 'test': 0, 'version': []} + self._lines_seen = {"plan": [], "test": 0, "version": []} def check(self, final_line_count): """Check the status of all provided data and update the suite.""" - if self._lines_seen['version']: + if self._lines_seen["version"]: self._process_version_lines() self._process_plan_lines(final_line_count) def _process_version_lines(self): """Process version line rules.""" - if len(self._lines_seen['version']) > 1: - self._add_error(_('Multiple version lines appeared.')) - elif self._lines_seen['version'][0] != 1: - self._add_error(_('The version must be on the first line.')) + if len(self._lines_seen["version"]) > 1: + self._add_error(_("Multiple version lines appeared.")) + elif self._lines_seen["version"][0] != 1: + self._add_error(_("The version must be on the first line.")) def _process_plan_lines(self, final_line_count): """Process plan line rules.""" - if not self._lines_seen['plan']: - self._add_error(_('Missing a plan.')) + if not self._lines_seen["plan"]: + self._add_error(_("Missing a plan.")) return - if len(self._lines_seen['plan']) > 1: - self._add_error(_('Only one plan line is permitted per file.')) + if len(self._lines_seen["plan"]) > 1: + self._add_error(_("Only one plan line is permitted per file.")) return - plan, at_line = self._lines_seen['plan'][0] + plan, at_line = self._lines_seen["plan"][0] if not self._plan_on_valid_line(at_line, final_line_count): self._add_error( - _('A plan must appear at the beginning or end of the file.')) + _("A plan must appear at the beginning or end of the file.") + ) return - if plan.expected_tests != self._lines_seen['test']: - self._add_error(_( - 'Expected {expected_count} tests ' - 'but only {seen_count} ran.').format( + if plan.expected_tests != self._lines_seen["test"]: + self._add_error( + _("Expected {expected_count} tests but only {seen_count} ran.").format( expected_count=plan.expected_tests, - seen_count=self._lines_seen['test'])) + seen_count=self._lines_seen["test"], + ) + ) def _plan_on_valid_line(self, at_line, final_line_count): """Check if a plan is on a valid line.""" @@ -57,9 +58,10 @@ def _plan_on_valid_line(self, at_line, final_line_count): # The plan may only appear on line 2 if the version is at line 1. after_version = ( - self._lines_seen['version'] and - self._lines_seen['version'][0] == 1 and - at_line == 2) + self._lines_seen["version"] + and self._lines_seen["version"][0] == 1 + and at_line == 2 + ) if after_version: return True @@ -67,32 +69,30 @@ def _plan_on_valid_line(self, at_line, final_line_count): def handle_bail(self, bail): """Handle a bail line.""" - self._add_error(_('Bailed: {reason}').format(reason=bail.reason)) + self._add_error(_("Bailed: {reason}").format(reason=bail.reason)) def handle_file_does_not_exist(self): """Handle a test file that does not exist.""" - self._add_error(_('{filename} does not exist.').format( - filename=self._filename)) + self._add_error(_("{filename} does not exist.").format(filename=self._filename)) def handle_skipping_plan(self, skip_plan): """Handle a plan that contains a SKIP directive.""" - skip_line = Result( - True, None, skip_plan.directive.text, Directive('SKIP')) + skip_line = Result(True, None, skip_plan.directive.text, Directive("SKIP")) self._suite.addTest(Adapter(self._filename, skip_line)) def saw_plan(self, plan, at_line): """Record when a plan line was seen.""" - self._lines_seen['plan'].append((plan, at_line)) + self._lines_seen["plan"].append((plan, at_line)) def saw_test(self): """Record when a test line was seen.""" - self._lines_seen['test'] += 1 + self._lines_seen["test"] += 1 def saw_version_at(self, line_counter): """Record when a version line was seen.""" - self._lines_seen['version'].append(line_counter) + self._lines_seen["version"].append(line_counter) def _add_error(self, message): """Add an error test to the suite.""" - error_line = Result(False, None, message, Directive('')) + error_line = Result(False, None, message, Directive("")) self._suite.addTest(Adapter(self._filename, error_line)) diff --git a/tap/runner.py b/tap/runner.py index d32bec1..4d8c508 100644 --- a/tap/runner.py +++ b/tap/runner.py @@ -1,6 +1,8 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors +from collections import Counter import os +from time import time from unittest import TextTestResult, TextTestRunner from unittest.runner import _WritelnDecorator import sys @@ -13,9 +15,27 @@ class TAPTestResult(TextTestResult): FORMAT = None + track_duration = False def __init__(self, stream, descriptions, verbosity): super(TAPTestResult, self).__init__(stream, descriptions, verbosity) + self._durationTracker = Counter({'startTime': time()}) + + def startTest(self, test): + super().startTest(test) + self._durationTracker[test.id()] = (time() - self._durationTracker['startTime']) * 1000 + + def addSubTest(self, test, subtest, err): + super(TAPTestResult, self).addSubTest(test, subtest, err) + if err is not None: + diagnostics = formatter.format_exception(err) + self.tracker.add_not_ok( + self._cls_name(test), + self._description(subtest), + diagnostics=diagnostics, + ) + else: + self.tracker.add_ok(self._cls_name(test), self._description(subtest)) def stopTestRun(self): """Once the test run is complete, generate each of the TAP files.""" @@ -26,15 +46,15 @@ def addError(self, test, err): super(TAPTestResult, self).addError(test, err) diagnostics = formatter.format_exception(err) self.tracker.add_not_ok( - self._cls_name(test), self._description(test), - diagnostics=diagnostics) + self._cls_name(test), self._description(test), diagnostics=diagnostics + ) def addFailure(self, test, err): super(TAPTestResult, self).addFailure(test, err) diagnostics = formatter.format_exception(err) self.tracker.add_not_ok( - self._cls_name(test), self._description(test), - diagnostics=diagnostics) + self._cls_name(test), self._description(test), diagnostics=diagnostics + ) def addSubTest(self, test, subtest, err): super(TAPTestResult, self).addSubTest(test, subtest, err) @@ -49,39 +69,54 @@ def addSubTest(self, test, subtest, err): def addSuccess(self, test): super(TAPTestResult, self).addSuccess(test) - self.tracker.add_ok(self._cls_name(test), self._description(test)) + self.tracker.add_ok(self._cls_name(test), self._description(test), + diagnostics=self._diagnostics(test)) def addSkip(self, test, reason): super(TAPTestResult, self).addSkip(test, reason) - self.tracker.add_skip( - self._cls_name(test), self._description(test), reason) + self.tracker.add_skip(self._cls_name(test), self._description(test), reason) def addExpectedFailure(self, test, err): super(TAPTestResult, self).addExpectedFailure(test, err) diagnostics = formatter.format_exception(err) self.tracker.add_not_ok( - self._cls_name(test), self._description(test), - _('(expected failure)'), diagnostics=diagnostics) + self._cls_name(test), + self._description(test), + "TODO {}".format(_("(expected failure)")), + diagnostics=diagnostics, + ) def addUnexpectedSuccess(self, test): super(TAPTestResult, self).addUnexpectedSuccess(test) - self.tracker.add_ok(self._cls_name(test), self._description(test), - _('(unexpected success)')) + self.tracker.add_ok( + self._cls_name(test), + self._description(test), + "TODO {}".format(_("(unexpected success)")), + ) def _cls_name(self, test): return test.__class__.__name__ + def _diagnostics(self, test): + if self.track_duration and test.id() in self._durationTracker: + return " ---\n duration_ms: {:0.2f}\n ...\n".format(self._durationTracker[test.id()]) + return None + def _description(self, test): if self.FORMAT: try: return self.FORMAT.format( method_name=str(test), - short_description=test.shortDescription() or '') + short_description=test.shortDescription() or "", + ) except KeyError: - sys.exit(_( - 'Bad format string: {format}\n' - 'Replacement options are: {{short_description}} and ' - '{{method_name}}').format(format=self.FORMAT)) + sys.exit( + _( + "Bad format string: {format}\n" + "Replacement options are: {{short_description}} and " + "{{method_name}}" + ).format(format=self.FORMAT) + ) return test.shortDescription() or str(test) @@ -107,13 +142,12 @@ def set_stream(self, streaming): The test runner default output will be suppressed in favor of TAP. """ - self.stream = _WritelnDecorator(open(os.devnull, 'w')) + self.stream = _WritelnDecorator(open(os.devnull, "w")) _tracker.streaming = streaming _tracker.stream = sys.stdout def _makeResult(self): - result = self.resultclass( - self.stream, self.descriptions, self.verbosity) + result = self.resultclass(self.stream, self.descriptions, self.verbosity) result.tracker = _tracker return result @@ -135,6 +169,11 @@ def set_header(cls, header): """Set the header display flag.""" _tracker.header = header + @classmethod + def set_track_duration(cls, track_duration): + """Track test duration (ms) and output in TAP file.""" + TAPTestResult.track_duration = track_duration + @classmethod def set_format(cls, fmt): """Set the format of each test line. diff --git a/tap/tests/__init__.py b/tap/tests/__init__.py index 7d168a3..44fdf65 100644 --- a/tap/tests/__init__.py +++ b/tap/tests/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors """Tests for tappy""" from tap.tests.testcase import TestCase # NOQA diff --git a/tap/tests/factory.py b/tap/tests/factory.py index 15a860f..3c6308d 100644 --- a/tap/tests/factory.py +++ b/tap/tests/factory.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import sys import tempfile @@ -11,22 +11,20 @@ class Factory(object): """A factory to produce commonly needed objects""" - def make_ok(self, directive_text=''): - return Result( - True, 1, 'This is a description.', Directive(directive_text)) + def make_ok(self, directive_text=""): + return Result(True, 1, "This is a description.", Directive(directive_text)) - def make_not_ok(self, directive_text=''): - return Result( - False, 1, 'This is a description.', Directive(directive_text)) + def make_not_ok(self, directive_text=""): + return Result(False, 1, "This is a description.", Directive(directive_text)) - def make_bail(self, reason='Because it is busted.'): + def make_bail(self, reason="Because it is busted."): return Bail(reason) - def make_plan(self, expected_tests=99, directive_text=''): + def make_plan(self, expected_tests=99, directive_text=""): return Plan(expected_tests, Directive(directive_text)) def make_test_result(self): - stream = tempfile.TemporaryFile(mode='w') + stream = tempfile.TemporaryFile(mode="w") return TextTestResult(stream, None, 1) def make_exc(self): @@ -35,6 +33,6 @@ def make_exc(self): Doing this intentionally is not straight forward. """ try: - raise ValueError('boom') + raise ValueError("boom") except ValueError: return sys.exc_info() diff --git a/tap/tests/run.py b/tap/tests/run.py index d3991c0..1e47fca 100644 --- a/tap/tests/run.py +++ b/tap/tests/run.py @@ -1,15 +1,18 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import os +import sys import unittest from tap import TAPTestRunner -if __name__ == '__main__': +if __name__ == "__main__": tests_dir = os.path.dirname(os.path.abspath(__file__)) loader = unittest.TestLoader() tests = loader.discover(tests_dir) runner = TAPTestRunner() - runner.set_outdir('testout') - runner.set_format('Hi: {method_name} - {short_description}') - runner.run(tests) + runner.set_outdir("testout") + runner.set_format("Hi: {method_name} - {short_description}") + result = runner.run(tests) + status = 0 if result.wasSuccessful() else 1 + sys.exit(status) diff --git a/tap/tests/test_adapter.py b/tap/tests/test_adapter.py index c3f16b1..1d89b87 100644 --- a/tap/tests/test_adapter.py +++ b/tap/tests/test_adapter.py @@ -1,9 +1,6 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors -try: - from unittest import mock -except ImportError: - import mock +from unittest import mock from tap.adapter import Adapter from tap.tests import TestCase @@ -14,7 +11,7 @@ class TestAdapter(TestCase): def test_adapter_has_filename(self): """The adapter has a TAP filename.""" - tap_filename = 'fake.tap' + tap_filename = "fake.tap" adapter = Adapter(tap_filename, None) self.assertEqual(tap_filename, adapter._filename) @@ -22,7 +19,7 @@ def test_adapter_has_filename(self): def test_handles_ok_test_line(self): """Add a success for an ok test line.""" ok_line = self.factory.make_ok() - adapter = Adapter('fake.tap', ok_line) + adapter = Adapter("fake.tap", ok_line) result = mock.Mock() adapter(result) @@ -31,21 +28,19 @@ def test_handles_ok_test_line(self): def test_handles_skip_test_line(self): """Add a skip when a test line contains a skip directive.""" - skip_line = self.factory.make_ok( - directive_text='SKIP This is the reason.') - adapter = Adapter('fake.tap', skip_line) + skip_line = self.factory.make_ok(directive_text="SKIP This is the reason.") + adapter = Adapter("fake.tap", skip_line) result = self.factory.make_test_result() adapter(result) self.assertEqual(1, len(result.skipped)) - self.assertEqual('This is the reason.', result.skipped[0][1]) + self.assertEqual("This is the reason.", result.skipped[0][1]) def test_handles_ok_todo_test_line(self): """Add an unexpected success for an ok todo test line.""" - todo_line = self.factory.make_ok( - directive_text='TODO An incomplete test') - adapter = Adapter('fake.tap', todo_line) + todo_line = self.factory.make_ok(directive_text="TODO An incomplete test") + adapter = Adapter("fake.tap", todo_line) result = self.factory.make_test_result() adapter(result) @@ -54,9 +49,8 @@ def test_handles_ok_todo_test_line(self): def test_handles_not_ok_todo_test_line(self): """Add an expected failure for a not ok todo test line.""" - todo_line = self.factory.make_not_ok( - directive_text='TODO An incomplete test') - adapter = Adapter('fake.tap', todo_line) + todo_line = self.factory.make_not_ok(directive_text="TODO An incomplete test") + adapter = Adapter("fake.tap", todo_line) result = self.factory.make_test_result() adapter(result) diff --git a/tap/tests/test_directive.py b/tap/tests/test_directive.py index 6ee273e..890e81a 100644 --- a/tap/tests/test_directive.py +++ b/tap/tests/test_directive.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import unittest @@ -9,39 +9,39 @@ class TestDirective(unittest.TestCase): """Tests for tap.directive.Directive""" def test_finds_todo(self): - text = 'ToDo This is something to do.' + text = "ToDo This is something to do." directive = Directive(text) self.assertTrue(directive.todo) def test_finds_simplest_todo(self): - text = 'TODO' + text = "TODO" directive = Directive(text) self.assertTrue(directive.todo) def test_todo_has_boundary(self): """TAP spec indicates TODO directives must be on a boundary.""" - text = 'TODO: Not a TODO directive because of an immediate colon.' + text = "TODO: Not a TODO directive because of an immediate colon." directive = Directive(text) self.assertFalse(directive.todo) def test_finds_skip(self): - text = 'Skipping This is something to skip.' + text = "Skipping This is something to skip." directive = Directive(text) self.assertTrue(directive.skip) def test_finds_simplest_skip(self): - text = 'SKIP' + text = "SKIP" directive = Directive(text) self.assertTrue(directive.skip) def test_skip_at_beginning(self): """Only match SKIP directives at the beginning.""" - text = 'This is not something to skip.' + text = "This is not something to skip." directive = Directive(text) self.assertFalse(directive.skip) diff --git a/tap/tests/test_formatter.py b/tap/tests/test_formatter.py index ad274b2..ec0e20e 100644 --- a/tap/tests/test_formatter.py +++ b/tap/tests/test_formatter.py @@ -3,15 +3,14 @@ class TestFormatter(TestCase): - def test_formats_as_diagnostics(self): - data = ['foo\n', 'bar\n'] - expected_diagnostics = '# foo\n# bar\n' + data = ["foo\n", "bar\n"] + expected_diagnostics = "# foo\n# bar\n" diagnostics = format_as_diagnostics(data) self.assertEqual(expected_diagnostics, diagnostics) def test_format_exception_as_diagnostics(self): exc = self.factory.make_exc() diagnostics = format_exception(exc) - self.assertTrue(diagnostics.startswith('# ')) - self.assertTrue('boom' in diagnostics) + self.assertTrue(diagnostics.startswith("# ")) + self.assertTrue("boom" in diagnostics) diff --git a/tap/tests/test_line.py b/tap/tests/test_line.py index abf13fa..3410e41 100644 --- a/tap/tests/test_line.py +++ b/tap/tests/test_line.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import unittest @@ -20,29 +20,25 @@ class TestResult(unittest.TestCase): def test_category(self): result = Result(True) - self.assertEqual('test', result.category) + self.assertEqual("test", result.category) def test_ok(self): result = Result(True) self.assertTrue(result.ok) def test_str_ok(self): - result = Result(True, 42, 'passing') - self.assertEqual( - 'ok 42 passing', str(result)) + result = Result(True, 42, "passing") + self.assertEqual("ok 42 passing", str(result)) def test_str_not_ok(self): - result = Result(False, 43, 'failing') - self.assertEqual( - 'not ok 43 failing', str(result)) + result = Result(False, 43, "failing") + self.assertEqual("not ok 43 failing", str(result)) def test_str_directive(self): - directive = Directive('SKIP a reason') - result = Result(True, 44, 'passing', directive) - self.assertEqual( - 'ok 44 passing # SKIP a reason', str(result)) + directive = Directive("SKIP a reason") + result = Result(True, 44, "passing", directive) + self.assertEqual("ok 44 passing # SKIP a reason", str(result)) def test_str_diagnostics(self): - result = Result(False, 43, 'failing', diagnostics='# more info') - self.assertEqual( - 'not ok 43 failing\n# more info', str(result)) + result = Result(False, 43, "failing", diagnostics="# more info") + self.assertEqual("not ok 43 failing\n# more info", str(result)) diff --git a/tap/tests/test_loader.py b/tap/tests/test_loader.py index d90315f..13e536c 100644 --- a/tap/tests/test_loader.py +++ b/tap/tests/test_loader.py @@ -1,14 +1,11 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import inspect +from io import StringIO import os import tempfile import unittest - -try: - from unittest import mock -except ImportError: - import mock +from unittest import mock from tap.i18n import _ from tap.loader import Loader @@ -28,9 +25,10 @@ def test_handles_file(self): not ok 2 A failing test This is an unknown line. Bail out! This test would abort. - """) + """ + ) temp = tempfile.NamedTemporaryFile(delete=False) - temp.write(sample.encode('utf-8')) + temp.write(sample.encode("utf-8")) temp.close() loader = Loader() @@ -43,21 +41,22 @@ def test_file_does_not_exist(self): """The loader records a failure when a file does not exist.""" loader = Loader() - suite = loader.load_suite_from_file('phony.tap') + suite = loader.load_suite_from_file("phony.tap") self.assertEqual(1, len(suite._tests)) self.assertEqual( - _('{filename} does not exist.').format(filename='phony.tap'), - suite._tests[0]._line.description) + _("{filename} does not exist.").format(filename="phony.tap"), + suite._tests[0]._line.description, + ) def test_handles_directory(self): directory = tempfile.mkdtemp() - sub_directory = os.path.join(directory, 'sub') + sub_directory = os.path.join(directory, "sub") os.mkdir(sub_directory) - with open(os.path.join(directory, 'a_file.tap'), 'w') as f: - f.write('ok A passing test') - with open(os.path.join(sub_directory, 'another_file.tap'), 'w') as f: - f.write('not ok A failing test') + with open(os.path.join(directory, "a_file.tap"), "w") as f: + f.write("ok A passing test") + with open(os.path.join(sub_directory, "another_file.tap"), "w") as f: + f.write("not ok A failing test") loader = Loader() suite = loader.load([directory]) @@ -69,9 +68,10 @@ def test_errors_with_multiple_version_lines(self): """TAP version 13 TAP version 13 1..0 - """) + """ + ) temp = tempfile.NamedTemporaryFile(delete=False) - temp.write(sample.encode('utf-8')) + temp.write(sample.encode("utf-8")) temp.close() loader = Loader() @@ -79,17 +79,18 @@ def test_errors_with_multiple_version_lines(self): self.assertEqual(1, len(suite._tests)) self.assertEqual( - _('Multiple version lines appeared.'), - suite._tests[0]._line.description) + _("Multiple version lines appeared."), suite._tests[0]._line.description + ) def test_errors_with_version_not_on_first_line(self): sample = inspect.cleandoc( """# Something that doesn't belong. TAP version 13 1..0 - """) + """ + ) temp = tempfile.NamedTemporaryFile(delete=False) - temp.write(sample.encode('utf-8')) + temp.write(sample.encode("utf-8")) temp.close() loader = Loader() @@ -97,27 +98,28 @@ def test_errors_with_version_not_on_first_line(self): self.assertEqual(1, len(suite._tests)) self.assertEqual( - _('The version must be on the first line.'), - suite._tests[0]._line.description) + _("The version must be on the first line."), + suite._tests[0]._line.description, + ) def test_skip_plan_aborts_loading(self): sample = inspect.cleandoc( """1..0 # Skipping this test file. ok This should not get processed. - """) + """ + ) temp = tempfile.NamedTemporaryFile(delete=False) - temp.write(sample.encode('utf-8')) + temp.write(sample.encode("utf-8")) temp.close() loader = Loader() suite = loader.load_suite_from_file(temp.name) self.assertEqual(1, len(suite._tests)) - self.assertEqual( - 'Skipping this test file.', suite._tests[0]._line.description) + self.assertEqual("Skipping this test file.", suite._tests[0]._line.description) - @mock.patch('tap.parser.sys') - def test_loads_from_stream(self, mock_sys): + @mock.patch("tap.parser.sys.stdin", StringIO(u"")) + def test_loads_from_stream(self): loader = Loader() suite = loader.load_suite_from_stdin() self.assertTrue(isinstance(suite, unittest.TestSuite)) diff --git a/tap/tests/test_main.py b/tap/tests/test_main.py index 9eab0f6..be85200 100644 --- a/tap/tests/test_main.py +++ b/tap/tests/test_main.py @@ -1,15 +1,11 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import argparse import os - -try: - from unittest import mock -except ImportError: - import mock +from unittest import mock from tap.loader import Loader -from tap.main import build_suite, get_status, main, parse_args +from tap.main import build_suite, get_status, main, main_module, parse_args from tap.tests import TestCase @@ -18,8 +14,8 @@ class TestMain(TestCase): def test_exits_with_error(self): """The main function returns an error status if there were failures.""" - argv = ['/bin/fake', 'fake.tap'] - stream = open(os.devnull, 'w') + argv = ["/bin/fake", "fake.tap"] + stream = open(os.devnull, "w") status = main(argv, stream=stream) @@ -30,7 +26,7 @@ def test_get_successful_status(self): result.wasSuccessful.return_value = True self.assertEqual(0, get_status(result)) - @mock.patch.object(Loader, 'load_suite_from_stdin') + @mock.patch.object(Loader, "load_suite_from_stdin") def test_build_suite_from_stdin(self, load_suite_from_stdin): args = mock.Mock() args.files = [] @@ -39,21 +35,29 @@ def test_build_suite_from_stdin(self, load_suite_from_stdin): suite = build_suite(args) self.assertEqual(expected_suite, suite) - @mock.patch.object(Loader, 'load_suite_from_stdin') + @mock.patch.object(Loader, "load_suite_from_stdin") def test_build_suite_from_stdin_dash(self, load_suite_from_stdin): - argv = ['/bin/fake', '-'] + argv = ["/bin/fake", "-"] args = parse_args(argv) expected_suite = mock.Mock() load_suite_from_stdin.return_value = expected_suite suite = build_suite(args) self.assertEqual(expected_suite, suite) - @mock.patch('tap.main.sys.stdin') - @mock.patch('tap.main.sys.exit') - @mock.patch.object(argparse.ArgumentParser, 'print_help') + @mock.patch("tap.main.sys.stdin") + @mock.patch("tap.main.sys.exit") + @mock.patch.object(argparse.ArgumentParser, "print_help") def test_when_no_pipe_to_stdin(self, print_help, sys_exit, mock_stdin): - argv = ['/bin/fake'] + argv = ["/bin/fake"] mock_stdin.isatty = mock.Mock(return_value=True) parse_args(argv) self.assertTrue(print_help.called) self.assertTrue(sys_exit.called) + + +class TestMainModule(TestCase): + @mock.patch("tap.main.unittest") + def test_main_set_to_stream(self, mock_unittest): + main_module() + + mock_unittest.main.called diff --git a/tap/tests/test_parser.py b/tap/tests/test_parser.py index 92e7aa0..94850bc 100644 --- a/tap/tests/test_parser.py +++ b/tap/tests/test_parser.py @@ -1,15 +1,36 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors +from contextlib import contextmanager import inspect +from io import BytesIO, StringIO +import sys import tempfile import unittest +from unittest import mock + +from tap.parser import Parser try: - from unittest import mock + import yaml + from more_itertools import peekable # noqa + + have_yaml = True except ImportError: - import mock + have_yaml = False -from tap.parser import Parser + +@contextmanager +def captured_output(): + if sys.version_info[0] < 3: + new_out, new_err = BytesIO(), BytesIO() + else: + new_out, new_err = StringIO(), StringIO() + old_out, old_err = sys.stdout, sys.stderr + try: + sys.stdout, sys.stderr = new_out, new_err + yield sys.stdout, sys.stderr + finally: + sys.stdout, sys.stderr = old_out, old_err class TestParser(unittest.TestCase): @@ -19,9 +40,9 @@ def test_finds_ok(self): """The parser extracts an ok line.""" parser = Parser() - line = parser.parse_line('ok - This is a passing test line.') + line = parser.parse_line("ok - This is a passing test line.") - self.assertEqual('test', line.category) + self.assertEqual("test", line.category) self.assertTrue(line.ok) self.assertTrue(line.number is None) @@ -29,102 +50,102 @@ def test_finds_number(self): """The parser extracts a test number.""" parser = Parser() - line = parser.parse_line('ok 42 is the magic number.') + line = parser.parse_line("ok 42 is the magic number.") - self.assertEqual('test', line.category) + self.assertEqual("test", line.category) self.assertEqual(42, line.number) def test_finds_description(self): parser = Parser() - line = parser.parse_line('ok 42 A passing test.') + line = parser.parse_line("ok 42 A passing test.") - self.assertEqual('test', line.category) - self.assertEqual('A passing test.', line.description) + self.assertEqual("test", line.category) + self.assertEqual("A passing test.", line.description) def test_after_hash_is_not_description(self): parser = Parser() - line = parser.parse_line('ok A description # Not part of description.') + line = parser.parse_line("ok A description # Not part of description.") - self.assertEqual('test', line.category) - self.assertEqual('A description', line.description) + self.assertEqual("test", line.category) + self.assertEqual("A description", line.description) def test_finds_todo(self): parser = Parser() - line = parser.parse_line('ok A description # TODO Not done') + line = parser.parse_line("ok A description # TODO Not done") - self.assertEqual('test', line.category) + self.assertEqual("test", line.category) self.assertTrue(line.todo) def test_finds_skip(self): parser = Parser() - line = parser.parse_line('ok A description # SKIP for now') + line = parser.parse_line("ok A description # SKIP for now") - self.assertEqual('test', line.category) + self.assertEqual("test", line.category) self.assertTrue(line.skip) def test_finds_not_ok(self): """The parser extracts a not ok line.""" parser = Parser() - line = parser.parse_line('not ok - This is a failing test line.') + line = parser.parse_line("not ok - This is a failing test line.") - self.assertEqual('test', line.category) + self.assertEqual("test", line.category) self.assertFalse(line.ok) self.assertTrue(line.number is None) - self.assertEqual('', line.directive.text) + self.assertEqual("", line.directive.text) def test_finds_directive(self): """The parser extracts a directive""" parser = Parser() - test_line = 'not ok - This line fails # TODO not implemented' + test_line = "not ok - This line fails # TODO not implemented" line = parser.parse_line(test_line) directive = line.directive - self.assertEqual('test', line.category) - self.assertEqual('TODO not implemented', directive.text) + self.assertEqual("test", line.category) + self.assertEqual("TODO not implemented", directive.text) self.assertFalse(directive.skip) self.assertTrue(directive.todo) - self.assertEqual('not implemented', directive.reason) + self.assertEqual("not implemented", directive.reason) def test_unrecognizable_line(self): """The parser returns an unrecognizable line.""" parser = Parser() - line = parser.parse_line('This is not a valid TAP line. # srsly') + line = parser.parse_line("This is not a valid TAP line. # srsly") - self.assertEqual('unknown', line.category) + self.assertEqual("unknown", line.category) def test_diagnostic_line(self): """The parser extracts a diagnostic line.""" - text = '# An example diagnostic line' + text = "# An example diagnostic line" parser = Parser() line = parser.parse_line(text) - self.assertEqual('diagnostic', line.category) + self.assertEqual("diagnostic", line.category) self.assertEqual(text, line.text) def test_bail_out_line(self): """The parser extracts a bail out line.""" parser = Parser() - line = parser.parse_line('Bail out! This is the reason to bail.') + line = parser.parse_line("Bail out! This is the reason to bail.") - self.assertEqual('bail', line.category) - self.assertEqual('This is the reason to bail.', line.reason) + self.assertEqual("bail", line.category) + self.assertEqual("This is the reason to bail.", line.reason) def test_finds_version(self): """The parser extracts a version line.""" parser = Parser() - line = parser.parse_line('TAP version 13') + line = parser.parse_line("TAP version 13") - self.assertEqual('version', line.category) + self.assertEqual("version", line.category) self.assertEqual(13, line.version) def test_errors_on_old_version(self): @@ -132,39 +153,40 @@ def test_errors_on_old_version(self): parser = Parser() with self.assertRaises(ValueError): - parser.parse_line('TAP version 12') + parser.parse_line("TAP version 12") def test_finds_plan(self): """The parser extracts a plan line.""" parser = Parser() - line = parser.parse_line('1..42') + line = parser.parse_line("1..42") - self.assertEqual('plan', line.category) + self.assertEqual("plan", line.category) self.assertEqual(42, line.expected_tests) def test_finds_plan_with_skip(self): """The parser extracts a plan line containing a SKIP.""" parser = Parser() - line = parser.parse_line('1..42 # Skipping this test file.') + line = parser.parse_line("1..42 # Skipping this test file.") - self.assertEqual('plan', line.category) + self.assertEqual("plan", line.category) self.assertTrue(line.skip) def test_ignores_plan_with_any_non_skip_directive(self): """The parser only recognizes SKIP directives in plans.""" parser = Parser() - line = parser.parse_line('1..42 # TODO will not work.') + line = parser.parse_line("1..42 # TODO will not work.") - self.assertEqual('unknown', line.category) + self.assertEqual("unknown", line.category) def test_parses_text(self): sample = inspect.cleandoc( u"""1..2 ok 1 A passing test - not ok 2 A failing test""") + not ok 2 A failing test""" + ) parser = Parser() lines = [] @@ -172,19 +194,20 @@ def test_parses_text(self): lines.append(line) self.assertEqual(3, len(lines)) - self.assertEqual('plan', lines[0].category) - self.assertEqual('test', lines[1].category) + self.assertEqual("plan", lines[0].category) + self.assertEqual("test", lines[1].category) self.assertTrue(lines[1].ok) - self.assertEqual('test', lines[2].category) + self.assertEqual("test", lines[2].category) self.assertFalse(lines[2].ok) def test_parses_file(self): sample = inspect.cleandoc( """1..2 ok 1 A passing test - not ok 2 A failing test""") + not ok 2 A failing test""" + ) temp = tempfile.NamedTemporaryFile(delete=False) - temp.write(sample.encode('utf-8')) + temp.write(sample.encode("utf-8")) temp.close() parser = Parser() lines = [] @@ -193,21 +216,282 @@ def test_parses_file(self): lines.append(line) self.assertEqual(3, len(lines)) - self.assertEqual('plan', lines[0].category) - self.assertEqual('test', lines[1].category) + self.assertEqual("plan", lines[0].category) + self.assertEqual("test", lines[1].category) self.assertTrue(lines[1].ok) - self.assertEqual('test', lines[2].category) + self.assertIsNone(lines[1].yaml_block) + self.assertEqual("test", lines[2].category) self.assertFalse(lines[2].ok) - @mock.patch('tap.parser.sys') - def test_parses_stdin(self, mock_sys): - mock_sys.stdin.__iter__.return_value = iter([ - '1..2\n', - 'ok 1 A passing test\n', - 'not ok 2 A failing test\n', - ]) - mock_sys.stdin.__enter__.return_value = None - mock_sys.stdin.__exit__.return_value = None + def test_parses_yaml(self): + sample = inspect.cleandoc( + u"""TAP version 13 + 1..2 + ok 1 A passing test + --- + test: sample yaml + ... + not ok 2 A failing test""" + ) + parser = Parser() + lines = [] + + for line in parser.parse_text(sample): + lines.append(line) + + if have_yaml: + converted_yaml = yaml.safe_load(u"""test: sample yaml""") + self.assertEqual(4, len(lines)) + self.assertEqual(13, lines[0].version) + self.assertEqual(converted_yaml, lines[2].yaml_block) + self.assertEqual("test", lines[3].category) + self.assertIsNone(lines[3].yaml_block) + else: + self.assertEqual(7, len(lines)) + self.assertEqual(13, lines[0].version) + for line_index in list(range(3, 6)): + self.assertEqual("unknown", lines[line_index].category) + self.assertEqual("test", lines[6].category) + + def test_parses_mixed(self): + # Test that we can parse both a version 13 and earlier version files + # using the same parser. Make sure that parsing works regardless of + # the order of the incoming documents. + sample_version_13 = inspect.cleandoc( + u"""TAP version 13 + 1..2 + ok 1 A passing version 13 test + --- + test: sample yaml + ... + not ok 2 A failing version 13 test""" + ) + sample_pre_13 = inspect.cleandoc( + """1..2 + ok 1 A passing pre-13 test + not ok 2 A failing pre-13 test""" + ) + + parser = Parser() + lines = [] + lines.extend(parser.parse_text(sample_version_13)) + lines.extend(parser.parse_text(sample_pre_13)) + if have_yaml: + self.assertEqual(13, lines[0].version) + self.assertEqual("A passing version 13 test", lines[2].description) + self.assertEqual("A failing version 13 test", lines[3].description) + self.assertEqual("A passing pre-13 test", lines[5].description) + self.assertEqual("A failing pre-13 test", lines[6].description) + else: + self.assertEqual(13, lines[0].version) + self.assertEqual("A passing version 13 test", lines[2].description) + self.assertEqual("A failing version 13 test", lines[6].description) + self.assertEqual("A passing pre-13 test", lines[8].description) + self.assertEqual("A failing pre-13 test", lines[9].description) + + # Test parsing documents in reverse order + parser = Parser() + lines = [] + lines.extend(parser.parse_text(sample_pre_13)) + lines.extend(parser.parse_text(sample_version_13)) + if have_yaml: + self.assertEqual("A passing pre-13 test", lines[1].description) + self.assertEqual("A failing pre-13 test", lines[2].description) + self.assertEqual(13, lines[3].version) + self.assertEqual("A passing version 13 test", lines[5].description) + self.assertEqual("A failing version 13 test", lines[6].description) + else: + self.assertEqual("A passing pre-13 test", lines[1].description) + self.assertEqual("A failing pre-13 test", lines[2].description) + self.assertEqual(13, lines[3].version) + self.assertEqual("A passing version 13 test", lines[5].description) + self.assertEqual("A failing version 13 test", lines[9].description) + + def test_parses_yaml_no_end(self): + sample = inspect.cleandoc( + u"""TAP version 13 + 1..2 + ok 1 A passing test + --- + test: sample yaml + not ok 2 A failing test""" + ) + parser = Parser() + lines = [] + + for line in parser.parse_text(sample): + lines.append(line) + + if have_yaml: + converted_yaml = yaml.safe_load(u"""test: sample yaml""") + self.assertEqual(4, len(lines)) + self.assertEqual(13, lines[0].version) + self.assertEqual(converted_yaml, lines[2].yaml_block) + self.assertEqual("test", lines[3].category) + self.assertIsNone(lines[3].yaml_block) + else: + self.assertEqual(6, len(lines)) + self.assertEqual(13, lines[0].version) + for line_index in list(range(3, 5)): + self.assertEqual("unknown", lines[line_index].category) + self.assertEqual("test", lines[5].category) + + def test_parses_yaml_more_complex(self): + sample = inspect.cleandoc( + u"""TAP version 13 + 1..2 + ok 1 A passing test + --- + message: test + severity: fail + data: + got: + - foo + expect: + - bar + output: |- + a multiline string + must be handled properly + even with | pipes + | here > and: there""" + ) + parser = Parser() + lines = [] + + for line in parser.parse_text(sample): + lines.append(line) + + if have_yaml: + converted_yaml = yaml.safe_load( + u''' + message: test + severity: fail + data: + got: + - foo + expect: + - bar + output: "a multiline string\\nmust be handled properly\\neven with | pipes\\n| here > and: there"''' # noqa + ) + self.assertEqual(3, len(lines)) + self.assertEqual(13, lines[0].version) + self.assertEqual(converted_yaml, lines[2].yaml_block) + else: + self.assertEqual(16, len(lines)) + self.assertEqual(13, lines[0].version) + for line_index in list(range(3, 11)): + self.assertEqual("unknown", lines[line_index].category) + + def test_parses_yaml_no_association(self): + sample = inspect.cleandoc( + u"""TAP version 13 + 1..2 + ok 1 A passing test + # Diagnostic line + --- + test: sample yaml + ... + not ok 2 A failing test""" + ) + parser = Parser() + lines = [] + + for line in parser.parse_text(sample): + lines.append(line) + + self.assertEqual(8, len(lines)) + self.assertEqual(13, lines[0].version) + self.assertIsNone(lines[2].yaml_block) + self.assertEqual("diagnostic", lines[3].category) + for line_index in list(range(4, 7)): + self.assertEqual("unknown", lines[line_index].category) + self.assertEqual("test", lines[7].category) + + def test_parses_yaml_no_start(self): + sample = inspect.cleandoc( + u"""TAP version 13 + 1..2 + ok 1 A passing test + test: sample yaml + ... + not ok 2 A failing test""" + ) + parser = Parser() + lines = [] + + for line in parser.parse_text(sample): + lines.append(line) + + self.assertEqual(6, len(lines)) + self.assertEqual(13, lines[0].version) + self.assertIsNone(lines[2].yaml_block) + for line_index in list(range(3, 5)): + self.assertEqual("unknown", lines[line_index].category) + self.assertEqual("test", lines[5].category) + + def test_malformed_yaml(self): + self.maxDiff = None + sample = inspect.cleandoc( + u"""TAP version 13 + 1..2 + ok 1 A passing test + --- + test: sample yaml + \tfail: tabs are not allowed! + ... + not ok 2 A failing test""" + ) + yaml_err = inspect.cleandoc( + u""" +WARNING: Optional imports not found, TAP 13 output will be + ignored. To parse yaml, see requirements in docs: + https://tappy.readthedocs.io/en/latest/consumers.html#tap-version-13""" + ) + parser = Parser() + lines = [] + + with captured_output() as (parse_out, _): + for line in parser.parse_text(sample): + lines.append(line) + + if have_yaml: + self.assertEqual(4, len(lines)) + self.assertEqual(13, lines[0].version) + with captured_output() as (out, _): + self.assertIsNone(lines[2].yaml_block) + self.assertEqual( + "Error parsing yaml block. Check formatting.", out.getvalue().strip() + ) + self.assertEqual("test", lines[3].category) + self.assertIsNone(lines[3].yaml_block) + else: + self.assertEqual(8, len(lines)) + self.assertEqual(13, lines[0].version) + for line_index in list(range(3, 7)): + self.assertEqual("unknown", lines[line_index].category) + self.assertEqual("test", lines[7].category) + self.assertEqual(yaml_err, parse_out.getvalue().strip()) + + def test_parse_empty_file(self): + temp = tempfile.NamedTemporaryFile(delete=False) + temp.close() + parser = Parser() + lines = [] + + for line in parser.parse_file(temp.name): + lines.append(line) + + self.assertEqual(0, len(lines)) + + @mock.patch( + "tap.parser.sys.stdin", + StringIO( + u"""1..2 +ok 1 A passing test +not ok 2 A failing test""" + ), + ) + def test_parses_stdin(self): parser = Parser() lines = [] @@ -215,8 +499,8 @@ def test_parses_stdin(self, mock_sys): lines.append(line) self.assertEqual(3, len(lines)) - self.assertEqual('plan', lines[0].category) - self.assertEqual('test', lines[1].category) + self.assertEqual("plan", lines[0].category) + self.assertEqual("test", lines[1].category) self.assertTrue(lines[1].ok) - self.assertEqual('test', lines[2].category) + self.assertEqual("test", lines[2].category) self.assertFalse(lines[2].ok) diff --git a/tap/tests/test_result.py b/tap/tests/test_result.py index 1d0094e..b5c1c9a 100644 --- a/tap/tests/test_result.py +++ b/tap/tests/test_result.py @@ -1,7 +1,9 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors +import contextlib import os import unittest +import unittest.case from tap.i18n import _ from tap.runner import TAPTestResult @@ -10,20 +12,26 @@ class FakeTestCase(unittest.TestCase): - def runTest(self): pass + @contextlib.contextmanager + def subTest(self, *args, **kwargs): + try: + self._subtest = unittest.case._SubTest(self, object(), {}) + yield + finally: + self._subtest = None + def __call__(self, result): pass class TestTAPTestResult(TestCase): - @classmethod def _make_one(cls): # Yep, the stream is not being closed. - stream = open(os.devnull, 'w') + stream = open(os.devnull, "w") result = TAPTestResult(stream, False, 0) result.tracker = Tracker() return result @@ -35,7 +43,7 @@ def test_adds_error(self): ex = Exception() ex.__cause__ = None result.addError(FakeTestCase(), (None, ex, None)) - self.assertEqual(len(result.tracker._test_cases['FakeTestCase']), 1) + self.assertEqual(len(result.tracker._test_cases["FakeTestCase"]), 1) def test_adds_failure(self): result = self._make_one() @@ -44,29 +52,53 @@ def test_adds_failure(self): ex = Exception() ex.__cause__ = None result.addFailure(FakeTestCase(), (None, ex, None)) - self.assertEqual(len(result.tracker._test_cases['FakeTestCase']), 1) + self.assertEqual(len(result.tracker._test_cases["FakeTestCase"]), 1) def test_adds_success(self): result = self._make_one() result.addSuccess(FakeTestCase()) - self.assertEqual(len(result.tracker._test_cases['FakeTestCase']), 1) + self.assertEqual(len(result.tracker._test_cases["FakeTestCase"]), 1) def test_adds_skip(self): result = self._make_one() - result.addSkip(FakeTestCase(), 'a reason') - self.assertEqual(len(result.tracker._test_cases['FakeTestCase']), 1) + result.addSkip(FakeTestCase(), "a reason") + self.assertEqual(len(result.tracker._test_cases["FakeTestCase"]), 1) def test_adds_expected_failure(self): exc = self.factory.make_exc() result = self._make_one() result.addExpectedFailure(FakeTestCase(), exc) - line = result.tracker._test_cases['FakeTestCase'][0] + line = result.tracker._test_cases["FakeTestCase"][0] self.assertFalse(line.ok) - self.assertEqual(line.directive.text, _('(expected failure)')) + self.assertEqual(line.directive.text, "TODO {}".format(_("(expected failure)"))) def test_adds_unexpected_success(self): result = self._make_one() result.addUnexpectedSuccess(FakeTestCase()) - line = result.tracker._test_cases['FakeTestCase'][0] + line = result.tracker._test_cases["FakeTestCase"][0] self.assertTrue(line.ok) - self.assertEqual(line.directive.text, _('(unexpected success)')) + self.assertEqual( + line.directive.text, "TODO {}".format(_("(unexpected success)")) + ) + + def test_adds_subtest_success(self): + """Test that the runner handles subtest success results.""" + result = self._make_one() + test = FakeTestCase() + with test.subTest(): + result.addSubTest(test, test._subtest, None) + line = result.tracker._test_cases["FakeTestCase"][0] + self.assertTrue(line.ok) + + def test_adds_subtest_failure(self): + """Test that the runner handles subtest failure results.""" + result = self._make_one() + # Python 3 does some extra testing in unittest on exceptions so fake + # the cause as if it were raised. + ex = Exception() + ex.__cause__ = None + test = FakeTestCase() + with test.subTest(): + result.addSubTest(test, test._subtest, (ex.__class__, ex, None)) + line = result.tracker._test_cases["FakeTestCase"][0] + self.assertFalse(line.ok) diff --git a/tap/tests/test_rules.py b/tap/tests/test_rules.py index a01f5ea..9234689 100644 --- a/tap/tests/test_rules.py +++ b/tap/tests/test_rules.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import unittest @@ -12,17 +12,16 @@ class TestRules(TestCase): def _make_one(self): self.suite = unittest.TestSuite() - return Rules('foobar.tap', self.suite) + return Rules("foobar.tap", self.suite) def test_handles_skipping_plan(self): - skip_plan = self.factory.make_plan(directive_text='Skip on Mondays.') + skip_plan = self.factory.make_plan(directive_text="Skip on Mondays.") rules = self._make_one() rules.handle_skipping_plan(skip_plan) self.assertEqual(1, len(self.suite._tests)) - self.assertEqual( - 'Skip on Mondays.', self.suite._tests[0]._line.description) + self.assertEqual("Skip on Mondays.", self.suite._tests[0]._line.description) def test_tracks_plan_line(self): plan = self.factory.make_plan() @@ -30,8 +29,8 @@ def test_tracks_plan_line(self): rules.saw_plan(plan, 28) - self.assertEqual(rules._lines_seen['plan'][0][0], plan) - self.assertEqual(rules._lines_seen['plan'][0][1], 28) + self.assertEqual(rules._lines_seen["plan"][0][0], plan) + self.assertEqual(rules._lines_seen["plan"][0][1], 28) def test_errors_plan_not_at_end(self): plan = self.factory.make_plan() @@ -41,16 +40,16 @@ def test_errors_plan_not_at_end(self): rules.check(42) self.assertEqual( - _('A plan must appear at the beginning or end of the file.'), - self.suite._tests[0]._line.description) + _("A plan must appear at the beginning or end of the file."), + self.suite._tests[0]._line.description, + ) def test_requires_plan(self): rules = self._make_one() rules.check(42) - self.assertEqual( - _('Missing a plan.'), self.suite._tests[0]._line.description) + self.assertEqual(_("Missing a plan."), self.suite._tests[0]._line.description) def test_only_one_plan(self): plan = self.factory.make_plan() @@ -61,8 +60,9 @@ def test_only_one_plan(self): rules.check(42) self.assertEqual( - _('Only one plan line is permitted per file.'), - self.suite._tests[0]._line.description) + _("Only one plan line is permitted per file."), + self.suite._tests[0]._line.description, + ) def test_plan_line_two(self): """A plan may appear on line 2 when line 1 is a version line.""" @@ -82,18 +82,20 @@ def test_errors_when_expected_tests_differs_from_actual(self): rules.check(2) self.assertEqual( - _('Expected {expected_count} tests but only ' - '{seen_count} ran.').format(expected_count=42, seen_count=1), - self.suite._tests[0]._line.description) + _("Expected {expected_count} tests but only {seen_count} ran.").format( + expected_count=42, seen_count=1 + ), + self.suite._tests[0]._line.description, + ) def test_errors_on_bail(self): - bail = self.factory.make_bail(reason='Missing something important.') + bail = self.factory.make_bail(reason="Missing something important.") rules = self._make_one() rules.handle_bail(bail) self.assertEqual(1, len(self.suite._tests)) self.assertEqual( - _('Bailed: {reason}').format( - reason='Missing something important.'), - self.suite._tests[0]._line.description) + _("Bailed: {reason}").format(reason="Missing something important."), + self.suite._tests[0]._line.description, + ) diff --git a/tap/tests/test_runner.py b/tap/tests/test_runner.py index 7b7c2a2..507764e 100644 --- a/tap/tests/test_runner.py +++ b/tap/tests/test_runner.py @@ -1,21 +1,16 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import os import sys import tempfile import unittest - -try: - from unittest import mock -except ImportError: - import mock +from unittest import mock from tap import TAPTestRunner from tap.runner import TAPTestResult, _tracker class TestTAPTestRunner(unittest.TestCase): - def test_has_tap_test_result(self): runner = TAPTestRunner() self.assertEqual(runner.resultclass, TAPTestResult) @@ -60,7 +55,7 @@ def test_runner_uses_combined(self): _tracker.combined = previous_combined - @mock.patch('sys.exit') + @mock.patch("sys.exit") def test_bad_format_string(self, fake_exit): """A bad format string exits the runner.""" previous_format = TAPTestResult.FORMAT diff --git a/tap/tests/test_tracker.py b/tap/tests/test_tracker.py index 05cdb87..684568b 100644 --- a/tap/tests/test_tracker.py +++ b/tap/tests/test_tracker.py @@ -1,12 +1,10 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import inspect +from io import StringIO import os import tempfile -try: - from cStringIO import StringIO -except ImportError: - from io import StringIO +from unittest import mock from tap.i18n import _ from tap.tests import TestCase @@ -14,9 +12,8 @@ class TestTracker(TestCase): - def _make_header(self, test_case): - return _('# TAP results for {test_case}').format(test_case=test_case) + return _("# TAP results for {test_case}").format(test_case=test_case) def test_has_test_cases(self): tracker = Tracker() @@ -24,50 +21,50 @@ def test_has_test_cases(self): def test_tracks_class(self): tracker = Tracker() - tracker._track('FakeTestClass') - self.assertEqual(tracker._test_cases.get('FakeTestClass'), []) + tracker._track("FakeTestClass") + self.assertEqual(tracker._test_cases.get("FakeTestClass"), []) def test_adds_ok(self): tracker = Tracker() - tracker.add_ok('FakeTestCase', 'a description') - line = tracker._test_cases['FakeTestCase'][0] + tracker.add_ok("FakeTestCase", "a description") + line = tracker._test_cases["FakeTestCase"][0] self.assertTrue(line.ok) - self.assertEqual(line.description, 'a description') + self.assertEqual(line.description, "a description") def test_adds_not_ok(self): tracker = Tracker() - tracker.add_not_ok('FakeTestCase', 'a description') - line = tracker._test_cases['FakeTestCase'][0] + tracker.add_not_ok("FakeTestCase", "a description") + line = tracker._test_cases["FakeTestCase"][0] self.assertFalse(line.ok) - self.assertEqual(line.description, 'a description') + self.assertEqual(line.description, "a description") def test_adds_skip(self): tracker = Tracker() - tracker.add_skip('FakeTestCase', 'a description', 'a reason') - line = tracker._test_cases['FakeTestCase'][0] + tracker.add_skip("FakeTestCase", "a description", "a reason") + line = tracker._test_cases["FakeTestCase"][0] self.assertTrue(line.ok) - self.assertEqual(line.description, 'a description') - self.assertEqual(line.directive.text, 'SKIP a reason') + self.assertEqual(line.description, "a description") + self.assertEqual(line.directive.text, "SKIP a reason") def test_generates_tap_reports_in_new_outdir(self): tempdir = tempfile.mkdtemp() - outdir = os.path.join(tempdir, 'non', 'existent', 'path') + outdir = os.path.join(tempdir, "non", "existent", "path") tracker = Tracker(outdir=outdir) - tracker.add_ok('FakeTestCase', 'I should be in the specified dir.') + tracker.add_ok("FakeTestCase", "I should be in the specified dir.") tracker.generate_tap_reports() - tap_file = os.path.join(outdir, 'FakeTestCase.tap') + tap_file = os.path.join(outdir, "FakeTestCase.tap") self.assertTrue(os.path.exists(tap_file)) def test_generates_tap_reports_in_existing_outdir(self): outdir = tempfile.mkdtemp() tracker = Tracker(outdir=outdir) - tracker.add_ok('FakeTestCase', 'I should be in the specified dir.') + tracker.add_ok("FakeTestCase", "I should be in the specified dir.") tracker.generate_tap_reports() - tap_file = os.path.join(outdir, 'FakeTestCase.tap') + tap_file = os.path.join(outdir, "FakeTestCase.tap") self.assertTrue(os.path.exists(tap_file)) def test_results_not_combined_by_default(self): @@ -77,29 +74,31 @@ def test_results_not_combined_by_default(self): def test_individual_report_has_no_plan_when_combined(self): outdir = tempfile.mkdtemp() tracker = Tracker(outdir=outdir, combined=True) - tracker.add_ok('FakeTestCase', 'Look ma, no plan!') + tracker.add_ok("FakeTestCase", "Look ma, no plan!") out_file = StringIO() tracker.generate_tap_report( - 'FakeTestCase', tracker._test_cases['FakeTestCase'], out_file) + "FakeTestCase", tracker._test_cases["FakeTestCase"], out_file + ) report = out_file.getvalue() - self.assertTrue('Look ma' in report) - self.assertFalse('1..' in report) + self.assertTrue("Look ma" in report) + self.assertFalse("1.." in report) - def test_combined_results_in_one_file(self): + @mock.patch("tap.tracker.ENABLE_VERSION_13", False) + def test_combined_results_in_one_file_tap_version_12(self): outdir = tempfile.mkdtemp() tracker = Tracker(outdir=outdir, combined=True) - tracker.add_ok('FakeTestCase', 'YESSS!') - tracker.add_ok('DifferentFakeTestCase', 'GOAAL!') + tracker.add_ok("FakeTestCase", "YESSS!") + tracker.add_ok("DifferentFakeTestCase", "GOAAL!") tracker.generate_tap_reports() + self.assertFalse(os.path.exists(os.path.join(outdir, "FakeTestCase.tap"))) self.assertFalse( - os.path.exists(os.path.join(outdir, 'FakeTestCase.tap'))) - self.assertFalse( - os.path.exists(os.path.join(outdir, 'DifferentFakeTestCase.tap'))) - with open(os.path.join(outdir, 'testresults.tap'), 'r') as f: + os.path.exists(os.path.join(outdir, "DifferentFakeTestCase.tap")) + ) + with open(os.path.join(outdir, "testresults.tap"), "r") as f: report = f.read() expected = inspect.cleandoc( """{header_1} @@ -108,8 +107,40 @@ def test_combined_results_in_one_file(self): ok 2 GOAAL! 1..2 """.format( - header_1=self._make_header('FakeTestCase'), - header_2=self._make_header('DifferentFakeTestCase'))) + header_1=self._make_header("FakeTestCase"), + header_2=self._make_header("DifferentFakeTestCase"), + ) + ) + self.assertEqual(report.strip(), expected) + + @mock.patch("tap.tracker.ENABLE_VERSION_13", True) + def test_combined_results_in_one_file_tap_version_13(self): + outdir = tempfile.mkdtemp() + tracker = Tracker(outdir=outdir, combined=True) + tracker.add_ok("FakeTestCase", "YESSS!") + tracker.add_ok("DifferentFakeTestCase", "GOAAL!") + + tracker.generate_tap_reports() + + self.assertFalse(os.path.exists(os.path.join(outdir, "FakeTestCase.tap"))) + self.assertFalse( + os.path.exists(os.path.join(outdir, "DifferentFakeTestCase.tap")) + ) + with open(os.path.join(outdir, "testresults.tap"), "r") as f: + report = f.read() + expected = inspect.cleandoc( + """ + TAP version 13 + {header_1} + ok 1 YESSS! + {header_2} + ok 2 GOAAL! + 1..2 + """.format( + header_1=self._make_header("FakeTestCase"), + header_2=self._make_header("DifferentFakeTestCase"), + ) + ) self.assertEqual(report.strip(), expected) def test_tracker_does_not_stream_by_default(self): @@ -120,12 +151,13 @@ def test_tracker_has_stream(self): tracker = Tracker() self.assertTrue(tracker.stream is None) + @mock.patch("tap.tracker.ENABLE_VERSION_13", False) def test_add_ok_writes_to_stream_while_streaming(self): stream = StringIO() tracker = Tracker(streaming=True, stream=stream) - tracker.add_ok('FakeTestCase', 'YESSS!') - tracker.add_ok('AnotherTestCase', 'Sure.') + tracker.add_ok("FakeTestCase", "YESSS!") + tracker.add_ok("AnotherTestCase", "Sure.") expected = inspect.cleandoc( """{header_1} @@ -133,47 +165,55 @@ def test_add_ok_writes_to_stream_while_streaming(self): {header_2} ok 2 Sure. """.format( - header_1=self._make_header('FakeTestCase'), - header_2=self._make_header('AnotherTestCase'))) + header_1=self._make_header("FakeTestCase"), + header_2=self._make_header("AnotherTestCase"), + ) + ) self.assertEqual(stream.getvalue().strip(), expected) + @mock.patch("tap.tracker.ENABLE_VERSION_13", False) def test_add_not_ok_writes_to_stream_while_streaming(self): stream = StringIO() tracker = Tracker(streaming=True, stream=stream) - tracker.add_not_ok('FakeTestCase', 'YESSS!') + tracker.add_not_ok("FakeTestCase", "YESSS!") expected = inspect.cleandoc( """{header} not ok 1 YESSS! """.format( - header=self._make_header('FakeTestCase'))) + header=self._make_header("FakeTestCase") + ) + ) self.assertEqual(stream.getvalue().strip(), expected) + @mock.patch("tap.tracker.ENABLE_VERSION_13", False) def test_add_skip_writes_to_stream_while_streaming(self): stream = StringIO() tracker = Tracker(streaming=True, stream=stream) - tracker.add_skip('FakeTestCase', 'YESSS!', 'a reason') + tracker.add_skip("FakeTestCase", "YESSS!", "a reason") expected = inspect.cleandoc( """{header} ok 1 YESSS! # SKIP a reason """.format( - header=self._make_header('FakeTestCase'))) + header=self._make_header("FakeTestCase") + ) + ) self.assertEqual(stream.getvalue().strip(), expected) def test_streaming_does_not_write_files(self): outdir = tempfile.mkdtemp() stream = StringIO() tracker = Tracker(outdir=outdir, streaming=True, stream=stream) - tracker.add_ok('FakeTestCase', 'YESSS!') + tracker.add_ok("FakeTestCase", "YESSS!") tracker.generate_tap_reports() - self.assertFalse( - os.path.exists(os.path.join(outdir, 'FakeTestCase.tap'))) + self.assertFalse(os.path.exists(os.path.join(outdir, "FakeTestCase.tap"))) + @mock.patch("tap.tracker.ENABLE_VERSION_13", False) def test_streaming_writes_plan(self): stream = StringIO() tracker = Tracker(streaming=True, stream=stream) @@ -181,24 +221,82 @@ def test_streaming_writes_plan(self): tracker.generate_tap_reports() - self.assertEqual(stream.getvalue(), '1..42\n') + self.assertEqual(stream.getvalue(), "1..42\n") + + @mock.patch("tap.tracker.ENABLE_VERSION_13", False) + def test_write_plan_first_streaming(self): + outdir = tempfile.mkdtemp() + stream = StringIO() + tracker = Tracker(outdir=outdir, streaming=True, stream=stream) + tracker.set_plan(123) + tracker.add_ok("FakeTestCase", "YESSS!") + + tracker.generate_tap_reports() + + self.assertEqual( + stream.getvalue(), + "1..123\n{header}\nok 1 YESSS!\n".format( + header=self._make_header("FakeTestCase") + ), + ) + self.assertFalse(os.path.exists(os.path.join(outdir, "FakeTestCase.tap"))) + + @mock.patch("tap.tracker.ENABLE_VERSION_13", False) + def test_write_plan_immediate_streaming(self): + stream = StringIO() + Tracker(streaming=True, stream=stream, plan=123) + self.assertEqual(stream.getvalue(), "1..123\n") + + @mock.patch("tap.tracker.ENABLE_VERSION_13", False) + def test_write_plan_first_combined(self): + outdir = tempfile.mkdtemp() + tracker = Tracker(streaming=False, outdir=outdir, combined=True) + tracker.set_plan(123) + tracker.generate_tap_reports() + with open(os.path.join(outdir, "testresults.tap"), "r") as f: + lines = f.readlines() + self.assertEqual(lines[0], "1..123\n") + + @mock.patch("tap.tracker.ENABLE_VERSION_13", False) + def test_write_plan_first_not_combined(self): + outdir = tempfile.mkdtemp() + tracker = Tracker(streaming=False, outdir=outdir, combined=False) + with self.assertRaises(ValueError): + tracker.set_plan(123) + + @mock.patch("tap.tracker.ENABLE_VERSION_13", True) + def test_streaming_writes_tap_version_13(self): + stream = StringIO() + tracker = Tracker(streaming=True, stream=stream) + + tracker.add_skip("FakeTestCase", "YESSS!", "a reason") + + expected = inspect.cleandoc( + """ + TAP version 13 + {header} + ok 1 YESSS! # SKIP a reason + """.format( + header=self._make_header("FakeTestCase") + ) + ) + self.assertEqual(stream.getvalue().strip(), expected) def test_get_default_tap_file_path(self): tracker = Tracker() - file_path = tracker._get_tap_file_path('foo') - self.assertEqual('foo.tap', file_path) + file_path = tracker._get_tap_file_path("foo") + self.assertEqual("foo.tap", file_path) def test_sanitizes_tap_file_path(self): tracker = Tracker() - file_path = tracker._get_tap_file_path('an awful \\ testcase / name\n') - self.assertEqual('an-awful---testcase---name-.tap', file_path) + file_path = tracker._get_tap_file_path("an awful \\ testcase / name\n") + self.assertEqual("an-awful---testcase---name-.tap", file_path) def test_adds_not_ok_with_diagnostics(self): tracker = Tracker() - tracker.add_not_ok( - 'FakeTestCase', 'a description', diagnostics='# more info\n') - line = tracker._test_cases['FakeTestCase'][0] - self.assertEqual('# more info\n', line.diagnostics) + tracker.add_not_ok("FakeTestCase", "a description", diagnostics="# more info\n") + line = tracker._test_cases["FakeTestCase"][0] + self.assertEqual("# more info\n", line.diagnostics) def test_header_displayed_by_default(self): tracker = Tracker() @@ -208,12 +306,16 @@ def test_header_set_by_init(self): tracker = Tracker(header=False) self.assertFalse(tracker.header) + @mock.patch("tap.tracker.ENABLE_VERSION_13", False) def test_does_not_write_header(self): stream = StringIO() tracker = Tracker(streaming=True, stream=stream, header=False) - tracker.add_skip('FakeTestCase', 'YESSS!', 'a reason') + tracker.add_skip("FakeTestCase", "YESSS!", "a reason") expected = inspect.cleandoc( - """ok 1 YESSS! # SKIP a reason""") + """ + ok 1 YESSS! # SKIP a reason + """ + ) self.assertEqual(stream.getvalue().strip(), expected) diff --git a/tap/tests/testcase.py b/tap/tests/testcase.py index d4134e2..3a41ece 100644 --- a/tap/tests/testcase.py +++ b/tap/tests/testcase.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors import unittest @@ -6,7 +6,6 @@ class TestCase(unittest.TestCase): - - def __init__(self, methodName='runTest'): + def __init__(self, methodName="runTest"): super(TestCase, self).__init__(methodName) self.factory = Factory() diff --git a/tap/tracker.py b/tap/tracker.py index baea295..b8a55dd 100644 --- a/tap/tracker.py +++ b/tap/tracker.py @@ -1,20 +1,30 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors -from __future__ import print_function import os -import string -import sys from tap.directive import Directive from tap.i18n import _ from tap.line import Result +try: + import more_itertools # noqa + import yaml # noqa + + ENABLE_VERSION_13 = True +except ImportError: # pragma: no cover + ENABLE_VERSION_13 = False -class Tracker(object): +class Tracker(object): def __init__( - self, outdir=None, combined=False, streaming=False, stream=None, - header=True): + self, + outdir=None, + combined=False, + streaming=False, + stream=None, + header=True, + plan=None, + ): self.outdir = outdir # Combine all the test results into one file. @@ -29,6 +39,9 @@ def __init__( # Stream output directly to a stream instead of file output. self.streaming = streaming self.stream = stream + # The total number of tests we expect (or None if we don't know yet). + self.plan = plan + self._plan_written = False # Display the test case header unless told not to. self.header = header @@ -36,11 +49,12 @@ def __init__( # Internal state for tracking each test case. self._test_cases = {} - # Python versions 2 and 3 keep maketrans in different locations. - if sys.version_info[0] < 3: - self._sanitized_table = string.maketrans(' \\/\n', '----') - else: # pragma: no cover - self._sanitized_table = str.maketrans(' \\/\n', '----') + self._sanitized_table = str.maketrans(" \\/\n", "----") + + if self.streaming: + self._write_tap_version(self.stream) + if self.plan is not None: + self._write_plan(self.stream) def _get_outdir(self): return self._outdir @@ -62,25 +76,34 @@ def _track(self, class_name): if self.combined: self.combined_test_cases_seen.append(class_name) - def add_ok(self, class_name, description, directive=''): + def add_ok(self, class_name, description, directive="", diagnostics=None): result = Result( - ok=True, number=self._get_next_line_number(class_name), - description=description, directive=Directive(directive)) + ok=True, + number=self._get_next_line_number(class_name), + description=description, + diagnostics=diagnostics, + directive=Directive(directive), + ) self._add_line(class_name, result) - def add_not_ok( - self, class_name, description, directive='', diagnostics=None): + def add_not_ok(self, class_name, description, directive="", diagnostics=None): result = Result( - ok=False, number=self._get_next_line_number(class_name), - description=description, diagnostics=diagnostics, - directive=Directive(directive)) + ok=False, + number=self._get_next_line_number(class_name), + description=description, + diagnostics=diagnostics, + directive=Directive(directive), + ) self._add_line(class_name, result) def add_skip(self, class_name, description, reason): - directive = 'SKIP {0}'.format(reason) + directive = "SKIP {0}".format(reason) result = Result( - ok=True, number=self._get_next_line_number(class_name), - description=description, directive=Directive(directive)) + ok=True, + number=self._get_next_line_number(class_name), + description=description, + directive=Directive(directive), + ) self._add_line(class_name, result) def _add_line(self, class_name, result): @@ -102,6 +125,21 @@ def _get_next_line_number(self, class_name): # case may not be tracked yet. In that case, the line is 1. return 1 + def set_plan(self, total): + """Notify the tracker how many total tests there will be.""" + self.plan = total + if self.streaming: + # This will only write the plan if we haven't written it + # already but we want to check if we already wrote a + # test out (in which case we can't just write the plan out + # right here). + if not self.combined_test_cases_seen: + self._write_plan(self.stream) + elif not self.combined: + raise ValueError( + "set_plan can only be used with combined or streaming output" + ) + def generate_tap_reports(self): """Generate TAP reports. @@ -109,23 +147,31 @@ def generate_tap_reports(self): the output file name is generated from the test case. """ if self.streaming: - # The results already went to the stream, record the plan. - print('1..{0}'.format(self.combined_line_number), file=self.stream) + # We're streaming but set_plan wasn't called, so we can only + # know the plan now (at the end). + if not self._plan_written: + print("1..{0}".format(self.combined_line_number), file=self.stream) + self._plan_written = True return if self.combined: - combined_file = 'testresults.tap' + combined_file = "testresults.tap" if self.outdir: combined_file = os.path.join(self.outdir, combined_file) - with open(combined_file, 'w') as out_file: + with open(combined_file, "w") as out_file: + self._write_tap_version(out_file) + if self.plan is not None: + print("1..{0}".format(self.plan), file=out_file) for test_case in self.combined_test_cases_seen: self.generate_tap_report( - test_case, self._test_cases[test_case], out_file) - print( - '1..{0}'.format(self.combined_line_number), file=out_file) + test_case, self._test_cases[test_case], out_file + ) + if self.plan is None: + print("1..{0}".format(self.combined_line_number), file=out_file) else: for test_case, tap_lines in self._test_cases.items(): - with open(self._get_tap_file_path(test_case), 'w') as out_file: + with open(self._get_tap_file_path(test_case), "w") as out_file: + self._write_tap_version(out_file) self.generate_tap_report(test_case, tap_lines, out_file) def generate_tap_report(self, test_case, tap_lines, out_file): @@ -137,16 +183,36 @@ def generate_tap_report(self, test_case, tap_lines, out_file): # For combined results, the plan is only output once after # all the test cases complete. if not self.combined: - print('1..{0}'.format(len(tap_lines)), file=out_file) + print("1..{0}".format(len(tap_lines)), file=out_file) + + def _write_tap_version(self, filename): + """Write a Version 13 TAP row. + + ``filename`` can be a filename or a stream. + """ + if ENABLE_VERSION_13: + print("TAP version 13", file=filename) + + def _write_plan(self, stream): + """Write the plan line to the stream. + + If we have a plan and have not yet written it out, write it to + the given stream. + """ + if self.plan is not None: + if not self._plan_written: + print("1..{0}".format(self.plan), file=stream) + self._plan_written = True def _write_test_case_header(self, test_case, stream): - print(_('# TAP results for {test_case}').format( - test_case=test_case), file=stream) + print( + _("# TAP results for {test_case}").format(test_case=test_case), file=stream + ) def _get_tap_file_path(self, test_case): """Get the TAP output file path for the test case.""" sanitized_test_case = test_case.translate(self._sanitized_table) - tap_file = sanitized_test_case + '.tap' + tap_file = sanitized_test_case + ".tap" if self.outdir: return os.path.join(self.outdir, tap_file) return tap_file diff --git a/tox.ini b/tox.ini index 1c3d62d..b3e4037 100644 --- a/tox.ini +++ b/tox.ini @@ -1,48 +1,49 @@ [tox] envlist = - py27 - py34 py35 py36 - pypy + py37 + py38 + pypy3 runner - flake8 + module + lint integration coverage - language_ar - language_de - language_es - language_fr - language_it - language_ja - language_nl - language_pt - language_ru - language_zh [testenv] deps = - Babel - mock pytest commands = pytest {envsitepackagesdir}/tap -[testenv:runner] +[testenv:windows] +basepython = python3.6 +deps = + pytest +commands = pytest + +[testenv:with_optional] deps = - Babel - mock + pyyaml + more-itertools +commands = python tap/tests/run.py + +[testenv:runner] commands = python tap/tests/run.py -[testenv:flake8] +[testenv:module] +commands = python -m tap + +[testenv:lint] deps = - Babel + black flake8 -commands = flake8 tap setup.py transifex.py +commands = + flake8 tap setup.py transifex.py + black --check tap setup.py transifex.py [testenv:integration] deps = - Babel - mock pytest pytest-tap commands = @@ -54,52 +55,11 @@ setenv = CI = true passenv = TRAVIS* deps = - Babel coverage - mock codecov + pyyaml + more-itertools commands = coverage run tap/tests/run.py coverage report -m --include "*/tap/*" --omit "*/tests/*" codecov - -# Test that each language's strings contain no errors. -[testenv:language_ar] -setenv = LANGUAGE=ar -commands = pytest {envsitepackagesdir}/tap - -[testenv:language_de] -setenv = LANGUAGE=de -commands = pytest {envsitepackagesdir}/tap - -[testenv:language_es] -setenv = LANGUAGE=es -commands = pytest {envsitepackagesdir}/tap - -[testenv:language_fr] -setenv = LANGUAGE=fr -commands = pytest {envsitepackagesdir}/tap - -[testenv:language_it] -setenv = LANGUAGE=it -commands = pytest {envsitepackagesdir}/tap - -[testenv:language_ja] -setenv = LANGUAGE=ja -commands = pytest {envsitepackagesdir}/tap - -[testenv:language_nl] -setenv = LANGUAGE=nl -commands = pytest {envsitepackagesdir}/tap - -[testenv:language_pt] -setenv = LANGUAGE=pt -commands = pytest {envsitepackagesdir}/tap - -[testenv:language_ru] -setenv = LANGUAGE=ru -commands = pytest {envsitepackagesdir}/tap - -[testenv:language_zh] -setenv = LANGUAGE=zh -commands = pytest {envsitepackagesdir}/tap diff --git a/transifex.py b/transifex.py index 38b249c..91dd787 100644 --- a/transifex.py +++ b/transifex.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018, Matt Layman and contributors +# Copyright (c) 2019, Matt Layman and contributors from ConfigParser import ConfigParser, NoOptionError, NoSectionError import os @@ -6,58 +6,45 @@ import requests -API_URL = 'https://www.transifex.com/api/2' -LANGUAGES = [ - 'ar', - 'de', - 'es', - 'fr', - 'it', - 'ja', - 'nl', - 'pt', - 'ru', - 'zh', -] +API_URL = "https://www.transifex.com/api/2" +LANGUAGES = ["ar", "de", "es", "fr", "it", "ja", "nl", "pt", "ru", "zh"] def fetch_po_for(language, username, password): - print 'Downloading po file for {0} ...'.format(language) - po_api = '/project/tappy/resource/tappypot/translation/{0}/'.format( - language) + print("Downloading po file for {0} ...".format(language)) + po_api = "/project/tappy/resource/tappypot/translation/{0}/".format(language) po_url = API_URL + po_api - params = {'file': '1'} + params = {"file": "1"} r = requests.get(po_url, auth=(username, password), params=params) if r.status_code == 200: - r.encoding = 'utf-8' + r.encoding = "utf-8" output_file = os.path.join( - here, 'tap', 'locale', language, 'LC_MESSAGES', 'tappy.po') - with open(output_file, 'wb') as out: - out.write(r.text.encode('utf-8')) + here, "tap", "locale", language, "LC_MESSAGES", "tappy.po" + ) + with open(output_file, "wb") as out: + out.write(r.text.encode("utf-8")) else: - print('Something went wrong fetching the {0} po file.'.format( - language)) + print("Something went wrong fetching the {0} po file.".format(language)) def get_auth_from_conf(here): - transifex_conf = os.path.join(here, '.transifex.ini') + transifex_conf = os.path.join(here, ".transifex.ini") config = ConfigParser() try: - with open(transifex_conf, 'r') as conf: + with open(transifex_conf, "r") as conf: config.readfp(conf) except IOError as ex: - sys.exit('Failed to load authentication configuration file.\n' - '{0}'.format(ex)) + sys.exit("Failed to load authentication configuration file.\n" "{0}".format(ex)) try: - username = config.get('auth', 'username') - password = config.get('auth', 'password') + username = config.get("auth", "username") + password = config.get("auth", "password") except (NoOptionError, NoSectionError) as ex: - sys.exit('Oops. Incomplete configuration file: {0}'.format(ex)) + sys.exit("Oops. Incomplete configuration file: {0}".format(ex)) return username, password -if __name__ == '__main__': +if __name__ == "__main__": here = os.path.abspath(os.path.dirname(__file__)) username, password = get_auth_from_conf(here)