diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000000..a006fb092c8 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,191 @@ +name: main + +on: + push: + branches: + - 4.6.x + tags: + - "*" + + pull_request: + branches: + - 4.6.x + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + name: [ + "windows-py27", + "windows-py35", + "windows-py36", + "windows-py37", + "windows-py37-pluggy", + "windows-py38", + + "ubuntu-py27-pluggy", + "ubuntu-py27-nobyte", + "ubuntu-py37", + "ubuntu-py37-pluggy", + "ubuntu-py37-pexpect-py37-twisted", + "ubuntu-py37-freeze", + "ubuntu-pypy", + "ubuntu-pypy3", + + "macos-py27", + "macos-py38", + + ] + + include: + # Windows jobs + - name: "windows-py27" + python: "2.7" + os: windows-latest + tox_env: "py27-xdist" + use_coverage: true + - name: "windows-py35" + python: "3.5" + os: windows-latest + tox_env: "py35-xdist" + use_coverage: true + - name: "windows-py36" + python: "3.6" + os: windows-latest + tox_env: "py36-xdist" + use_coverage: true + - name: "windows-py37" + python: "3.7" + os: windows-latest + tox_env: "py37-twisted-numpy" + use_coverage: true + - name: "windows-py37-pluggy" + python: "3.7" + os: windows-latest + tox_env: "py37-pluggymaster-xdist" + use_coverage: true + - name: "windows-py38" + python: "3.8" + os: windows-latest + tox_env: "py38-xdist" + use_coverage: true + + # Ubuntu jobs – find the rest of them in .travis.yml + - name: "ubuntu-py27-pluggy" + python: "2.7" + os: ubuntu-latest + tox_env: "py27-pluggymaster-xdist" + use_coverage: true + - name: "ubuntu-py27-nobyte" + python: "2.7" + os: ubuntu-latest + tox_env: "py27-nobyte-numpy-xdist" + use_coverage: true + - name: "ubuntu-py37" + python: "3.7" + os: ubuntu-latest + tox_env: "py37-lsof-numpy-xdist" + use_coverage: true + - name: "ubuntu-py37-pluggy" + python: "3.7" + os: ubuntu-latest + tox_env: "py37-pluggymaster-xdist" + use_coverage: true + - name: "ubuntu-py37-pexpect-py37-twisted" + python: "3.7" + os: ubuntu-latest + tox_env: "py37-pexpect,py37-twisted" + use_coverage: true + - name: "ubuntu-py37-freeze" + python: "3.7" + os: ubuntu-latest + tox_env: "py37-freeze" + - name: "ubuntu-pypy" + python: "pypy2" + os: ubuntu-latest + tox_env: "pypy-xdist" + use_coverage: true + - name: "ubuntu-pypy3" + python: "pypy3" + os: ubuntu-latest + tox_env: "pypy3-xdist" + use_coverage: true + + # MacOS jobs + - name: "macos-py27" + python: "2.7" + os: macos-latest + tox_env: "py27-xdist" + use_coverage: true + - name: "macos-py38" + python: "3.8" + os: macos-latest + tox_env: "py38-xdist" + use_coverage: true + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python }} on ${{ matrix.os }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox coverage + - name: Test without coverage + if: "! matrix.use_coverage" + run: "tox -e ${{ matrix.tox_env }}" + + - name: Test with coverage + if: "matrix.use_coverage" + env: + _PYTEST_TOX_COVERAGE_RUN: "coverage run -m" + COVERAGE_PROCESS_START: ".coveragerc" + _PYTEST_TOX_EXTRA_DEP: "coverage-enable-subprocess" + run: "tox -vv -e ${{ matrix.tox_env }}" + + - name: Prepare coverage token + if: (matrix.use_coverage && ( github.repository == 'pytest-dev/pytest' || github.event_name == 'pull_request' )) + run: | + python scripts/append_codecov_token.py + - name: Report coverage + if: (matrix.use_coverage) + env: + CODECOV_NAME: ${{ matrix.name }} + run: bash scripts/report-coverage.sh -F GHA,${{ runner.os }} + + deploy: + if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'pytest-dev/pytest' + + runs-on: ubuntu-latest + + needs: [build] + + steps: + - uses: actions/checkout@v1 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: "3.7" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install --upgrade wheel setuptools tox + - name: Build package + run: | + python setup.py sdist bdist_wheel + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.pypi_token }} + - name: Publish GitHub release notes + env: + GH_RELEASE_NOTES_TOKEN: ${{ secrets.release_notes }} + run: | + sudo apt-get install pandoc + tox -e publish-gh-release-notes diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 34895e1a30f..3aee45c62c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ exclude: doc/en/example/py2py3/test_py2.py repos: -- repo: https://github.com/python/black - rev: 19.3b0 +- repo: https://github.com/psf/black + rev: 19.10b0 hooks: - id: black args: [--safe, --quiet] diff --git a/.travis.yml b/.travis.yml index 9914800f808..d4f2d80483d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,121 +1,60 @@ language: python dist: xenial -stages: -- baseline -- name: test - if: repo = pytest-dev/pytest AND tag IS NOT present -- name: deploy - if: repo = pytest-dev/pytest AND tag IS present -python: '3.7' +python: '3.7.4' cache: false env: global: - - PYTEST_ADDOPTS=-vv + - PYTEST_ADDOPTS="-vv --showlocals --durations=100 --exitfirst" + - PYTEST_COVERAGE=1 + +# setuptools-scm needs all tags in order to obtain a proper version +git: + depth: false install: - python -m pip install --upgrade --pre tox jobs: include: - # OSX tests - first (in test stage), since they are the slower ones. - - &test-macos - os: osx - osx_image: xcode10.1 - language: generic - # Coverage for: - # - py2 with symlink in test_cmdline_python_package_symlink. - env: TOXENV=py27-xdist PYTEST_COVERAGE=1 - before_install: - - python -V - - test $(python -c 'import sys; print("%d%d" % sys.version_info[0:2])') = 27 - - <<: *test-macos - env: TOXENV=py37-pexpect,py37-xdist PYTEST_COVERAGE=1 - before_install: - - which python3 - - python3 -V - - ln -sfn "$(which python3)" /usr/local/bin/python - - python -V - - test $(python -c 'import sys; print("%d%d" % sys.version_info[0:2])') = 37 - - # Full run of latest (major) supported versions, without xdist. - - env: TOXENV=py27 + # Coverage for: + # - TestArgComplete (linux only) + # - numpy + # - verbose=0 + - stage: baseline + env: TOXENV=py27-xdist python: '2.7' - - env: TOXENV=py37 - python: '3.7' - # Coverage tracking is slow with pypy, skip it. - - env: TOXENV=pypy-xdist - python: 'pypy' - - env: TOXENV=pypy3-xdist - python: 'pypy3' + - env: TOXENV=py38-xdist + python: '3.8' + + - stage: tests + # - _pytest.unittest._handle_skip (via pexpect). + env: TOXENV=py27-pexpect,py27-twisted + python: '2.7' - - env: TOXENV=py34-xdist - python: '3.4' - env: TOXENV=py35-xdist - python: '3.5' + python: '3.5.9' - # Coverage for: - # - pytester's LsofFdLeakChecker - # - TestArgComplete (linux only) - # - numpy - # Empty PYTEST_ADDOPTS to run this non-verbose. - - env: TOXENV=py37-lsof-numpy-xdist PYTEST_COVERAGE=1 PYTEST_ADDOPTS= + - env: TOXENV=py36-xdist PYTEST_REORDER_TESTS=0 + python: '3.6.9' - # Specialized factors for py27. - - env: TOXENV=py27-nobyte-numpy-xdist - python: '2.7' - - env: TOXENV=py27-pluggymaster-xdist - python: '2.7' + - env: TOXENV=py37-numpy-pexpect-twisted + python: '3.7.4' - # Specialized factors for py37. - # Coverage for: # - test_sys_breakpoint_interception (via pexpect). - - env: TOXENV=py37-pexpect,py37-twisted PYTEST_COVERAGE=1 - - env: TOXENV=py37-pluggymaster-xdist - - env: TOXENV=py37-freeze + - env: TOXENV=py37-pexpect,py37-twisted + python: '3.7.4' - # Jobs only run via Travis cron jobs (currently daily). - - env: TOXENV=py38-xdist - python: '3.8-dev' - if: type = cron + # Run also non-verbosely, to gain coverage + - env: TOXENV=py38-xdist PYTEST_ADDOPTS="" + python: '3.8' - - stage: baseline - # Coverage for: - # - _pytest.unittest._handle_skip (via pexpect). - env: TOXENV=py27-pexpect,py27-twisted PYTEST_COVERAGE=1 - python: '2.7' - # Use py36 here for faster baseline. - - env: TOXENV=py36-xdist - python: '3.6' - - env: TOXENV=linting,docs,doctesting PYTEST_COVERAGE=1 + - env: TOXENV=linting,docs,doctesting cache: directories: - $HOME/.cache/pre-commit - - stage: deploy - python: '3.6' - install: pip install -U setuptools setuptools_scm - script: skip - deploy: - provider: pypi - user: nicoddemus - distributions: sdist bdist_wheel - skip_upload_docs: true - password: - secure: xanTgTUu6XDQVqB/0bwJQXoDMnU5tkwZc5koz6mBkkqZhKdNOi2CLoC1XhiSZ+ah24l4V1E0GAqY5kBBcy9d7NVe4WNg4tD095LsHw+CRU6/HCVIFfyk2IZ+FPAlguesCcUiJSXOrlBF+Wj68wEvLoK7EoRFbJeiZ/f91Ww1sbtDlqXABWGHrmhPJL5Wva7o7+wG7JwJowqdZg1pbQExsCc7b53w4v2RBu3D6TJaTAzHiVsW+nUSI67vKI/uf+cR/OixsTfy37wlHgSwihYmrYLFls3V0bSpahCim3bCgMaFZx8S8xrdgJ++PzBCof2HeflFKvW+VCkoYzGEG4NrTWJoNz6ni4red9GdvfjGH3YCjAKS56h9x58zp2E5rpsb/kVq5/45xzV+dq6JRuhQ1nJWjBC6fSKAc/bfwnuFK3EBxNLkvBssLHvsNjj5XG++cB8DdS9wVGUqjpoK4puaXUWFqy4q3S9F86HEsKNgExtieA9qNx+pCIZVs6JCXZNjr0I5eVNzqJIyggNgJG6RyravsU35t9Zd9doL5g4Y7UKmAGTn1Sz24HQ4sMQgXdm2SyD8gEK5je4tlhUvfGtDvMSlstq71kIn9nRpFnqB6MFlbYSEAZmo8dGbCquoUc++6Rum208wcVbrzzVtGlXB/Ow9AbFMYeAGA0+N/K1e59c= - on: - tags: true - repo: pytest-dev/pytest - -matrix: - allow_failures: - - python: '3.8-dev' - env: TOXENV=py38-xdist - # Temporary (https://github.com/pytest-dev/pytest/pull/5334). - - env: TOXENV=pypy3-xdist - python: 'pypy3' - before_script: - | # Do not (re-)upload coverage with cron runs. @@ -129,27 +68,13 @@ before_script: export _PYTEST_TOX_COVERAGE_RUN="coverage run -m" export _PYTEST_TOX_EXTRA_DEP=coverage-enable-subprocess fi - -script: tox --recreate +script: env COLUMNS=120 python -m tox after_success: - | if [[ "$PYTEST_COVERAGE" = 1 ]]; then - set -e - # Add last TOXENV to $PATH. - PATH="$PWD/.tox/${TOXENV##*,}/bin:$PATH" - coverage combine - coverage xml - coverage report -m - bash <(curl -s https://codecov.io/bash) -Z -X gcov -X coveragepy -X search -X xcode -X gcovout -X fix -f coverage.xml -n $TOXENV-$TRAVIS_OS_NAME + env CODECOV_NAME="$TOXENV-$TRAVIS_OS_NAME" scripts/report-coverage.sh fi - -notifications: - irc: - channels: - - "chat.freenode.net#pytest" - on_success: change - on_failure: change - skip_join: true - email: - - pytest-commit@python.org +branches: + only: + - 4.6.x diff --git a/AUTHORS b/AUTHORS index 0672d4abf6e..80fce539294 100644 --- a/AUTHORS +++ b/AUTHORS @@ -58,6 +58,7 @@ Christian Theunert Christian Tismer Christopher Gilling Christopher Dignam +Claudio Madotto CrazyMerlyn Cyrus Maden Damian Skrzypczak @@ -91,6 +92,7 @@ Evan Kepner Fabien Zarifian Fabio Zadrozny Feng Ma +Fernando Mezzabotta Rey Florian Bruhin Floris Bruynooghe Gabriel Reis @@ -135,6 +137,7 @@ Kale Kundert Katarzyna Jachim Katerina Koukiou Kevin Cox +Kevin J. Foley Kodi B. Arfer Kostis Anagnostopoulos Kristoffer Nordström diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 32267f4dd42..89437663f59 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,201 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 4.6.11 (2020-06-04) +========================== + +Bug Fixes +--------- + +- `#6334 `_: Fix summary entries appearing twice when ``f/F`` and ``s/S`` report chars were used at the same time in the ``-r`` command-line option (for example ``-rFf``). + + The upper case variants were never documented and the preferred form should be the lower case. + + +- `#7310 `_: Fix ``UnboundLocalError: local variable 'letter' referenced before + assignment`` in ``_pytest.terminal.pytest_report_teststatus()`` + when plugins return report objects in an unconventional state. + + This was making ``pytest_report_teststatus()`` skip + entering if-block branches that declare the ``letter`` variable. + + The fix was to set the initial value of the ``letter`` before + the if-block cascade so that it always has a value. + + +pytest 4.6.10 (2020-05-08) +========================== + +Features +-------- + +- `#6870 `_: New ``Config.invocation_args`` attribute containing the unchanged arguments passed to ``pytest.main()``. + + Remark: while this is technically a new feature and according to our `policy `_ it should not have been backported, we have opened an exception in this particular case because it fixes a serious interaction with ``pytest-xdist``, so it can also be considered a bugfix. + +Trivial/Internal Changes +------------------------ + +- `#6404 `_: Remove usage of ``parser`` module, deprecated in Python 3.9. + + +pytest 4.6.9 (2020-01-04) +========================= + +Bug Fixes +--------- + +- `#6301 `_: Fix assertion rewriting for egg-based distributions and ``editable`` installs (``pip install --editable``). + + +pytest 4.6.8 (2019-12-19) +========================= + +Features +-------- + +- `#5471 `_: JUnit XML now includes a timestamp and hostname in the testsuite tag. + + + +Bug Fixes +--------- + +- `#5430 `_: junitxml: Logs for failed test are now passed to junit report in case the test fails during call phase. + + + +Trivial/Internal Changes +------------------------ + +- `#6345 `_: Pin ``colorama`` to ``0.4.1`` only for Python 3.4 so newer Python versions can still receive colorama updates. + + +pytest 4.6.7 (2019-12-05) +========================= + +Bug Fixes +--------- + +- `#5477 `_: The XML file produced by ``--junitxml`` now correctly contain a ```` root element. + + +- `#6044 `_: Properly ignore ``FileNotFoundError`` (``OSError.errno == NOENT`` in Python 2) exceptions when trying to remove old temporary directories, + for instance when multiple processes try to remove the same directory (common with ``pytest-xdist`` + for example). + + +pytest 4.6.6 (2019-10-11) +========================= + +Bug Fixes +--------- + +- `#5523 `_: Fixed using multiple short options together in the command-line (for example ``-vs``) in Python 3.8+. + + +- `#5537 `_: Replace ``importlib_metadata`` backport with ``importlib.metadata`` from the + standard library on Python 3.8+. + + +- `#5806 `_: Fix "lexer" being used when uploading to bpaste.net from ``--pastebin`` to "text". + + +- `#5902 `_: Fix warnings about deprecated ``cmp`` attribute in ``attrs>=19.2``. + + + +Trivial/Internal Changes +------------------------ + +- `#5801 `_: Fixes python version checks (detected by ``flake8-2020``) in case python4 becomes a thing. + + +pytest 4.6.5 (2019-08-05) +========================= + +Bug Fixes +--------- + +- `#4344 `_: Fix RuntimeError/StopIteration when trying to collect package with "__init__.py" only. + + +- `#5478 `_: Fix encode error when using unicode strings in exceptions with ``pytest.raises``. + + +- `#5524 `_: Fix issue where ``tmp_path`` and ``tmpdir`` would not remove directories containing files marked as read-only, + which could lead to pytest crashing when executed a second time with the ``--basetemp`` option. + + +- `#5547 `_: ``--step-wise`` now handles ``xfail(strict=True)`` markers properly. + + +- `#5650 `_: Improved output when parsing an ini configuration file fails. + + +pytest 4.6.4 (2019-06-28) +========================= + +Bug Fixes +--------- + +- `#5404 `_: Emit a warning when attempting to unwrap a broken object raises an exception, + for easier debugging (`#5080 `__). + + +- `#5444 `_: Fix ``--stepwise`` mode when the first file passed on the command-line fails to collect. + + +- `#5482 `_: Fix bug introduced in 4.6.0 causing collection errors when passing + more than 2 positional arguments to ``pytest.mark.parametrize``. + + +- `#5505 `_: Fix crash when discovery fails while using ``-p no:terminal``. + + +pytest 4.6.3 (2019-06-11) +========================= + +Bug Fixes +--------- + +- `#5383 `_: ``-q`` has again an impact on the style of the collected items + (``--collect-only``) when ``--log-cli-level`` is used. + + +- `#5389 `_: Fix regressions of `#5063 `__ for ``importlib_metadata.PathDistribution`` which have their ``files`` attribute being ``None``. + + +- `#5390 `_: Fix regression where the ``obj`` attribute of ``TestCase`` items was no longer bound to methods. + + +pytest 4.6.2 (2019-06-03) +========================= + +Bug Fixes +--------- + +- `#5370 `_: Revert unrolling of ``all()`` to fix ``NameError`` on nested comprehensions. + + +- `#5371 `_: Revert unrolling of ``all()`` to fix incorrect handling of generators with ``if``. + + +- `#5372 `_: Revert unrolling of ``all()`` to fix incorrect assertion when using ``all()`` in an expression. + + +pytest 4.6.1 (2019-06-02) +========================= + +Bug Fixes +--------- + +- `#5354 `_: Fix ``pytest.mark.parametrize`` when the argvalues is an iterator. + + +- `#5358 `_: Fix assertion rewriting of ``all()`` calls to deal with non-generators. + + pytest 4.6.0 (2019-05-31) ========================= diff --git a/LICENSE b/LICENSE index 477af2d2e45..d14fb7ff4b3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2004-2019 Holger Krekel and others +Copyright (c) 2004-2020 Holger Krekel and others Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.rst b/README.rst index 44fa8ac728d..69408978703 100644 --- a/README.rst +++ b/README.rst @@ -131,7 +131,7 @@ Tidelift will coordinate the fix and disclosure. License ------- -Copyright Holger Krekel and others, 2004-2019. +Copyright Holger Krekel and others, 2004-2020. Distributed under the terms of the `MIT`_ license, pytest is free and open source software. diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 8e50486de55..5184610fd18 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -48,12 +48,6 @@ jobs: # pypy3: # python.version: 'pypy3' # tox.env: 'pypy3' - py34-xdist: - python.version: '3.4' - tox.env: 'py34-xdist' - # Coverage for: - # - _pytest.compat._bytes_to_ascii - PYTEST_COVERAGE: '1' py35-xdist: python.version: '3.5' tox.env: 'py35-xdist' @@ -91,7 +85,7 @@ jobs: condition: eq(variables['python.needs_vc'], True) displayName: 'Install VC for py27' - - script: python -m pip install --upgrade pip && python -m pip install tox + - script: python -m pip install tox displayName: 'Install tox' - script: | diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000000..a0a308588e2 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,7 @@ +coverage: + status: + project: true + patch: true + changes: true + +comment: off diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index fa53441ce59..74ad0285509 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,17 @@ Release announcements :maxdepth: 2 + release-4.6.11 + release-4.6.10 + release-4.6.9 + release-4.6.8 + release-4.6.7 + release-4.6.6 + release-4.6.5 + release-4.6.4 + release-4.6.3 + release-4.6.2 + release-4.6.1 release-4.6.0 release-4.5.0 release-4.4.2 diff --git a/doc/en/announce/release-4.6.1.rst b/doc/en/announce/release-4.6.1.rst new file mode 100644 index 00000000000..78d017544d2 --- /dev/null +++ b/doc/en/announce/release-4.6.1.rst @@ -0,0 +1,19 @@ +pytest-4.6.1 +======================================= + +pytest 4.6.1 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-4.6.10.rst b/doc/en/announce/release-4.6.10.rst new file mode 100644 index 00000000000..57938b8751a --- /dev/null +++ b/doc/en/announce/release-4.6.10.rst @@ -0,0 +1,20 @@ +pytest-4.6.10 +======================================= + +pytest 4.6.10 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Fernando Mez + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-4.6.11.rst b/doc/en/announce/release-4.6.11.rst new file mode 100644 index 00000000000..276584bdf52 --- /dev/null +++ b/doc/en/announce/release-4.6.11.rst @@ -0,0 +1,20 @@ +pytest-4.6.11 +======================================= + +pytest 4.6.11 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Sviatoslav Sydorenko + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-4.6.2.rst b/doc/en/announce/release-4.6.2.rst new file mode 100644 index 00000000000..8526579b9e7 --- /dev/null +++ b/doc/en/announce/release-4.6.2.rst @@ -0,0 +1,18 @@ +pytest-4.6.2 +======================================= + +pytest 4.6.2 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-4.6.3.rst b/doc/en/announce/release-4.6.3.rst new file mode 100644 index 00000000000..0bfb355a15a --- /dev/null +++ b/doc/en/announce/release-4.6.3.rst @@ -0,0 +1,21 @@ +pytest-4.6.3 +======================================= + +pytest 4.6.3 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Daniel Hahler +* Dirk Thomas + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-4.6.4.rst b/doc/en/announce/release-4.6.4.rst new file mode 100644 index 00000000000..7b35ed4f0d4 --- /dev/null +++ b/doc/en/announce/release-4.6.4.rst @@ -0,0 +1,22 @@ +pytest-4.6.4 +======================================= + +pytest 4.6.4 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Daniel Hahler +* Thomas Grainger +* Zac Hatfield-Dodds + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-4.6.5.rst b/doc/en/announce/release-4.6.5.rst new file mode 100644 index 00000000000..6998d4e4c5f --- /dev/null +++ b/doc/en/announce/release-4.6.5.rst @@ -0,0 +1,21 @@ +pytest-4.6.5 +======================================= + +pytest 4.6.5 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Daniel Hahler +* Thomas Grainger + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-4.6.6.rst b/doc/en/announce/release-4.6.6.rst new file mode 100644 index 00000000000..c47a31695b2 --- /dev/null +++ b/doc/en/announce/release-4.6.6.rst @@ -0,0 +1,20 @@ +pytest-4.6.6 +======================================= + +pytest 4.6.6 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Michael Goerz + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-4.6.7.rst b/doc/en/announce/release-4.6.7.rst new file mode 100644 index 00000000000..0e6cf6a950a --- /dev/null +++ b/doc/en/announce/release-4.6.7.rst @@ -0,0 +1,19 @@ +pytest-4.6.7 +======================================= + +pytest 4.6.7 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Bruno Oliveira +* Daniel Hahler + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-4.6.8.rst b/doc/en/announce/release-4.6.8.rst new file mode 100644 index 00000000000..3c04e5dbe9b --- /dev/null +++ b/doc/en/announce/release-4.6.8.rst @@ -0,0 +1,20 @@ +pytest-4.6.8 +======================================= + +pytest 4.6.8 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Ryan Mast + + +Happy testing, +The pytest Development Team diff --git a/doc/en/announce/release-4.6.9.rst b/doc/en/announce/release-4.6.9.rst new file mode 100644 index 00000000000..ae0478c52d9 --- /dev/null +++ b/doc/en/announce/release-4.6.9.rst @@ -0,0 +1,21 @@ +pytest-4.6.9 +======================================= + +pytest 4.6.9 has just been released to PyPI. + +This is a bug-fix release, being a drop-in replacement. To upgrade:: + + pip install --upgrade pytest + +The full changelog is available at https://docs.pytest.org/en/latest/changelog.html. + +Thanks to all who contributed to this release, among them: + +* Anthony Sottile +* Bruno Oliveira +* Felix Yan +* Hugo + + +Happy testing, +The pytest Development Team diff --git a/doc/en/conf.py b/doc/en/conf.py index 5daa15a069e..39ba5e4f854 100644 --- a/doc/en/conf.py +++ b/doc/en/conf.py @@ -65,7 +65,7 @@ # General information about the project. project = u"pytest" year = datetime.datetime.utcnow().year -copyright = u"2015–2019 , holger krekel and pytest-dev team" +copyright = u"2015–2020, holger krekel and pytest-dev team" # The language for content autogenerated by Sphinx. Refer to documentation @@ -275,7 +275,7 @@ epub_title = u"pytest" epub_author = u"holger krekel at merlinux eu" epub_publisher = u"holger krekel at merlinux eu" -epub_copyright = u"2013, holger krekel et alii" +epub_copyright = u"2013-2020, holger krekel et alii" # The language of the text. It defaults to the language option # or en if the language is not set. diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst index ef792afe7b6..2ab5e3ab10c 100644 --- a/doc/en/example/parametrize.rst +++ b/doc/en/example/parametrize.rst @@ -434,10 +434,11 @@ Running it results in some skips if we don't have all the python interpreters in .. code-block:: pytest . $ pytest -rs -q multipython.py - ...sss...sssssssss...sss... [100%] + ...ssssssssssssssssssssssss [100%] ========================= short test summary info ========================== - SKIPPED [15] $REGENDOC_TMPDIR/CWD/multipython.py:31: 'python3.4' not found - 12 passed, 15 skipped in 0.12 seconds + SKIPPED [12] $REGENDOC_TMPDIR/CWD/multipython.py:31: 'python3.4' not found + SKIPPED [12] $REGENDOC_TMPDIR/CWD/multipython.py:31: 'python3.5' not found + 3 passed, 24 skipped in 0.12 seconds Indirect parametrization of optional implementations/imports -------------------------------------------------------------------- diff --git a/doc/en/example/reportingdemo.rst b/doc/en/example/reportingdemo.rst index 928c365cafc..d00c9836265 100644 --- a/doc/en/example/reportingdemo.rst +++ b/doc/en/example/reportingdemo.rst @@ -436,7 +436,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: items = [1, 2, 3] print("items is %r" % items) > a, b = items.pop() - E TypeError: 'int' object is not iterable + E TypeError: cannot unpack non-iterable int object failure_demo.py:182: TypeError --------------------------- Captured stdout call --------------------------- @@ -515,7 +515,7 @@ Here is a nice run of several failures and how ``pytest`` presents things: def test_z2_type_error(self): items = 3 > a, b = items - E TypeError: 'int' object is not iterable + E TypeError: cannot unpack non-iterable int object failure_demo.py:222: TypeError ______________________ TestMoreErrors.test_startswith ______________________ diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index 140f4b840f1..1dee981be77 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -440,7 +440,7 @@ Now we can profile which test functions execute the slowest: test_some_are_slow.py ... [100%] ========================= slowest 3 test durations ========================= - 0.30s call test_some_are_slow.py::test_funcslow2 + 0.31s call test_some_are_slow.py::test_funcslow2 0.20s call test_some_are_slow.py::test_funcslow1 0.10s call test_some_are_slow.py::test_funcfast ========================= 3 passed in 0.12 seconds ========================= diff --git a/doc/en/getting-started.rst b/doc/en/getting-started.rst index d6c62cbe8c0..882385b2960 100644 --- a/doc/en/getting-started.rst +++ b/doc/en/getting-started.rst @@ -28,7 +28,7 @@ Install ``pytest`` .. code-block:: bash $ pytest --version - This is pytest version 4.x.y, imported from $PYTHON_PREFIX/lib/python3.6/site-packages/pytest.py + This is pytest version 4.x.y, imported from $PYTHON_PREFIX/lib/python3.7/site-packages/pytest.py .. _`simpletest`: diff --git a/doc/en/index.rst b/doc/en/index.rst index 3ace95effe6..edcbbeb84e7 100644 --- a/doc/en/index.rst +++ b/doc/en/index.rst @@ -87,7 +87,7 @@ Consult the :ref:`Changelog ` page for fixes and enhancements of each License ------- -Copyright Holger Krekel and others, 2004-2017. +Copyright Holger Krekel and others, 2004-2020. Distributed under the terms of the `MIT`_ license, pytest is free and open source software. diff --git a/doc/en/license.rst b/doc/en/license.rst index 5ee55cf96f8..c6c10bbf358 100644 --- a/doc/en/license.rst +++ b/doc/en/license.rst @@ -9,7 +9,7 @@ Distributed under the terms of the `MIT`_ license, pytest is free and open sourc The MIT License (MIT) - Copyright (c) 2004-2017 Holger Krekel and others + Copyright (c) 2004-2020 Holger Krekel and others Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/doc/en/py27-py34-deprecation.rst b/doc/en/py27-py34-deprecation.rst index 95e96de0456..0ca97539c82 100644 --- a/doc/en/py27-py34-deprecation.rst +++ b/doc/en/py27-py34-deprecation.rst @@ -17,9 +17,9 @@ are available on PyPI. While pytest ``5.0`` will be the new mainstream and development version, until **January 2020** the pytest core team plans to make bug-fix releases of the pytest ``4.6`` series by -back-porting patches to the ``4.6-maintenance`` branch that affect Python 2 users. +back-porting patches to the ``4.6.x`` branch that affect Python 2 users. -**After 2020**, the core team will no longer actively backport patches, but the ``4.6-maintenance`` +**After 2020**, the core team will no longer actively backport patches, but the ``4.6.x`` branch will continue to exist so the community itself can contribute patches. The core team will be happy to accept those patches and make new ``4.6`` releases **until mid-2020**. diff --git a/scripts/append_codecov_token.py b/scripts/append_codecov_token.py new file mode 100644 index 00000000000..b3a82075fa1 --- /dev/null +++ b/scripts/append_codecov_token.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +""" +Appends the codecov token to the 'codecov.yml' file at the root of the repository. +This is done by CI during PRs and builds on the pytest-dev repository so we can upload coverage, at least +until codecov grows some native integration like it has with Travis and AppVeyor. +See discussion in https://github.com/pytest-dev/pytest/pull/6441 for more information. +""" +import os.path +from textwrap import dedent + + +def main(): + this_dir = os.path.dirname(__file__) + cov_file = os.path.join(this_dir, "..", "codecov.yml") + + assert os.path.isfile(cov_file), "{cov_file} does not exist".format( + cov_file=cov_file + ) + + with open(cov_file, "a") as f: + # token from: https://codecov.io/gh/pytest-dev/pytest/settings + # use same URL to regenerate it if needed + text = dedent( + """ + codecov: + token: "1eca3b1f-31a2-4fb8-a8c3-138b441b50a7" + """ + ) + f.write(text) + + print("Token updated:", cov_file) + + +if __name__ == "__main__": + main() diff --git a/scripts/publish_gh_release_notes.py b/scripts/publish_gh_release_notes.py new file mode 100644 index 00000000000..3ff946b58a3 --- /dev/null +++ b/scripts/publish_gh_release_notes.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +""" +Script used to publish GitHub release notes extracted from CHANGELOG.rst. + +This script is meant to be executed after a successful deployment in Travis. + +Uses the following environment variables: + +* GIT_TAG: the name of the tag of the current commit. +* GH_RELEASE_NOTES_TOKEN: a personal access token with 'repo' permissions. It should be encrypted using: + + $travis encrypt GH_RELEASE_NOTES_TOKEN= -r pytest-dev/pytest + + And the contents pasted in the ``deploy.env.secure`` section in the ``travis.yml`` file. + +The script also requires ``pandoc`` to be previously installed in the system. + +Requires Python3.6+. +""" +import os +import re +import sys +from pathlib import Path + +import github3 +import pypandoc + + +def publish_github_release(slug, token, tag_name, body): + github = github3.login(token=token) + owner, repo = slug.split("/") + repo = github.repository(owner, repo) + return repo.create_release(tag_name=tag_name, body=body) + + +def parse_changelog(tag_name): + p = Path(__file__).parent.parent / "CHANGELOG.rst" + changelog_lines = p.read_text(encoding="UTF-8").splitlines() + + title_regex = re.compile(r"pytest (\d\.\d+\.\d+) \(\d{4}-\d{2}-\d{2}\)") + consuming_version = False + version_lines = [] + for line in changelog_lines: + m = title_regex.match(line) + if m: + # found the version we want: start to consume lines until we find the next version title + if m.group(1) == tag_name: + consuming_version = True + # found a new version title while parsing the version we want: break out + elif consuming_version: + break + if consuming_version: + version_lines.append(line) + + return "\n".join(version_lines) + + +def convert_rst_to_md(text): + return pypandoc.convert_text(text, "md", format="rst") + + +def main(argv): + if len(argv) > 1: + tag_name = argv[1] + else: + tag_name = os.environ.get("TRAVIS_TAG") + if not tag_name: + print("tag_name not given and $TRAVIS_TAG not set", file=sys.stderr) + return 1 + + token = os.environ.get("GH_RELEASE_NOTES_TOKEN") + if not token: + print("GH_RELEASE_NOTES_TOKEN not set", file=sys.stderr) + return 1 + + slug = os.environ.get("TRAVIS_REPO_SLUG") + if not slug: + print("TRAVIS_REPO_SLUG not set", file=sys.stderr) + return 1 + + rst_body = parse_changelog(tag_name) + md_body = convert_rst_to_md(rst_body) + if not publish_github_release(slug, token, tag_name, md_body): + print("Could not publish release notes:", file=sys.stderr) + print(md_body, file=sys.stderr) + return 5 + + print() + print(f"Release notes for {tag_name} published successfully:") + print(f"https://github.com/{slug}/releases/tag/{tag_name}") + print() + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/report-coverage.sh b/scripts/report-coverage.sh new file mode 100755 index 00000000000..fbcf20ca929 --- /dev/null +++ b/scripts/report-coverage.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -e +set -x + +if [ -z "$TOXENV" ]; then + python -m pip install coverage +else + # Add last TOXENV to $PATH. + PATH="$PWD/.tox/${TOXENV##*,}/bin:$PATH" +fi + +python -m coverage combine +python -m coverage xml +python -m coverage report -m +# Set --connect-timeout to work around https://github.com/curl/curl/issues/4461 +curl -S -L --connect-timeout 5 --retry 6 -s https://codecov.io/bash -o codecov-upload.sh +bash codecov-upload.sh -Z -X fix -f coverage.xml "$@" diff --git a/setup.cfg b/setup.cfg index 9d0aa332e94..368df1e1ac0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,8 @@ classifiers = Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 platforms = unix, linux, osx, cygwin, win32 [options] diff --git a/setup.py b/setup.py index 18d32201caf..1053bce4a5a 100644 --- a/setup.py +++ b/setup.py @@ -13,9 +13,10 @@ "atomicwrites>=1.0", 'funcsigs>=1.0;python_version<"3.0"', 'pathlib2>=2.2.0;python_version<"3.6"', - 'colorama;sys_platform=="win32"', + 'colorama<=0.4.1;sys_platform=="win32" and python_version=="3.4"', + 'colorama;sys_platform=="win32" and python_version!="3.4"', "pluggy>=0.12,<1.0", - "importlib-metadata>=0.12", + 'importlib-metadata>=0.12;python_version<"3.8"', "wcwidth", ] diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 8c73ccc6adc..175d6fda01d 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -572,8 +572,13 @@ def match(self, regexp): raised. """ __tracebackhide__ = True - if not re.search(regexp, str(self.value)): - assert 0, "Pattern '{!s}' not found in '{!s}'".format(regexp, self.value) + value = ( + text_type(self.value) if isinstance(regexp, text_type) else str(self.value) + ) + if not re.search(regexp, value): + raise AssertionError( + u"Pattern {!r} not found in {!r}".format(regexp, value) + ) return True diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index c8a4b6adf38..b35e97b9cec 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -123,18 +123,13 @@ def isparseable(self, deindent=True): """ return True if source is parseable, heuristically deindenting it by default. """ - from parser import suite as syntax_checker - if deindent: source = str(self.deindent()) else: source = str(self) try: - # compile(source+'\n', "x", "exec") - syntax_checker(source + "\n") - except KeyboardInterrupt: - raise - except Exception: + ast.parse(source) + except (SyntaxError, ValueError, TypeError): return False else: return True diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 5e2c5397bb8..1c6161b212d 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -953,8 +953,6 @@ def visit_Call_35(self, call): """ visit `ast.Call` nodes on Python3.5 and after """ - if isinstance(call.func, ast.Name) and call.func.id == "all": - return self._visit_all(call) new_func, func_expl = self.visit(call.func) arg_expls = [] new_args = [] @@ -978,27 +976,6 @@ def visit_Call_35(self, call): outer_expl = "%s\n{%s = %s\n}" % (res_expl, res_expl, expl) return res, outer_expl - def _visit_all(self, call): - """Special rewrite for the builtin all function, see #5062""" - if not isinstance(call.args[0], (ast.GeneratorExp, ast.ListComp)): - return - gen_exp = call.args[0] - assertion_module = ast.Module( - body=[ast.Assert(test=gen_exp.elt, lineno=1, msg="", col_offset=1)] - ) - AssertionRewriter(module_path=None, config=None).run(assertion_module) - for_loop = ast.For( - iter=gen_exp.generators[0].iter, - target=gen_exp.generators[0].target, - body=assertion_module.body, - orelse=[], - ) - self.statements.append(for_loop) - return ( - ast.Num(n=1), - "", - ) # Return an empty expression, all the asserts are in the for_loop - def visit_Starred(self, starred): # From Python 3.5, a Starred node can appear in a function call res, expl = self.visit(starred.value) @@ -1009,8 +986,6 @@ def visit_Call_legacy(self, call): """ visit `ast.Call nodes on 3.4 and below` """ - if isinstance(call.func, ast.Name) and call.func.id == "all": - return self._visit_all(call) new_func, func_expl = self.visit(call.func) arg_expls = [] new_args = [] diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 1fee64ce08d..c382f1c6091 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -12,6 +12,7 @@ from ..compat import Sequence from _pytest import outcomes from _pytest._io.saferepr import saferepr +from _pytest.compat import ATTRS_EQ_FIELD # The _reprcompare attribute on the util module is used by the new assertion # interpretation code and assertion rewriter to detect this plugin was @@ -374,7 +375,9 @@ def _compare_eq_cls(left, right, verbose, type_fns): fields_to_check = [field for field, info in all_fields.items() if info.compare] elif isattrs(left): all_fields = left.__attrs_attrs__ - fields_to_check = [field.name for field in all_fields if field.cmp] + fields_to_check = [ + field.name for field in all_fields if getattr(field, ATTRS_EQ_FIELD) + ] same = [] diff = [] diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 045248cb7c2..f5c55454849 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -21,7 +21,7 @@ from .compat import _PY2 as PY2 from .pathlib import Path from .pathlib import resolve_from_str -from .pathlib import rmtree +from .pathlib import rm_rf README_CONTENT = u"""\ # pytest cache directory # @@ -51,7 +51,7 @@ class Cache(object): def for_config(cls, config): cachedir = cls.cache_dir_from_config(config) if config.getoption("cacheclear") and cachedir.exists(): - rmtree(cachedir, force=True) + rm_rf(cachedir) cachedir.mkdir() return cls(cachedir, config) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 7668c3a94c7..d0add530268 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -13,6 +13,7 @@ import sys from contextlib import contextmanager +import attr import py import six from six import text_type @@ -61,6 +62,12 @@ def spec_from_file_location(*_, **__): return None +if sys.version_info >= (3, 8): + from importlib import metadata as importlib_metadata # noqa +else: + import importlib_metadata # noqa + + def _format_args(func): return str(signature(func)) @@ -377,7 +384,7 @@ def safe_str(v): else: def safe_str(v): - """returns v as string, converting to ascii if necessary""" + """returns v as string, converting to utf-8 if necessary""" try: return str(v) except UnicodeError: @@ -406,8 +413,8 @@ def _setup_collect_fakemodule(): pytest.collect = ModuleType("pytest.collect") pytest.collect.__all__ = [] # used for setns - for attr in COLLECT_FAKEMODULE_ATTRIBUTES: - setattr(pytest.collect, attr, getattr(pytest, attr)) + for attribute in COLLECT_FAKEMODULE_ATTRIBUTES: + setattr(pytest.collect, attribute, getattr(pytest, attribute)) if _PY2: @@ -455,3 +462,9 @@ def dec(fn): else: from functools import lru_cache # noqa: F401 + + +if getattr(attr, "__version_info__", ()) >= (19, 2): + ATTRS_EQ_FIELD = "eq" +else: + ATTRS_EQ_FIELD = "cmp" diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 7a5deb13f53..0737ff9d51c 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -13,7 +13,7 @@ import types import warnings -import importlib_metadata +import attr import py import six from packaging.version import Version @@ -31,10 +31,12 @@ from _pytest import deprecated from _pytest._code import ExceptionInfo from _pytest._code import filter_traceback +from _pytest.compat import importlib_metadata from _pytest.compat import lru_cache from _pytest.compat import safe_str from _pytest.outcomes import fail from _pytest.outcomes import Skipped +from _pytest.pathlib import Path from _pytest.warning_types import PytestConfigWarning hookimpl = HookimplMarker("pytest") @@ -116,13 +118,13 @@ def directory_arg(path, optname): # Plugins that cannot be disabled via "-p no:X" currently. -essential_plugins = ( # fmt: off +essential_plugins = ( "mark", "main", "runner", "fixtures", "helpconfig", # Provides -p. -) # fmt: on +) default_plugins = essential_plugins + ( "python", @@ -154,10 +156,15 @@ def directory_arg(path, optname): builtin_plugins.add("pytester") -def get_config(args=None): +def get_config(args=None, plugins=None): # subsequent calls to main will create a fresh instance pluginmanager = PytestPluginManager() - config = Config(pluginmanager) + config = Config( + pluginmanager, + invocation_params=Config.InvocationParams( + args=args, plugins=plugins, dir=Path().resolve() + ), + ) if args is not None: # Handle any "-p no:plugin" args. @@ -190,7 +197,7 @@ def _prepareconfig(args=None, plugins=None): msg = "`args` parameter expected to be a list or tuple of strings, got: {!r} (type: {})" raise TypeError(msg.format(args, type(args))) - config = get_config(args) + config = get_config(args, plugins) pluginmanager = config.pluginmanager try: if plugins: @@ -622,25 +629,116 @@ def __repr__(self): def _iter_rewritable_modules(package_files): + """ + Given an iterable of file names in a source distribution, return the "names" that should + be marked for assertion rewrite (for example the package "pytest_mock/__init__.py" should + be added as "pytest_mock" in the assertion rewrite mechanism. + + This function has to deal with dist-info based distributions and egg based distributions + (which are still very much in use for "editable" installs). + + Here are the file names as seen in a dist-info based distribution: + + pytest_mock/__init__.py + pytest_mock/_version.py + pytest_mock/plugin.py + pytest_mock.egg-info/PKG-INFO + + Here are the file names as seen in an egg based distribution: + + src/pytest_mock/__init__.py + src/pytest_mock/_version.py + src/pytest_mock/plugin.py + src/pytest_mock.egg-info/PKG-INFO + LICENSE + setup.py + + We have to take in account those two distribution flavors in order to determine which + names should be considered for assertion rewriting. + + More information: + https://github.com/pytest-dev/pytest-mock/issues/167 + """ + package_files = list(package_files) + seen_some = False for fn in package_files: is_simple_module = "/" not in fn and fn.endswith(".py") is_package = fn.count("/") == 1 and fn.endswith("__init__.py") if is_simple_module: module_name, _ = os.path.splitext(fn) - yield module_name + # we ignore "setup.py" at the root of the distribution + if module_name != "setup": + seen_some = True + yield module_name elif is_package: package_name = os.path.dirname(fn) + seen_some = True yield package_name + if not seen_some: + # at this point we did not find any packages or modules suitable for assertion + # rewriting, so we try again by stripping the first path component (to account for + # "src" based source trees for example) + # this approach lets us have the common case continue to be fast, as egg-distributions + # are rarer + new_package_files = [] + for fn in package_files: + parts = fn.split("/") + new_fn = "/".join(parts[1:]) + if new_fn: + new_package_files.append(new_fn) + if new_package_files: + for _module in _iter_rewritable_modules(new_package_files): + yield _module + class Config(object): - """ access to configuration values, pluginmanager and plugin hooks. """ + """ + Access to configuration values, pluginmanager and plugin hooks. + + :ivar PytestPluginManager pluginmanager: the plugin manager handles plugin registration and hook invocation. + + :ivar argparse.Namespace option: access to command line option as attributes. + + :ivar InvocationParams invocation_params: + + Object containing the parameters regarding the ``pytest.main`` + invocation. + Contains the followinig read-only attributes: + * ``args``: list of command-line arguments as passed to ``pytest.main()``. + * ``plugins``: list of extra plugins, might be None + * ``dir``: directory where ``pytest.main()`` was invoked from. + """ + + @attr.s(frozen=True) + class InvocationParams(object): + """Holds parameters passed during ``pytest.main()`` + + .. note:: + + Currently the environment variable PYTEST_ADDOPTS is also handled by + pytest implicitly, not being part of the invocation. + + Plugins accessing ``InvocationParams`` must be aware of that. + """ + + args = attr.ib() + plugins = attr.ib() + dir = attr.ib() + + def __init__(self, pluginmanager, invocation_params=None, *args): + from .argparsing import Parser, FILE_OR_DIR + + if invocation_params is None: + invocation_params = self.InvocationParams( + args=(), plugins=None, dir=Path().resolve() + ) - def __init__(self, pluginmanager): #: access to command line option as attributes. #: (deprecated), use :py:func:`getoption() <_pytest.config.Config.getoption>` instead self.option = argparse.Namespace() - from .argparsing import Parser, FILE_OR_DIR + + self.invocation_params = invocation_params _a = FILE_OR_DIR self._parser = Parser( @@ -657,9 +755,13 @@ def __init__(self, pluginmanager): self._cleanup = [] self.pluginmanager.register(self, "pytestconfig") self._configured = False - self.invocation_dir = py.path.local() self.hook.pytest_addoption.call_historic(kwargs=dict(parser=self._parser)) + @property + def invocation_dir(self): + """Backward compatibility""" + return py.path.local(str(self.invocation_params.dir)) + def add_cleanup(self, func): """ Add a function to be called when the config object gets out of use (usually coninciding with pytest_unconfigure).""" @@ -800,7 +902,7 @@ def _mark_plugins_for_rewrite(self, hook): str(file) for dist in importlib_metadata.distributions() if any(ep.group == "pytest11" for ep in dist.entry_points) - for file in dist.files + for file in dist.files or [] ) for name in _iter_rewritable_modules(package_files): diff --git a/src/_pytest/config/findpaths.py b/src/_pytest/config/findpaths.py index 3ece3bdc130..e6779b289bc 100644 --- a/src/_pytest/config/findpaths.py +++ b/src/_pytest/config/findpaths.py @@ -33,7 +33,11 @@ def getcfg(args, config=None): for inibasename in inibasenames: p = base.join(inibasename) if exists(p): - iniconfig = py.iniconfig.IniConfig(p) + try: + iniconfig = py.iniconfig.IniConfig(p) + except py.iniconfig.ParseError as exc: + raise UsageError(str(exc)) + if ( inibasename == "setup.cfg" and "tool:pytest" in iniconfig.sections diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index 6283a840246..12394aca3f5 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -40,8 +40,8 @@ RAISES_MESSAGE_PARAMETER = PytestDeprecationWarning( "The 'message' parameter is deprecated.\n" "(did you mean to use `match='some regex'` to check the exception message?)\n" - "Please comment on https://github.com/pytest-dev/pytest/issues/3974 " - "if you have concerns about removal of this parameter." + "Please see:\n" + " https://docs.pytest.org/en/4.6-maintenance/deprecations.html#message-parameter-of-pytest-raises" ) RESULT_LOG = PytestDeprecationWarning( diff --git a/src/_pytest/doctest.py b/src/_pytest/doctest.py index 95a80bb4fa2..659d24aeebc 100644 --- a/src/_pytest/doctest.py +++ b/src/_pytest/doctest.py @@ -8,6 +8,7 @@ import platform import sys import traceback +import warnings from contextlib import contextmanager import pytest @@ -17,6 +18,7 @@ from _pytest.compat import safe_getattr from _pytest.fixtures import FixtureRequest from _pytest.outcomes import Skipped +from _pytest.warning_types import PytestWarning DOCTEST_REPORT_CHOICE_NONE = "none" DOCTEST_REPORT_CHOICE_CDIFF = "cdiff" @@ -374,10 +376,18 @@ def _patch_unwrap_mock_aware(): else: def _mock_aware_unwrap(obj, stop=None): - if stop is None: - return real_unwrap(obj, stop=_is_mocked) - else: + try: + if stop is None or stop is _is_mocked: + return real_unwrap(obj, stop=_is_mocked) return real_unwrap(obj, stop=lambda obj: _is_mocked(obj) or stop(obj)) + except Exception as e: + warnings.warn( + "Got %r when unwrapping %r. This is usually caused " + "by a violation of Python's object protocol; see e.g. " + "https://github.com/pytest-dev/pytest/issues/5080" % (e, obj), + PytestWarning, + ) + raise inspect.unwrap = _mock_aware_unwrap try: diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 3972113cbb0..853dcb77449 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -15,9 +15,11 @@ import functools import os +import platform import re import sys import time +from datetime import datetime import py import six @@ -595,6 +597,8 @@ def pytest_runtest_logreport(self, report): if report.when == "call": reporter.append_failure(report) self.open_reports.append(report) + if not self.log_passing_tests: + reporter.write_captured_output(report) else: reporter.append_error(report) elif report.skipped: @@ -667,18 +671,19 @@ def pytest_sessionfinish(self): ) logfile.write('') - logfile.write( - Junit.testsuite( - self._get_global_properties_node(), - [x.to_xml() for x in self.node_reporters_ordered], - name=self.suite_name, - errors=self.stats["error"], - failures=self.stats["failure"], - skipped=self.stats["skipped"], - tests=numtests, - time="%.3f" % suite_time_delta, - ).unicode(indent=0) + suite_node = Junit.testsuite( + self._get_global_properties_node(), + [x.to_xml() for x in self.node_reporters_ordered], + name=self.suite_name, + errors=self.stats["error"], + failures=self.stats["failure"], + skipped=self.stats["skipped"], + tests=numtests, + time="%.3f" % suite_time_delta, + timestamp=datetime.fromtimestamp(self.suite_start_time).isoformat(), + hostname=platform.node(), ) + logfile.write(Junit.testsuites([suite_node]).unicode(indent=0)) logfile.close() def pytest_terminal_summary(self, terminalreporter): diff --git a/src/_pytest/logging.py b/src/_pytest/logging.py index 577a5407be0..2400737ee4e 100644 --- a/src/_pytest/logging.py +++ b/src/_pytest/logging.py @@ -424,10 +424,6 @@ def __init__(self, config): """ self._config = config - # enable verbose output automatically if live logging is enabled - if self._log_cli_enabled() and config.getoption("verbose") < 1: - config.option.verbose = 1 - self.print_logs = get_option_ini(config, "log_print") self.formatter = self._create_formatter( get_option_ini(config, "log_format"), @@ -644,6 +640,15 @@ def pytest_sessionstart(self): @pytest.hookimpl(hookwrapper=True) def pytest_runtestloop(self, session): """Runs all collected test items.""" + + if session.config.option.collectonly: + yield + return + + if self._log_cli_enabled() and self._config.getoption("verbose") < 1: + # setting verbose flag is needed to avoid messy test progress output + self._config.option.verbose = 1 + with self.live_logs_context(): if self.log_file_handler is not None: with catching_logs(self.log_file_handler, level=self.log_file_level): diff --git a/src/_pytest/main.py b/src/_pytest/main.py index fa4d8d3d509..a9d310cb62d 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -621,7 +621,13 @@ def _collect(self, arg): # Module itself, so just use that. If this special case isn't taken, then all # the files in the package will be yielded. if argpath.basename == "__init__.py": - yield next(m[0].collect()) + try: + yield next(m[0].collect()) + except StopIteration: + # The package collects nothing with only an __init__.py + # file in it, which gets ignored by the default + # "python_files" option. + pass return for y in m: yield y diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 561ccc3f45d..0ccd8141dd9 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -8,6 +8,7 @@ import six from ..compat import ascii_escaped +from ..compat import ATTRS_EQ_FIELD from ..compat import getfslineno from ..compat import MappingMixin from ..compat import NOTSET @@ -104,23 +105,24 @@ def extract_from(cls, parameterset, force_tuple=False): return cls(parameterset, marks=[], id=None) @staticmethod - def _parse_parametrize_args(argnames, argvalues, **_): - """It receives an ignored _ (kwargs) argument so this function can - take also calls from parametrize ignoring scope, indirect, and other - arguments...""" + def _parse_parametrize_args(argnames, argvalues, *args, **kwargs): if not isinstance(argnames, (tuple, list)): argnames = [x.strip() for x in argnames.split(",") if x.strip()] force_tuple = len(argnames) == 1 else: force_tuple = False - parameters = [ + return argnames, force_tuple + + @staticmethod + def _parse_parametrize_parameters(argvalues, force_tuple): + return [ ParameterSet.extract_from(x, force_tuple=force_tuple) for x in argvalues ] - return argnames, parameters @classmethod def _for_parametrize(cls, argnames, argvalues, func, config, function_definition): - argnames, parameters = cls._parse_parametrize_args(argnames, argvalues) + argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues) + parameters = cls._parse_parametrize_parameters(argvalues, force_tuple) del argvalues if parameters: @@ -376,7 +378,8 @@ def __repr__(self): return "" % (self.node,) -@attr.s(cmp=False, hash=False) +# mypy cannot find this overload, remove when on attrs>=19.2 +@attr.s(hash=False, **{ATTRS_EQ_FIELD: False}) # type: ignore class NodeMarkers(object): """ internal structure for storing marks belonging to a node diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index 7342d960738..206e9ae163e 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -329,7 +329,7 @@ def repr_failure(self, excinfo): # Respect explicit tbstyle option, but default to "short" # (None._repr_failure_py defaults to "long" without "fulltrace" option). - tbstyle = self.config.getoption("tbstyle") + tbstyle = self.config.getoption("tbstyle", "auto") if tbstyle == "auto": tbstyle = "short" diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index 3f4171207a3..7a3e80231c2 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -77,11 +77,7 @@ def create_new_paste(contents): from urllib.request import urlopen from urllib.parse import urlencode - params = { - "code": contents, - "lexer": "python3" if sys.version_info[0] == 3 else "python", - "expiry": "1week", - } + params = {"code": contents, "lexer": "text", "expiry": "1week"} url = "https://bpaste.net" response = urlopen(url, data=urlencode(params).encode("ascii")).read() m = re.search(r'href="/raw/(\w+)"', response.decode("utf-8")) diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index 729c41797d9..42071f43104 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from __future__ import absolute_import + import atexit import errno import fnmatch @@ -8,6 +10,8 @@ import shutil import sys import uuid +import warnings +from functools import partial from functools import reduce from os.path import expanduser from os.path import expandvars @@ -19,6 +23,7 @@ from six.moves import map from .compat import PY36 +from _pytest.warning_types import PytestWarning if PY36: from pathlib import Path, PurePath @@ -38,17 +43,72 @@ def ensure_reset_dir(path): ensures the given path is an empty directory """ if path.exists(): - rmtree(path, force=True) + rm_rf(path) path.mkdir() -def rmtree(path, force=False): - if force: - # NOTE: ignore_errors might leave dead folders around. - # Python needs a rm -rf as a followup. - shutil.rmtree(str(path), ignore_errors=True) - else: - shutil.rmtree(str(path)) +def on_rm_rf_error(func, path, exc, **kwargs): + """Handles known read-only errors during rmtree. + + The returned value is used only by our own tests. + """ + start_path = kwargs["start_path"] + exctype, excvalue = exc[:2] + + # another process removed the file in the middle of the "rm_rf" (xdist for example) + # more context: https://github.com/pytest-dev/pytest/issues/5974#issuecomment-543799018 + if isinstance(excvalue, OSError) and excvalue.errno == errno.ENOENT: + return False + + if not isinstance(excvalue, OSError) or excvalue.errno not in ( + errno.EACCES, + errno.EPERM, + ): + warnings.warn( + PytestWarning( + "(rm_rf) error removing {}\n{}: {}".format(path, exctype, excvalue) + ) + ) + return False + + if func not in (os.rmdir, os.remove, os.unlink): + warnings.warn( + PytestWarning( + "(rm_rf) unknown function {} when removing {}:\n{}: {}".format( + path, func, exctype, excvalue + ) + ) + ) + return False + + # Chmod + retry. + import stat + + def chmod_rw(p): + mode = os.stat(p).st_mode + os.chmod(p, mode | stat.S_IRUSR | stat.S_IWUSR) + + # For files, we need to recursively go upwards in the directories to + # ensure they all are also writable. + p = Path(path) + if p.is_file(): + for parent in p.parents: + chmod_rw(str(parent)) + # stop when we reach the original path passed to rm_rf + if parent == start_path: + break + chmod_rw(str(path)) + + func(path) + return True + + +def rm_rf(path): + """Remove the path contents recursively, even if some elements + are read-only. + """ + onerror = partial(on_rm_rf_error, start_path=path) + shutil.rmtree(str(path), onerror=onerror) def find_prefixed(root, prefix): @@ -186,7 +246,7 @@ def maybe_delete_a_numbered_dir(path): garbage = parent.joinpath("garbage-{}".format(uuid.uuid4())) path.rename(garbage) - rmtree(garbage, force=True) + rm_rf(garbage) except (OSError, EnvironmentError): # known races: # * other process did a cleanup at the same time diff --git a/src/_pytest/pytester.py b/src/_pytest/pytester.py index 605451630a6..f1d739c9917 100644 --- a/src/_pytest/pytester.py +++ b/src/_pytest/pytester.py @@ -1124,7 +1124,7 @@ def handle_timeout(): if timeout is None: ret = popen.wait() - elif six.PY3: + elif not six.PY2: try: ret = popen.wait(timeout) except subprocess.TimeoutExpired: diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index df09aa32d8f..f6e475c3a24 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -694,7 +694,7 @@ def raises(expected_exception, *args, **kwargs): return RaisesContext(expected_exception, message, match_expr) elif isinstance(args[0], str): warnings.warn(deprecated.RAISES_EXEC, stacklevel=2) - code, = args + (code,) = args assert isinstance(code, str) frame = sys._getframe(1) loc = frame.f_locals.copy() diff --git a/src/_pytest/recwarn.py b/src/_pytest/recwarn.py index 574c6a1cced..7abf2e93550 100644 --- a/src/_pytest/recwarn.py +++ b/src/_pytest/recwarn.py @@ -95,7 +95,7 @@ def warns(expected_warning, *args, **kwargs): return WarningsChecker(expected_warning, match_expr=match_expr) elif isinstance(args[0], str): warnings.warn(WARNS_EXEC, stacklevel=2) - code, = args + (code,) = args assert isinstance(code, str) frame = sys._getframe(1) loc = frame.f_locals.copy() diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py index 0427cd0ea45..88902595896 100644 --- a/src/_pytest/stepwise.py +++ b/src/_pytest/stepwise.py @@ -29,6 +29,7 @@ def __init__(self, config): self.config = config self.active = config.getvalue("stepwise") self.session = None + self.report_status = "" if self.active: self.lastfailed = config.cache.get("cache/stepwise", None) @@ -70,15 +71,8 @@ def pytest_collection_modifyitems(self, session, config, items): config.hook.pytest_deselected(items=already_passed) - def pytest_collectreport(self, report): - if self.active and report.failed: - self.session.shouldstop = ( - "Error when collecting test, stopping test execution." - ) - def pytest_runtest_logreport(self, report): - # Skip this hook if plugin is not active or the test is xfailed. - if not self.active or "xfail" in report.keywords: + if not self.active: return if report.failed: @@ -104,7 +98,7 @@ def pytest_runtest_logreport(self, report): self.lastfailed = None def pytest_report_collectionfinish(self): - if self.active and self.config.getoption("verbose") >= 0: + if self.active and self.config.getoption("verbose") >= 0 and self.report_status: return "stepwise: %s" % self.report_status def pytest_sessionfinish(self, session): diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index eb1970d5103..4418338c656 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -166,7 +166,11 @@ def getreportopt(config): reportchars += "w" elif config.option.disable_warnings and "w" in reportchars: reportchars = reportchars.replace("w", "") + aliases = {"F", "S"} for char in reportchars: + # handle old aliases + if char in aliases: + char = char.lower() if char == "a": reportopts = "sxXwEf" elif char == "A": @@ -179,15 +183,18 @@ def getreportopt(config): @pytest.hookimpl(trylast=True) # after _pytest.runner def pytest_report_teststatus(report): + letter = "F" if report.passed: letter = "." elif report.skipped: letter = "s" - elif report.failed: - letter = "F" - if report.when != "call": - letter = "f" - return report.outcome, letter, report.outcome.upper() + + outcome = report.outcome + if report.when in ("collect", "setup", "teardown") and outcome == "failed": + outcome = "error" + letter = "E" + + return outcome, letter, outcome.upper() @attr.s @@ -935,9 +942,7 @@ def show_skipped(lines): "x": show_xfailed, "X": show_xpassed, "f": partial(show_simple, "failed"), - "F": partial(show_simple, "failed"), "s": show_skipped, - "S": show_skipped, "p": partial(show_simple, "passed"), "E": partial(show_simple, "error"), } diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index e2e7efdc539..3ff6f45d8db 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -114,6 +114,7 @@ class TestCaseFunction(Function): def setup(self): self._testcase = self.parent.obj(self.name) self._fix_unittest_skip_decorator() + self._obj = getattr(self._testcase, self.name) if hasattr(self, "_request"): self._request._fillfixtures() @@ -132,6 +133,7 @@ def _fix_unittest_skip_decorator(self): def teardown(self): self._testcase = None + self._obj = None def startTest(self, testcase): pass diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 7016cf13b6e..9b33930a06c 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -9,11 +9,11 @@ import types import attr -import importlib_metadata import py import six import pytest +from _pytest.compat import importlib_metadata from _pytest.main import EXIT_NOTESTSCOLLECTED from _pytest.main import EXIT_USAGEERROR from _pytest.warnings import SHOW_PYTEST_WARNINGS_ARG @@ -223,7 +223,7 @@ def foo(): "conftest.py:2: in foo", " import qwerty", "E {}: No module named {q}qwerty{q}".format( - exc_name, q="'" if six.PY3 else "" + exc_name, q="" if six.PY2 else "'" ), ] ) diff --git a/testing/code/test_source.py b/testing/code/test_source.py index 0a2a98c0202..d2e3c134cd5 100644 --- a/testing/code/test_source.py +++ b/testing/code/test_source.py @@ -501,7 +501,7 @@ class A(object): class B(object): pass - B.__name__ = "B2" + B.__name__ = B.__qualname__ = "B2" assert getfslineno(B)[1] == -1 diff --git a/testing/conftest.py b/testing/conftest.py index 6e01d710d11..627ee763d2f 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -1,6 +1,21 @@ # -*- coding: utf-8 -*- +import sys + import pytest +if sys.gettrace(): + + @pytest.fixture(autouse=True) + def restore_tracing(): + """Restore tracing function (when run with Coverage.py). + + https://bugs.python.org/issue37011 + """ + orig_trace = sys.gettrace() + yield + if sys.gettrace() != orig_trace: + sys.settrace(orig_trace) + @pytest.hookimpl(hookwrapper=True, tryfirst=True) def pytest_collection_modifyitems(config, items): diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index a072f6b210c..70460214fbd 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -152,7 +152,7 @@ def test_pytest_plugins_in_non_top_level_conftest_unsupported_pyargs( def test_pytest_plugins_in_non_top_level_conftest_unsupported_no_top_level_conftest( - testdir + testdir, ): from _pytest.deprecated import PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST @@ -181,7 +181,7 @@ def test_func(): def test_pytest_plugins_in_non_top_level_conftest_unsupported_no_false_positives( - testdir + testdir, ): from _pytest.deprecated import PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index e7a3a80ebf1..6ff5ccfb55a 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -921,14 +921,45 @@ def test_collection_live_logging(testdir): result = testdir.runpytest("--log-cli-level=INFO") result.stdout.fnmatch_lines( - [ - "collecting*", - "*--- live log collection ---*", - "*Normal message*", - "collected 0 items", - ] + ["*--- live log collection ---*", "*Normal message*", "collected 0 items"] + ) + + +@pytest.mark.parametrize("verbose", ["", "-q", "-qq"]) +def test_collection_collect_only_live_logging(testdir, verbose): + testdir.makepyfile( + """ + def test_simple(): + pass + """ ) + result = testdir.runpytest("--collect-only", "--log-cli-level=INFO", verbose) + + expected_lines = [] + + if not verbose: + expected_lines.extend( + [ + "*collected 1 item*", + "**", + "*no tests ran*", + ] + ) + elif verbose == "-q": + assert "collected 1 item*" not in result.stdout.str() + expected_lines.extend( + [ + "*test_collection_collect_only_live_logging.py::test_simple*", + "no tests ran in * seconds", + ] + ) + elif verbose == "-qq": + assert "collected 1 item*" not in result.stdout.str() + expected_lines.extend(["*test_collection_collect_only_live_logging.py: 1*"]) + + result.stdout.fnmatch_lines(expected_lines) + def test_collection_logging_to_file(testdir): log_file = testdir.tmpdir.join("pytest.log").strpath diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index bd1b1d97541..4869431c639 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -464,7 +464,7 @@ def test_func(something): pass assert repr(req).find(req.function.__name__) != -1 def test_request_attributes_method(self, testdir): - item, = testdir.getitems( + (item,) = testdir.getitems( """ import pytest class TestB(object): @@ -492,7 +492,7 @@ def test_method(self, something): pass """ ) - item1, = testdir.genitems([modcol]) + (item1,) = testdir.genitems([modcol]) assert item1.name == "test_method" arg2fixturedefs = fixtures.FixtureRequest(item1)._arg2fixturedefs assert len(arg2fixturedefs) == 1 @@ -756,7 +756,7 @@ def test_second(): def test_request_getmodulepath(self, testdir): modcol = testdir.getmodulecol("def test_somefunc(): pass") - item, = testdir.genitems([modcol]) + (item,) = testdir.genitems([modcol]) req = fixtures.FixtureRequest(item) assert req.fspath == modcol.fspath diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 945ab8627cb..d90787894ae 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -1765,3 +1765,16 @@ def test_func_b(y): result.stdout.fnmatch_lines( ["*test_func_a*0*PASS*", "*test_func_a*2*PASS*", "*test_func_b*10*PASS*"] ) + + def test_parametrize_positional_args(self, testdir): + testdir.makepyfile( + """ + import pytest + + @pytest.mark.parametrize("a", [1], False) + def test_foo(a): + pass + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) diff --git a/testing/python/raises.py b/testing/python/raises.py index cd463d74b07..fa25d9f73ee 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -4,6 +4,7 @@ import six import pytest +from _pytest.compat import dummy_context_manager from _pytest.outcomes import Failed from _pytest.warning_types import PytestDeprecationWarning @@ -220,7 +221,7 @@ def test_raises_match(self): int("asdf") msg = "with base 16" - expr = r"Pattern '{}' not found in 'invalid literal for int\(\) with base 10: 'asdf''".format( + expr = r"Pattern '{}' not found in \"invalid literal for int\(\) with base 10: 'asdf'\"".format( msg ) with pytest.raises(AssertionError, match=expr): @@ -278,3 +279,47 @@ def __class__(self): with pytest.raises(CrappyClass()): pass assert "via __class__" in excinfo.value.args[0] + + +class TestUnicodeHandling: + """Test various combinations of bytes and unicode with pytest.raises (#5478) + + https://github.com/pytest-dev/pytest/pull/5479#discussion_r298852433 + """ + + success = dummy_context_manager + py2_only = pytest.mark.skipif( + not six.PY2, reason="bytes in raises only supported in Python 2" + ) + + @pytest.mark.parametrize( + "message, match, expectation", + [ + (u"\u2603", u"\u2603", success()), + (u"\u2603", u"\u2603foo", pytest.raises(AssertionError)), + pytest.param(b"hello", b"hello", success(), marks=py2_only), + pytest.param( + b"hello", b"world", pytest.raises(AssertionError), marks=py2_only + ), + pytest.param(u"hello", b"hello", success(), marks=py2_only), + pytest.param( + u"hello", b"world", pytest.raises(AssertionError), marks=py2_only + ), + pytest.param( + u"😊".encode("UTF-8"), + b"world", + pytest.raises(AssertionError), + marks=py2_only, + ), + pytest.param( + u"world", + u"😊".encode("UTF-8"), + pytest.raises(AssertionError), + marks=py2_only, + ), + ], + ) + def test_handling(self, message, match, expectation): + with expectation: + with pytest.raises(RuntimeError, match=match): + raise RuntimeError(message) diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 2085ffd8b44..225362e64ef 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -14,6 +14,7 @@ from _pytest import outcomes from _pytest.assertion import truncate from _pytest.assertion import util +from _pytest.compat import ATTRS_EQ_FIELD PY3 = sys.version_info >= (3, 0) @@ -179,7 +180,8 @@ def check(values, value): return check """, "mainwrapper.py": """\ - import pytest, importlib_metadata + import pytest + from _pytest.compat import importlib_metadata class DummyEntryPoint(object): name = 'spam' @@ -687,7 +689,7 @@ def test_attrs_with_attribute_comparison_off(self): @attr.s class SimpleDataObject(object): field_a = attr.ib() - field_b = attr.ib(cmp=False) + field_b = attr.ib(**{ATTRS_EQ_FIELD: False}) left = SimpleDataObject(1, "b") right = SimpleDataObject(1, "b") diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 19d050f8769..87dada213d6 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -656,12 +656,6 @@ def __repr__(self): else: assert lines == ["assert 0 == 1\n + where 1 = \\n{ \\n~ \\n}.a"] - def test_unroll_expression(self): - def f(): - assert all(x == 1 for x in range(10)) - - assert "0 == 1" in getmsg(f) - def test_custom_repr_non_ascii(self): def f(): class A(object): @@ -677,53 +671,6 @@ def __repr__(self): assert "UnicodeDecodeError" not in msg assert "UnicodeEncodeError" not in msg - def test_unroll_generator(self, testdir): - testdir.makepyfile( - """ - def check_even(num): - if num % 2 == 0: - return True - return False - - def test_generator(): - odd_list = list(range(1,9,2)) - assert all(check_even(num) for num in odd_list)""" - ) - result = testdir.runpytest() - result.stdout.fnmatch_lines(["*assert False*", "*where False = check_even(1)*"]) - - def test_unroll_list_comprehension(self, testdir): - testdir.makepyfile( - """ - def check_even(num): - if num % 2 == 0: - return True - return False - - def test_list_comprehension(): - odd_list = list(range(1,9,2)) - assert all([check_even(num) for num in odd_list])""" - ) - result = testdir.runpytest() - result.stdout.fnmatch_lines(["*assert False*", "*where False = check_even(1)*"]) - - def test_for_loop(self, testdir): - testdir.makepyfile( - """ - def check_even(num): - if num % 2 == 0: - return True - return False - - def test_for_loop(): - odd_list = list(range(1,9,2)) - for num in odd_list: - assert check_even(num) - """ - ) - result = testdir.runpytest() - result.stdout.fnmatch_lines(["*assert False*", "*where False = check_even(1)*"]) - class TestRewriteOnImport(object): def test_pycache_is_a_file(self, testdir): diff --git a/testing/test_collection.py b/testing/test_collection.py index 1cc8938666f..0fbbbec54df 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -494,7 +494,7 @@ def test_collect_protocol_single_function(self, testdir): p = testdir.makepyfile("def test_func(): pass") id = "::".join([p.basename, "test_func"]) items, hookrec = testdir.inline_genitems(id) - item, = items + (item,) = items assert item.name == "test_func" newid = item.nodeid assert newid == id @@ -613,9 +613,9 @@ def test_serialization_byid(self, testdir): testdir.makepyfile("def test_func(): pass") items, hookrec = testdir.inline_genitems() assert len(items) == 1 - item, = items + (item,) = items items2, hookrec = testdir.inline_genitems(item.nodeid) - item2, = items2 + (item2,) = items2 assert item2.name == item.name assert item2.fspath == item.fspath @@ -630,7 +630,7 @@ def test_method(self): arg = p.basename + "::TestClass::test_method" items, hookrec = testdir.inline_genitems(arg) assert len(items) == 1 - item, = items + (item,) = items assert item.nodeid.endswith("TestClass::test_method") # ensure we are reporting the collection of the single test item (#2464) assert [x.name for x in self.get_reported_items(hookrec)] == ["test_method"] @@ -1211,6 +1211,18 @@ def test_collect_pkg_init_and_file_in_args(testdir): ) +def test_collect_pkg_init_only(testdir): + subdir = testdir.mkdir("sub") + init = subdir.ensure("__init__.py") + init.write("def test_init(): pass") + + result = testdir.runpytest(str(init)) + result.stdout.fnmatch_lines(["*no tests ran in*"]) + + result = testdir.runpytest("-v", "-o", "python_files=*.py", str(init)) + result.stdout.fnmatch_lines(["sub/__init__.py::test_init PASSED*", "*1 passed in*"]) + + @pytest.mark.skipif( not hasattr(py.path.local, "mksymlinkto"), reason="symlink not available on this platform", diff --git a/testing/test_config.py b/testing/test_config.py index e588c262a9c..d13f119b020 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -6,19 +6,20 @@ import sys import textwrap -import importlib_metadata - import _pytest._code import pytest +from _pytest.compat import importlib_metadata from _pytest.config import _iter_rewritable_modules from _pytest.config.exceptions import UsageError from _pytest.config.findpaths import determine_setup from _pytest.config.findpaths import get_common_ancestor from _pytest.config.findpaths import getcfg +from _pytest.main import EXIT_INTERRUPTED from _pytest.main import EXIT_NOTESTSCOLLECTED from _pytest.main import EXIT_OK from _pytest.main import EXIT_TESTSFAILED from _pytest.main import EXIT_USAGEERROR +from _pytest.pathlib import Path class TestParseIni(object): @@ -130,6 +131,12 @@ def test_toxini_before_lower_pytestini(self, testdir): config = testdir.parseconfigure(sub) assert config.getini("minversion") == "2.0" + def test_ini_parse_error(self, testdir): + testdir.tmpdir.join("pytest.ini").write("addopts = -x") + result = testdir.runpytest() + assert result.ret != 0 + result.stderr.fnmatch_lines(["ERROR: *pytest.ini:1: no section header defined"]) + @pytest.mark.xfail(reason="probably not needed") def test_confcutdir(self, testdir): sub = testdir.mkdir("sub") @@ -425,15 +432,21 @@ def test_confcutdir_check_isdir(self, testdir): @pytest.mark.parametrize( "names, expected", [ + # dist-info based distributions root are files as will be put in PYTHONPATH (["bar.py"], ["bar"]), - (["foo", "bar.py"], []), - (["foo", "bar.pyc"], []), - (["foo", "__init__.py"], ["foo"]), - (["foo", "bar", "__init__.py"], []), + (["foo/bar.py"], ["bar"]), + (["foo/bar.pyc"], []), + (["foo/__init__.py"], ["foo"]), + (["bar/__init__.py", "xz.py"], ["bar", "xz"]), + (["setup.py"], []), + # egg based distributions root contain the files from the dist root + (["src/bar/__init__.py"], ["bar"]), + (["src/bar/__init__.py", "setup.py"], ["bar"]), + (["source/python/bar/__init__.py", "setup.py"], ["bar"]), ], ) def test_iter_rewritable_modules(self, names, expected): - assert list(_iter_rewritable_modules(["/".join(names)])) == expected + assert list(_iter_rewritable_modules(names)) == expected class TestConfigFromdictargs(object): @@ -586,6 +599,29 @@ def distributions(): testdir.parseconfig() +def test_importlib_metadata_broken_distribution(testdir, monkeypatch): + """Integration test for broken distributions with 'files' metadata being None (#5389)""" + monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) + + class DummyEntryPoint: + name = "mytestplugin" + group = "pytest11" + + def load(self): + return object() + + class Distribution: + version = "1.0" + files = None + entry_points = (DummyEntryPoint(),) + + def distributions(): + return (Distribution(),) + + monkeypatch.setattr(importlib_metadata, "distributions", distributions) + testdir.parseconfig() + + @pytest.mark.parametrize("block_it", [True, False]) def test_plugin_preparse_prevents_setuptools_loading(testdir, monkeypatch, block_it): monkeypatch.delenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", raising=False) @@ -729,10 +765,10 @@ def pytest_addoption(parser): **{ "conftest": conftest_source, "subdir/conftest": conftest_source, - "subdir/test_foo": """ + "subdir/test_foo": """\ def test_foo(pytestconfig): assert pytestconfig.getini('foo') == 'subdir' - """, + """, } ) @@ -765,6 +801,12 @@ def pytest_internalerror(self, excrepr): assert "ValueError" in err +def test_no_terminal_discovery_error(testdir): + testdir.makepyfile("raise TypeError('oops!')") + result = testdir.runpytest("-p", "no:terminal", "--collect-only") + assert result.ret == EXIT_INTERRUPTED + + def test_load_initial_conftest_last_ordering(testdir, _config_for_test): pm = _config_for_test.pluginmanager @@ -1181,6 +1223,29 @@ def test_config_does_not_load_blocked_plugin_from_args(testdir): assert result.ret == EXIT_USAGEERROR +def test_invocation_args(testdir): + """Ensure that Config.invocation_* arguments are correctly defined""" + + class DummyPlugin(object): + pass + + p = testdir.makepyfile("def test(): pass") + plugin = DummyPlugin() + rec = testdir.inline_run(p, "-v", plugins=[plugin]) + calls = rec.getcalls("pytest_runtest_protocol") + assert len(calls) == 1 + call = calls[0] + config = call.item.config + + assert config.invocation_params.args == [p, "-v"] + assert config.invocation_params.dir == Path(str(testdir.tmpdir)) + + plugins = config.invocation_params.plugins + assert len(plugins) == 2 + assert plugins[0] is plugin + assert type(plugins[1]).__name__ == "Collect" # installed by testdir.inline_run() + + @pytest.mark.parametrize( "plugin", [ diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 25a35c3c136..723f1fe9642 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -3,11 +3,14 @@ from __future__ import division from __future__ import print_function +import inspect import sys import textwrap import pytest from _pytest.compat import MODULE_NOT_FOUND_ERROR +from _pytest.doctest import _is_mocked +from _pytest.doctest import _patch_unwrap_mock_aware from _pytest.doctest import DoctestItem from _pytest.doctest import DoctestModule from _pytest.doctest import DoctestTextfile @@ -1237,3 +1240,25 @@ class Example(object): ) result = testdir.runpytest("--doctest-modules") result.stdout.fnmatch_lines(["* 1 passed *"]) + + +class Broken: + def __getattr__(self, _): + raise KeyError("This should be an AttributeError") + + +@pytest.mark.skipif(not hasattr(inspect, "unwrap"), reason="nothing to patch") +@pytest.mark.parametrize( # pragma: no branch (lambdas are not called) + "stop", [None, _is_mocked, lambda f: None, lambda f: False, lambda f: True] +) +def test_warning_on_unwrap_of_broken_object(stop): + bad_instance = Broken() + assert inspect.unwrap.__module__ == "inspect" + with _patch_unwrap_mock_aware(): + assert inspect.unwrap.__module__ != "inspect" + with pytest.warns( + pytest.PytestWarning, match="^Got KeyError.* when unwrapping" + ): + with pytest.raises(KeyError): + inspect.unwrap(bad_instance, stop=stop) + assert inspect.unwrap.__module__ == "inspect" diff --git a/testing/test_entry_points.py b/testing/test_entry_points.py index ad64d004f1c..95ebc415b15 100644 --- a/testing/test_entry_points.py +++ b/testing/test_entry_points.py @@ -3,7 +3,7 @@ from __future__ import division from __future__ import print_function -import importlib_metadata +from _pytest.compat import importlib_metadata def test_pytest_entry_points_are_identical(): diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 4c21c94d365..ba529cf331d 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -4,7 +4,9 @@ from __future__ import print_function import os +import platform import sys +from datetime import datetime from xml.dom import minidom import py @@ -47,6 +49,16 @@ def find_first_by_tag(self, tag): def _by_tag(self, tag): return self.__node.getElementsByTagName(tag) + @property + def children(self): + return [type(self)(x) for x in self.__node.childNodes] + + @property + def get_unique_child(self): + children = self.children + assert len(children) == 1 + return children[0] + def find_nth_by_tag(self, tag, n): items = self._by_tag(tag) try: @@ -81,7 +93,7 @@ def tag(self): return self.__node.tagName @property - def next_siebling(self): + def next_sibling(self): return type(self)(self.__node.nextSibling) @@ -135,6 +147,30 @@ def test_xpass(): node = dom.find_first_by_tag("testsuite") node.assert_attr(name="pytest", errors=1, failures=2, skipped=1, tests=5) + def test_hostname_in_xml(self, testdir): + testdir.makepyfile( + """ + def test_pass(): + pass + """ + ) + result, dom = runandparse(testdir) + node = dom.find_first_by_tag("testsuite") + node.assert_attr(hostname=platform.node()) + + def test_timestamp_in_xml(self, testdir): + testdir.makepyfile( + """ + def test_pass(): + pass + """ + ) + start_time = datetime.now() + result, dom = runandparse(testdir) + node = dom.find_first_by_tag("testsuite") + timestamp = datetime.strptime(node["timestamp"], "%Y-%m-%dT%H:%M:%S.%f") + assert start_time <= timestamp < datetime.now() + def test_timing_function(self, testdir): testdir.makepyfile( """ @@ -390,11 +426,11 @@ def test_fail(): fnode = tnode.find_first_by_tag("failure") fnode.assert_attr(message="ValueError: 42") assert "ValueError" in fnode.toxml() - systemout = fnode.next_siebling + systemout = fnode.next_sibling assert systemout.tag == "system-out" assert "hello-stdout" in systemout.toxml() assert "info msg" not in systemout.toxml() - systemerr = systemout.next_siebling + systemerr = systemout.next_sibling assert systemerr.tag == "system-err" assert "hello-stderr" in systemerr.toxml() assert "info msg" not in systemerr.toxml() @@ -1101,6 +1137,20 @@ def test_x(i): assert failed == ["test_x[22]"] +def test_root_testsuites_tag(testdir): + testdir.makepyfile( + """ + def test_x(): + pass + """ + ) + _, dom = runandparse(testdir) + root = dom.get_unique_child + assert root.tag == "testsuites" + suite_node = root.get_unique_child + assert suite_node.tag == "testsuite" + + def test_runs_twice(testdir): f = testdir.makepyfile( """ @@ -1359,3 +1409,39 @@ def test_func(): node = dom.find_first_by_tag("testcase") assert len(node.find_by_tag("system-err")) == 0 assert len(node.find_by_tag("system-out")) == 0 + + +@pytest.mark.parametrize("junit_logging", ["no", "system-out", "system-err"]) +def test_logging_passing_tests_disabled_logs_output_for_failing_test_issue5430( + testdir, junit_logging +): + testdir.makeini( + """ + [pytest] + junit_log_passing_tests=False + """ + ) + testdir.makepyfile( + """ + import pytest + import logging + import sys + + def test_func(): + logging.warning('hello') + assert 0 + """ + ) + result, dom = runandparse(testdir, "-o", "junit_logging=%s" % junit_logging) + assert result.ret == 1 + node = dom.find_first_by_tag("testcase") + if junit_logging == "system-out": + assert len(node.find_by_tag("system-err")) == 0 + assert len(node.find_by_tag("system-out")) == 1 + elif junit_logging == "system-err": + assert len(node.find_by_tag("system-err")) == 1 + assert len(node.find_by_tag("system-out")) == 0 + else: + assert junit_logging == "no" + assert len(node.find_by_tag("system-err")) == 0 + assert len(node.find_by_tag("system-out")) == 0 diff --git a/testing/test_mark.py b/testing/test_mark.py index 5bd97d547e8..727bd9420c2 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -413,6 +413,28 @@ def test_func(a, b): assert result.ret == 0 +def test_parametrize_iterator(testdir): + """parametrize should work with generators (#5354).""" + py_file = testdir.makepyfile( + """\ + import pytest + + def gen(): + yield 1 + yield 2 + yield 3 + + @pytest.mark.parametrize('a', gen()) + def test(a): + assert a >= 1 + """ + ) + result = testdir.runpytest(py_file) + assert result.ret == 0 + # should not skip any tests + result.stdout.fnmatch_lines(["*3 passed*"]) + + class TestFunctional(object): def test_merging_markers_deep(self, testdir): # issue 199 - propagate markers into nested classes @@ -986,7 +1008,7 @@ def test_custom_mark_parametrized(obj_type): def test_pytest_param_id_requires_string(): with pytest.raises(TypeError) as excinfo: pytest.param(id=True) - msg, = excinfo.value.args + (msg,) = excinfo.value.args if six.PY2: assert msg == "Expected id to be a string, got : True" else: @@ -1003,7 +1025,7 @@ def test_pytest_param_warning_on_unknown_kwargs(): # typo, should be marks= pytest.param(1, 2, mark=pytest.mark.xfail()) assert warninfo[0].filename == __file__ - msg, = warninfo[0].message.args + (msg,) = warninfo[0].message.args assert msg == ( "pytest.param() got unexpected keyword arguments: ['mark'].\n" "This will be an error in future versions." diff --git a/testing/test_monkeypatch.py b/testing/test_monkeypatch.py index 9b2f4550240..961c57e06a3 100644 --- a/testing/test_monkeypatch.py +++ b/testing/test_monkeypatch.py @@ -209,7 +209,7 @@ class TestEnvironWarnings(object): VAR_NAME = u"PYTEST_INTERNAL_MY_VAR" - @pytest.mark.skipif(six.PY3, reason="Python 2 only test") + @pytest.mark.skipif(not six.PY2, reason="Python 2 only test") def test_setenv_unicode_key(self, monkeypatch): with pytest.warns( pytest.PytestWarning, @@ -217,7 +217,7 @@ def test_setenv_unicode_key(self, monkeypatch): ): monkeypatch.setenv(self.VAR_NAME, "2") - @pytest.mark.skipif(six.PY3, reason="Python 2 only test") + @pytest.mark.skipif(not six.PY2, reason="Python 2 only test") def test_delenv_unicode_key(self, monkeypatch): with pytest.warns( pytest.PytestWarning, diff --git a/testing/test_pastebin.py b/testing/test_pastebin.py index 9491f6d9019..02efdb55407 100644 --- a/testing/test_pastebin.py +++ b/testing/test_pastebin.py @@ -74,7 +74,7 @@ def test(): """ ) result = testdir.runpytest("--pastebin=all") - if sys.version_info[0] == 3: + if sys.version_info[0] >= 3: expected_msg = "*assert '☺' == 1*" else: expected_msg = "*assert '\\xe2\\x98\\xba' == 1*" @@ -126,7 +126,7 @@ def test_create_new_paste(self, pastebin, mocked_urlopen): assert len(mocked_urlopen) == 1 url, data = mocked_urlopen[0] assert type(data) is bytes - lexer = "python3" if sys.version_info[0] == 3 else "python" + lexer = "text" assert url == "https://bpaste.net" assert "lexer=%s" % lexer in data.decode() assert "code=full-paste-contents" in data.decode() diff --git a/testing/test_pytester.py b/testing/test_pytester.py index 54d364ca1fd..f7cc0936cce 100644 --- a/testing/test_pytester.py +++ b/testing/test_pytester.py @@ -245,8 +245,8 @@ def test_inline_run_taking_and_restoring_a_sys_modules_snapshot( ): spy_factory = self.spy_factory() monkeypatch.setattr(pytester, "SysModulesSnapshot", spy_factory) - original = dict(sys.modules) testdir.syspathinsert() + original = dict(sys.modules) testdir.makepyfile(import1="# you son of a silly person") testdir.makepyfile(import2="# my hovercraft is full of eels") test_mod = testdir.makepyfile( diff --git a/testing/test_recwarn.py b/testing/test_recwarn.py index 2246085f59e..d652219df3e 100644 --- a/testing/test_recwarn.py +++ b/testing/test_recwarn.py @@ -225,7 +225,7 @@ def test_strings(self): assert len(warninfo) == 3 for w in warninfo: assert w.filename == __file__ - msg, = w.message.args + (msg,) = w.message.args assert msg.startswith("warns(..., 'code(as_a_string)') is deprecated") def test_function(self): diff --git a/testing/test_runner.py b/testing/test_runner.py index 6906efb910c..804c2831c89 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -336,8 +336,10 @@ def test_method(self): assert reps[2].failed assert reps[2].when == "teardown" assert reps[2].longrepr.reprcrash.message in ( - # python3 error + # python3 < 3.10 error "TypeError: teardown_method() missing 2 required positional arguments: 'y' and 'z'", + # python3 >= 3.10 error + "TypeError: TestClass.teardown_method() missing 2 required positional arguments: 'y' and 'z'", # python2 error "TypeError: teardown_method() takes exactly 4 arguments (2 given)", ) diff --git a/testing/test_skipping.py b/testing/test_skipping.py index 55119ae1269..7834971125a 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -136,7 +136,7 @@ def test_func(): ) def test_skipif_class(self, testdir): - item, = testdir.getitems( + (item,) = testdir.getitems( """ import pytest class TestClass(object): diff --git a/testing/test_stepwise.py b/testing/test_stepwise.py index 68e5989ecdb..d8d11917ade 100644 --- a/testing/test_stepwise.py +++ b/testing/test_stepwise.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +import sys + import pytest @@ -157,14 +159,66 @@ def test_change_testfile(stepwise_testdir): assert "test_success PASSED" in stdout -def test_stop_on_collection_errors(broken_testdir): - result = broken_testdir.runpytest( - "-v", - "--strict-markers", - "--stepwise", - "working_testfile.py", - "broken_testfile.py", +@pytest.mark.parametrize("broken_first", [True, False]) +def test_stop_on_collection_errors(broken_testdir, broken_first): + """Stop during collection errors. Broken test first or broken test last + actually surfaced a bug (#5444), so we test both situations.""" + files = ["working_testfile.py", "broken_testfile.py"] + if broken_first: + files.reverse() + result = broken_testdir.runpytest("-v", "--strict-markers", "--stepwise", *files) + result.stdout.fnmatch_lines("*errors during collection*") + + +def test_xfail_handling(testdir): + """Ensure normal xfail is ignored, and strict xfail interrupts the session in sw mode + + (#5547) + """ + contents = """ + import pytest + def test_a(): pass + + @pytest.mark.xfail(strict={strict}) + def test_b(): assert {assert_value} + + def test_c(): pass + def test_d(): pass + """ + testdir.makepyfile(contents.format(assert_value="0", strict="False")) + result = testdir.runpytest("--sw", "-v") + result.stdout.fnmatch_lines( + [ + "*::test_a PASSED *", + "*::test_b XFAIL *", + "*::test_c PASSED *", + "*::test_d PASSED *", + "* 3 passed, 1 xfailed in *", + ] ) - stdout = result.stdout.str() - assert "errors during collection" in stdout + testdir.makepyfile(contents.format(assert_value="1", strict="True")) + result = testdir.runpytest("--sw", "-v") + result.stdout.fnmatch_lines( + [ + "*::test_a PASSED *", + "*::test_b FAILED *", + "* Interrupted*", + "* 1 failed, 1 passed in *", + ] + ) + + # because we are writing to the same file, mtime might not be affected enough to + # invalidate the cache, making this next run flaky + if not sys.dont_write_bytecode: + testdir.tmpdir.join("__pycache__").remove() + testdir.makepyfile(contents.format(assert_value="0", strict="True")) + result = testdir.runpytest("--sw", "-v") + result.stdout.fnmatch_lines( + [ + "*::test_b XFAIL *", + "*::test_c PASSED *", + "*::test_d PASSED *", + "* 2 passed, 1 deselected, 1 xfailed in *", + ] + ) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 1b2e46c7c2b..752c894ca93 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -759,6 +759,35 @@ def test(i): result = testdir.runpytest(*params) result.stdout.fnmatch_lines(["collected 3 items", "hello from hook: 3 items"]) + def test_summary_f_alias(self, testdir): + """Test that 'f' and 'F' report chars are aliases and don't show up twice in the summary (#6334)""" + testdir.makepyfile( + """ + def test(): + assert False + """ + ) + result = testdir.runpytest("-rfF") + expected = "FAILED test_summary_f_alias.py::test - assert False" + result.stdout.fnmatch_lines([expected]) + assert result.stdout.lines.count(expected) == 1 + + def test_summary_s_alias(self, testdir): + """Test that 's' and 'S' report chars are aliases and don't show up twice in the summary""" + testdir.makepyfile( + """ + import pytest + + @pytest.mark.skip + def test(): + pass + """ + ) + result = testdir.runpytest("-rsS") + expected = "SKIPPED [1] test_summary_s_alias.py:3: unconditional skip" + result.stdout.fnmatch_lines([expected]) + assert result.stdout.lines.count(expected) == 1 + def test_fail_extra_reporting(testdir, monkeypatch): monkeypatch.setenv("COLUMNS", "80") @@ -1551,12 +1580,16 @@ def test_teardown_with_test_also_failing( testdir.makepyfile( """ def test_foo(fail_teardown): - assert False + assert 0 """ ) - output = testdir.runpytest() + output = testdir.runpytest("-rfE") output.stdout.re_match_lines( - [r"test_teardown_with_test_also_failing.py FE\s+\[100%\]"] + [ + r"test_teardown_with_test_also_failing.py FE\s+\[100%\]", + "FAILED test_teardown_with_test_also_failing.py::test_foo - assert 0", + "ERROR test_teardown_with_test_also_failing.py::test_foo - assert False", + ] ) def test_teardown_many(self, testdir, many_files): diff --git a/testing/test_tmpdir.py b/testing/test_tmpdir.py index 40ffe98af98..7622342b1d7 100644 --- a/testing/test_tmpdir.py +++ b/testing/test_tmpdir.py @@ -3,6 +3,9 @@ from __future__ import division from __future__ import print_function +import errno +import os +import stat import sys import attr @@ -270,7 +273,7 @@ def test_lock_register_cleanup_removal(self, tmp_path): registry = [] register_cleanup_lock_removal(lock, register=registry.append) - cleanup_func, = registry + (cleanup_func,) = registry assert lock.is_file() @@ -317,12 +320,25 @@ def test_cleanup_locked(self, tmp_path): p, consider_lock_dead_if_created_before=p.stat().st_mtime + 1 ) - def test_rmtree(self, tmp_path): - from _pytest.pathlib import rmtree + def test_cleanup_ignores_symlink(self, tmp_path): + the_symlink = tmp_path / (self.PREFIX + "current") + attempt_symlink_to(the_symlink, tmp_path / (self.PREFIX + "5")) + self._do_cleanup(tmp_path) + + def test_removal_accepts_lock(self, tmp_path): + folder = pathlib.make_numbered_dir(root=tmp_path, prefix=self.PREFIX) + pathlib.create_cleanup_lock(folder) + pathlib.maybe_delete_a_numbered_dir(folder) + assert folder.is_dir() + + +class TestRmRf: + def test_rm_rf(self, tmp_path): + from _pytest.pathlib import rm_rf adir = tmp_path / "adir" adir.mkdir() - rmtree(adir) + rm_rf(adir) assert not adir.exists() @@ -330,19 +346,75 @@ def test_rmtree(self, tmp_path): afile = adir / "afile" afile.write_bytes(b"aa") - rmtree(adir, force=True) + rm_rf(adir) assert not adir.exists() - def test_cleanup_ignores_symlink(self, tmp_path): - the_symlink = tmp_path / (self.PREFIX + "current") - attempt_symlink_to(the_symlink, tmp_path / (self.PREFIX + "5")) - self._do_cleanup(tmp_path) + def test_rm_rf_with_read_only_file(self, tmp_path): + """Ensure rm_rf can remove directories with read-only files in them (#5524)""" + from _pytest.pathlib import rm_rf - def test_removal_accepts_lock(self, tmp_path): - folder = pathlib.make_numbered_dir(root=tmp_path, prefix=self.PREFIX) - pathlib.create_cleanup_lock(folder) - pathlib.maybe_delete_a_numbered_dir(folder) - assert folder.is_dir() + fn = tmp_path / "dir/foo.txt" + fn.parent.mkdir() + + fn.touch() + + self.chmod_r(fn) + + rm_rf(fn.parent) + + assert not fn.parent.is_dir() + + def chmod_r(self, path): + mode = os.stat(str(path)).st_mode + os.chmod(str(path), mode & ~stat.S_IWRITE) + + def test_rm_rf_with_read_only_directory(self, tmp_path): + """Ensure rm_rf can remove read-only directories (#5524)""" + from _pytest.pathlib import rm_rf + + adir = tmp_path / "dir" + adir.mkdir() + + (adir / "foo.txt").touch() + self.chmod_r(adir) + + rm_rf(adir) + + assert not adir.is_dir() + + def test_on_rm_rf_error(self, tmp_path): + from _pytest.pathlib import on_rm_rf_error + + adir = tmp_path / "dir" + adir.mkdir() + + fn = adir / "foo.txt" + fn.touch() + self.chmod_r(fn) + + # unknown exception + with pytest.warns(pytest.PytestWarning): + exc_info = (None, RuntimeError(), None) + on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path) + assert fn.is_file() + + # we ignore FileNotFoundError + file_not_found = OSError() + file_not_found.errno = errno.ENOENT + exc_info = (None, file_not_found, None) + assert not on_rm_rf_error(None, str(fn), exc_info, start_path=tmp_path) + + permission_error = OSError() + permission_error.errno = errno.EACCES + # unknown function + with pytest.warns(pytest.PytestWarning): + exc_info = (None, permission_error, None) + on_rm_rf_error(None, str(fn), exc_info, start_path=tmp_path) + assert fn.is_file() + + exc_info = (None, permission_error, None) + on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path) + assert not fn.is_file() def attempt_symlink_to(path, to_path): @@ -358,3 +430,24 @@ def attempt_symlink_to(path, to_path): def test_tmpdir_equals_tmp_path(tmpdir, tmp_path): assert Path(tmpdir) == tmp_path + + +def test_basetemp_with_read_only_files(testdir): + """Integration test for #5524""" + testdir.makepyfile( + """ + import os + import stat + + def test(tmp_path): + fn = tmp_path / 'foo.txt' + fn.write_text(u'hello') + mode = os.stat(str(fn)).st_mode + os.chmod(str(fn), mode & ~stat.S_IREAD) + """ + ) + result = testdir.runpytest("--basetemp=tmp") + assert result.ret == 0 + # running a second time and ensure we don't crash + result = testdir.runpytest("--basetemp=tmp") + assert result.ret == 0 diff --git a/testing/test_unittest.py b/testing/test_unittest.py index bb41952abbe..6b721d1c0ce 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -144,6 +144,29 @@ def test_func2(self): reprec.assertoutcome(passed=2) +def test_function_item_obj_is_instance(testdir): + """item.obj should be a bound method on unittest.TestCase function items (#5390).""" + testdir.makeconftest( + """ + def pytest_runtest_makereport(item, call): + if call.when == 'call': + class_ = item.parent.obj + assert isinstance(item.obj.__self__, class_) + """ + ) + testdir.makepyfile( + """ + import unittest + + class Test(unittest.TestCase): + def test_foo(self): + pass + """ + ) + result = testdir.runpytest_inprocess() + result.stdout.fnmatch_lines(["* 1 passed in*"]) + + def test_teardown(testdir): testpath = testdir.makepyfile( """ @@ -365,7 +388,7 @@ def test_hello(self): def test_testcase_totally_incompatible_exception_info(testdir): - item, = testdir.getitems( + (item,) = testdir.getitems( """ from unittest import TestCase class MyTestCase(TestCase): diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 092604d7db0..65f57e024f3 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -569,7 +569,7 @@ def test_hidden_by_system(self, testdir, monkeypatch): assert WARNINGS_SUMMARY_HEADER not in result.stdout.str() -@pytest.mark.skipif(six.PY3, reason="Python 2 only issue") +@pytest.mark.skipif(not six.PY2, reason="Python 2 only issue") def test_infinite_loop_warning_against_unicode_usage_py2(testdir): """ We need to be careful when raising the warning about unicode usage with "warnings.warn" diff --git a/tox.ini b/tox.ini index 0b1be0d33fe..f75a0610482 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ envlist = py36 py37 py38 + py39 pypy pypy3 {py27,py37}-{pexpect,xdist,twisted,numpy,pluggymaster} @@ -93,7 +94,7 @@ commands = [testenv:regen] changedir = doc/en skipsdist = True -basepython = python3.6 +basepython = python3 deps = sphinx PyYAML @@ -119,13 +120,14 @@ changedir = testing/freeze # Disable PEP 517 with pip, which does not work with PyInstaller currently. deps = pyinstaller + setuptools < 45.0.0 commands = {envpython} create_executable.py {envpython} tox_run.py [testenv:release] decription = do a release, required posarg of the version number -basepython = python3.6 +basepython = python3 usedevelop = True passenv = * deps = @@ -136,6 +138,17 @@ deps = wheel commands = python scripts/release.py {posargs} +[testenv:publish_gh_release_notes] +description = create GitHub release after deployment +basepython = python3 +usedevelop = True +passenv = GH_RELEASE_NOTES_TOKEN TRAVIS_TAG TRAVIS_REPO_SLUG +deps = + github3.py + pypandoc +commands = python scripts/publish_gh_release_notes.py + + [pytest] minversion = 2.0 addopts = -ra -p pytester --strict-markers