diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..37c30522e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.tox* +.*_cache +*.egg-info +Dockerfile +build +dist diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f6aae5679..0f9e29ba8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @gaborbernat @asottile @obestwalter @jugmac00 +* @gaborbernat diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..37bd7c9ce --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing to `tox` + +Thank you for your interest in contributing to `tox`! There are many ways to contribute, and we appreciate all of them. +As a reminder, all contributors are expected to follow our [Code of Conduct][coc]. + +[coc]: https://www.pypa.io/en/latest/code-of-conduct/ + +## Development Documentation + +Our [development documentation](http://tox.readthedocs.org/en/latest/development.html#development) contains details on +how to get started with contributing to `tox`, and details of our development processes. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 3e233f9e6..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -tidelift: "pypi/tox" diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 000000000..a467dcee0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "" +labels: bug +assignees: "" +--- + +## Issue + +Describe what's the expected behaviour and what you're observing. + +## Environment + +Provide at least: + +- OS: +- `pip list` of the host Python where `tox` is installed: + +```console + +``` + +## Output of running tox + +Provide the output of `tox -rvv`: + +```console + +``` + +## Minimal example + +If possible, provide a minimal reproducer for the issue: + +```console + +``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 383a84d45..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Bug report -about: Something that does not work as expected -title: "" -labels: bug:normal -assignees: '' - ---- - -When submitting a bug make sure you can reproduce it via ``tox -rvv`` and attach the output of that to the bug. Ideally, you should also submit a project that allows easily reproducing the bug. Thanks! diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..0766ca63f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,10 @@ +# Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser +blank_issues_enabled: true # default +contact_links: + - name: 🤷💻🤦 Discourse + url: https://discuss.python.org/c/packaging + about: | + Please ask typical Q&A here: general ideas for Python packaging, questions about structuring projects and so on + - name: 📝 PyPA Code of Conduct + url: https://www.pypa.io/en/latest/code-of-conduct/ + about: ❤ Be nice to other members of the community. ☮ Behave. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 000000000..d065c2197 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,25 @@ +--- +name: Feature request +about: Suggest an enhancement for this project +title: "" +labels: enhancement +assignees: "" +--- + +## What's the problem this feature will solve? + + + +## Describe the solution you'd like + + + + + +## Alternative Solutions + + + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index e3cf4e465..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Feature request -about: Suggest an improvement for the project -title: "" -labels: feature:new -assignees: '' - ---- - -Describe what improvement you want and how would this be used. Thanks! diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e540913f3..7be9e33d8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,25 +1,10 @@ -## Thanks for contributing a pull request! +# Thanks for contribution -If you are contributing for the first time or provide a trivial fix don't worry too -much about the checklist - we will help you get started. - -## Contribution checklist: - -(also see [CONTRIBUTING.rst](../tree/master/CONTRIBUTING.rst) for details) +Please, make sure you address all the checklists (for details on how see +[development documentation](http://tox.readthedocs.org/en/latest/development.html#development))! +- [ ] ran the linter to address style issues (`tox -e fix_lint`) - [ ] wrote descriptive pull request text -- [ ] added/updated test(s) +- [ ] ensured there are test(s) validating the fix +- [ ] added news fragment in `docs/changelog` folder - [ ] updated/extended the documentation -- [ ] added relevant [issue keyword](https://help.github.com/articles/closing-issues-using-keywords/) - in message body -- [ ] added news fragment in [changelog folder](../tree/master/docs/changelog) - * fragment name: `..rst` for example (588.bugfix.rst) - * `` is must be one of `bugfix`, `feature`, `deprecation`, `breaking`, `doc`, `misc` - * if PR has no issue: consider creating one first or change it to the PR number after creating the PR - * "sign" fragment with ```-- by :user:``.``` - * please, use full sentences with correct case and punctuation, for example: - ```rst - Fixed an issue with non-ascii contents in doctest text files -- by :user:`superuser`. - ``` - * also see [examples](../tree/master/docs/changelog) -- [ ] added yourself to `CONTRIBUTORS` (preserving alphabetical order) diff --git a/.github/SECURITY.md b/.github/SECURITY.md deleted file mode 100644 index c6c2892b2..000000000 --- a/.github/SECURITY.md +++ /dev/null @@ -1,13 +0,0 @@ -# Security Policy - -## Supported Versions - -| Version | Supported | -| -------- | ------------------ | -| 3.25 + | :white_check_mark: | -| < 3.25 | :x: | - -## Reporting a Vulnerability - -To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift -will coordinate the fix and disclosure. diff --git a/.github/config.yml b/.github/config.yml index 4650149a9..15e725b07 100644 --- a/.github/config.yml +++ b/.github/config.yml @@ -1,5 +1,3 @@ chronographer: enforce_name: suffix: .rst -rtd: - project: tox diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dfc7b312d..43c6d09c3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,8 +1,6 @@ name: check on: push: - branches: [master, 'test-me-*'] - tags: pull_request: schedule: - cron: "0 8 * * *" @@ -13,145 +11,101 @@ concurrency: jobs: test: - name: test ${{ matrix.py }} - ${{ matrix.os }} + name: test ${{ matrix.py }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: py: - - "3.11.0-beta.5" - - "pypy-3.7-v7.3.9" # ahead to start it earlier because takes longer - - "pypy-2.7-v7.3.9" # ahead to start it earlier because takes longer + - "3.11" - "3.10" - "3.9" - "3.8" - "3.7" - - "3.6" - - "3.5" - - "2.7" os: - - ubuntu-20.04 - - macos-12 + - ubuntu-22.04 - windows-2022 + - macos-12 steps: - name: Setup python for tox uses: actions/setup-python@v4 with: - python-version: "3.10" - - name: Install tox - run: python -m pip install tox + python-version: "3.11" - uses: actions/checkout@v3 with: fetch-depth: 0 + - name: Install self-tox + run: python -m pip install . - name: Setup python for test ${{ matrix.py }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.py }} - name: Pick environment to run run: | - import codecs; import os; import platform; import sys - env = 'TOXENV=py{}{}{}'.format("" if platform.python_implementation() == "CPython" else "py", sys.version_info.major, sys.version_info.minor) - print("Picked: {} for {} based of {}".format(env, sys.version, sys.executable)) - with codecs.open(os.environ["GITHUB_ENV"], "a", "utf-8") as file_handler: - file_handler.write(env) + import os; import platform; import sys; from pathlib import Path + env = f'TOXENV=py{"" if platform.python_implementation() == "CPython" else "py"}3{sys.version_info.minor}' + print(f"Picked: {env} for {sys.version} based of {sys.executable}") + with Path(os.environ["GITHUB_ENV"]).open("ta") as file_handler: + file_handler.write(env) shell: python - name: Setup test suite - run: tox -vv --notest + run: tox r -vv --notest - name: Run test suite - run: tox --skip-pkg-install + run: tox r --skip-pkg-install env: - PYTEST_ADDOPTS: "-vv --durations=20" CI_RUN: "yes" DIFF_AGAINST: HEAD - - name: Rename coverage report file - run: import os; import sys; os.rename(".tox/.coverage.{}".format(os.environ['TOXENV']), ".tox/.coverage.{}-{}.format(os.environ['TOXENV'], sys.platform)") - shell: python - - name: Upload coverage data - uses: actions/upload-artifact@v3 - with: - name: coverage-data - path: ".tox/.coverage.*" - - coverage: - name: Combine coverage - runs-on: ubuntu-22.04 - needs: test - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - name: Install tox - run: python -m pip install tox - - name: Setup coverage tool - run: tox -e coverage --notest - - name: Install package builder - run: python -m pip install build - - name: Build package - run: pyproject-build --wheel . - - name: Download coverage data - uses: actions/download-artifact@v3 - with: - name: coverage-data - path: .tox - - name: Combine and report coverage - run: tox -e coverage - - name: Upload HTML report - uses: actions/upload-artifact@v3 - with: - name: html-report - path: .tox/htmlcov + PYTEST_XDIST_AUTO_NUM_WORKERS: 0 check: - name: ${{ matrix.tox_env }} - ${{ matrix.os }} + name: tox env ${{ matrix.tox_env }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: - - ubuntu-22.04 - - windows-2022 tox_env: + - type - dev - docs - - readme + - pkg_meta + os: + - ubuntu-22.04 + - windows-2022 exclude: - - { os: windows-2022, tox_env: readme } + - { os: windows-2022, tox_env: pkg_meta } steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - - name: Setup Python "3.10" + - name: Setup Python 3.11 uses: actions/setup-python@v4 with: - python-version: "3.10" - - name: Install tox - run: python -m pip install tox - - name: Setup test suite - run: tox -vv --notest -e ${{ matrix.tox_env }} - - name: Run test suite - run: tox --skip-pkg-install -e ${{ matrix.tox_env }} + python-version: "3.11" + - name: Install self-tox + run: python -m pip install . + - name: Run check for ${{ matrix.tox_env }} + run: tox r -e ${{ matrix.tox_env }} + env: + UPGRADE_ADVISORY: "yes" publish: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - needs: [ check, coverage ] + needs: [check, test] runs-on: ubuntu-22.04 steps: - name: Setup python to build package uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" - name: Install build run: python -m pip install build - uses: actions/checkout@v3 with: fetch-depth: 0 - - name: Build sdist and wheel - run: python -m build -s -w . -o dist - - name: Publish to PyPi - uses: pypa/gh-action-pypi-publish@v1.5.1 + - name: Build package + run: pyproject-build -s -w . -o dist + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@v1.6.4 with: skip_existing: true user: __token__ diff --git a/.gitignore b/.gitignore index 8e754eaaf..0ae8ecde4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,13 @@ -# python -*.pyc -*.pyo -*.swp -__pycache__ - - -# packaging folders -/src/tox/version.py -/build/ -/dist/ -/src/tox.egg-info - -# tox working folder -/.tox - -# IDEs -/.idea -/.vscode - -# tools /.*_cache - -# documentation +/build +/dist /docs/_draft.rst - -# release -credentials.json - -pip-wheel-metadata +/src/tox/version.py +/toxfile.py +/Dockerfile +/.tox +*.py[co] +__pycache__ +*.swp +*.egg-info +/tests/demo_pkg_setuptools/build/lib/demo_pkg_setuptools/__init__.py diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 000000000..68e8082c7 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,12 @@ +MD013: + code_blocks: false + headers: false + line_length: 120 + tables: false + +MD046: + style: fenced + +MD033: + allowed_elements: + - a diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a498341a7..d396b00c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 hooks: - id: check-ast - id: check-builtin-literals @@ -11,24 +11,33 @@ repos: - id: debug-statements - id: end-of-file-fixer - id: trailing-whitespace + - repo: https://github.com/asottile/add-trailing-comma + rev: v2.3.0 + hooks: + - id: add-trailing-comma + args: [--py36-plus] - repo: https://github.com/asottile/pyupgrade - rev: v2.38.2 + rev: v3.3.0 hooks: - id: pyupgrade + args: ["--py37-plus"] + exclude: "^(tests/demo_pkg_inline/build.py)$" + - id: pyupgrade + files: "^(tests/demo_pkg_inline/build.py)$" - repo: https://github.com/PyCQA/isort rev: 5.10.1 hooks: - id: isort - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 22.10.0 hooks: - id: black - args: [ --safe ] + args: [--safe] - repo: https://github.com/asottile/blacken-docs rev: v1.12.1 hooks: - id: blacken-docs - additional_dependencies: [ black==22.8.0 ] + additional_dependencies: [black==22.10] - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.9.0 hooks: @@ -37,32 +46,41 @@ repos: rev: "0.5.2" hooks: - id: tox-ini-fmt - args: [ "-p", "fix_lint" ] - - repo: https://github.com/asottile/setup-cfg-fmt - rev: v2.0.0 - hooks: - - id: setup-cfg-fmt - args: [ --min-py3-version, "3.5", "--max-py-version", "3.10" ] + args: ["-p", "fix"] - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 + rev: 6.0.0 hooks: - id: flake8 additional_dependencies: - - flake8-bugbear==22.7.1 + - flake8-bugbear==22.12.6 + - flake8-comprehensions==3.10.1 + - flake8-pytest-style==1.6 + - flake8-spellcheck==0.28 + - flake8-unused-arguments==0.0.12 + - flake8-noqa==1.3 + - pep8-naming==0.13.2 + - flake8-pyproject==1.2.2 + - repo: https://github.com/pre-commit/mirrors-prettier + rev: "v2.7.1" + hooks: + - id: prettier + additional_dependencies: + - prettier@2.7.1 + - "@prettier/plugin-xml@2.2" + args: ["--print-width=120", "--prose-wrap=always"] + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.32.2 + hooks: + - id: markdownlint - repo: local hooks: - id: changelogs-rst name: changelog filenames language: fail - entry: >- - changelog files must be named - ####.(bugfix|feature|deprecation|breaking|doc|misc).rst - exclude: >- - ^docs/changelog/(\d+\.(bugfix|feature|deprecation|breaking|doc|misc).rst|README.rst|template.jinja2) + entry: "changelog files must be named ####.(feature|bugfix|doc|removal|misc).rst" + exclude: ^docs/changelog/(\d+\.(feature|bugfix|doc|removal|misc).rst|README.rst|template.jinja2) files: ^docs/changelog/ - - id: changelogs-user-role - name: changelog files have a non-broken :user:`name` role - language: pygrep - entry: :user:([^`]+`?|`[^`]+[\s,]) - pass_filenames: true - types: [file, rst] + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes diff --git a/.readthedocs.yml b/.readthedocs.yml index 8f8b623a6..ab3011302 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,17 +1,14 @@ version: 2 build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: - python: "3.10" -formats: - - htmlzip - - epub - - pdf + python: "3" python: - install: - - method: pip - path: . - extra_requirements: ["docs"] + install: + - method: pip + path: . + extra_requirements: + - docs sphinx: builder: html configuration: docs/conf.py diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 7a228a7f6..77325a15a 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -43,10 +43,10 @@ event. Representation of a project may be further defined and clarified by proje ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team. -The project team will review and investigate all complaints, and will respond in a way that it deems -appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter -of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team. The +project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the +circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index 41e58b5f8..000000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,238 +0,0 @@ -Contribution getting started -============================ - -Contributions are highly welcomed and appreciated. Every little help counts, -so do not hesitate! If you like tox, also share some love on Twitter or in your blog posts. - -.. contents:: Contribution links - :depth: 2 - -.. _submitfeedback: - -Feature requests and feedback ------------------------------ - -We'd also like to hear about your propositions and suggestions. Feel free to -`submit them as issues `_ and: - -* Explain in detail how they should work. -* Keep the scope as narrow as possible. This will make it easier to implement. - -.. _reportbugs: - -Report bugs ------------ - -Report bugs for tox in the `issue tracker `_. - -If you are reporting a bug, please include: - -* Your operating system name and version. -* Any details about your local setup that might be helpful in troubleshooting, - specifically the Python interpreter version, installed libraries, and tox - version. -* Detailed steps to reproduce the bug, or - even better, an xfailing test reproduces the bug - -If you can write a demonstration test that currently fails but should pass -(xfail), that is a very useful commit to make as well, even if you cannot -fix the bug itself (e.g. something like this in -`test_config `_). - -.. _fixbugs: - -Fix bugs --------- - -Look through the GitHub issues for bugs. Here is a filter you can use: -https://github.com/tox-dev/tox/labels/bug:normal - -Don't forget to check the issue trackers of your favourite plugins, too! - -.. _writeplugins: - -Implement features ------------------- - -Look through the GitHub issues for enhancements. Here is a filter you can use: -https://github.com/tox-dev/tox/labels/feature:new - -Write documentation -------------------- - -tox could always use more documentation. What exactly is needed? - -* More complementary documentation. Have you perhaps found something unclear? -* Docstrings. There can never be too many of them. -* Blog posts, articles and such -- they're all very appreciated. - -You can also edit documentation files directly in the GitHub web interface, -without using a local copy. This can be convenient for small fixes. - -.. note:: - Build the documentation locally with the following command: - - .. code:: bash - - $ tox -e docs - - The built documentation should be available in the ``.tox/docs_out/``. - -.. _submitplugin: - -.. _`pull requests`: -.. _pull-requests: - -Preparing Pull Requests ------------------------ - -Short version -^^^^^^^^^^^^^ - -#. `Fork the repository `_. -#. Make your changes. -#. open a `pull request `_ targeting the ``master`` branch. -#. Follow **PEP-8**. There's a ``tox`` command to help fixing it: ``tox -e fix_lint``. - You can also add a pre commit hook to your local clone to run the style checks and fixes - (see hint after running ``tox -e fix_lint``) -#. Tests for tox are (obviously) run using ``tox``:: - - tox -e fix_lint,py27,py36 - - The test environments above are usually enough to cover most cases locally. - -#. Consider the - `checklist `_ - in the pull request form - -Long version -^^^^^^^^^^^^ - -What is a "pull request"? It informs the project's core developers about the -changes you want to review and merge. Pull requests are stored on -`GitHub servers `_. -Once you send a pull request, we can discuss its potential modifications and -even add more commits to it later on. There's an excellent tutorial on how Pull -Requests work in the -`GitHub Help Center `_. - -Here is a simple overview, with tox-specific bits: - -#. Fork the - `tox GitHub repository `__. It's - fine to use ``tox`` as your fork repository name because it will live - under your user. - -#. Clone your fork locally using `git `_ and create a branch:: - - $ git clone git@github.com:YOUR_GITHUB_USERNAME/tox.git - $ cd tox - # now, to fix a bug create your own branch off "master": - - $ git checkout -b your-bugfix-branch-name master - - # or to instead add a feature create your own branch off "features": - - $ git checkout -b your-feature-branch-name features - - If you need some help with Git, follow this quick start - guide: https://git.wiki.kernel.org/index.php/QuickStart - -#. Install tox - - Of course tox is used to run all the tests of itself:: - - $ cd - $ pip install [-e] . - -#. Run all the tests - - You need to have Python 2.7 and 3.6 available in your system. Now - running tests is as simple as issuing this command:: - - $ tox -e fix_lint,py27,py36 - - This command will run tests via the "tox" tool against Python 2.7 and 3.6 - and also perform style checks with some automatic fixes. - -#. You can now edit your local working copy. Please follow PEP-8. - - You can now make the changes you want and run the tests again as necessary. - - $ tox -e py27 -- --pdb - - Or to only run tests in a particular test module on Python 3.6:: - - $ tox -e py36 -- testing/test_config.py - - You can also use the dev environment: - - $ tox -e dev - - To get information about all environments, type: - - $ tox -av - -#. Commit and push once your tests pass and you are happy with your change(s):: - - $ git commit -a -m "" - $ git push -u - - -#. submit a pull request through the GitHub website and and consider the `checklist `_ in the pull request form:: - - head-fork: YOUR_GITHUB_USERNAME/tox - compare: your-branch-name - - base-fork: tox-dev/tox - base: master - -Submitting plugins to tox-dev ------------------------------ - -tox development of the core, some plugins and support code happens -in repositories living under the ``tox-dev`` organisation: - -- `tox-dev on GitHub `_ - -All tox-dev team members have write access to all contained -repositories. tox core and plugins are generally developed -using `pull requests`_ to respective repositories. - -The objectives of the ``tox-dev`` organisation are: - -* Having a central location for popular tox plugins -* Sharing some of the maintenance responsibility (in case a maintainer no - longer wishes to maintain a plugin) - -You can submit your plugin by opening an `issue `_ -requesting to add you as a member of tox-dev to be able to integrate the plugin. -As a member of the or you can then transfer the plugin yourself. - -The plugin must have the following: - -- PyPI presence with a ``setup.py`` that contains a license, ``tox-`` - prefixed name, version number, authors, short and long description. - -- a ``tox.ini`` for running tests using `tox `_. - -- a ``README`` describing how to use the plugin and on which - platforms it runs. - -- a ``LICENSE`` file or equivalent containing the licensing - information, with matching info in ``setup.py``. - -- an issue tracker for bug reports and enhancement requests. - -- a `changelog `_ - -If no contributor strongly objects, the repository can then be -transferred to the ``tox-dev`` organisation. For details see -`about repository transfers `_ - -Members of the tox organization have write access to all projects. -We recommend that each plugin has at least three people who have the right to release to PyPI. - -Repository owners can rest assured that no ``tox-dev`` administrator will ever make -releases of your repository or take ownership in any way, except in rare cases -where someone becomes unresponsive after months of contact attempts. -As stated, the objective is to share maintenance and avoid "plugin-abandon". diff --git a/CONTRIBUTORS b/CONTRIBUTORS deleted file mode 100644 index cf1ac350e..000000000 --- a/CONTRIBUTORS +++ /dev/null @@ -1,129 +0,0 @@ -Adam Johnson -Albin Vass -Alex Grönholm -Alexander Loechel -Alexander Schepanovski -Alexandre Conrad -Allan Feldman -Andrey Bienkowski -Andrii Soldatenko -Andrzej Klajnert -Anthon van der Neuth -Anthony Sottile -Antoine Dechaume -Anudit Nagar -Ashley Whetter -Asmund Grammeltwedt -Barney Gale -Barry Warsaw -Bartolome Sanchez Salado -Bastien Vallet -Benoit Pierre -Bernat Gabor -Brett Langdon -Brett Smith -Bruno Oliveira -Carl Meyer -Charles Brunet -Chris Down -Chris Jerdonek -Chris Rose -Clark Boylan -Cyril Roelandt -Dane Hillard -David Staheli -David Diaz -Ederag -Eli Collins -Eugene Yunak -Fabian Poggenhans -Felix Hildén -Fernando L. Pereira -Florian Bruhin -Florian Preinstorfer -Florian Schulze -George Alton -Gleb Nikonorov -Gonéri Le Bouder -Hazal Ozturk -Henk-Jaap Wagenaar -Hugo van Kemenade -Ian Stapleton Cordasco -Igor Duarte Cardoso -Ilya Kulakov -Ionel Maries Cristian -Isaac Pedisich -Itxaka Serrano -Jake Windle -Jannis Leidel -Jason R. Coombs -Jesse Schwartzentruber -Joachim Brandon LeBlanc -Johannes Christ -John Mark Vandenberg -Jon Dufresne -Josh Smeaton -Josh Snyder -Joshua Hesketh -Julian Krause -Jürgen Gmach -Jurko Gospodnetić -Karthikeyan Singaravelan -Krisztian Fekete -Kian-Meng Ang -Laszlo Vasko -Lukasz Balcerzak -Lukasz Rogalski -Manuel Jacob -Marc Abramowitz -Marc Schlaich -Marius Gedminas -Mariusz Rusiniak -Mark Hirota -Masen Furer -Matt Good -Matt Jeffery -Matthew Kenigsberg -Mattieu Agopian -Mauricio Villegas -Mehdi Abaakouk -Michael Manganiello -Mickaël Schoentgen -Mikhail Kyshtymov -Miro Hrončok -Monty Taylor -Morgan Fainberg -Naveen S R -Niander Assis -Nick Douma -Nick Prendergast -Nicolas Vivet -Oliver Bestwalter -Pablo Galindo -Paul Moore -Paweł Adamczak -Peter Kolbus -Philip Thiem -Pierre-Jean Campigotto -Pierre-Luc Tessier Gagné -Prakhar Gurunani -Rahul Bangar -Robert Gomulka -Ronald Evers -Ronny Pfannschmidt -Ryuichi Ohori -Selim Belhaouane -Sorin Sbarnea -Sridhar Ratnakumar -Stephen Finucane -Sviatoslav Sydorenko -Thomas Grainger -Tim Laurence -Tyagraj Desigar -Usama Sadiq -Vladislav Doster -Ville Skyttä -Vincent Vanlaer -Vlastimil Zíma -Xander Johnson -anatoly techtonik diff --git a/HOWTORELEASE.rst b/HOWTORELEASE.rst deleted file mode 100644 index 02061f185..000000000 --- a/HOWTORELEASE.rst +++ /dev/null @@ -1,54 +0,0 @@ -================== -How to release tox -================== - -This matches the current model that can be summarized as this: - -* tox has no long lived branches. - -* Pull requests get integrated into master by members of the project when they feel confident that this could be part of the next release. Small fix ups might be done right after merge instead of discussing back and forth to get minor problems fixed, to keep the workflow simple. - - -**Normal releases**: done from master when enough changes have accumulated (whatever that means at any given point in time). - -**"Special" releases**: (in seldom cases when master has moved on and is not in a state where a quick release should be done from that state): the current release tag is checked out, the necessary fixes are cherry picked and a package with a patch release increase is built from that state. This is not very clean but seems good enough at the moment as it does not happen very often. If it does happen more often, this needs some rethinking (and rather into the direction of making less buggy releases than complicating release development/release process). - -HOWTO -===== - -Prerequisites -------------- - -* Push and merge rights for https://github.com/tox-dev/tox, also referred to as the *upstream*. -* A UNIX system that has: - - - ``tox`` - - ``git`` able to push to upstream - -* Accountability: if you cut a release that breaks the CI builds of projects using tox, you are expected to fix this within a reasonable time frame (hours/days - not weeks/months) - if you don't feel quite capable of doing this yet, partner up with a more experienced member of the team and make sure they got your back if things break. - -Release -------- -Run the release command and make sure you pass in the desired release number: - -.. code-block:: bash - - tox -e release -- - -Create a pull request and wait until it the CI passes. Now make sure you merge the PR -and delete the release branch. The CI will automatically pick the tag up and -release it, wait to appear in PyPI. Only merge if the later happens. - -Post release activities ------------------------ - -Make sure to let the world know that a new version is out by whatever means you see fit. - -As a minimum, send out a mail notification by triggering the notify tox environment: - - -.. code-block:: bash - - TOX_DEV_GOOGLE_SECRET=our_secret tox -e notify - -Note you'll need the ``TOX_DEV_GOOGLE_SECRET`` key, what you can acquire from other maintainers. diff --git a/LICENSE b/LICENSE index 4ba2dca2f..364982344 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,18 @@ -MIT License +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 the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -Copyright (c) 2012-202x The tox developers +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. -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 the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index ed0088a1b..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,11 +0,0 @@ -include CHANGELOG.rst -include README.rst -include CONTRIBUTORS -include LICENSE -include setup.py -include tox.ini -graft docs -graft tests - -global-exclude __pycache__ -global-exclude *.py[cod] diff --git a/README.md b/README.md index d137ad14f..1df8055f8 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,27 @@ +# tox + [![PyPI](https://img.shields.io/pypi/v/tox)](https://pypi.org/project/tox/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/tox.svg)](https://pypi.org/project/tox/) -[![check](https://github.com/tox-dev/tox/actions/workflows/check.yml/badge.svg)](https://github.com/tox-dev/tox/actions/workflows/check.yml) [![Documentation status](https://readthedocs.org/projects/tox/badge/?version=latest)](https://tox.readthedocs.io/en/latest/?badge=latest) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Downloads](https://pepy.tech/badge/tox/month)](https://pepy.tech/project/tox/) - - - tox logo - - -# tox automation project - -**Command line driven CI frontend and development task automation tool** - -At its core tox provides a convenient way to run arbitrary commands in isolated environments to serve as a single entry -point for build, test and release activities. - -tox is highly [configurable](https://tox.readthedocs.io/en/latest/config.html) and -[pluggable](https://tox.readthedocs.io/en/latest/plugins.html). - -## Example: run tests with Python 3.7 and Python 3.8 - -tox is mainly used as a command line tool and needs a `tox.ini` or a `tool.tox` section in `pyproject.toml` containing -the configuration. - -To test a simple project that has some tests, here is an example with a `tox.ini` in the root of the project: - -```ini -[tox] -envlist = py37,py38 - -[testenv] -deps = pytest -commands = pytest -``` - -```console -$ tox - -[lots of output from what tox does] -[lots of output from commands that were run] - -__________________ summary _________________ - py37: commands succeeded - py38: commands succeeded - congratulations :) -``` - -tox created two `testenvs` - one based on Python 3.7 and one based on Python 3.8, it installed pytest in them and ran -the tests. The report at the end summarizes which `testenvs` have failed and which have succeeded. - -**Note:** To learn more about what you can do with tox, have a look at -[the collection of examples in the documentation](https://tox.readthedocs.io/en/latest/examples.html) or -[existing projects using tox](https://github.com/search?l=INI&q=tox.ini+in%3Apath&type=Code). - -### How it works - -tox creates virtual environments for all configured so-called `testenvs`, it then installs the project and other -necessary dependencies and runs the configured set of commands. See -[system overview](https://tox.readthedocs.io/en/latest/#system-overview) for more details. - - - tox flow - - -### tox can be used for ... - -- creating development environments -- running static code analysis and test tools -- automating package builds -- running tests against the package built by tox -- checking that packages install correctly with different Python versions/interpreters -- unifying Continuous Integration and command line based testing -- building and deploying project documentation -- releasing a package to PyPI or any other platform -- limit: your imagination - -### Documentation - -Documentation for tox can be found at [Read The Docs](https://tox.readthedocs.org). - -### Communication and questions - -For the fastest and interactive feedback please join our -[![Discord](https://img.shields.io/discord/802911963368783933?style=flat-square)](https://discord.gg/edtj86wzBX) server. -If you have questions or suggestions you can first check if they have already been answered or discussed on our -[issue tracker](https://github.com/tox-dev/tox/issues?utf8=%E2%9C%93&q=is%3Aissue+sort%3Aupdated-desc+label%3A%22type%3Aquestion+%3Agrey_question%3A%22+). -On [Stack Overflow (tagged with `tox`)](https://stackoverflow.com/questions/tagged/tox). - -### Contributing - -Contributions are welcome. See [contributing](https://github.com/tox-dev/tox/blob/master/CONTRIBUTING.rst) and our -[Contributor Covenant Code of Conduct](https://github.com/tox-dev/tox/blob/master/CODE_OF_CONDUCT.md). - -Currently, the [code](https://github.com/tox-dev/tox) and the [issues](https://github.com/tox-dev/tox/issues) are hosted -on GitHub. +[![Downloads](https://pepy.tech/badge/tox/month)](https://pepy.tech/project/tox/month) +[![check](https://github.com/tox-dev/tox/actions/workflows/check.yml/badge.svg)](https://github.com/tox-dev/tox/actions/workflows/check.yml) -The project is licensed under [MIT](https://github.com/tox-dev/tox/blob/master/LICENSE). +`tox` aims to automate and standardize testing in Python. It is part of a larger vision of easing the packaging, testing +and release process of Python software (alongside [pytest](https://docs.pytest.org/en/latest/) and +[devpi](https://www.devpi.net)). -## tox for enterprise +tox is a generic virtual environment management and test command line tool you can use for: -Available as part of the Tidelift Subscription. +- checking your package builds and installs correctly under different environments (such as different Python + implementations, versions or installation dependencies), +- running your tests in each of the environments with the test tool of choice, +- acting as a frontend to continuous integration servers, greatly reducing boilerplate and merging CI and shell-based + testing. -The maintainers of tox and thousands of other packages are working with Tidelift to deliver commercial support and -maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code -health, while paying the maintainers of the exact packages you use. -[Learn more.](https://tidelift.com/subscription/pkg/pypi-tox?utm_source=pypi-tox&utm_medium=referral&utm_campaign=readme) +Please read our [user guide](https://tox.wiki/en/latest/user_guide.html#basic-example) for an example and more detailed +introduction, or watch [this YouTube video](https://www.youtube.com/watch?v=SFqna5ilqig) that presents the problem space +and how tox solves it. diff --git a/docs/_static/custom.css b/docs/_static/custom.css index b6d4bbdca..ef2a0a702 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,63 +1,7 @@ -div.document { - width: 100%; - max-width: 1520px; -} - -div.body { - max-width: 1280px; -} - -div.body p, ol > li, div.body td { - text-align: justify; -} - - -img, div.figure { - margin: 0 !important -} - -ul > li { - text-align: justify; -} - -ul > li > p { - margin-bottom: 0; - -} - - -ol > li > p { - margin-bottom: 0; - -} - -div.body code.descclassname { - display: none -} - -.wy-table-responsive table td { - white-space: normal !important; -} - -.wy-table-responsive { - overflow: visible !important; -} - -div.toctree-wrapper.compound > ul > li { - margin: 0; - padding: 0 -} - -code.docutils.literal { - background-color: #ECF0F3; - padding: 0 1px; -} - -div#changelog-history h3{ - margin-top: 10px; -} - -div#changelog-history h2{ - font-style: italic; - font-weight: bold; +blockquote { + border-left: none; + font-style: normal; + margin-left: 1.5rem; + margin-right: 0; + padding: 0; } diff --git a/docs/_static/img/tox.svg b/docs/_static/img/tox.svg index 462a7530d..578e1914f 100644 --- a/docs/_static/img/tox.svg +++ b/docs/_static/img/tox.svg @@ -1,472 +1,505 @@ - + - - - - + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + width="130.38553mm" + height="69.617622mm" + viewBox="0 0 130.38553 69.617624" + version="1.1" + id="svg8" + style="enable-background:new" +> + + + + - - - - - - + gradientTransform="translate(-164.42213,-271.42458)" + id="meshgradient818" + gradientUnits="userSpaceOnUse" + x="-65.352081" + y="100.94376" + > + + + + + + - + + ry="43.127083" + rx="43.426579" + cy="151.6328" + cx="-24.598219" + id="ellipse1764" + style="opacity:0.244;fill:#1144a7;fill-opacity:1;stroke:none;stroke-width:0.26499999;stroke-miterlimit:4;stroke-dasharray:0, 0.26499999, 0.52999997, 0.795, 1.05999998, 1.32499997, 1.58999996, 1.85499998, 2.11999997, 2.38499996, 2.64999994, 2.91499997, 3.17999995, 3.44499994, 3.70999993;stroke-dashoffset:0;stroke-opacity:1" + /> - - + + + gradientUnits="userSpaceOnUse" + y2="-8.8817842e-16" + x2="8.4163942" + y1="-8.8817842e-16" + x1="-8.4163942" + id="linearGradient6252" + xlink:href="#linearGradient6236" + /> - + - + image/svg+xml - - + + - diff --git a/docs/_templates/localtoc.html b/docs/_templates/localtoc.html deleted file mode 100644 index f6ad30653..000000000 --- a/docs/_templates/localtoc.html +++ /dev/null @@ -1,37 +0,0 @@ - -{%- if pagename != "search" %} - - -{%- endif %} - -

quicklinks

-
- - - -
- home - - examples -
- install - - changelog -
- config - - issues[gh] -
- support - - plugins/hooks -
-
-{% extends "basic/localtoc.html" %} diff --git a/docs/announce/changelog-only.rst b/docs/announce/changelog-only.rst deleted file mode 100644 index 17a390cb4..000000000 --- a/docs/announce/changelog-only.rst +++ /dev/null @@ -1,26 +0,0 @@ -Less announcing, more change-logging ------------------------------------- - -With version 2.5.0 we dropped creating special announcement documents and rely on communicating -all relevant changes through the -`CHANGELOG `_. See at -`PyPI `_ for a rendered version of the last changes containing -links to the important issues and pull requests that were integrated into the release. - -The historic release announcements are still online here for various versions: - -* `0.5 `_, -* `1.0 `_, -* `1.1 `_, -* `1.2 `_, -* `1.3 `_, -* `1.4 `_, -* `1.4.3 `_, -* `1.8 `_, -* `1.9 `_, -* `2.0 `_, -* `2.4.0 `_. - - -Happy testing, -The tox maintainers diff --git a/docs/changelog.rst b/docs/changelog.rst index 54f8428e0..7b1fc51ba 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,2402 +1,550 @@ -.. _changelog: - -Changelog history -================= - -Versions follow `Semantic Versioning `_ (``..``). -Backward incompatible (breaking) changes will only be introduced in major versions -with advance notice in the **Deprecations** section of releases. - -.. include:: _draft.rst - -.. towncrier release notes start - -v3.27.0 (2022-10-25) --------------------- - -Bugfixes -^^^^^^^^ - -- Dropped ``--build-option`` in isolated builds, an alternative fix for the ``SetuptoolsDeprecationWarning`` about using ``--global-option`` -- by :user:`adamchainz` - `#2497 `_ -- Remove read-only files in ``ensure_empty_dir``. - `#2498 `_ -- Multiple tox instances no longer clobber the ``.tox`` directory when - ``provision_tox_env`` is used. - by :user:`masenf` - `#2515 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Clarify that ``install_command`` only takes one command - by :user:`jugmac00` - `#2433 `_ -- Documented problems with plugin and provision env - by :user:`ziima`. - `#2469 `_ - - -v3.26.0 (2022-09-07) --------------------- - -Bugfixes -^^^^^^^^ - -- Fix fallback to ``python`` environment when ``isolated_build = true`` is set -- by :user:`Unrud` - `#2474 `_ -- Fixed ``SetuptoolsDeprecationWarning`` about using ``--global-option`` -- by :user:`adamchainz` - `#2478 `_ - - -Features -^^^^^^^^ - -- Use ``tomllib`` on Python 3.11 or later and ``tomli`` instead of ``toml`` library on lower versions - by :user:`hroncok`. - `#2463 `_ - - -v3.25.1 (2022-06-29) --------------------- - -Bugfixes -^^^^^^^^ - -- ``sitepackages = true`` will add user's site-package to the python path on Windows as expected -- by :user:`niander` - `#2402 `_ -- Avoid importing ``pipes`` on Python 3.3+ to avoid ``DeprecationWarning`` on Python 3.11 -- by :user:`adamchainz` - `#2417 `_ -- Fix ``isolated_build`` when the build process produces stderr at exit. - `#2449 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Explain advantages of ``PIP_CONSTRAINT`` environment variable over ``--constraint`` argument. - `#2423 `_ - - -v3.25.0 (2022-04-11) --------------------- - -Bugfixes -^^^^^^^^ - -- Fixed failing isolated_build because setuptools warning was captured - in ``build_requires``. -- by :user:`zariiii9003` - `#2332 `_ -- Avoid potential 30s delay caused by socket.getfqdn(). -- by :user:`ssbarnea` - `#2375 `_ - - -Features -^^^^^^^^ - -- Ignore missing commands if they are prefixed by ``-`` - -- by :user:`cdown`. - `#2315 `_ -- Add default environment variables (such as http_proxy) regardless of their case to passenv on UNIX -- by :user:`poggenhans`. - `#2372 `_ -- On Windows ``PROGRAMFILES``, ``PROGRAMFILES(X86)``, and ``PROGRAMDATA`` environment variables are now passed through, unmasking system values necessary to locate resources such as a C compiler. - `#2382 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Deleted the tox mailing list -- by :user:`jugmac00` - `#2364 `_ - - -v3.24.5 (2021-12-29) --------------------- - -Bugfixes -^^^^^^^^ - -- Fixed an issue where ``usedevelop`` would cause an invocation error if setup.py does not exist. -- by :user:`VincentVanlaer` - `#2197 `_ - - -v3.24.4 (2021-09-16) --------------------- - -Bugfixes -^^^^^^^^ - -- Fixed handling of ``-e ALL`` in parallel mode by ignoring the ``ALL`` in subprocesses -- by :user:`guahki`. - `#2167 `_ -- Prevent tox from using a truncated interpreter when using - ``TOX_LIMITED_SHEBANG`` -- by :user:`jdknight`. - `#2208 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Enabled the use of the favicon in the Sphinx docs first - introduced in :pull:`764` but not integrated fully - -- :user:`webknjaz` - `#2177 `_ - - -v3.24.3 (2021-08-21) --------------------- - -Bugfixes -^^^^^^^^ - -- ``--parallel`` reports now show ASCII OK/FAIL/SKIP lines when full Unicode output is not available - by :user:`brettcs` - `#1421 `_ - - -Miscellaneous -^^^^^^^^^^^^^ - -- Started enforcing valid references in Sphinx docs -- :user:`webknjaz` - `#2168 `_ - - -v3.24.2 (2021-08-18) --------------------- - -Bugfixes -^^^^^^^^ - -- include ``LC_ALL`` to implicit list of passenv variables - by :user:`ssbarnea` - `#2162 `_ - - -v3.24.1 (2021-07-31) --------------------- - -Bugfixes -^^^^^^^^ - -- ``get_requires_for_build_sdist`` hook (PEP 517) is assumed to return an empty list if left unimplemented by the backend build system - by :user:`oczkoisse` - `#2130 `_ - - -Documentation -^^^^^^^^^^^^^ - -- The documentation of ``install_command`` now also mentions that you can provide arbitrary commands - by :user:`jugmac00` - `#2081 `_ - - -v3.24.0 (2021-07-14) --------------------- - -Bugfixes -^^^^^^^^ - -- ``--devenv`` no longer modifies the directory in which the ``.tox`` environment is provisioned - by :user:`isaac-ped` - `#2065 `_ -- Fix show config when the package names are not in canonical form - by :user:`gaborbernat`. - `#2103 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Extended environment variables section - by :user:`majiang` - `#2036 `_ - - -Miscellaneous -^^^^^^^^^^^^^ - -- ``tox`` no longer shows deprecation warnings for ``distutils.sysconfig`` on - Python 3.10 - by :user:`9999years` - `#2100 `_ - - -v3.23.1 (2021-05-05) --------------------- - -Bugfixes -^^^^^^^^ - -- Distinguish between normal Windows Python and MSYS2 Python when looking for - virtualenv executable path. Adds os.sep to :class:`~tox.interpreters.InterpreterInfo` - - by :user:`jschwartzentruber` - `#1982 `_ -- Fix a ``tox-conda`` isolation build bug - by :user:`AntoineD`. - `#2056 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Update examples in the documentation to use ``setenv`` in the ``[testenv]`` sections, not wrongly in the ``[tox]`` main section. - - by :user:`AndreyNautilus` - `#1999 `_ - - -Miscellaneous -^^^^^^^^^^^^^ - -- Enable building tox with ``setuptools_scm`` 6+ by :user:`hroncok` - `#1984 `_ - - -v3.23.0 (2021-03-03) --------------------- - -Features -^^^^^^^^ - -- tox can now be invoked with a new ``--no-provision`` flag that prevents provision, - if :conf:`requires` or :conf:`minversion` are not satisfied, - tox will fail; - if a path is specified as an argument to the flag - (e.g. as ``tox --no-provision missing.json``) and provision is prevented, - provision metadata are written as JSON to that path - by :user:`hroncok` - `#1921 `_ -- Unicode support in ``pyproject.toml`` - by :user:`domdfcoding` - `#1940 `_ - - -v3.22.0 (2021-02-16) --------------------- - -Features -^^^^^^^^ - -- The value of the :conf:`requires` configuration option is now exposed via - the :class:`tox.config.Config` object - by :user:`hroncok` - `#1918 `_ - - -v3.21.4 (2021-02-02) --------------------- - -Bugfixes -^^^^^^^^ - -- Adapt tests not to assume the ``easy_install`` command exists, as it was removed from ``setuptools`` 52.0.0+ - by :user:`hroncok` - `#1893 `_ - - -v3.21.3 (2021-01-28) --------------------- - -Bugfixes -^^^^^^^^ - -- Fix a killed tox (via SIGTERM) leaving the commands subprocesses running - by handling it as if it were a KeyboardInterrupt - by :user:`dajose` - `#1772 `_ - - -v3.21.2 (2021-01-19) --------------------- - -Bugfixes -^^^^^^^^ - -- Newer coverage tools update the ``COV_CORE_CONTEXT`` environment variable, add it to the list of environment variables - that can change in our pytest plugin - by :user:`gaborbernat`. - `#1854 `_ - - -v3.21.1 (2021-01-13) --------------------- - -Bugfixes -^^^^^^^^ - -- Fix regression that broke using install_command in config replacements - by :user:`jayvdb` - `#1777 `_ -- Fix regression parsing posargs default containing colon. - by :user:`jayvdb` - `#1785 `_ - - -Features -^^^^^^^^ - -- Prevent .tox in envlist - by :user:`jayvdb` - `#1684 `_ - - -Miscellaneous -^^^^^^^^^^^^^ - -- Enable building tox with ``setuptools_scm`` 4 and 5 by :user:`hroncok` - `#1799 `_ - - -v3.21.0 (2021-01-08) --------------------- - -Bugfixes -^^^^^^^^ - -- Fix the false ``congratulations`` message that appears when a ``KeyboardInterrupt`` occurs during package installation. - by :user:`gnikonorov` - `#1453 `_ -- Fix ``platform`` support for ``install_command``. - by :user:`jayvdb` - `#1464 `_ -- Fixed regression in v3.20.0 that caused escaped curly braces in setenv - to break usage of the variable elsewhere in tox.ini. - by :user:`jayvdb` - `#1690 `_ -- Prevent ``{}`` and require ``{:`` is only followed by ``}``. - by :user:`jayvdb` - `#1711 `_ -- Raise ``MissingSubstitution`` on access of broken ini setting. - by :user:`jayvdb` - `#1716 `_ - - -Features -^^^^^^^^ - -- Allow \{ and \} in default of {env:key:default}. - by :user:`jayvdb` - `#1502 `_ -- Allow {posargs} in setenv. - by :user:`jayvdb` - `#1695 `_ -- Allow {/} to refer to os.sep. - by :user:`jayvdb` - `#1700 `_ -- Make parsing [testenv] sections in setup.cfg official. - by :user:`mauvilsa` - `#1727 `_ -- Relax importlib requirement to allow 3.0.0 or any newer version - by - :user:`pkolbus` - `#1763 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Document more info about using ``platform`` setting. - by :user:`prakhargurunani` - `#1144 `_ -- Replace ``indexserver`` in documentation with environment variables - by :user:`ziima`. - `#1357 `_ -- Document that the ``passenv`` environment setting is case insensitive. - by :user:`gnikonorov` - `#1534 `_ - - -v3.20.1 (2020-10-09) --------------------- - -Bugfixes -^^^^^^^^ - -- Relax importlib requirement to allow version<3 - by :user:`usamasadiq` - `#1682 `_ - - -v3.20.0 (2020-09-01) --------------------- - -Bugfixes -^^^^^^^^ - -- Allow hyphens and empty factors in generative section name. - by :user:`tyagdit` - `#1636 `_ -- Support for PEP517 in-tree build backend-path key in ``get-build-requires``. - by :user:`nizox` - `#1654 `_ -- Allow escaping curly braces in setenv. - by :user:`mkenigs` - `#1656 `_ - - -Features -^^^^^^^^ - -- Support for comments within ``setenv`` and environment files via the ``files|`` prefix. - by :user:`gaborbernat` - `#1667 `_ - - -v3.19.0 (2020-08-06) --------------------- - -Bugfixes -^^^^^^^^ - -- skip ``setup.cfg`` if it has no ``tox:tox`` namespace - by :user:`hroncok` - `#1045 `_ - - -Features -^^^^^^^^ - -- Implement support for building projects - having :pep:`517#in-tree-build-backends` ``backend-path`` setting - - by :user:`webknjaz` - `#1575 `_ -- Don't require a tox config file for ``tox --devenv`` - by :user:`hroncok` - `#1643 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Fixed grammar in top-level documentation - by :user:`tfurf` - `#1631 `_ - - -v3.18.1 (2020-07-28) --------------------- - -Bugfixes -^^^^^^^^ - -- Fix ``TypeError`` when using isolated_build with backends that are not submodules (e.g. ``maturin``) - `#1629 `_ - - -v3.18.0 (2020-07-23) --------------------- - -Deprecations (removal in next major release) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- Add allowlist_externals alias to whitelist_externals (whitelist_externals is now deprecated). - by :user:`dajose` - `#1491 `_ - - -v3.17.1 (2020-07-15) --------------------- - -Bugfixes -^^^^^^^^ - -- Fix tests when the ``HOSTNAME`` environment variable is set, but empty string - by :user:`hroncok` - `#1616 `_ - - -v3.17.0 (2020-07-14) --------------------- - -Features -^^^^^^^^ - -- The long arguments ``--verbose`` and ``--quiet`` (rather than only their short forms, ``-v`` and ``-q``) are now accepted. - `#1612 `_ -- The ``ResultLog`` now prefers ``HOSTNAME`` environment variable value (if set) over the full qualified domain name of localhost. - This makes it possible to disable an undesired DNS lookup, - which happened on all ``tox`` invocations, including trivial ones - by :user:`hroncok` - `#1615 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Update packaging information for Flit. - `#1613 `_ - - -v3.16.1 (2020-06-29) --------------------- - -Bugfixes -^^^^^^^^ - -- Fixed the support for using ``{temp_dir}`` in ``tox.ini`` - by :user:`webknjaz` - `#1609 `_ - - -v3.16.0 (2020-06-26) --------------------- - -Features -^^^^^^^^ - -- Allow skipping the package and installation step when passing the ``--skip-pkg-install``. This should be used in pair with the ``--notest``, so you can separate environment setup and test run: - - .. code-block:: console - - tox -e py --notest - tox -e py --skip-pkg-install - - by :user:`gaborbernat`. - `#1605 `_ - - -Miscellaneous -^^^^^^^^^^^^^ - -- Improve config parsing performance by precompiling commonly used regular expressions - by :user:`brettlangdon` - `#1603 `_ - - -v3.15.2 (2020-06-06) --------------------- - -Bugfixes -^^^^^^^^ - -- Add an option to allow a process to suicide before sending the SIGTERM. - by :user:`jhesketh` - `#1497 `_ -- PyPy 7.3.1 on Windows uses the ``Script`` folder instead of ``bin``. - by :user:`gaborbernat` - `#1597 `_ - - -Miscellaneous -^^^^^^^^^^^^^ - -- Allow to run the tests with pip 19.3.1 once again while preserving the ability to use pip 20.1 - by :user:`hroncok` - `#1594 `_ - - -v3.15.1 (2020-05-20) --------------------- - -Bugfixes -^^^^^^^^ - -- ``tox --showconfig`` no longer tries to interpolate '%' signs. - `#1585 `_ - - -v3.15.0 (2020-05-02) --------------------- - -Bugfixes -^^^^^^^^ - -- Respect attempts to change ``PATH`` via ``setenv`` - by :user:`aklajnert`. - `#1423 `_ -- Fix parsing of architecture in python interpreter name. - by :user:`bruchar1` - `#1542 `_ -- Prevent exception when command is empty. - by :user:`bruchar1` - `#1544 `_ -- Fix irrelevant Error message for invalid argument when running outside a directory with tox support files by :user:`nkpro2000sr`. - `#1547 `_ - - -Features -^^^^^^^^ - -- Allow parallel mode without arguments. - by :user:`ssbarnea` - `#1418 `_ -- Allow generative section name expansion. - by :user:`bruchar1` - `#1545 `_ -- default to passing the env var PIP_EXTRA_INDEX_URL by :user:`georgealton`. - `#1561 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Improve documentation about config by adding tox environment description at start - by :user:`stephenfin`. - `#1573 `_ - - -v3.14.6 (2020-03-25) --------------------- - -Bugfixes -^^^^^^^^ - -- Exclude virtualenv dependency versions with known - regressions (20.0.[0-7]) - by :user:`webknjaz`. - `#1537 `_ -- Fix ``tox -h`` and ``tox --hi`` shows an error when run outside a directory with tox support files by :user:`nkpro2000sr`. - `#1539 `_ -- Fix ValueError on ``tox -l`` for a ``tox.ini`` file that does not contain an - ``envlist`` definition. - by :user:`jquast`. - `#1343 `_ - - -v3.14.5 (2020-02-16) --------------------- - -Features -^^^^^^^^ - -- Add ``--discover`` (fallback to ``TOX_DISCOVER`` environment variable via path separator) to inject python executables - to try as first step of a discovery - note the executable still needs to match the environment by :user:`gaborbernat`. - `#1526 `_ - - -v3.14.4 (2020-02-13) --------------------- - -Bugfixes -^^^^^^^^ - -- Bump minimal six version needed to avoid using one incompatible with newer - virtualenv. - by :user:`ssbarnea` - `#1519 `_ -- Avoid pypy test failure due to undefined printout var. - by :user:`ssbarnea` - `#1521 `_ - - -Features -^^^^^^^^ - -- Add ``interrupt_timeout`` and ``terminate_timeout`` that configure delay between SIGINT, SIGTERM and SIGKILL when tox is interrupted. - by :user:`sileht` - `#1493 `_ -- Add ``HTTP_PROXY``, ``HTTPS_PROXY`` and ``NO_PROXY`` to default passenv. - by :user:`pfmoore` - `#1498 `_ - - -v3.14.3 (2019-12-27) --------------------- - -Bugfixes -^^^^^^^^ - -- Relax importlib requirement to allow either version 0 or 1 - by :user:`chyzzqo2` - `#1476 `_ - -Miscellaneous -^^^^^^^^^^^^^ - -- Clarify legacy setup.py error message: python projects should commit to a strong consistency of message regarding packaging. We no-longer tell people to add a setup.py to their already configured pep-517 project, otherwise it could imply that pyproject.toml isn't as well supported and recommended as it truly is - by :user:`graingert` - `#1478 `_ - -v3.14.2 (2019-12-02) --------------------- - -Bugfixes -^^^^^^^^ - -- Fix fallback to global configuration when running in Jenkins. - by :user:`daneah` - `#1428 `_ -- Fix colouring on windows: colorama is a dep. - by :user:`1138-4EB` - `#1471 `_ - - -Miscellaneous -^^^^^^^^^^^^^ - -- improve performance with internal lookup of Python version information - by :user:`blueyed` - `#1462 `_ -- Use latest version of importlib_metadata package - by :user:`kammala` - `#1472 `_ -- Mark poetry related tests as xfail since its dependency pyrsistent won't install in ci due to missing wheels/build deps. - by :user:`RonnyPfannschmidt` - `#1474 `_ - - -v3.14.1 (2019-11-13) --------------------- - -Bugfixes -^^^^^^^^ - -- fix reporting of exiting due to (real) signals - by :user:`blueyed` - `#1401 `_ -- Bump minimal virtualenv to 16.0.0 to improve own transitive - deps handling in some ancient envs. — by :user:`webknjaz` - `#1429 `_ -- Adds ``CURL_CA_BUNDLE``, ``REQUESTS_CA_BUNDLE``, ``SSL_CERT_FILE`` to the default passenv values. - by :user:`ssbarnea` - `#1437 `_ -- Fix nested tox execution in the parallel mode by separating the environment - variable that let's tox know it is invoked in the parallel mode - (``_TOX_PARALLEL_ENV``) from the variable that informs the tests that tox is - running in parallel mode (``TOX_PARALLEL_ENV``). - — by :user:`hroncok` - `#1444 `_ -- Fix provisioning from a pyvenv interpreter. — by :user:`kentzo` - `#1452 `_ - - -Deprecations (removal in next major release) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- Python ``3.4`` is no longer supported. — by :user:`gaborbernat` - `#1456 `_ - - -v3.14.0 (2019-09-03) --------------------- - -Bugfixes -^^^^^^^^ - -- Fix ``PythonSpec`` detection of ``python3.10`` - by :user:`asottile` - `#1374 `_ -- Fix regression failing to detect future and past ``py##`` factors - by :user:`asottile` - `#1377 `_ -- Fix ``current_tox_py`` for ``pypy`` / ``pypy3`` - by :user:`asottile` - `#1378 `_ -- Honor environment markers in ``requires`` list - by :user:`asottile` - `#1380 `_ -- improve recreate check by allowing directories containing ``.tox-config1`` (the marker file created by tox) - by :user:`asottile` - `#1383 `_ -- Recognize correctly interpreters that have suffixes (like python3.7-dbg). - `#1415 `_ - - -Features -^^^^^^^^ - -- Add support for minor versions with multiple digits ``tox -e py310`` works for ``python3.10`` - by :user:`asottile` - `#1374 `_ -- Remove dependence on ``md5`` hashing algorithm - by :user:`asottile` - `#1384 `_ - - -Documentation -^^^^^^^^^^^^^ - -- clarify behaviour if recreate is set to false - by :user:`PJCampi` - `#1399 `_ - - -Miscellaneous -^^^^^^^^^^^^^ - -- Fix relative URLs to files in the repo in ``.github/PULL_REQUEST_TEMPLATE.md`` — by :user:`webknjaz` - `#1363 `_ -- Replace ``importlib_metadata`` backport with ``importlib.metadata`` - from the standard library on Python ``3.8+`` - by :user:`hroncok` - `#1367 `_ -- Render the change fragment help on the ``docs/changelog/`` directory view on GitHub — by :user:`webknjaz` - `#1370 `_ - - -v3.13.2 (2019-07-01) --------------------- - -Bugfixes -^^^^^^^^ - -- on venv cleanup: add explicit check for pypy venv to make it possible to recreate it - by :user:`obestwalter` - `#1355 `_ -- non canonical names within :conf:`requires` cause infinite provisioning loop - by :user:`gaborbernat` - `#1359 `_ - - -v3.13.1 (2019-06-25) --------------------- - -Bugfixes -^^^^^^^^ - -- Fix isolated build double-requirement - by :user:`asottile`. - `#1349 `_ - - -v3.13.0 (2019-06-24) --------------------- - -Bugfixes -^^^^^^^^ - -- tox used Windows shell rules on non-Windows platforms when transforming - positional arguments to a string - by :user:`barneygale`. - `#1336 `_ - - -Features -^^^^^^^^ - -- Replace ``pkg_resources`` with ``importlib_metadata`` for speed - by :user:`asottile`. - `#1324 `_ -- Add the ``--devenv ENVDIR`` option for creating development environments from ``[testenv]`` configurations - by :user:`asottile`. - `#1326 `_ -- Refuse to delete ``envdir`` if it doesn't look like a virtualenv - by :user:`asottile`. - `#1340 `_ - - -v3.12.1 (2019-05-23) --------------------- - -Bugfixes -^^^^^^^^ - -- Ensure ``TOX_WORK_DIR`` is a native string in ``os.environ`` - by :user:`asottile`. - `#1313 `_ -- Fix import and usage of ``winreg`` for python2.7 on windows - by :user:`asottile`. - `#1315 `_ -- Fix Windows selects incorrect spec on first discovery - by :user:`gaborbernat` - `#1317 `_ - - -v3.12.0 (2019-05-23) --------------------- - -Bugfixes -^^^^^^^^ - -- When using ``--parallel`` with ``--result-json`` the test results are now included the same way as with serial runs - by :user:`fschulze` - `#1295 `_ -- Turns out the output of the ``py -0p`` is not stable yet and varies depending on various edge cases. Instead now we read the interpreter values directly from registry via `PEP-514 `_ - by :user:`gaborbernat`. - `#1306 `_ - - -Features -^^^^^^^^ - -- Adding ``TOX_PARALLEL_NO_SPINNER`` environment variable to disable the spinner in parallel mode for the purposes of clean output when using CI tools - by :user:`zeroshift` - `#1184 `_ - - -v3.11.1 (2019-05-16) --------------------- - -Bugfixes -^^^^^^^^ - -- When creating virtual environments we no longer ask the python to tell its path, but rather use the discovered path. - `#1301 `_ - - -v3.11.0 (2019-05-15) --------------------- - -Features -^^^^^^^^ - -- ``--showconfig`` overhaul: - - - now fully generated via the config parser, so anyone can load it by using the built-in python config parser - - the ``tox`` section contains all configuration data from config - - the ``tox`` section contains a ``host_python`` key detailing the path of the host python - - the ``tox:version`` section contains the versions of all packages tox depends on with their version - - passing ``-l`` now allows only listing default target envs - - allows showing config for a given set of tox environments only via the ``-e`` cli flag or the ``TOXENV`` environment - variable, in this case the ``tox`` and ``tox:version`` section is only shown if at least one verbosity flag is passed - - this should help inspecting the options. - `#1298 `_ - - -v3.10.0 (2019-05-13) --------------------- - -Bugfixes -^^^^^^^^ - -- fix for ``tox -l`` command: do not allow setting the ``TOXENV`` or the ``-e`` flag to override the listed default environment variables, they still show up under extra if non defined target - by :user:`gaborbernat` - `#720 `_ -- tox ignores unknown CLI arguments when provisioning is on and outside of the provisioned environment (allowing - provisioning arguments to be forwarded freely) - by :user:`gaborbernat` - `#1270 `_ - - -Features -^^^^^^^^ - -- Virtual environments created now no longer upgrade pip/wheel/setuptools to the latest version. Instead the start - packages after virtualenv creation now is whatever virtualenv has bundled in. This allows faster virtualenv - creation and builds that are easier to reproduce. - `#448 `_ -- Improve python discovery and add architecture support: - - UNIX: - - - First, check if the tox host Python matches. - - Second, check if the the canonical name (e.g. ``python3.7``, ``python3``) matches or the base python is an absolute path, use that. - - Third, check if the the canonical name without version matches (e.g. ``python``, ``pypy``) matches. - - - Windows: - - - First, check if the tox host Python matches. - - Second, use the ``py.exe`` to list registered interpreters and any of those match. - - Third, check if the the canonical name (e.g. ``python3.7``, ``python3``) matches or the base python is an absolute path, use that. - - Fourth, check if the the canonical name without version matches (e.g. ``python``, ``pypy``) matches. - - Finally, check for known locations (``c:\python{major}{minor}\python.exe``). - - - tox environment configuration generation is now done in parallel (to alleviate the slowdown due to extra - checks). - `#1290 `_ - - -v3.9.0 (2019-04-17) -------------------- - -Bugfixes -^^^^^^^^ - -- Fix ``congratulations`` when using ``^C`` during virtualenv creation - by :user:`asottile` - `#1257 `_ - - -Features -^^^^^^^^ - -- Allow having inline comments in :conf:`deps` — by :user:`webknjaz` - `#1262 `_ - - -v3.8.6 (2019-04-03) -------------------- - -Bugfixes -^^^^^^^^ - -- :conf:`parallel_show_output` does not work with tox 3.8 - `#1245 `_ - - -v3.8.5 (2019-04-03) -------------------- - -Bugfixes -^^^^^^^^ - -- the isolated build env now ignores :conf:`sitepackages`, :conf:`deps` and :conf:`description` as these do not make - sense - by :user:`gaborbernat` - `#1239 `_ -- Do not print timings with more than 3 decimal digits on Python 3 - by :user:`mgedmin`. - `#1241 `_ - - -v3.8.4 (2019-04-01) -------------------- - -Bugfixes -^^^^^^^^ - -- Fix sdist creation on python2.x when there is non-ascii output. - `#1234 `_ -- fix typos in isolated.py that made it impossible to install package with requirements in pyproject.toml - by :user:`unmade` - `#1236 `_ - - -v3.8.3 (2019-03-29) -------------------- - -Bugfixes -^^^^^^^^ - -- don't crash when version information is not available for a proposed base python - by :user:`gaborbernat` - `#1227 `_ -- Do not print exception traceback when the provisioned tox fails - by :user:`gaborbernat` - `#1228 `_ - - -v3.8.2 (2019-03-29) -------------------- - -Bugfixes -^^^^^^^^ - -- using -v and -e connected (as -ve) fails - by :user:`gaborbernat` - `#1218 `_ -- Changes to the plugin tester module (cmd no longer sets ``PYTHONPATH``), and ``action.popen`` no longer returns the - command identifier information from within the logs. No public facing changes. - `#1222 `_ -- Spinner fails in CI on ``UnicodeEncodeError`` - by :user:`gaborbernat` - `#1223 `_ - - -v3.8.1 (2019-03-28) -------------------- - -Bugfixes -^^^^^^^^ - -- The ``-eALL`` command line argument now expands the ``envlist`` key and includes all its environment. - `#1155 `_ -- Isolated build environment dependency overrides were not taken in consideration (and such it inherited the deps - from the testenv section) - by :user:`gaborbernat` - `#1207 `_ -- ``--result-json`` puts the command into setup section instead of test (pre and post commands are now also correctly - put into the commands section) - by :user:`gaborbernat` - `#1210 `_ -- Set ``setup.cfg`` encoding to UTF-8 as it contains Unicode characters. - `#1212 `_ -- Fix tox CI, better error reporting when locating via the py fails - by :user:`gaborbernat` - `#1215 `_ - - -v3.8.0 (2019-03-27) -------------------- - -Bugfixes -^^^^^^^^ - -- In a posix shell, setting the PATH environment variable to an empty value is equivalent to not setting it at all; - therefore we no longer if the user sets PYTHONPATH an empty string on python 3.4 or later - by :user:`gaborbernat`. - `#1092 `_ -- Fixed bug of children process calls logs clashing (log already exists) - by :user:`gaborbernat` - `#1137 `_ -- Interpreter discovery and virtualenv creation process calls that failed will now print out on the screen their output - (via the logfile we automatically save) - by :user:`gaborbernat` - `#1150 `_ -- Using ``py2`` and ``py3`` with a specific ``basepython`` will no longer raise a warning unless the major version conflicts - by :user:`demosdemon`. - `#1153 `_ -- Fix missing error for ``tox -e unknown`` when tox.ini declares ``envlist``. - by :user:`medmunds` - `#1160 `_ -- Resolve symlinks with ``toxworkdir`` - by :user:`blueyed`. - `#1169 `_ -- Interrupting a tox call (e.g. via CTRL+C) now will ensure that spawn child processes (test calls, interpreter discovery, - parallel sub-instances, provisioned hosts) are correctly stopped before exiting (via the pattern of INTERRUPT - 300 ms, - TERMINATE - 200 ms, KILL signals) - by :user:`gaborbernat` - `#1172 `_ -- Fix a ``ResourceWarning: unclosed file`` in ``Action`` - by :user:`BoboTiG`. - `#1179 `_ -- Fix deadlock when using ``--parallel`` and having environments with lots of output - by :user:`asottile`. - `#1183 `_ -- Removed code that sometimes caused a difference in results between ``--parallel`` and ``-p`` when using ``posargs`` - by :user:`timdaman` - `#1192 `_ - - -Features -^^^^^^^^ - -- tox now auto-provisions itself if needed (see :ref:`auto-provision`). Plugins or minimum version of tox no longer - need to be manually satisfied by the user, increasing their ease of use. - by :user:`gaborbernat` - `#998 `_ -- tox will inject the ``TOX_PARALLEL_ENV`` environment variable, set to the current running tox environment name, - only when running in parallel mode. - by :user:`gaborbernat` - `#1139 `_ -- Parallel children now save their output to a disk logfile - by :user:`gaborbernat` - `#1143 `_ -- Parallel children now are added to ``--result-json`` - by :user:`gaborbernat` - `#1159 `_ -- Display pattern and ``sys.platform`` with platform mismatch - by :user:`blueyed`. - `#1176 `_ -- Setting the environment variable ``TOX_REPORTER_TIMESTAMP`` to ``1`` will enable showing for each output line its delta - since the tox startup. This can be especially handy when debugging parallel runs.- by :user:`gaborbernat` - `#1203 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Add a ``poetry`` examples to packaging - by :user:`gaborbernat` - `#1163 `_ - - -v3.7.0 (2019-01-11) -------------------- - -Features -^^^^^^^^ - -- Parallel mode added (alternative to ``detox`` which is being deprecated), for more details see :ref:`parallel_mode` - by :user:`gaborbernat`. - `#439 `_ -- Added command line shortcut ``-s`` for ``--skip-missing-interpreters`` - by :user:`evandrocoan` - `#1119 `_ - - -Deprecations (removal in next major release) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- Whitelisting of externals will be mandatory in tox 4: issue a deprecation warning as part of the already existing warning - by :user:`obestwalter` - `#1129 `_ - - -Documentation -^^^^^^^^^^^^^ - -- Clarify explanations in examples and avoid unsupported end line comments - by :user:`obestwalter` - `#1110 `_ -- Set to PULL_REQUEST_TEMPLATE.md use relative instead of absolute URLs - by :user:`evandrocoan` - Fixed PULL_REQUEST_TEMPLATE.md path for changelog/examples.rst to docs/changelog/examples.rst - by :user:`evandrocoan` - `#1120 `_ - - -v3.6.1 (2018-12-24) -------------------- - -Features -^^^^^^^^ - -- if the packaging phase successfully builds a package set it as environment variable under ``TOX_PACKAGE`` (useful to make assertions on the built package itself, instead of just how it ends up after installation) - by :user:`gaborbernat` (`#1081 `_) - - -v3.6.0 (2018-12-13) -------------------- - -Bugfixes -^^^^^^^^ - -- On windows, check ``sys.executable`` before others for interpreter version lookup. This matches what happens on non-windows. (`#1087 `_) -- Don't rewrite ``{posargs}`` substitution for absolute paths. (`#1095 `_) -- Correctly fail ``tox --notest`` when setup fails. (`#1097 `_) - - -Documentation -^^^^^^^^^^^^^ - -- Update Contributor Covenant URL to use https:// - by :user:`jdufresne`. (`#1082 `_) -- Correct the capitalization of PyPI throughout the documentation - by :user:`jdufresne`. (`#1084 `_) -- Link to related projects (Invoke and Nox) from the documentation - by :user:`theacodes`. (`#1088 `_) - - -Miscellaneous -^^^^^^^^^^^^^ - -- Include the license file in the wheel distribution - by :user:`jdufresne`. (`#1083 `_) - - -v3.5.3 (2018-10-28) -------------------- - -Bugfixes -^^^^^^^^ - -- Fix bug with incorrectly defactorized dependencies - by :user:`bartsanchez` (`#706 `_) -- do the same transformation to ``egg_info`` folders that ``pkg_resources`` does; - this makes it possible for hyphenated names to use the ``develop-inst-noop`` optimization (cf. 910), - which previously only worked with non-hyphenated egg names - by - :user:`hashbrowncipher` (`#1051 `_) -- previously, if a project's ``setup.py --name`` emitted extra information to - stderr, tox would capture it and consider it part of the project's name; now, - emissions to stderr are printed to the console - by :user:`hashbrowncipher` (`#1052 `_) -- change the way we acquire interpreter information to make it compatible with ``jython`` interpreter, note to create jython envs one needs ``virtualenv > 16.0`` which will be released later :user:`gaborbernat` (`#1073 `_) - - -Documentation -^^^^^^^^^^^^^ - -- document substitutions with additional content starting with a space cannot be alone on a line inside the ini file - by :user:`gaborbernat` (`#437 `_) -- change the spelling of a single word from contrains to the proper word, constraints - by :user:`metasyn` (`#1061 `_) -- Mention the minimum version required for ``commands_pre``/``commands_post`` support. (`#1071 `_) - - -v3.5.2 (2018-10-09) -------------------- - -Bugfixes -^^^^^^^^ - -- session packages are now put inside a numbered directory (instead of prefix numbering it, - because pip fails when wheels are not named according to - `PEP-491 `_, and prefix numbering messes with this) - - by :user:`gaborbernat` (`#1042 `_) - - -Features -^^^^^^^^ - -- level three verbosity (``-vvv``) show the packaging output - by :user:`gaborbernat` (`#1047 `_) - - -v3.5.1 (2018-10-08) -------------------- - -Bugfixes -^^^^^^^^ - -- fix regression with ``3.5.0``: specifying ``--installpkg`` raises ``AttributeError: 'str' object has no attribute 'basename'`` (`#1042 `_) - - -v3.5.0 (2018-10-08) -------------------- - -Bugfixes -^^^^^^^^ - -- intermittent failures with ``--parallel--safe-build``, instead of mangling with the file paths now uses a lock to make the package build operation thread safe and is now on by default (``--parallel--safe-build`` is now deprecated) - by :user:`gaborbernat` (`#1026 `_) - - -Features -^^^^^^^^ - -- Added ``temp_dir`` folder configuration (defaults to ``{toxworkdir}/.tmp``) that contains tox - temporary files. Package builds now create a hard link (if possible, otherwise copy - notably in - case of Windows Python 2.7) to the built file, and feed that file downstream (e.g. for pip to - install it). The hard link is removed at the end of the run (what it points though is kept - inside ``distdir``). This ensures that a tox session operates on the same package it built, even - if a parallel tox run builds another version. Note ``distdir`` will contain only the last built - package in such cases. - by :user:`gaborbernat` (`#1026 `_) - - -Documentation -^^^^^^^^^^^^^ - -- document tox environment recreate rules (:ref:`recreate`) - by :user:`gaborbernat` (`#93 `_) -- document inside the ``--help`` how to disable colorized output via the ``PY_COLORS`` operating system environment variable - by :user:`gaborbernat` (`#163 `_) -- document all global tox flags and a more concise format to express default and type - by :user:`gaborbernat` (`#683 `_) -- document command line interface under the config section `cli `_ - by :user:`gaborbernat` (`#829 `_) - -v3.4.0 (2018-09-20) -------------------- - -Bugfixes -^^^^^^^^ - -- add ``--exists-action w`` to default pip flags to handle better VCS dependencies (`pip documentation on this `_) - by :user:`gaborbernat` (`#503 `_) -- instead of assuming the Python version from the base python name ask the interpreter to reveal the version for the ``ignore_basepython_conflict`` flag - by :user:`gaborbernat` (`#908 `_) -- PEP-517 packaging fails with sdist already exists, fixed via ensuring the dist folder is empty before invoking the backend and `pypa/setuptools 1481 `_ - by :user:`gaborbernat` (`#1003 `_) - - -Features -^^^^^^^^ - -- add ``commands_pre`` and ``commands_post`` that run before and after running - the ``commands`` (setup runs always, commands only if setup succeeds, teardown always - all - run until the first failing command) - by :user:`gaborbernat` (`#167 `_) -- ``pyproject.toml`` config support initially by just inline the tox.ini under ``tool.tox.legacy_tox_ini`` key; config source priority order is ``pyproject.toml``, ``tox.ini`` and then ``setup.cfg`` - by :user:`gaborbernat` (`#814 `_) -- use the os environment variable ``TOX_SKIP_ENV`` to filter out tox environment names from the run list (set by ``envlist``) - by :user:`gaborbernat` (`#824 `_) -- always set ``PIP_USER=0`` (do not install into the user site package, but inside the virtual environment created) and ``PIP_NO_DEPS=0`` (installing without dependencies can cause broken package installations) inside tox - by :user:`gaborbernat` (`#838 `_) -- tox will inject some environment variables that to indicate a command is running within tox: ``TOX_WORK_DIR`` env var is set to the tox work directory, - ``TOX_ENV_NAME`` is set to the current running tox environment name, ``TOX_ENV_DIR`` is set to the current tox environments working dir - by :user:`gaborbernat` (`#847 `_) -- While running tox invokes various commands (such as building the package, pip installing dependencies and so on), these were printed in case they failed as Python arrays. Changed the representation to a shell command, allowing the users to quickly replicate/debug the failure on their own - by :user:`gaborbernat` (`#851 `_) -- skip missing interpreters value from the config file can now be overridden via the ``--skip-missing-interpreters`` cli flag - by :user:`gaborbernat` (`#903 `_) -- keep additional environments config order when listing them - by :user:`gaborbernat` (`#921 `_) -- allow injecting config value inside the ini file dependent of the fact that we're connected to an interactive shell or not via exposing a ``{tty}`` substitution - by :user:`gaborbernat` (`#947 `_) -- do not build sdist if skip install is specified for the envs to be run - by :user:`gaborbernat` (`#974 `_) -- when verbosity level increases above two start passing through verbosity flags to pip - by :user:`gaborbernat` (`#982 `_) -- when discovering the interpreter to use check if the tox host Python matches and use that if so - by :user:`gaborbernat` (`#994 `_) -- ``-vv`` will print out why a virtual environment is re-created whenever this operation is triggered - by :user:`gaborbernat` (`#1004 `_) - - -Documentation -^^^^^^^^^^^^^ - -- clarify that ``python`` and ``pip`` refer to the virtual environments executable - by :user:`gaborbernat` (`#305 `_) -- add Sphinx and mkdocs example of generating documentation via tox - by :user:`gaborbernat` (`#374 `_) -- specify that ``setup.cfg`` tox configuration needs to be inside the ``tox:tox`` namespace - by :user:`gaborbernat` (`#545 `_) - - -v3.3.0 (2018-09-11) -------------------- - -Bugfixes -^^^^^^^^ - -- fix ``TOX_LIMITED_SHEBANG`` when running under python3 - by :user:`asottile` (`#931 `_) - - -Features -^^^^^^^^ - -- `PEP-517 `_ source distribution support (create a - ``.package`` virtual environment to perform build operations inside) by :user:`gaborbernat` (`#573 `_) -- `flit `_ support via implementing ``PEP-517`` by :user:`gaborbernat` (`#820 `_) -- packaging now is exposed as a hook via ``tox_package(session, venv)`` - by :user:`gaborbernat` (`#951 `_) - - -Miscellaneous -^^^^^^^^^^^^^ - -- Updated the VSTS build YAML to use the latest jobs and pools syntax - by :user:`davidstaheli` (`#955 `_) - - -v3.2.1 (2018-08-10) -------------------- - -Bugfixes -^^^^^^^^ - -- ``--parallel--safe-build`` no longer cleans up its folders (``distdir``, ``distshare``, ``log``). - by :user:`gaborbernat` (`#849 `_) - - -v3.2.0 (2018-08-10) -------------------- - -Features -^^^^^^^^ - -- Switch pip invocations to use the module ``-m pip`` instead of direct invocation. This could help - avoid some of the shebang limitations. - by :user:`gaborbernat` (`#935 `_) -- Ability to specify package requirements for the tox run via the ``tox.ini`` (``tox`` section under key ``requires`` - PEP-508 style): can be used to specify both plugin requirements or build dependencies. - by :user:`gaborbernat` (`#783 `_) -- Allow one to run multiple tox instances in parallel by providing the - ``--parallel--safe-build`` flag. - by :user:`gaborbernat` (`#849 `_) - - -v3.1.3 (2018-08-03) -------------------- - -Bugfixes -^^^^^^^^ - -- A caching issue that caused the ``develop-inst-nodeps`` action, which - reinstalls the package under test, to always run has been resolved. The - ``develop-inst-noop`` action, which, as the name suggests, is a no-op, will now - run unless there are changes to ``setup.py`` or ``setup.cfg`` files that have - not been reflected - by @stephenfin (`#909 `_) - - -Features -^^^^^^^^ - -- Python version testenvs are now automatically detected instead of comparing - against a hard-coded list of supported versions. This enables ``py38`` and - eventually ``py39`` / ``py40`` / etc. to work without requiring an upgrade to - ``tox``. As such, the following public constants are now deprecated - (and scheduled for removal in ``tox`` 4.0: ``CPYTHON_VERSION_TUPLES``, - ``PYPY_VERSION_TUPLES``, ``OTHER_PYTHON_INTERPRETERS``, and ``DEFAULT_FACTORS`` - - by :user:`asottile` (`#914 `_) - - -Documentation -^^^^^^^^^^^^^ - -- Add a system overview section on the index page that explains briefly how tox works - - by :user:`gaborbernat`. (`#867 `_) - - -v3.1.2 (2018-07-12) -------------------- - -Bugfixes -^^^^^^^^ - -- Revert "Fix bug with incorrectly defactorized dependencies (`#772 `_)" due to a regression (`(#799) `_) - by :user:`obestwalter` - -v3.1.1 (2018-07-09) -------------------- - -Bugfixes -^^^^^^^^ - -- PyPI documentation for ``3.1.0`` is broken. Added test to check for this, and - fix it by :user:`gaborbernat`. (`#879 - `_) - - -v3.1.0 (2018-07-08) -------------------- - -Bugfixes -^^^^^^^^ - -- Add ``ignore_basepython_conflict``, which determines whether conflicting - ``basepython`` settings for environments containing default factors, such as - ``py27`` or ``django18-py35``, should be ignored or result in warnings. This - was a common source of misconfiguration and is rarely, if ever, desirable from - a user perspective - by :user:`stephenfin` (`#477 `_) -- Fix bug with incorrectly defactorized dependencies (deps passed to pip were not de-factorized) - by :user:`bartsanchez` (`#706 `_) - - -Features -^^^^^^^^ - -- Add support for multiple PyPy versions using default factors. This allows you - to use, for example, ``pypy27`` knowing that the correct interpreter will be - used by default - by :user:`stephenfin` (`#19 `_) -- Add support to explicitly invoke interpreter directives for environments with - long path lengths. In the event that ``tox`` cannot invoke scripts with a - system-limited shebang (e.x. a Linux host running a Jenkins Pipeline), a user - can set the environment variable ``TOX_LIMITED_SHEBANG`` to workaround the - system's limitation (e.x. ``export TOX_LIMITED_SHEBANG=1``) - by :user:`jdknight` (`#794 `_) -- introduce a constants module to be used internally and as experimental API - by :user:`obestwalter` (`#798 `_) -- Make ``py2`` and ``py3`` aliases also resolve via ``py`` on windows by :user:`asottile`. This enables the following things: - ``tox -e py2`` and ``tox -e py3`` work on windows (they already work on posix); and setting ``basepython=python2`` or ``basepython=python3`` now works on windows. (`#856 `_) -- Replace the internal version parsing logic from the not well tested `PEP-386 `_ parser for the more general `PEP-440 `_. `packaging >= 17.1 `_ is now an install dependency by :user:`gaborbernat`. (`#860 `_) - - -Documentation -^^^^^^^^^^^^^ - -- extend the plugin documentation and make lot of small fixes and improvements - by :user:`obestwalter` (`#797 `_) -- tidy up tests - remove unused fixtures, update old cinstructs, etc. - by :user:`obestwalter` (`#799 `_) -- Various improvements to documentation: open browser once documentation generation is done, show Github/Travis info on documentation page, remove duplicate header for changelog, generate unreleased news as DRAFT on top of changelog, make the changelog page more compact and readable (width up to 1280px) by :user:`gaborbernat` (`#859 `_) - - -Miscellaneous -^^^^^^^^^^^^^ - -- filter out unwanted files in package - by :user:`obestwalter` (`#754 `_) -- make the already existing implicit API explicit - by :user:`obestwalter` (`#800 `_) -- improve tox quickstart and corresponding tests - by :user:`obestwalter` (`#801 `_) -- tweak codecov settings via .codecov.yml - by :user:`obestwalter` (`#802 `_) - - -v3.0.0 (2018-04-02) -------------------- - -Bugfixes -^^^^^^^^ - -- Write directly to stdout buffer if possible to prevent str vs bytes issues - - by @asottile (`#426 `_) -- fix #672 reporting to json file when skip-missing-interpreters option is used - - by @r2dan (`#672 `_) -- avoid ``Requested Python version (X.Y) not installed`` stderr output when a - Python environment is looked up using the ``py`` Python launcher on Windows - and the environment is not found installed on the system - by - @jurko-gospodnetic (`#692 `_) -- Fixed an issue where invocation of tox from the Python package, where - invocation errors (failed actions) occur results in a change in the - sys.stdout stream encoding in Python 3.x. New behaviour is that sys.stdout is - reset back to its original encoding after invocation errors - by @tonybaloney - (`#723 `_) -- The reading of command output sometimes failed with ``IOError: [Errno 0] - Error`` on Windows, this was fixed by using a simpler method to update the - read buffers. - by @fschulze (`#727 - `_) -- (only affected rc releases) fix up tox.cmdline to be callable without args - by - @gaborbernat. (`#773 `_) -- (only affected rc releases) Revert breaking change of tox.cmdline not callable - with no args - by @gaborbernat. (`#773 `_) -- (only affected rc releases) fix #755 by reverting the ``cmdline`` import to the old - location and changing the entry point instead - by @fschulze - (`#755 `_) - - -Features -^^^^^^^^ - -- ``tox`` displays exit code together with ``InvocationError`` - by @blueyed - and @ederag. (`#290 `_) -- Hint for possible signal upon ``InvocationError``, on posix systems - by - @ederag and @asottile. (`#766 `_) -- Add a ``-q`` option to progressively silence tox's output. For each time you - specify ``-q`` to tox, the output provided by tox reduces. This option allows - you to see only your command output without the default verbosity of what tox - is doing. This also counter-acts usage of ``-v``. For example, running ``tox - -v -q ...`` will provide you with the default verbosity. ``tox -vv -q`` is - equivalent to ``tox -v``. By @sigmavirus24 (`#256 - `_) -- add support for negated factor conditions, e.g. ``!dev: production_log`` - by - @jurko-gospodnetic (`#292 `_) -- Headings like ``installed: `` will not be printed if there is no - output to display after the :, unless verbosity is set. By @cryvate (`#601 - `_) -- Allow spaces in command line options to pip in deps. Where previously only - ``deps=-rreq.txt`` and ``deps=--requirement=req.txt`` worked, now also - ``deps=-r req.txt`` and ``deps=--requirement req.txt`` work - by @cryvate - (`#668 `_) -- drop Python ``2.6`` and ``3.3`` support: ``setuptools`` dropped supporting - these, and as we depend on it we'll follow up with doing the same (use ``tox - <= 2.9.1`` if you still need this support) - by @gaborbernat (`#679 - `_) -- Add tox_runenvreport as a possible plugin, allowing the overriding of the - default behaviour to execute a command to get the installed packages within a - virtual environment - by @tonybaloney (`#725 - `_) -- Forward ``PROCESSOR_ARCHITECTURE`` by default on Windows to fix - ``platform.machine()``. (`#740 `_) - - -Documentation -^^^^^^^^^^^^^ - -- Change favicon to the vector beach ball - by @hazalozturk - (`#748 `_) -- Change sphinx theme to alabaster and add logo/favicon - by @hazalozturk - (`#639 `_) - - -Miscellaneous -^^^^^^^^^^^^^ - -- Running ``tox`` without a ``setup.py`` now has a more friendly error message - and gives troubleshooting suggestions - by @Volcyy. - (`#331 `_) -- Fix pycodestyle (formerly pep8) errors E741 (ambiguous variable names, in - this case, 'l's) and remove ignore of this error in tox.ini - by @cryvate - (`#663 `_) -- touched up ``interpreters.py`` code and added some missing tests for it - by - @jurko-gospodnetic (`#708 `_) -- The ``PYTHONDONTWRITEBYTECODE`` environment variable is no longer unset - by - @stephenfin. (`#744 `_) - - -v2.9.1 (2017-09-29) -------------------- - -Miscellaneous -^^^^^^^^^^^^^ - -- integrated new release process and fixed changelog rendering for pypi.org - - by `@obestwalter `_. - - -v2.9.0 (2017-09-29) -------------------- - -Features -^^^^^^^^ - -- ``tox --version`` now shows information about all registered plugins - by - `@obestwalter `_ - (`#544 `_) - - -Bugfixes -^^^^^^^^ - -- ``skip_install`` overrides ``usedevelop`` (``usedevelop`` is an option to - choose the installation type if the package is installed and ``skip_install`` - determines if it should be installed at all) - by `@ferdonline `_ - (`#571 `_) - - -Miscellaneous -^^^^^^^^^^^^^ - -- `#635 `_ inherit from correct exception - - by `@obestwalter `_ - (`#635 `_). -- spelling and escape sequence fixes - by `@scoop `_ - (`#637 `_ and - `#638 `_). -- add a badge to show build status of documentation on readthedocs.io - - by `@obestwalter `_. - - -Documentation -^^^^^^^^^^^^^ - -- add `towncrier `_ to allow adding - changelog entries with the pull requests without generating merge conflicts; - with this release notes are now grouped into four distinct collections: - ``Features``, ``Bugfixes``, ``Improved Documentation`` and ``Deprecations and - Removals``. (`#614 `_) - - -v2.8.2 (2017-10-09) -------------------- - -- `#466 `_: stop env var leakage if popen failed with resultjson or redirect - -v2.8.1 (2017-09-04) -------------------- - -- `pull request 599 `_: fix problems with implementation of `#515 `_. - Substitutions from other sections were not made anymore if they were not in ``envlist``. - Thanks to Clark Boylan (`@cboylan `_) for helping to get this fixed (`pull request 597 `_). - -v2.8.0 (2017-09-01) --------------------- - -- `#276 `_: Remove easy_install from docs (TL;DR: use pip). Thanks Martin Andrysík (`@sifuraz `_). - -- `#301 `_: Expand nested substitutions in ``tox.ini``. Thanks `@vlaci `_. Thanks to Eli Collins - (`@eli-collins `_) for creating a reproducer. - -- `#315 `_: add ``--help`` and ``--version`` to helptox-quickstart. Thanks `@vlaci `_. - -- `#326 `_: Fix ``OSError`` 'Not a directory' when creating env on Jython 2.7.0. Thanks Nick Douma (`@LordGaav `_). - -- `#429 `_: Forward ``MSYSTEM`` by default on Windows. Thanks Marius Gedminas (`@mgedmin `_) for reporting this. - -- `#449 `_: add multi platform example to the docs. Thanks Aleks Bunin (`@sashkab `_) and `@rndr `_. - -- `#474 `_: Start using setuptools_scm for tag based versioning. - -- `#484 `_: Renamed ``py.test`` to ``pytest`` throughout the project. Thanks Slam (`@3lnc `_). - -- `#504 `_: With ``-a``: do not show additional environments header if there are none. Thanks `@rndr `_. - -- `#515 `_: Don't require environment variables in test environments where they are not used. - Thanks André Caron (`@AndreLouisCaron `_). -- `#517 `_: Forward ``NUMBER_OF_PROCESSORS`` by default on Windows to fix ``multiprocessor.cpu_count()``. - Thanks André Caron (`@AndreLouisCaron `_). - -- `#518 `_: Forward ``USERPROFILE`` by default on Windows. Thanks André Caron (`@AndreLouisCaron `_). - -- `pull request 528 `_: Fix some of the warnings displayed by pytest 3.1.0. Thanks Bruno Oliveira (`@nicoddemus `_). - -- `pull request 547 `_: Add regression test for `#137 `_. Thanks Martin Andrysík (`@sifuraz `_). - -- `pull request 553 `_: Add an XFAIL test to reproduce upstream bug `#203 `_. Thanks - Bartolomé Sánchez Salado (`@bartsanchez `_). - -- `pull request 556 `_: Report more meaningful errors on why virtualenv creation failed. Thanks `@vlaci `_. - Also thanks to Igor Sadchenko (`@igor-sadchenko `_) for pointing out a problem with that PR - before it hit the masses ☺ - -- `pull request 575 `_: Add announcement doc to end all announcement docs - (using only ``CHANGELOG`` and Github issues since 2.5 already). - -- `pull request 580 `_: Do not ignore Sphinx warnings anymore. Thanks Bernát Gábor (`@gaborbernat `_). - -- `pull request 585 `_: Expand documentation to explain pass through of flags from deps to pip - (e.g. ``-rrequirements.txt``, ``-cconstraints.txt``). Thanks Alexander Loechel (`@loechel `_). - -- `pull request 588 `_: Run pytest with xfail_strict and adapt affected tests. - -v2.7.0 (2017-04-02) -------------------- - -- `pull request 450 `_: Stop after the first installdeps and first testenv create hooks - succeed. This changes the default behaviour of ``tox_testenv_create`` and ``tox_testenv_install_deps`` to not execute other registered hooks when the first hook returns a result that is not ``None``. - Thanks Anthony Sottile (`@asottile `_). - -- `#271 `_ and `#464 `_: - Improve environment information for users. - - New command line parameter: ``-a`` show **all** defined environments - - not just the ones defined in (or generated from) envlist. - - New verbosity settings for ``-l`` and ``-a``: show user defined descriptions - of the environments. This also works for generated environments from factors - by concatenating factor descriptions into a complete description. - - Note that for backwards compatibility with scripts using the output of ``-l`` - it's output remains unchanged. - - Thanks Bernát Gábor (`@gaborbernat `_). - -- `#464 `_: Fix incorrect egg-info location for modified package_dir in setup.py. - Thanks Selim Belhaouane (`@selimb `_). - -- `#431 `_: Add 'LANGUAGE' to default passed environment variables. - Thanks Paweł Adamczak (`@pawelad `_). - -- `#455 `_: Add a Vagrantfile with a customized Arch Linux box for local testing. - Thanks Oliver Bestwalter (`@obestwalter `_). - -- `#454 `_: Revert `pull request 407 `_, empty commands is not treated as an error. - Thanks Anthony Sottile (`@asottile `_). - -- `#446 `_: (infrastructure) Travis CI tests for tox now also run on OS X now. - Thanks Jason R. Coombs (`@jaraco `_). - -v2.6.0 (2017-02-04) -------------------- - -- add "alwayscopy" config option to instruct virtualenv to always copy - files instead of symlinking. Thanks Igor Duarte Cardoso (`@igordcard `_). - -- pass setenv variables to setup.py during a usedevelop install. - Thanks Eli Collins (`@eli-collins `_). - -- replace all references to testrun.org with readthedocs ones. - Thanks Oliver Bestwalter (`@obestwalter `_). - -- fix `#323 `_ by avoiding virtualenv14 is not used on py32 - (although we don't officially support py32). - Thanks Jason R. Coombs (`@jaraco `_). - -- add Python 3.6 to envlist and CI. - Thanks Andrii Soldatenko (`@andriisoldatenko `_). - -- fix glob resolution from TOX_TESTENV_PASSENV env variable - Thanks Allan Feldman (`@a-feld `_). - -v2.5.0 (2016-11-16) -------------------- - -- slightly backward incompatible: fix `#310 `_: the {posargs} substitution - now properly preserves the tox command line positional arguments. Positional - arguments with spaces are now properly handled. - NOTE: if your tox invocation previously used extra quoting for positional arguments to - work around `#310 `_, you need to remove the quoting. Example: - tox -- "'some string'" # has to now be written simply as - tox -- "some string" - thanks holger krekel. You can set ``minversion = 2.5.0`` in the ``[tox]`` - section of ``tox.ini`` to make sure people using your tox.ini use the correct version. - -- fix `#359 `_: add COMSPEC to default passenv on windows. Thanks - `@anthrotype `_. - -- add support for py36 and py37 and add py36-dev and py37(nightly) to - travis builds of tox. Thanks John Vandenberg. - -- fix `#348 `_: add py2 and py3 as default environments pointing to - "python2" and "python3" basepython executables. Also fix `#347 `_ by - updating the list of default envs in the tox basic example. - Thanks Tobias McNulty. - -- make "-h" and "--help-ini" options work even if there is no tox.ini, - thanks holger krekel. - -- add {:} substitution, which is replaced with os-specific path - separator, thanks Lukasz Rogalski. - -- fix `#305 `_: ``downloadcache`` test env config is now ignored as pip-8 - does caching by default. Thanks holger krekel. - -- output from install command in verbose (-vv) mode is now printed to console instead of - being redirected to file, thanks Lukasz Rogalski - -- fix `#399 `_. Make sure {envtmpdir} is created if it doesn't exist at the - start of a testenvironment run. Thanks Manuel Jacob. - -- fix `#316 `_: Lack of commands key in ini file is now treated as an error. - Reported virtualenv status is 'nothing to do' instead of 'commands - succeeded', with relevant error message displayed. Thanks Lukasz Rogalski. - -v2.4.1 (2016-10-12) -------------------- - -- fix `#380 `_: properly perform substitution again. Thanks Ian - Cordasco. - -v2.4.0 (2016-10-12) -------------------- - -- remove PYTHONPATH from environment during the install phase because a - tox-run should not have hidden dependencies and the test commands will also - not see a PYTHONPATH. If this causes unforeseen problems it may be - reverted in a bugfix release. Thanks Jason R. Coombs. - -- fix `#352 `_: prevent a configuration where envdir==toxinidir and - refine docs to warn people about changing "envdir". Thanks Oliver Bestwalter and holger krekel. - -- fix `#375 `_, fix `#330 `_: warn against tox-setup.py integration as - "setup.py test" should really just test with the current interpreter. Thanks Ronny Pfannschmidt. - -- fix `#302 `_: allow cross-testenv substitution where we substitute - with ``{x,y}`` generative syntax. Thanks Andrew Pashkin. - -- fix `#212 `_: allow escaping curly brace chars "\{" and "\}" if you need the - chars "{" and "}" to appear in your commands or other ini values. - Thanks John Vandenberg. - -- addresses `#66 `_: add --workdir option to override where tox stores its ".tox" directory - and all of the virtualenv environment. Thanks Danring. - -- introduce per-venv list_dependencies_command which defaults - to "pip freeze" to obtain the list of installed packages. - Thanks Ted Shaw, Holger Krekel. - -- close `#66 `_: add documentation to jenkins page on how to avoid - "too long shebang" lines when calling pip from tox. Note that we - can not use "python -m pip install X" by default because the latter - adds the CWD and pip will think X is installed if it is there. - "pip install X" does not do that. - -- new list_dependencies_command to influence how tox determines - which dependencies are installed in a testenv. - -- (experimental) New feature: When a search for a config file fails, tox tries loading - setup.cfg with a section prefix of "tox". - -- fix `#275 `_: Introduce hooks ``tox_runtest_pre``` and - ``tox_runtest_post`` which run before and after the tests of a venv, - respectively. Thanks to Matthew Schinckel and itxaka serrano. - -- fix `#317 `_: evaluate minversion before tox config is parsed completely. - Thanks Sachi King for the PR. - -- added the "extras" environment option to specify the extras to use when doing the - sdist or develop install. Contributed by Alex Grönholm. - -- use pytest-catchlog instead of pytest-capturelog (latter is not - maintained, uses deprecated pytest API) - -v2.3.2 (2016-02-11) -------------------- - -- fix `#314 `_: fix command invocation with .py scripts on windows. - -- fix `#279 `_: allow cross-section substitution when the value contains - posargs. Thanks Sachi King for the PR. - -v2.3.1 (2015-12-14) -------------------- - -- fix `#294 `_: re-allow cross-section substitution for setenv. - -v2.3.0 (2015-12-09) -------------------- - -- DEPRECATE use of "indexservers" in tox.ini. It complicates - the internal code and it is recommended to rather use the - devpi system for managing indexes for pip. - -- fix `#285 `_: make setenv processing fully lazy to fix regressions - of tox-2.2.X and so that we can now have testenv attributes like - "basepython" depend on environment variables that are set in - a setenv section. Thanks Nelfin for some tests and initial - work on a PR. - -- allow "#" in commands. This is slightly incompatible with commands - sections that used a comment after a "\" line continuation. - Thanks David Stanek for the PR. - -- fix `#289 `_: fix build_sphinx target, thanks Barry Warsaw. - -- fix `#252 `_: allow environment names with special characters. - Thanks Julien Castets for initial PR and patience. - -- introduce experimental tox_testenv_create(venv, action) and - tox_testenv_install_deps(venv, action) hooks to allow - plugins to do additional work on creation or installing - deps. These hooks are experimental mainly because of - the involved "venv" and session objects whose current public - API is not fully guaranteed. - -- internal: push some optional object creation into tests because - tox core doesn't need it. - -v2.2.1 (2015-12-09) -------------------- - -- fix bug where {envdir} substitution could not be used in setenv - if that env value is then used in {basepython}. Thanks Florian Bruhin. - -v2.2.0 (2015-11-11) -------------------- - -- fix `#265 `_ and add LD_LIBRARY_PATH to passenv on linux by default - because otherwise the python interpreter might not start up in - certain configurations (redhat software collections). Thanks David Riddle. - -- fix `#246 `_: fix regression in config parsing by reordering - such that {envbindir} can be used again in tox.ini. Thanks Olli Walsh. - -- fix `#99 `_: the {env:...} substitution now properly uses environment - settings from the ``setenv`` section. Thanks Itxaka Serrano. - -- fix `#281 `_: make --force-dep work when urls are present in - dependency configs. Thanks Glyph Lefkowitz for reporting. - -- fix `#174 `_: add new ``ignore_outcome`` testenv attribute which - can be set to True in which case it will produce a warning instead - of an error on a failed testenv command outcome. - Thanks Rebecka Gulliksson for the PR. - -- fix `#280 `_: properly skip missing interpreter if - {envsitepackagesdir} is present in commands. Thanks BB:ceridwenv - - -v2.1.1 (2015-06-23) -------------------- - -- fix platform skipping for detox - -- report skipped platforms as skips in the summary - -v2.1.0 (2015-06-19) -------------------- - -- fix `#258 `_, fix `#248 `_, fix `#253 `_: for non-test commands - (installation, venv creation) we pass in the full invocation environment. - -- remove experimental --set-home option which was hardly used and - hackily implemented (if people want home-directory isolation we should - figure out a better way to do it, possibly through a plugin) - -- fix `#259 `_: passenv is now a line-list which allows interspersing - comments. Thanks stefano-m. - -- allow envlist to be a multi-line list, to intersperse comments - and have long envlist settings split more naturally. Thanks Andre Caron. - -- introduce a TOX_TESTENV_PASSENV setting which is honored - when constructing the set of environment variables for test environments. - Thanks Marc Abramowitz for pushing in this direction. - -v2.0.2 (2015-06-03) -------------------- - -- fix `#247 `_: tox now passes the LANG variable from the tox invocation - environment to the test environment by default. - -- add SYSTEMDRIVE into default passenv on windows to allow pip6 to work. - Thanks Michael Krause. - -v2.0.1 (2015-05-13) -------------------- - -- fix wheel packaging to properly require argparse on py26. - -v2.0.0 (2015-05-12) -------------------- - -- (new) introduce environment variable isolation: - tox now only passes the PATH and PIP_INDEX_URL variable from the tox - invocation environment to the test environment and on Windows - also ``SYSTEMROOT``, ``PATHEXT``, ``TEMP`` and ``TMP`` whereas - on unix additionally ``TMPDIR`` is passed. If you need to pass - through further environment variables you can use the new ``passenv`` setting, - a space-separated list of environment variable names. Each name - can make use of fnmatch-style glob patterns. All environment - variables which exist in the tox-invocation environment will be copied - to the test environment. - -- a new ``--help-ini`` option shows all possible testenv settings and - their defaults. - -- (new) introduce a way to specify on which platform a testenvironment is to - execute: the new per-venv "platform" setting allows one to specify - a regular expression which is matched against sys.platform. - If platform is set and doesn't match the platform spec in the test - environment the test environment is ignored, no setup or tests are attempted. - -- (new) add per-venv "ignore_errors" setting, which defaults to False. - If ``True``, a non-zero exit code from one command will be ignored and - further commands will be executed (which was the default behavior in tox < - 2.0). If ``False`` (the default), then a non-zero exit code from one command - will abort execution of commands for that environment. - -- show and store in json the version dependency information for each venv - -- remove the long-deprecated "distribute" option as it has no effect these days. - -- fix `#233 `_: avoid hanging with tox-setuptools integration example. Thanks simonb. - -- fix `#120 `_: allow substitution for the commands section. Thanks - Volodymyr Vitvitski. - -- fix `#235 `_: fix AttributeError with --installpkg. Thanks - Volodymyr Vitvitski. - -- tox has now somewhat pep8 clean code, thanks to Volodymyr Vitvitski. - -- fix `#240 `_: allow one to specify empty argument list without it being - rewritten to ".". Thanks Daniel Hahler. - -- introduce experimental (not much documented yet) plugin system - based on pytest's externalized "pluggy" system. - See tox/hookspecs.py for the current hooks. - -- introduce parser.add_testenv_attribute() to register an ini-variable - for testenv sections. Can be used from plugins through the - tox_add_option hook. - -- rename internal files -- tox offers no external API except for the - experimental plugin hooks, use tox internals at your own risk. - -- DEPRECATE distshare in documentation - -v1.9.2 (2015-03-23) -------------------- - -- backout ability that --force-dep substitutes name/versions in - requirement files due to various issues. - This fixes `#228 `_, fixes `#230 `_, fixes `#231 `_ - which popped up with 1.9.1. - -v1.9.1 (2015-03-23) -------------------- - -- use a file instead of a pipe for command output in "--result-json". - Fixes some termination issues with python2.6. - -- allow --force-dep to override dependencies in "-r" requirements - files. Thanks Sontek for the PR. - -- fix `#227 `_: use "-m virtualenv" instead of "-mvirtualenv" to make - it work with pyrun. Thanks Marc-Andre Lemburg. - - -v1.9.0 (2015-02-24) -------------------- - -- fix `#193 `_: Remove ``--pre`` from the default ``install_command``; by - default tox will now only install final releases from PyPI for unpinned - dependencies. Use ``pip_pre = true`` in a testenv or the ``--pre`` - command-line option to restore the previous behavior. - -- fix `#199 `_: fill resultlog structure ahead of virtualenv creation - -- refine determination if we run from Jenkins, thanks Borge Lanes. - -- echo output to stdout when ``--report-json`` is used - -- fix `#11 `_: add a ``skip_install`` per-testenv setting which - prevents the installation of a package. Thanks Julian Krause. - -- fix `#124 `_: ignore command exit codes; when a command has a "-" prefix, - tox will ignore the exit code of that command - -- fix `#198 `_: fix broken envlist settings, e.g. {py26,py27}{-lint,} - -- fix `#191 `_: lessen factor-use checks - - -v1.8.1 (2014-10-24) -------------------- - -- fix `#190 `_: allow setenv to be empty. - -- allow escaping curly braces with "\". Thanks Marc Abramowitz for the PR. - -- allow "." names in environment names such that "py27-django1.7" is a - valid environment name. Thanks Alex Gaynor and Alex Schepanovski. - -- report subprocess exit code when execution fails. Thanks Marius - Gedminas. - -v1.8.0 (2014-09-24) -------------------- - -- new multi-dimensional configuration support. Many thanks to - Alexander Schepanovski for the complete PR with docs. - And to Mike Bayer and others for testing and feedback. - -- fix `#148 `_: remove "__PYVENV_LAUNCHER__" from os.environ when starting - subprocesses. Thanks Steven Myint. - -- fix `#152 `_: set VIRTUAL_ENV when running test commands, - thanks Florian Ludwig. - -- better report if we can't get version_info from an interpreter - executable. Thanks Floris Bruynooghe. - - -v1.7.2 (2014-07-15) -------------------- - -- fix `#150 `_: parse {posargs} more like we used to do it pre 1.7.0. - The 1.7.0 behaviour broke a lot of OpenStack projects. - See PR85 and the issue discussions for (far) more details, hopefully - resulting in a more refined behaviour in the 1.8 series. - And thanks to Clark Boylan for the PR. - -- fix `#59 `_: add a config variable ``skip-missing-interpreters`` as well as - command line option ``--skip-missing-interpreters`` which won't fail the - build if Python interpreters listed in tox.ini are missing. Thanks - Alexandre Conrad for PR104. - -- fix `#164 `_: better traceback info in case of failing test commands. - Thanks Marc Abramowitz for PR92. - -- support optional env variable substitution, thanks Morgan Fainberg - for PR86. - -- limit python hashseed to 1024 on Windows to prevent possible - memory errors. Thanks March Schlaich for the PR90. - -v1.7.1 (2014-03-28) -------------------- - -- fix `#162 `_: don't list python 2.5 as compatible/supported - -- fix `#158 `_ and fix `#155 `_: windows/virtualenv properly works now: - call virtualenv through "python -m virtualenv" with the same - interpreter which invoked tox. Thanks Chris Withers, Ionel Maries Cristian. - -v1.7.0 (2014-01-29) -------------------- - -- don't lookup "pip-script" anymore but rather just "pip" on windows - as this is a pip implementation detail and changed with pip-1.5. - It might mean that tox-1.7 is not able to install a different pip - version into a virtualenv anymore. - -- drop Python2.5 compatibility because it became too hard due - to the setuptools-2.0 dropping support. tox now has no - support for creating python2.5 based environments anymore - and all internal special-handling has been removed. - -- merged PR81: new option --force-dep which allows one to - override tox.ini specified dependencies in setuptools-style. - For example "--force-dep 'django<1.6'" will make sure - that any environment using "django" as a dependency will - get the latest 1.5 release. Thanks Bruno Oliveria for - the complete PR. - -- merged PR125: tox now sets "PYTHONHASHSEED" to a random value - and offers a "--hashseed" option to repeat a test run with a specific seed. - You can also use --hashsheed=noset to instruct tox to leave the value - alone. Thanks Chris Jerdonek for all the work behind this. - -- fix `#132 `_: removing zip_safe setting (so it defaults to false) - to allow installation of tox - via easy_install/eggs. Thanks Jenisys. - -- fix `#126 `_: depend on virtualenv>=1.11.2 so that we can rely - (hopefully) on a pip version which supports --pre. (tox by default - uses to --pre). also merged in PR84 so that we now call "virtualenv" - directly instead of looking up interpreters. Thanks Ionel Maries Cristian. - This also fixes `#140 `_. - -- fix `#130 `_: you can now set install_command=easy_install {opts} {packages} - and expect it to work for repeated tox runs (previously it only worked - when always recreating). Thanks jenisys for precise reporting. - -- fix `#129 `_: tox now uses Popen(..., universal_newlines=True) to force - creation of unicode stdout/stderr streams. fixes a problem on specific - platform configs when creating virtualenvs with Python3.3. Thanks - Jorgen Schäfer or investigation and solution sketch. - -- fix `#128 `_: enable full substitution in install_command, - thanks for the PR to Ronald Evers - -- rework and simplify "commands" parsing and in particular posargs - substitutions to avoid various win32/posix related quoting issues. - -- make sure that the --installpkg option trumps any usedevelop settings - in tox.ini or - -- introduce --no-network to tox's own test suite to skip tests - requiring networks - -- introduce --sitepackages to force sitepackages=True in all - environments. - -- fix `#105 `_ -- don't depend on an existing HOME directory from tox tests. - -v1.6.1 (2013-09-04) -------------------- - -- fix `#119 `_: {envsitepackagesdir} is now correctly computed and has - a better test to prevent regression. - -- fix `#116 `_: make 1.6 introduced behaviour of changing to a - per-env HOME directory during install activities dependent - on "--set-home" for now. Should re-establish the old behaviour - when no option is given. - -- fix `#118 `_: correctly have two tests use realpath(). Thanks Barry - Warsaw. - -- fix test runs on environments without a home directory - (in this case we use toxinidir as the homedir) - -- fix `#117 `_: python2.5 fix: don't use ``--insecure`` option because - its very existence depends on presence of "ssl". If you - want to support python2.5/pip1.3.1 based test environments you need - to install ssl and/or use PIP_INSECURE=1 through ``setenv``. section. +Release History +=============== +.. include:: _draft.rst -- fix `#102 `_: change to {toxinidir} when installing dependencies. - This allows one to use relative path like in "-rrequirements.txt". +.. towncrier release notes start -v1.6.0 (2013-08-15) +v4.0.2 (2022-12-07) ------------------- -- fix `#35 `_: add new EXPERIMENTAL "install_command" testenv-option to - configure the installation command with options for dep/pkg install. - Thanks Carl Meyer for the PR and docs. - -- fix `#91 `_: python2.5 support by vendoring the virtualenv-1.9.1 - script and forcing pip<1.4. Also the default [py25] environment - modifies the default installer_command (new config option) - to use pip without the "--pre" option which was introduced - with pip-1.4 and is now required if you want to install non-stable - releases. (tox defaults to install with "--pre" everywhere). - -- during installation of dependencies HOME is now set to a pseudo - location ({envtmpdir}/pseudo-home). If an index url was specified - a .pydistutils.cfg file will be written with an index_url setting - so that packages defining ``setup_requires`` dependencies will not - silently use your HOME-directory settings or PyPI. - -- fix `#1 `_: empty setup files are properly detected, thanks Anthon van - der Neuth +Bugfixes - 4.0.2 +~~~~~~~~~~~~~~~~ +- Unescaped comma in substitution should not be replaced during INI expansion - by :user:`gaborbernat`. (:issue:`2616`) +- ``tox --showconfig -e py311`` reports tox section, though it should not - by :user:`gaborbernat`. (:issue:`2624`) -- remove toxbootstrap.py for now because it is broken. -- fix `#109 `_ and fix `#111 `_: multiple "-e" options are now combined - (previously the last one would win). Thanks Anthon van der Neut. - -- add --result-json option to write out detailed per-venv information - into a json report file to be used by upstream tools. - -- add new config options ``usedevelop`` and ``skipsdist`` as well as a - command line option ``--develop`` to install the package-under-test in develop mode. - thanks Monty Tailor for the PR. - -- always unset PYTHONDONTWRITEBYTE because newer setuptools doesn't like it - -- if a HOMEDIR cannot be determined, use the toxinidir. - -- refactor interpreter information detection to live in new - tox/interpreters.py file, tests in tests/test_interpreters.py. - -v1.5.0 (2013-06-22) +v4.0.1 (2022-12-07) ------------------- -- fix `#104 `_: use setuptools by default, instead of distribute, - now that setuptools has distribute merged. - -- make sure test commands are searched first in the virtualenv - -- re-fix `#2 `_ - add whitelist_externals to be used in ``[testenv*]`` - sections, allowing to avoid warnings for commands such as ``make``, - used from the commands value. +Bugfixes - 4.0.1 +~~~~~~~~~~~~~~~~ +- Create session views of the build wheel/sdist into the :ref:`temp_dir` folder - by :user:`gaborbernat`. (:issue:`2612`) +- Default tox min_version to 4.0 instead of current tox version - by :user:`gaborbernat`. (:issue:`2613`) -- fix `#97 `_ - allow substitutions to reference from other sections - (thanks Krisztian Fekete) -- fix `#92 `_ - fix {envsitepackagesdir} to actually work again - -- show (test) command that is being executed, thanks - Lukasz Balcerzak - -- re-license tox to MIT license - -- depend on virtualenv-1.9.1 - -- rename README.txt to README.rst to make bitbucket happier - - -v1.4.3 (2013-02-28) +v4.0.0 (2022-12-07) ------------------- -- use pip-script.py instead of pip.exe on win32 to avoid the lock exe - file on execution issue (thanks Philip Thiem) +Bugfixes - 4.0.0 +~~~~~~~~~~~~~~~~ +- The temporary folder within the tox environment was named ``.temp`` instead of ``.tmp`` - by :user:`gaborbernat`. (:issue:`2608`) -- introduce -l|--listenv option to list configured environments - (thanks Lukasz Balcerzak) +Improved Documentation - 4.0.0 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Enumerate breaking changes of tox 4 in the FAQ, and also list major new improvements - by :user:`gaborbernat`. (:issue:`2587`) +- Document in the FAQ that tox 4 will raise a warning when finding conflicting environment names - by :user:`gaborbernat`. (:issue:`2602`) -- fix downloadcache determination to work according to docs: Only - make pip use a download cache if PIP_DOWNLOAD_CACHE or a - downloadcache=PATH testenv setting is present. (The ENV setting - takes precedence) -- fix `#84 `_ - pypy on windows creates a bin not a scripts venv directory - (thanks Lukasz Balcerzak) +v4.0.0rc4 (2022-12-06) +---------------------- -- experimentally introduce --installpkg=PATH option to install a package - rather than create/install an sdist package. This will still require - and use tox.ini and tests from the current working dir (and not from the - remote package). +Bugfixes - 4.0.0rc4 +~~~~~~~~~~~~~~~~~~~ +- Fix extras not being kept for install dependencies - by :user:`gaborbernat`. (:issue:`2603`) -- substitute {envsitepackagesdir} with the package installation - directory (closes `#72 `_) (thanks g2p) +Deprecations and Removals - 4.0.0rc4 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Remove deprecated configuration option ``whitelist_externals`` which was replaced by ``allowlist_externals`` - by :user:`jugmac00`. (:issue:`2599`) -- issue `#70 `_ remove PYTHONDONTWRITEBYTECODE workaround now that - virtualenv behaves properly (thanks g2p) -- merged tox-quickstart command, contributed by Marc Abramowitz, which - generates a default tox.ini after asking a few questions +v4.0.0rc3 (2022-12-05) +---------------------- -- fix `#48 `_ - win32 detection of pypy and other interpreters that are on PATH - (thanks Gustavo Picon) +Features - 4.0.0rc3 +~~~~~~~~~~~~~~~~~~~ +- Add ``--exit-and-dump-after`` flag that allows automatically killing tox if does not finish within the passed seconds, + and dump the thread stacks (useful to debug tox when it seemingly hangs) - by :user:`gaborbernat`. (:issue:`2595`) -- fix grouping of index servers, it is now done by name instead of - indexserver url, allowing to use it to separate dependencies - into groups even if using the same default indexserver. +Bugfixes - 4.0.0rc3 +~~~~~~~~~~~~~~~~~~~ +- Ensure that two parallel tox instance invocations on different tox environment targets will work by holding a file lock + onto the packaging operations (e.g., in bash ``tox4 r -e py311 &; tox4 r -e py310``) - by :user:`gaborbernat`. (:issue:`2594`) +- Fix leaking backend processes when the build backend does not support editable wheels and fix failure when multiple + environments exist that have a build backend that does not support editable wheels - by :user:`gaborbernat`. (:issue:`2595`) -- look for "tox.ini" files in parent dirs of current dir (closes `#34 `_) -- the "py" environment now by default uses the current interpreter - (sys.executable) make tox' own setup.py test execute tests with it - (closes `#46 `_) +v4.0.0rc2 (2022-12-04) +---------------------- -- change tests to not rely on os.path.expanduser (closes `#60 `_), - also make mock session return args[1:] for more precise checking (closes `#61 `_) - thanks to Barry Warsaw for both. +Features - 4.0.0rc2 +~~~~~~~~~~~~~~~~~~~ +- Support for recursive extras in Python package dependencies - by :user:`gaborbernat`. (:issue:`2567`) -v1.4.2 (2012-07-20) -------------------- +Bugfixes - 4.0.0rc2 +~~~~~~~~~~~~~~~~~~~ +- Support in INI files for ignore exit code marker the ``-`` without a subsequent space too - by :user:`gaborbernat`. (:issue:`2561`) +- Ensure paths constructed by tox are stable by resolving relative paths to fully qualified one, this insures that running + tox from a different folder than project root still generates meaningful paths - by :user:`gaborbernat`. (:issue:`2562`) +- Ensure only on run environment operates at a time on a packaging environment (fixes unexpected failures when running in + parallel mode) - by :user:`gaborbernat`. (:issue:`2564`) +- Fallback to ``editable-legacy`` if package target is ``editable`` but the build backend does not have ``build_editable`` + hook - by :user:`gaborbernat`. (:issue:`2567`) +- Allow reference replacement in INI configuration via keys that contain the ``-`` character - by :user:`gaborbernat`. (:issue:`2569`) +- Resolve symlinks when saving Python executable path - by :user:`ssbarnea`. (:issue:`2574`) +- Do not set ``COLUMNS`` or ``LINES`` environment to the current TTY size if already set by the user - + by :user:`gaborbernat`. (:issue:`2575`) +- Add missing :pypi:`build[virtualenv]` test dependency - by :user:`ssbarnea`. (:issue:`2576`) -- fix some tests which fail if /tmp is a symlink to some other place -- "python setup.py test" now runs tox tests via tox :) - also added an example on how to do it for your project. -v1.4.1 (2012-07-03) -------------------- +v4.0.0rc1 (2022-11-29) +---------------------- -- fix `#41 `_ better quoting on windows - you can now use "<" and ">" in - deps specifications, thanks Chris Withers for reporting - -v1.4 (2012-06-13) ------------------ - -- fix `#26 `_ - no warnings on absolute or relative specified paths for commands -- fix `#33 `_ - commentchars are ignored in key-value settings allowing - for specifying commands like: python -c "import sys ; print sys" - which would formerly raise irritating errors because the ";" - was considered a comment -- tweak and improve reporting -- refactor reporting and virtualenv manipulation - to be more accessible from 3rd party tools -- support value substitution from other sections - with the {[section]key} syntax -- fix `#29 `_ - correctly point to pytest explanation - for importing modules fully qualified -- fix `#32 `_ - use --system-site-packages and don't pass --no-site-packages -- add python3.3 to the default env list, so early adopters can test -- drop python2.4 support (you can still have your tests run on -- fix the links/checkout howtos in the docs - python-2.4, just tox itself requires 2.5 or higher. - -v1.3 2011-12-21 ---------------- - -- fix: allow one to specify wildcard filesystem paths when - specifying dependencies such that tox searches for - the highest version - -- fix issue `#21 `_: clear PIP_REQUIRES_VIRTUALENV which avoids - pip installing to the wrong environment, thanks to bb's streeter - -- make the install step honour a testenv's setenv setting - (thanks Ralf Schmitt) - - -v1.2 2011-11-10 ---------------- - -- remove the virtualenv.py that was distributed with tox and depend - on >=virtualenv-1.6.4 (possible now since the latter fixes a few bugs - that the inlining tried to work around) -- fix `#10 `_: work around UnicodeDecodeError when invoking pip (thanks - Marc Abramowitz) -- fix a problem with parsing {posargs} in tox commands (spotted by goodwill) -- fix the warning check for commands to be installed in testenvironment - (thanks Michael Foord for reporting) - -v1.1 (2011-07-08) ------------------ - -- fix `#5 `_ - don't require argparse for python versions that have it -- fix `#6 `_ - recreate virtualenv if installing dependencies failed -- fix `#3 `_ - fix example on frontpage -- fix `#2 `_ - warn if a test command does not come from the test - environment -- fixed/enhanced: except for initial install always call "-U - --no-deps" for installing the sdist package to ensure that a package - gets upgraded even if its version number did not change. (reported on - TIP mailing list and IRC) -- inline virtualenv.py (1.6.1) script to avoid a number of issues, - particularly failing to install python3 environments from a python2 - virtualenv installation. -- rework and enhance docs for display on readthedocs.org - -v1.0 ----- - -- move repository and toxbootstrap links to https://bitbucket.org/hpk42/tox -- fix `#7 `_: introduce a "minversion" directive such that tox - bails out if it does not have the correct version. -- fix `#24 `_: introduce a way to set environment variables for - for test commands (thanks Chris Rose) -- fix `#22 `_: require virtualenv-1.6.1, obsoleting virtualenv5 (thanks Jannis Leidel) - and making things work with pypy-1.5 and python3 more seamlessly -- toxbootstrap.py (used by jenkins build agents) now follows the latest release of virtualenv -- fix `#20 `_: document format of URLs for specifying dependencies -- fix `#19 `_: substitute Hudson for Jenkins everywhere following the renaming - of the project. NOTE: if you used the special [tox:hudson] - section it will now need to be named [tox:jenkins]. -- fix issue 23 / apply some ReST fixes -- change the positional argument specifier to use {posargs:} syntax and - fix issues `#15 `_ and `#10 `_ by refining the argument parsing method (Chris Rose) -- remove use of inipkg lazy importing logic - - the namespace/imports are anyway very small with tox. -- fix a fspath related assertion to work with debian installs which uses - symlinks -- show path of the underlying virtualenv invocation and bootstrap - virtualenv.py into a working subdir -- added a CONTRIBUTORS file - -v0.9 ----- - -- fix pip-installation mixups by always unsetting PIP_RESPECT_VIRTUALENV - (thanks Armin Ronacher) -- `#1 `_: Add a toxbootstrap.py script for tox, thanks to Sridhar - Ratnakumar -- added support for working with different and multiple PyPI indexservers. -- new option: -r|--recreate to force recreation of virtualenv -- depend on py>=1.4.0 which does not contain or install the py.test - anymore which is now a separate distribution "pytest". -- show logfile content if there is an error (makes CI output - more readable) - -v0.8 ----- - -- work around a virtualenv limitation which crashes if - PYTHONDONTWRITEBYTECODE is set. -- run pip/easy installs from the environment log directory, avoids - naming clashes between env names and dependencies (thanks ronny) -- require a more recent version of py lib -- refactor and refine config detection to work from a single file - and to detect the case where a python installation overwrote - an old one and resulted in a new executable. This invalidates - the existing virtualenvironment now. -- change all internal source to strip trailing whitespaces - -v0.7 ----- - -- use virtualenv5 (my own fork of virtualenv3) for now to create python3 - environments, fixes a couple of issues and makes tox more likely to - work with Python3 (on non-windows environments) - -- add ``sitepackages`` option for testenv sections so that environments - can be created with access to globals (default is not to have access, - i.e. create environments with ``--no-site-packages``. - -- addressing `#4 `_: always prepend venv-path to PATH variable when calling subprocesses - -- fix `#2 `_: exit with proper non-zero return code if there were - errors or test failures. - -- added unittest2 examples contributed by Michael Foord - -- only allow 'True' or 'False' for boolean config values - (lowercase / uppercase is irrelevant) - -- recreate virtualenv on changed configurations - -v0.6 ----- - -- fix OSX related bugs that could cause the caller's environment to get - screwed (sorry). tox was using the same file as virtualenv for tracking - the Python executable dependency and there also was confusion wrt links. - this should be fixed now. - -- fix long description, thanks Michael Foord - -v0.5 ----- - -- initial release +Features - 4.0.0rc1 +~~~~~~~~~~~~~~~~~~~ +- Add support for generative section headers - by :user:`gaborbernat`. (:issue:`2362`) + +Bugfixes - 4.0.0rc1 +~~~~~~~~~~~~~~~~~~~ +- Allow installing relative paths that go outside tox root folder. - by :user:`ssbarnea`. (:issue:`2366`) + + +v4.0.0b3 (2022-11-27) +--------------------- + +Features - 4.0.0b3 +~~~~~~~~~~~~~~~~~~ +- Improve coloring of logged commands - by :user:`ssbarnea`. (:issue:`2356`) +- Pass ``PROGRAMDATA``, ``PROGRAMFILES(x86)``, ``PROGRAMFILES`` environments on Windows by default as it is needed for discovering the VS C++ compiler and start testing against 3.11 - by :user:`gaborbernat`. (:issue:`2492`) +- Support PEP-621 static metadata for getting package dependencies - by :user:`gaborbernat`. (:issue:`2499`) +- Add support for editable wheels, make it the default development mode and rename ``dev-legacy`` mode to + ``editable-legacy`` - by :user:`gaborbernat`. (:issue:`2502`) + +Bugfixes - 4.0.0b3 +~~~~~~~~~~~~~~~~~~ +- Recognize ``TERM=dumb`` or ``NO_COLOR`` environment variables. - by :user:`ssbarnea`. (:issue:`1290`) +- Allow passing config directory without filename. - by :user:`ssbarnea`. (:issue:`2340`) +- Avoid ignored explicit argument 're' console message. - by :user:`ssbarnea`. (:issue:`2342`) +- Display registered plugins with ``tox --version`` - by :user:`mxd4`. (:issue:`2358`) +- Allow ``--hash`` to be specified in requirements.txt files. - by :user:`masenf`. (:issue:`2373`) +- Avoid impossible minversion version requirements. - by :user:`ssbarnea`. (:issue:`2414`) + +Improved Documentation - 4.0.0b3 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Add new documentation for tox 4 - by :user:`gaborbernat`. (:issue:`2408`) + + +v4.0.0b2 (2022-04-11) +--------------------- + +Features - 4.0.0b2 +~~~~~~~~~~~~~~~~~~ +- Use ``tox`` console entry point name instead of ``tox4`` - by :user:`gaborbernat`. (:issue:`2344`) +- Use ``.tox`` as working directory instead of ``.tox/4`` - by :user:`gaborbernat`. (:issue:`2346`) +- Switch to ``hatchling`` as build backend instead of ``setuptools`` - by :user:`gaborbernat`. (:issue:`2368`) + +Bugfixes - 4.0.0b2 +~~~~~~~~~~~~~~~~~~ +- Fix CLI raises an error for ``-va`` with ``ignored explicit argument 'a'`` - by :user:`gaborbernat`. (:issue:`2343`) +- Do not interpolate values when parsing ``tox.ini`` configuration files - by :user:`gaborbernat`. (:issue:`2350`) + +Improved Documentation - 4.0.0b2 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Deleted the tox mailing list -- by :user:`jugmac00` (:issue:`2364`) + + +v4.0.0b1 (2022-02-05) +--------------------- + +Features - 4.0.0b1 +~~~~~~~~~~~~~~~~~~ +- Display a hint for unrecognized argument CLI parse failures to use ``--`` separator to pass arguments to commands + - by :user:`gaborbernat`. (:issue:`2183`) +- Do not allow extending the config set beyond setup to ensures that all configuration values are visible via the config + sub-command. - by :user:`gaborbernat`. (:issue:`2243`) +- Print a message when ignoring outcome of commands - by :user:`gaborbernat`. (:issue:`2315`) + +Bugfixes - 4.0.0b1 +~~~~~~~~~~~~~~~~~~ +- Fix type annotation is broken for :meth:`tox.config.sets.ConfigSet.add_config` when adding a container type + - by :user:`gaborbernat`. (:issue:`2233`) +- Insert ``TOX_WORK_DIR``, ``TOX_ENV_NAME``, ``TOX_ENV_DIR`` and ``VIRTUAL_ENV`` into the environment variables for all + tox environments to keep contract with tox version 3 - by :user:`gaborbernat`. (:issue:`2259`) +- Fix plugin initialization order - core plugins first, then 3rd party and finally inline - by :user:`gaborbernat`. (:issue:`2264`) +- Legacy parallel mode should accept ``-p`` flag without arguments - by :user:`gaborbernat`. (:issue:`2299`) +- Sequential run fails because the packaging environment is deleted twice for sequential runs with recreate flag on + - by :user:`gaborbernat`. (:issue:`2300`) +- Require Python 3.10 to generate docs - by :user:`jugmac00`. (:issue:`2321`) +- Environment assignment for output breaks when using ``-rv`` (when we cannot guess upfront the verbosity level from the + CLI arguments) - by :user:`gaborbernat`. (:issue:`2324`) +- ``devenv`` command does not respect specified path - by :user:`gaborbernat`. (:issue:`2325`) + +Improved Documentation - 4.0.0b1 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Enable link check during documentation build - by :user:`gaborbernat`. (:issue:`806`) +- Document ownership of the ``tox.wiki`` root domain - by :user:`gaborbernat`. (:issue:`2242`) +- Document :meth:`tox.config.sets.ConfigSet.loaders` - by :user:`gaborbernat`. (:issue:`2287`) +- Fix CLI documentation is missing and broken documentation references - by :user:`gaborbernat`. (:issue:`2310`) + + +v4.0.0a10 (2022-01-04) +---------------------- + +Features - 4.0.0a10 +~~~~~~~~~~~~~~~~~~~ +- Support for grouping environment values together by applying labels to them either at :ref:`core ` and + :ref:`environment ` level, and allow selecting them via the :ref:`-m ` flag from the CLI - by + :user:`gaborbernat`. (:issue:`238`) +- Support for environment files within the :ref:`set_env` configuration via the ``file|`` prefix - by :user:`gaborbernat`. (:issue:`1938`) +- Support for ``--no-provision`` flag - by :user:`gaborbernat`. (:issue:`1951`) +- Missing ``pyproject.toml`` or ``setup.py`` file at the tox root folder without the ``--install-pkg`` flag assumes no + packaging - by :user:`gaborbernat`. (:issue:`1964`) +- Add ``external`` package type for :ref:`package` (see :ref:`external-package-builder`), and extract package dependencies + for packages passed in via :ref:`--installpkg ` - by :user:`gaborbernat`. (:issue:`2204`) +- Add support for rewriting script invocations that have valid shebang lines when the ``TOX_LIMITED_SHEBANG`` environment + variable is set and not empty - by :user:`gaborbernat`. (:issue:`2208`) +- Support for the ``--discover`` CLI flag - by :user:`gaborbernat`. (:pull:`2245`) +- Moved the python packaging logic into a dedicate package :pypi:`pyproject-api` and + use it as a dependency - by :user:`gaborbernat`. (:pull:`2274`) +- Drop python 3.6 support - by :user:`gaborbernat`. (:pull:`2275`) +- Support for selecting target environments with a given factor via the :ref:`-f ` CLI environment flag - by + :user:`gaborbernat`. (:pull:`2290`) + +Bugfixes - 4.0.0a10 +~~~~~~~~~~~~~~~~~~~ +- Fix ``CTRL+C`` is not stopping the process on Windows - by :user:`gaborbernat`. (:issue:`2159`) +- Fix list/depends commands can create tox package environment as runtime environment and display an error message + - by :user:`gaborbernat`. (:pull:`2234`) + +Deprecations and Removals - 4.0.0a10 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- ``tox_add_core_config`` and ``tox_add_env_config`` now take a ``state: State`` argument instead of a configuration one, + and ``Config`` not longer provides the ``envs`` property (instead users should migrate to ``State.envs``) - by + :user:`gaborbernat`. (:pull:`2275`) + + +v4.0.0a9 (2021-09-16) +--------------------- + +Features - 4.0.0a9 +~~~~~~~~~~~~~~~~~~ +- Expose the parsed CLI arguments on the main configuration object for plugins and allow plugins to define their own + configuration section -- by :user:`gaborbernat`. (:pull:`2191`) +- Let tox run fail when all envs are skipped -- by :user:`jugmac00`. (:issue:`2195`) +- Expose the configuration loading mechanism to plugins to define and load their own sections. Add + :meth:`tox_add_env_config ` plugin hook called after the configuration environment + is created for a tox environment and removed ``tox_configure``. Add the main configuration object as argument to + :meth:`tox_add_core_config `. Move the environment list method from the state to + the main configuration object to allow its use within plugins -- by :user:`gaborbernat`. (:issue:`2200`) +- Allow running code in plugins before and after commands via + :meth:`tox_before_run_commands ` and + :meth:`tox_after_run_commands ` plugin points -- by :user:`gaborbernat`. (:issue:`2201`) +- Allow plugins to update the :ref:`set_env` and change the :ref:`pass_env` configurations -- by :user:`gaborbernat`. (:issue:`2215`) + +Bugfixes - 4.0.0a9 +~~~~~~~~~~~~~~~~~~ +- Fix env variable substitutions with defaults containing colon (e.g. URL) -- by :user:`comabrewer`. (:issue:`2182`) +- Do not allow constructing ``ConfigSet`` directly and implement ``__contains__`` for ``Loader`` -- by + :user:`gaborbernat`. (:pull:`2209`) +- Fix old-new value on recreate cache miss-match are swapped -- by :user:`gaborbernat`. (:issue:`2211`) +- Report fails when report does not support Unicode characters -- by :user:`gaborbernat`. (:issue:`2213`) + +Improved Documentation - 4.0.0a9 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Adopt furo theme, update our state diagram and description in user docs (SVG + light/dark variant), split + the Python API into its own page from under the plugin page, and document plugin adoption under the ``tox-dev`` + organization - by :user:`gaborbernat`. (:issue:`1881`) + + +v4.0.0a8 (2021-08-21) +--------------------- + +Features - 4.0.0a8 +~~~~~~~~~~~~~~~~~~ +- Add support for :ref:`allowlist_externals`, commands not matching error - by :user:`gaborbernat`. (:issue:`1127`) +- Add outcome of environments into the result json (:ref:`--result-json `) under the ``result`` key + containing ``success`` boolean, ``exit_code`` integer and ``duration`` float value - by :user:`gaborbernat`. (:issue:`1405`) +- Add ``exec`` subcommand that allows users to run an arbitrary command within the tox environment (without needing to + modify their configuration) - by :user:`gaborbernat`. (:issue:`1790`) +- Add check to validate the base Python names and the environments name do not conflict Python spec wise, when they do + raise error if :ref:`ignore_base_python_conflict` is not set or ``False`` - by :user:`gaborbernat`. (:issue:`1840`) +- Allow any Unix shell-style wildcards expression for :ref:`pass_env` - by :user:`gaborbernat`. (:issue:`2121`) +- Add support for :ref:`args_are_paths` flag - by :user:`gaborbernat`. (:issue:`2122`) +- Add support for :ref:`env_log_dir` (compared to tox 3 extend content and keep only last run entries) - + by :user:`gaborbernat`. (:issue:`2123`) +- Add support for ``{:}`` substitution in ini files as placeholder for the OS path separator - by :user:`gaborbernat`. (:issue:`2125`) +- When cleaning directories (for tox environment, ``env_log_dir``, ``env_tmp_dir`` and packaging metadata folders) do not + delete the directory itself and recreate, but instead just delete its content (this allows the user to cd into it and + still be in a valid folder after a new run) - by :user:`gaborbernat`. (:pull:`2139`) +- Changes to help plugin development: simpler tox env creation argument list, expose python creation directly, + allow skipping list dependencies install command for pip and executable is only part of the python cache for virtualenv + - by :user:`gaborbernat`. (:pull:`2172`) + +Bugfixes - 4.0.0a8 +~~~~~~~~~~~~~~~~~~ +- Support ``#`` character in path for the tox project - by :user:`gaborbernat`. (:issue:`763`) +- If the command expression fails to parse with shlex fallback to literal pass through of the remaining elements + - by :user:`gaborbernat`. (:issue:`1944`) +- tox config fails on :ref:`--recreate ` flag, and once specified the output does not reflect the + impact of the CLI flags - by :user:`gaborbernat`. (:issue:`2037`) +- Virtual environment creation for Python is always triggered at every run - by :user:`gaborbernat`. (:issue:`2041`) +- Add support for setting :ref:`suicide_timeout`, :ref:`interrupt_timeout` and :ref:`terminate_timeout` - by + :user:`gaborbernat`. (:issue:`2124`) +- Parallel show output not working when there's a packaging phase in the run - by :user:`gaborbernat`. (:pull:`2161`) + +Improved Documentation - 4.0.0a8 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Note constraint files are a subset of requirement files - by :user:`gaborbernat`. (:issue:`1939`) +- Add a note about having a package with different Python requirements than tox and not specifying :ref:`base_python` - + by :user:`gaborbernat`. (:issue:`1975`) +- Fix :ref:`--runner ` is missing default value and documentation unclear - by :user:`gaborbernat`. (:issue:`2004`) + + +v4.0.0a7 (2021-07-28) +--------------------- + +Features - 4.0.0a7 +~~~~~~~~~~~~~~~~~~ +- Add support for configuration taken from the ``setup.cfg`` file -by :user:`gaborbernat`. (:issue:`1836`) +- Add support for configuration taken from the ``pyproject.toml`` file, ``tox`` section ``legacy_tox_ini`` key - by + :user:`gaborbernat`. (:issue:`1837`) +- Add configuration documentation - by :user:`gaborbernat`. (:issue:`1914`) +- Implemented ``[]`` substitution (alias for ``{posargs}``) - by + :user:`hexagonrecursion`. (:issue:`1928`) +- Implement ``[testenv] ignore_outcome`` - "a failing result of this testenv will not make tox fail" - by :user:`hexagonrecursion`. (:issue:`1947`) +- Inline plugin support via ``tox_.py``. This is loaded where the tox config source is discovered. It's a Python file + that can contain arbitrary Python code, such as definition of a plugin. Eventually we'll add a plugin that allows + succinct declaration/generation of new tox environments - by :user:`gaborbernat`. (:pull:`1963`) +- Introduce the installer concept, and collect pip installation into a ``pip`` package, also attach to this + the requirements file parsing which got a major rework - by :user:`gaborbernat`. (:pull:`1991`) +- Support CPython ``3.10`` -by :user:`gaborbernat`. (:pull:`2014`) + +Bugfixes - 4.0.0a7 +~~~~~~~~~~~~~~~~~~ +- Environments with a platform mismatch are no longer silently skipped, but properly reported - by :user:`jugmac00`. (:issue:`1926`) +- Port pip requirements file parser to ``tox`` to achieve full equivalency (such as support for the per requirement + ``--install-option`` and ``--global-option`` flags) - by :user:`gaborbernat`. (:issue:`1929`) +- Support for extras with paths for Python deps and requirement files - by :user:`gaborbernat`. (:issue:`1933`) +- Due to a bug ``\{posargs} {posargs}`` used to expand to literal ``{posargs} {posargs}``. + Now the second ``{posargs}`` is expanded. + ``\{posargs} {posargs}`` expands to ``{posargs} positional arguments here`` - by :user:`hexagonrecursion`. (:issue:`1956`) +- Enable setting a different ``upstream`` repository for the coverage diff report. + This has been hardcoded to ``upstream/rewrite`` until now. + by :user:`jugmac00`. (:issue:`1972`) +- Enable replacements (a.k.a section substitions) for section names containing a dash in sections + without the ``testenv:`` prefix - by :user:`jugmac00`, :user:`obestwalter`, :user:`eumiro`. (:issue:`1985`) +- Fix legacy list env command for empty/missing envlist - by :user:`jugmac00`. (:issue:`1987`) +- Requirements and constraints files handling got reimplemented, which should fix all open issues related to this area + - by :user:`gaborbernat`. (:pull:`1991`) +- Use importlib instead of ``__import__`` - by :user:`dmendek`. (:issue:`1995`) +- Evaluate factor conditions for ``command`` keys - by :user:`jugmac00`. (:issue:`2002`) +- Prefer f-strings instead of the str.format method - by :user:`eumiro`. (:issue:`2012`) +- Fix regex validation for SHA 512 hashes - by :user:`jugmac00`. (:issue:`2018`) +- Actually run all environments when ``ALL`` is provided to the legacy env command - by :user:`jugmac00`. (:issue:`2112`) +- Move from ``appdirs`` to ``platformdirs`` - by :user:`gaborbernat`. (:pull:`2117`) +- Move from ``toml`` to ``tomli`` - by :user:`gaborbernat`. (:pull:`2118`) + +Improved Documentation - 4.0.0a7 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Start documenting the plugin interface. Added :meth:`tox_register_tox_env `, + :meth:`tox_add_option `, + :meth:`tox_add_core_config `, + ``tox_configure`` - by :user:`gaborbernat`. (:pull:`1991`) +- Explain how ``-v`` and ``-q`` flags play together to determine CLI verbosity level - by :user:`jugmac00`. (:issue:`2005`) +- Start polishing the documentation for the upcoming final release - by :user:`jugmac00`. (:pull:`2006`) +- Update documentation about changelog entries for trivial changes - by :user:`jugmac00`. (:issue:`2007`) + + +v4.0.0a6 (2021-02-15) +--------------------- + +Features - 4.0.0a6 +~~~~~~~~~~~~~~~~~~ +- Add basic quickstart implementation (just use pytest with the current Python version) - by :user:`gaborbernat`. (:issue:`1829`) +- Support comments via the ``#`` character within the ini configuration (to force a literal ``#`` use ``\#``) - + by :user:`gaborbernat`. (:issue:`1831`) +- Add support for the ``install_command`` settings in the virtual env test environments - by :user:`gaborbernat`. (:issue:`1832`) +- Add support for the ``package_root`` \ ``setupdir`` ( Python scoped) configuration that sets the root directory used for + packaging (the location of the historical ``setup.py`` and modern ``pyproject.toml``). This can be set at root level, or + at tox environment level (the later takes precedence over the former) - by :user:`gaborbernat`. (:issue:`1838`) +- Implement support for the ``--installpkg`` CLI flag - by :user:`gaborbernat`. (:issue:`1839`) +- Add support for the ``list_dependencies_command`` settings in the virtual env test environments - by + :user:`gaborbernat`. (:issue:`1842`) +- Add support for the ``ignore_errors`` settings in tox test environments - by :user:`gaborbernat`. (:issue:`1843`) +- Add support for the ``pip_pre`` settings for virtual environment based tox environments - by :user:`gaborbernat`. (:issue:`1844`) +- Add support for the ``platform`` settings in tox test environments - by :user:`gaborbernat`. (:issue:`1845`) +- Add support for the ``recreate`` settings in tox test environments - by :user:`gaborbernat`. (:issue:`1846`) +- Allow Python test and packaging environments with version 2.7 - by :user:`gaborbernat`. (:pull:`1900`) +- Do not construct a requirements file for deps in virtualenv, instead pass content as CLI argument to pip - by + :user:`gaborbernat`. (:pull:`1906`) +- Do not display status update environment reports when interrupted or for the final environment ran (because at the + final report will be soon printed and makes the status update redundant) - by :user:`gaborbernat`. (:issue:`1909`) +- The ``_TOX_SHOW_THREAD`` environment variable can be used to print alive threads when tox exists (useful to debug + when tox hangs because of some non-finished thread) and also now prints the pid of the local subprocess when reporting + the outcome of a execution - by :user:`gaborbernat`. (:pull:`1915`) + +Bugfixes - 4.0.0a6 +~~~~~~~~~~~~~~~~~~ +- Normalize description text to collapse newlines and one or more than whitespace to a single space - by + :user:`gaborbernat`. (:issue:`1829`) +- Support aliases in show config key specification (will print with the primary key) - by :user:`gaborbernat`. (:issue:`1831`) +- Show config no longer marks as unused keys that are inherited (e.g. if the key is coming from ``testenv`` section and our + target is ``testenv:fix``) - by :user:`gaborbernat`. (:issue:`1833`) +- ``--alwayscopy`` and ``--sitepackages`` legacy only flags do not work - by :user:`gaborbernat`. (:issue:`1839`) +- Fix handling of ``commands_pre``/``commands``/``commands_post`` to be in line with tox 3 (returned incorrect exit codes + and post was not always executed) - by :user:`gaborbernat`. (:issue:`1843`) +- Support requirement files containing ``--hash`` constraints - by :user:`gaborbernat`. (:issue:`1903`) +- Fix a bug that caused tox to never finish when pulling configuration from a tox run environment that was never executed + - by :user:`gaborbernat`. (:pull:`1915`) + +Deprecations and Removals - 4.0.0a6 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- - Drop support for ``sdistsrc`` flag because introduces a significant complexity and is barely used (5 hits on a github + search). + - ``--skip-missing-interpreters``, ``--notest``, ``--sdistonly``, ``--installpkg``, ``--develop`` and + ``--skip-pkg-install`` CLI flags are no longer available for ``devenv`` (enforce the only sane value for these). + + By :user:`gaborbernat` (:issue:`1839`) +- Remove Jenkins override support: this feature goes against the spirit of tox - blurring the line between the CI and + local runs. It also singles out a single CI provider, which opens the door for other CIs wanting similar functionality. + Finally, only 54 code file examples came back on a Github search, showing this is a not widely used feature. People who + still want Jenkins override support may create a tox plugin to achieve this functionality - by :user:`gaborbernat`. (:issue:`1841`) + + +v4.0.0a5 (2021-01-23) +--------------------- + +Features - 4.0.0a5 +~~~~~~~~~~~~~~~~~~ +- Support the ``system_site_packages``/``sitepackages`` flag for virtual environment based tox environments - + by :user:`gaborbernat`. (:issue:`1847`) +- Support the ``always_copy``/``alwayscopy`` flag for virtual environment based tox environments - + by :user:`gaborbernat`. (:issue:`1848`) +- Support the ``download`` flag for virtual environment based tox environments - by :user:`gaborbernat`. (:issue:`1849`) +- Recreate virtual environment based tox environments when the ``virtualenv`` version changes - by :user:`gaborbernat`. (:issue:`1865`) + +Bugfixes - 4.0.0a5 +~~~~~~~~~~~~~~~~~~ +- Not all package dependencies are installed when different tox environments in the same run use different set of + extras - by :user:`gaborbernat`. (:issue:`1868`) +- Support ``=`` separator in requirement file flags, directories as requirements and correctly set the root of the + requirements file when using the ``--root`` CLI flag to change the root - by :user:`gaborbernat`. (:issue:`1853`) +- Cleanup local subprocess file handlers when exiting runs (fixes ``ResourceWarning: unclosed file`` errors when running + with ``env PYTHONTRACEMALLOC=5 PYTHONDEVMODE=y`` under a Python built with ``--with-pydebug``) + - by :user:`gaborbernat`. (:issue:`1857`) +- Various small bugfixes: + + - honor updating default environment variables set by internal tox via set env (``PIP_DISABLE_PIP_VERSION_CHECK``) + - do not multi-wrap ``HandledError`` in the ini file loader, + - skipped environments are logged now with their fail message at default verbosity level, + - fix an error that made the show configuration command crash when making the string of a config value failed, + - support empty-new lines within the set env configurations replacements, + + by :user:`gaborbernat`. (:pull:`1864`) + +Improved Documentation - 4.0.0a5 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Add CLI documentation - by :user:`gaborbernat`. (:pull:`1852`) + + +v4.0.0a4 (2021-01-16) +--------------------- + +Features - 4.0.0a4 +~~~~~~~~~~~~~~~~~~ +- Use ``.tox/4`` instead of ``.tox4`` folder (so ignores for tox 3 works for tox 4 too), reminder we'll rename this to + just ``.tox`` before public release, however to encourage testing tox 4 in parallel with tox 3 this is helpful + - by :user:`gaborbernat`. (:discussion:`1812`) +- Colorize the ``config`` command: section headers are yellow, keys are green, values remained white, exceptions are light + red and comments are cyan - by :user:`gaborbernat`. (:pull:`1821`) + +Bugfixes - 4.0.0a4 +~~~~~~~~~~~~~~~~~~ +- Support legacy format (``-cconstraint.txt``) of constraint files in ``deps``, and expand constraint files too when + viewing inside the ``deps`` or calculating weather our environment is up to date or not - by :user:`gaborbernat`. (:issue:`1788`) +- When specifying requirements/editable/constraint paths within ``deps`` escape space, unless already escaped to support + running specifying transitive requirements files within deps - by :user:`gaborbernat`. (:issue:`1792`) +- When using a provisioned tox environment requesting ``--recreate`` failed with ``AttributeError`` - + by :user:`gaborbernat`. (:issue:`1793`) +- Fix ``RequirementsFile`` from tox is rendered incorrectly in ``config`` command - by :user:`gaborbernat`. (:issue:`1820`) +- Fix a bug in the configuration system where referring to the same named key in another env/section causes circular + dependency error - by :user:`gaborbernat`. (:pull:`1821`) +- Raise ``ValueError`` with descriptive message when a requirements file specified does not exist + - by :user:`gaborbernat`. (:pull:`1828`) +- Support all valid requirement file specification without delimiting space in the ``deps`` of the ``tox.ini`` - + by :user:`gaborbernat`. (:issue:`1834`) + +Improved Documentation - 4.0.0a4 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Add code style guide for contributors - by :user:`gaborbernat`. (:issue:`1734`) + + +v4.0.0a3 (2021-01-13) +--------------------- + +Features - 4.0.0a3 +~~~~~~~~~~~~~~~~~~ +- Raise exception when set env enters into a circular reference - by :user:`gaborbernat`. (:issue:`1779`) +- - Raise exception when variable substitution enters into a circle. + - Add ``{/}`` as substitution for os specific path separator. + - Add ``{env_bin_dir}`` constant substitution. + - Implement support for ``--discover`` flag - by :user:`gaborbernat`. (:pull:`1784`) + +Bugfixes - 4.0.0a3 +~~~~~~~~~~~~~~~~~~ +- Entries in the ``set_env`` does not reference environments from ``set_env`` - by :user:`gaborbernat`. (:issue:`1776`) +- ``env`` substitution does not uses values from ``set_env`` - by :user:`gaborbernat`. (:issue:`1779`) +- Adopt tox 3 base pass env list, by adding: + + - on all platforms: ``LANG``, ``LANGUAGE``, ``CURL_CA_BUNDLE``, ``SSL_CERT_FILE`` , ``LD_LIBRARY_PATH`` and ``REQUESTS_CA_BUNLDE``, + - on Windows: ``SYSTEMDRIVE`` - by :user:`gaborbernat`. (:issue:`1780`) +- Fixed a bug that crashed tox where calling tox with the recreate flag and when multiple environments were reusing the + same package - by :user:`gaborbernat`. (:issue:`1782`) +- - Python version markers are stripped in package dependencies (after wrongfully being detected as an extra marker). + - In packaging APIs do not set ``PYTHONPATH`` (to empty string) if ``backend-path`` is empty. + - Fix commands parsing on Windows (do not auto-escape ``\`` - instead users should use the new ``{\}``, and on parsed + arguments strip both ``'`` and ``"`` quoted outcomes). + - Allow windows paths in substitution set/default (the ``:`` character used to separate substitution arguments may + also be present in paths on Windows - do not support single capital letter values as substitution arguments) - + by :user:`gaborbernat`. (:pull:`1784`) +- Rework how we handle Python packaging environments: + + - the base packaging environment changed from ``.package`` to ``.pkg``, + - merged the ``sdist``, ``wheel`` and ``dev`` separate packaging implementations into one, and internally dynamically + pick the one that's needed, + - the base packaging environment always uses the same Python environment as tox is installed into, + - the base packaging environment is used to get the metadata of the project (via PEP-517) and to build ``sdist`` and + ``dev`` packages, + - for building wheels introduced a new per env configurable option ``wheel_build_env``, if the target Python major/minor + and implementation for the run tox environment and the base package tox environment matches set this to ``.pkg``, + otherwise this is ``.pkg-{implementation}{major}{minor}``, + - internally now packaging environments can create further packaging environments they are responsible of managing, + - updated ``depends`` to use the packaging logic, + - add support skip missing interpreters for depends and show config, + + by :user:`gaborbernat`. (:issue:`1804`) + + +v4.0.0a2 (2021-01-09) +--------------------- + +Features - 4.0.0a2 +~~~~~~~~~~~~~~~~~~ +- Add option to disable colored output, and support ``NO_COLOR`` and ``FORCE_COLOR`` environment variables - by + :user:`gaborbernat`. (:pull:`1630`) + +Bugfixes - 4.0.0a2 +~~~~~~~~~~~~~~~~~~ +- Fix coverage generation in CI - by :user:`gaborbernat`. (:pull:`1551`) +- Fix the CI failures: + + - drop Python 3.5 support as it's not expected to get to a release before EOL, + - fix test using ``\n`` instead of ``os.linesep``, + - Windows Python 3.6 does not contain ``_overlapped.ReadFileInto`` + + - by :user:`gaborbernat`. (:pull:`1556`) + +Improved Documentation - 4.0.0a2 +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Add base documentation by merging virtualenv structure with tox 3 - by :user:`gaborbernat`. (:pull:`1551`) + + +v4.0.0a1 +-------- +* First version all is brand new. + +.. warning:: + + The current tox is the second iteration of implementation. From version ``0.5`` all the way to ``3.X`` + we numbered the first iteration. Version ``4.0.0a1`` is a complete rewrite of the package, and as such this release + history starts from there. The old changelog is still available in the + `legacy branch documentation `_. diff --git a/docs/changelog/README.rst b/docs/changelog/README.rst deleted file mode 100644 index 1765fc933..000000000 --- a/docs/changelog/README.rst +++ /dev/null @@ -1,17 +0,0 @@ -.. examples for changelog entries adding to your Pull Requests - -file ``544.doc.rst``:: - - explain everything much better - by :user:`passionate_technicalwriter` - -file ``544.feature.rst``:: - - ``tox --version`` now shows information about all registered plugins - by :user:`obestwalter` - -file ``571.bugfix.rst``:: - - ``skip_install`` overrides ``usedevelop`` (``usedevelop`` is an option to choose the - installation type if the package is installed and ``skip_install`` determines if it should be - installed at all) - by :user:`ferdonline` - -.. see pyproject.toml for all available categories (tool.towncrier.type) diff --git a/docs/changelog/template.jinja2 b/docs/changelog/template.jinja2 index c8dcae1fe..bb88fa2c2 100644 --- a/docs/changelog/template.jinja2 +++ b/docs/changelog/template.jinja2 @@ -1,32 +1,36 @@ -{% for section, _ in sections.items() %} -{% set underline = underlines[0] %} -{% if section %} -{{section}} -{{ underline * section|length }} -{% set underline = underlines[1] %} +{% set top_underline = underlines[0] %} +{% if versiondata.name %} +v{{ versiondata.version }} ({{ versiondata.date }}) +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}} +{% else %} +{{ versiondata.version }} ({{ versiondata.date }}) +{{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} {% endif %} +{% for section, _ in sections.items() %} +{% set underline = underlines[1] %} {% if sections[section] %} -{% for category, val in definitions.items() if category in sections[section] %} -{{ definitions[category]['name'] }} -{{ underline * definitions[category]['name']|length }} +{% for category, val in definitions.items() if category in sections[section]%} +{{ definitions[category]['name'] }} - {{ versiondata.version }} +{{ underline * ((definitions[category]['name'] + versiondata.version)|length + 3)}} {% if definitions[category]['showcontent'] %} - {% for text, values in sections[section][category].items() %} -- {{ text }} - {{ values|join(',\n ') }} +- {{ text }} ({{ values|join(', ') }}) {% endfor %} {% else %} - {{ sections[section][category]['']|join(', ') }} -{% endif %} +{% endif %} {% if sections[section][category]|length == 0 %} No significant changes. + +{% else %} {% endif %} {% endfor %} - {% else %} No significant changes. + + {% endif %} {% endfor %} diff --git a/docs/cli_interface.rst b/docs/cli_interface.rst new file mode 100644 index 000000000..f58dd8e7d --- /dev/null +++ b/docs/cli_interface.rst @@ -0,0 +1,5 @@ +.. _cli: + +.. sphinx_argparse_cli:: + :module: tox.config.cli.parse + :func: _get_parser_doc diff --git a/docs/conf.py b/docs/conf.py index cd0d8d265..9ca327cbe 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,141 +1,137 @@ -import os +from __future__ import annotations + import re import subprocess import sys -from datetime import date +from datetime import date, datetime +from importlib.machinery import SourceFileLoader from pathlib import Path - -from docutils import nodes -from sphinx import addnodes -from sphinx.util import logging - -import tox +from subprocess import check_output +from typing import Any + +from docutils.nodes import Element, reference +from sphinx.addnodes import pending_xref +from sphinx.application import Sphinx +from sphinx.builders import Builder +from sphinx.domains.python import PythonDomain +from sphinx.environment import BuildEnvironment +from sphinx.ext.autodoc import Options +from sphinx.ext.extlinks import ExternalLinksChecker + +from tox import __version__ + +company, name = "tox-dev", "tox" +release, version = __version__, ".".join(__version__.split(".")[:2]) +copyright = f"2010-{date.today().year}, {company}" +master_doc, source_suffix = "index", ".rst" + +html_theme = "furo" +html_title, html_last_updated_fmt = "tox", datetime.now().isoformat() +pygments_style, pygments_dark_style = "sphinx", "monokai" +html_static_path, html_css_files = ["_static"], ["custom.css"] +html_logo, html_favicon = "_static/img/tox.svg", "_static/img/toxfavi.ico" extensions = [ "sphinx.ext.autodoc", + "sphinx.ext.autosectionlabel", "sphinx.ext.extlinks", "sphinx.ext.intersphinx", - "sphinx.ext.viewcode", - "sphinxcontrib.autoprogram", + "sphinx_argparse_cli", + "sphinx_autodoc_typehints", + "sphinx_inline_tabs", + "sphinx_copybutton", ] -ROOT_SRC_TREE_DIR = Path(__file__).parents[1] - - -def generate_draft_news(): - home = "https://github.com/" - issue = f"{home}/issue" - fragments_path = ROOT_SRC_TREE_DIR / "docs" / "changelog" - for pattern, replacement in ( - (r"[^`]@([^,\s]+)", rf"`@\1 <{home}/\1>`_"), - (r"[^`]#([\d]+)", rf"`#pr\1 <{issue}/\1>`_"), - ): - for path in fragments_path.glob("*.rst"): - path.write_text(re.sub(pattern, replacement, path.read_text())) - env = os.environ.copy() - env["PATH"] += os.pathsep.join( - [os.path.dirname(sys.executable)] + env["PATH"].split(os.pathsep), - ) - changelog = subprocess.check_output( - ["towncrier", "--draft", "--version", "DRAFT"], - cwd=str(ROOT_SRC_TREE_DIR), - env=env, - ).decode("utf-8") - if "No significant changes" in changelog: - content = "" - else: - note = "*Changes in master, but not released yet are under the draft section*." - content = f"{note}\n\n{changelog}" - (ROOT_SRC_TREE_DIR / "docs" / "_draft.rst").write_text(content) - - -generate_draft_news() - -project = "tox" -_full_version = tox.__version__ -release = _full_version.split("+", 1)[0] -version = ".".join(release.split(".")[:2]) - -author = "holger krekel and others" -year = date.today().year -copyright = f"2010-{year}, {author}" - -master_doc = "index" -source_suffix = ".rst" - -exclude_patterns = ["changelog/*"] - -templates_path = ["_templates"] -pygments_style = "sphinx" - -html_theme = "alabaster" -html_theme_options = { - "logo": "img/tox.png", - "github_user": "tox-dev", - "github_repo": "tox", - "description": "standardise testing in Python", - "github_banner": "true", - "github_type": "star", - "travis_button": "false", - "badge_branch": "master", - "fixed_sidebar": "false", -} -html_sidebars = { - "**": ["about.html", "localtoc.html", "relations.html", "searchbox.html", "donate.html"], -} -html_favicon = "_static/img/toxfavi.ico" -html_show_sourcelink = False -html_static_path = ["_static"] -htmlhelp_basename = f"{project}doc" -latex_documents = [("index", "tox.tex", f"{project} Documentation", author, "manual")] -man_pages = [("index", project, f"{project} Documentation", [author], 1)] -epub_title = project -epub_author = author -epub_publisher = author -epub_copyright = copyright -suppress_warnings = ["epub.unknown_project_files"] # Prevent barking at `.ico` - -intersphinx_mapping = {"https://docs.python.org/": None} - - -def setup(app): - def parse_node(env, text, node): - args = text.split("^") - name = args[0].strip() - - node += addnodes.literal_strong(name, name) - - if len(args) > 2: - default = f"={args[2].strip()}" - node += nodes.literal(text=default) - - if len(args) > 1: - content = f"({args[1].strip()})" - node += addnodes.compact_paragraph(text=content) - - return name # this will be the link - - app.add_object_type( - directivename="conf", - rolename="conf", - objname="configuration value", - indextemplate="pair: %s; configuration value", - parse_node=parse_node, - ) - -tls_cacerts = os.getenv("SSL_CERT_FILE") # we don't care here about the validity of certificates -linkcheck_timeout = 30 -linkcheck_ignore = [r"https://holgerkrekel.net/"] +exclude_patterns = ["_build", "changelog/*", "_draft.rst"] +autoclass_content, autodoc_member_order, autodoc_typehints = "class", "bysource", "none" +autodoc_default_options = { + "member-order": "bysource", + "undoc-members": True, + "show-inheritance": True, +} +autosectionlabel_prefix_document = True extlinks = { - "issue": ("https://github.com/tox-dev/tox/issues/%s", "#"), - "pull": ("https://github.com/tox-dev/tox/pull/%s", "p"), - "user": ("https://github.com/%s", "@"), + "issue": ("https://github.com/tox-dev/tox/issues/%s", "#%s"), + "pull": ("https://github.com/tox-dev/tox/pull/%s", "PR #%s"), + "discussion": ("https://github.com/tox-dev/tox/discussions/%s", "#%s"), + "user": ("https://github.com/%s", "@%s"), + "gh_repo": ("https://github.com/%s", "%s"), + "gh": ("https://github.com/%s", "%s"), + "pypi": ("https://pypi.org/project/%s", "%s"), +} +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "packaging": ("https://packaging.pypa.io/en/latest", None), } - nitpicky = True -nitpick_ignore = [ - ("py:class", "tox.interpreters.InterpreterInfo"), +nitpick_ignore = [] +linkcheck_workers = 10 +linkcheck_ignore = [ + re.escape(i) + for i in ( + r"https://github.com/tox-dev/tox/issues/new?title=Trouble+with+development+environment", + r"https://www.unix.org/version2/sample/abort.html", + ) ] -# workaround for https://github.com/sphinx-doc/sphinx/issues/10112 -logging.getLogger("sphinx.ext.extlinks").setLevel(40) +extlinks_detect_hardcoded_links = True + + +def process_signature( + app: Sphinx, # noqa: U100 + objtype: str, + name: str, # noqa: U100 + obj: Any, # noqa: U100 + options: Options, + args: str, # noqa: U100 + retann: str | None, # noqa: U100 +) -> None | tuple[None, None]: + # skip-member is not checked for class level docs, so disable via signature processing + return (None, None) if objtype == "class" and "__init__" in options.get("exclude-members", set()) else None + + +def setup(app: Sphinx) -> None: + here = Path(__file__).parent + # 1. run towncrier + root, exe = here.parent, Path(sys.executable) + towncrier = exe.with_name(f"towncrier{exe.suffix}") + cmd = [str(towncrier), "build", "--draft", "--version", "NEXT"] + new = check_output(cmd, cwd=root, text=True, stderr=subprocess.DEVNULL) + (root / "docs" / "_draft.rst").write_text("" if "No significant changes" in new else new) + + class PatchedPythonDomain(PythonDomain): + def resolve_xref( + self, + env: BuildEnvironment, + fromdocname: str, + builder: Builder, + type: str, + target: str, + node: pending_xref, + contnode: Element, + ) -> Element: + # fixup some wrongly resolved mappings + mapping = { + "_io.TextIOWrapper": "io.TextIOWrapper", + "tox.config.of_type.T": "typing.TypeVar", # used by Sphinx bases + "tox.config.loader.api.T": "typing.TypeVar", # used by Sphinx bases + "tox.config.loader.convert.T": "typing.TypeVar", # used by Sphinx bases + "tox.tox_env.installer.T": "typing.TypeVar", # used by Sphinx bases + "concurrent.futures._base.Future": "concurrent.futures.Future", + } + if target in mapping: + target = node["reftarget"] = mapping[target] + # node.children[0].children[0] = Text(target, target) + return super().resolve_xref(env, fromdocname, builder, type, target, node, contnode) + + app.connect("autodoc-process-signature", process_signature, priority=400) + app.add_domain(PatchedPythonDomain, override=True) + tox_cfg = SourceFileLoader("tox_conf", str(here / "tox_conf.py")).load_module().ToxConfig + app.add_directive(tox_cfg.name, tox_cfg) + + def check_uri(self, refnode: reference) -> None: + if refnode.document.attributes["source"].endswith("index.rst"): + return # do not use for the index file + return prev_check(self, refnode) + + prev_check, ExternalLinksChecker.check_uri = ExternalLinksChecker.check_uri, check_uri diff --git a/docs/config.rst b/docs/config.rst index d3b28ff72..df8155dfd 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -1,1168 +1,752 @@ -.. be in -*- rst -*- mode! +.. _configuration: -tox configuration specification -=============================== +Configuration ++++++++++++++ -Configuration discovery ------------------------ +tox configuration can be split into two categories: core and environment specific. Core settings are options that can +be set once and used for all tox environments, while environment options are applied to the given tox environment only. -At the moment tox supports three configuration locations prioritized in the following order: +Discovery and file types +------------------------ -1. ``pyproject.toml``, -2. ``tox.ini``, -3. ``setup.cfg``. +Out of box tox supports three configuration locations prioritized in the following order: -As far as the configuration format at the moment we only support standard ConfigParser_ "ini-style" format -(there is a plan to add a pure TOML one soon). -``tox.ini`` and ``setup.cfg`` are such files. Note that ``setup.cfg`` requires the content to be under the -``tox:tox`` and ``testenv`` sections and is otherwise ignored. ``pyproject.toml`` on the other hand is -in TOML format. However, one can inline the *ini-style* format under the ``tool.tox.legacy_tox_ini`` key -as a multi-line string. +1. ``tox.ini``, +2. ``pyproject.toml``, +3. ``setup.cfg``. -Below you find the specification for the *ini-style* format, but you might want to skim some -:doc:`examples` first and use this page as a reference. +As far as the configuration format at the moment we only support a *ini-style*. ``tox.ini`` and ``setup.cfg`` are by +nature such file, while in ``pyprojec.toml`` currently you can only inline the *ini-style* config. -tox global settings -------------------- +Note that ``setup.cfg`` requires the content to be under the ``tox:tox`` and ``testenv`` sections and is otherwise +ignored. ``pyproject.toml`` on the other hand is in TOML format. However, one can inline the *ini-style* format under +the ``tool.tox.legacy_tox_ini`` key as a multi-line string. -Global settings are defined under the ``tox`` section as: +``tox.ini`` +~~~~~~~~~~~ +The core settings are under the ``tox`` section while the environment sections are under the ``testenv:{env_name}`` +section. All tox environments by default inherit setting from the ``testenv`` section. This means if tox needs an option +and is not available under ``testenv:{env_name}`` will first try to use the value from ``testenv``, before falling back +to the default value for that setting. For example: .. code-block:: ini [tox] - minversion = 3.4.0 - -.. conf:: minversion - - Define the minimal tox version required to run; if the host's tox version is less - than this the tool will create an environment and provision it with a version of - tox that satisfies this under :conf:`provision_tox_env`. - - .. versionchanged:: 3.23.0 - - When tox is invoked with the ``--no-provision`` flag, - the provision won't be attempted, tox will fail instead. - -.. conf:: requires ^ LIST of PEP-508 - - .. versionadded:: 3.2.0 - - Specify python packages that need to exist alongside the tox installation for the tox build - to be able to start (must be PEP-508_ compliant). Use this to specify plugin requirements - (or the version of ``virtualenv`` - determines the default ``pip``, ``setuptools``, and ``wheel`` - versions the tox environments start with). If these dependencies are not specified tox will create - :conf:`provision_tox_env` environment so that they are satisfied and delegate all calls to that. - - .. code-block:: ini - - [tox] - requires = tox-pipenv - setuptools >= 30.0.0 - - .. versionchanged:: 3.23.0 - - When tox is invoked with the ``--no-provision`` flag, - the provision won't be attempted, tox will fail instead. - -.. conf:: provision_tox_env ^ string ^ .tox - - .. versionadded:: 3.8.0 - - Name of the virtual environment used to provision a tox having all dependencies specified - inside :conf:`requires` and :conf:`minversion`. - - .. versionchanged:: 3.23.0 - - When tox is invoked with the ``--no-provision`` flag, - the provision won't be attempted, tox will fail instead. - - .. versionchanged:: 3.27.0 - - When provisioning, tox will take a lock to ensure exclusive access to the - `provision_tox_env` and avoid clobbering by other tox instances. - - .. warning:: - - The new virtual environment will only contain dependencies specified by the :conf:`requires` keyword. - Any plugin used by the `tox` executable and not specified in `requires` explicitely won't be used for subsequent tasks. - -.. conf:: toxworkdir ^ PATH ^ {toxinidir}/.tox - - Directory for tox to generate its environments into, will be created if it does not exist. - -.. conf:: temp_dir ^ PATH ^ {toxworkdir}/.tmp - - .. versionadded:: 3.5.0 - - Directory where to put tox temporary files. For example: we create a hard link (if possible, - otherwise new copy) in this directory for the project package. This ensures tox works correctly - when having parallel runs (as each session will have its own copy of the project package - e.g. - the source distribution). - -.. conf:: skipsdist ^ true|false ^ false - - Flag indicating to perform the packaging operation or not. Set it to ``true`` when using tox for - an application, instead of a library. - -.. conf:: setupdir ^ PATH ^ {toxinidir} - - Indicates where the packaging root file exists (historically the ``setup.py`` for ``setuptools``). - This will be the working directory when performing the packaging. - -.. conf:: distdir ^ PATH ^ {toxworkdir}/dist - - Directory where the packaged source distribution should be put. Note this is cleaned at the start of - every packaging invocation. - -.. conf:: sdistsrc ^ PATH ^ {toxworkdir}/dist - - Do not build the package, but instead use the latest package available under this path. - You can override it via the command line flag ``--installpkg``. - -.. conf:: distshare ^ PATH ^ {homedir}/.tox/distshare - - Folder where the packaged source distribution will be moved, this is not cleaned between packaging - invocations. On Jenkins (exists ``JENKINS_URL`` or ``HUDSON_URL`` environment variable) - the default path is ``{toxworkdir}/distshare``. - -.. conf:: envlist ^ comma separated values - - Determining the environment list that ``tox`` is to operate on happens in this order (if any is found, - no further lookups are made): - - * command line option ``-eENVLIST`` - * environment variable ``TOXENV`` - * ``tox.ini`` file's ``envlist`` - - .. versionadded:: 3.4.0 - - Which tox environments are run during the tox invocation can be further filtered - via the operating system environment variable ``TOX_SKIP_ENV`` regular expression - (e.g. ``py27.*`` means **don't** evaluate environments that start with the key ``py27``). - Skipped environments will be logged at level two verbosity level. - -.. conf:: skip_missing_interpreters ^ config|true|false ^ config - - .. versionadded:: 1.7.2 - - Setting this to ``true`` will force ``tox`` to return success even - if some of the specified environments were missing. This is useful for some CI - systems or when running on a developer box, where you might only have a subset of - all your supported interpreters installed but don't want to mark the build as - failed because of it. As expected, the command line switch always overrides - this setting if passed on the invocation. Setting it to ``config`` - means that the value is read from the config file. + min_version = 4.0 + env_list = + py310 + py39 + type -.. conf:: ignore_basepython_conflict ^ true|false ^ false - - .. versionadded:: 3.1.0 - - tox allows setting the python version for an environment via the :conf:`basepython` - setting. If that's not set tox can set a default value from the environment name ( - e.g. ``py37`` implies Python 3.7). Matching up the python version with the environment - name has became expected at this point, leading to surprises when some configs don't - do so. To help with sanity of users a warning will be emitted whenever the environment - name version does not matches up with this expectation. In a future version of tox, - this warning will become an error. - - Furthermore, we allow hard enforcing this rule (and bypassing the warning) by setting - this flag to ``true``. In such cases we ignore the :conf:`basepython` and instead - always use the base python implied from the Python name. This allows you to - configure :conf:`basepython` in the global testenv without affecting environments - that have implied base python versions. - -.. conf:: isolated_build ^ true|false ^ false - - .. versionadded:: 3.3.0 - - Activate isolated build environment. tox will use a virtual environment to build - a source distribution from the source tree. For build tools and arguments use - the ``pyproject.toml`` file as specified in `PEP-517`_ and `PEP-518`_. To specify the - virtual environment Python version define use the :conf:`isolated_build_env` config - section. - -.. conf:: isolated_build_env ^ string ^ .package - - .. versionadded:: 3.3.0 - - Name of the virtual environment used to create a source distribution from the - source tree. + [testenv] + deps = pytest + commands = pytest tests -Jenkins override -++++++++++++++++ + [testenv:type] + deps = mypy + commands = mypy src -It is possible to override global settings inside a Jenkins_ instance (detection -is done by checking for existence of the ``JENKINS_URL`` environment variable) -by using the ``tox:jenkins`` section: +``setup.cfg`` +~~~~~~~~~~~~~ +The core settings are under the ``tox:tox`` section while the environment sections are under the ``testenv:{env_name}`` +section. All tox environments by default inherit setting from the ``testenv`` section. This means if tox needs an option +and is not available under ``testenv:{env_name}`` will first try to use the value from ``testenv``, before falling back +to the default value for that setting. For example: .. code-block:: ini - [tox:jenkins] - commands = ... # override settings for the jenkins context - - -tox environments ----------------- - -Test environments are defined under the ``testenv`` section and individual -``testenv:NAME`` sections, where ``NAME`` is the name of a specific -environment. - -.. code-block:: ini + [tox:tox] + min_version = 4.0 + env_list = + py310 + py39 + type [testenv] - commands = ... - - [testenv:NAME] - commands = ... - -Settings defined in the top-level ``testenv`` section are automatically -inherited by individual environments unless overridden. Test environment names -can consist of alphanumeric characters and dashes; for example: -``py38-django30``. The name will be split on dashes into multiple factors, -meaning ``py38-django30`` will be split into two factors: ``py38`` and -``django30``. *tox* defines a number of default factors, which correspond to -various versions and implementations of Python and provide default values for -:conf:`basepython`: - -- ``pyNM``: configures ``basepython = pythonN.M`` -- ``pyN``: configures ``basepython = pythonN`` -- ``py``: configures ``basepython = python`` -- ``pypyN``: configures ``basepython = pypyN`` -- ``pypy``: configures ``basepython = pypy`` -- ``jythonN``: configures ``basepython = jythonN`` -- ``jython``: configures ``basepython = jython`` - -It is also possible to define what's know as *generative names*, where an -individual section maps to multiple environments. For example, -``py{37,38}-django{30,31}`` would generate four environments, each -consisting of two factors: ``py37-django30`` (``py37``, ``django30``), -``py37-django31`` (``py37``, ``django31``), ``py38-django30`` (``py38``, -``django30``), and ``py38-django31`` (``py38``, ``django31``). Combined, these -features provide the ability to write very concise ``tox.ini`` files. This is -discussed further in :ref:`below `. - - -tox environment settings ------------------------- - -Complete list of settings that you can put into ``testenv*`` sections: - -.. conf:: basepython ^ NAME-OR-PATH - - Name or path to a Python interpreter which will be used for creating the virtual environment, - this determines in practice the python for what we'll create a virtual isolated environment. - Use this to specify the python version for a tox environment. If not specified, the virtual - environments factors (e.g. name part) will be used to automatically set one. For example, ``py37`` - means ``python3.7``, ``py3`` means ``python3`` and ``py`` means ``python``. - :conf:`provision_tox_env` environment does not inherit this setting from the ``toxenv`` section. - - .. versionchanged:: 3.1 - - After resolving this value if the interpreter reports back a different version number - than implied from the name a warning will be printed by default. However, if - :conf:`ignore_basepython_conflict` is set, the value is ignored and we force the - ``basepython`` implied from the factor name. - - -.. conf:: commands ^ ARGVLIST - - The commands to be called for testing. Only execute if :conf:`commands_pre` succeed. - - Each line is interpreted as one command; however a command can be split over - multiple lines by ending the line with the ``\`` character. - - Commands will execute one by one in sequential fashion until one of them fails (their exit - code is non-zero) or all of them succeed. The exit code of a command may be ignored (meaning - they are always considered successful even if they don't exist) by prefixing the command with a dash (``-``) - this is - similar to how ``make`` recipe lines work. The outcome of the environment is considered successful - only if all commands (these + setup + teardown) succeeded (exit code ignored via the - ``-`` or success exit code value of zero). - - :note: the virtual environment binary path (the ``bin`` folder within) is prepended to the os ``PATH``, - meaning commands will first try to resolve to an executable from within the - virtual environment, and only after that outside of it. Therefore ``python`` - translates as the virtual environments ``python`` (having the same runtime version - as the :conf:`basepython`), and ``pip`` translates as the virtual environments ``pip``. - - :note: Inline scripts can be used, however note these are discovered from the project root directory, - and is not influenced by :conf:`changedir` (this only affects the runtime current working directory). - To make this behaviour explicit we recommend that you make inline scripts absolute paths by - prepending ``{toxinidir}``, instead of ``path/to/my_script`` prefer - ``{toxinidir}{/}path{/}to{/}my_script``. If your inline script is platform dependent refer to - :ref:`platform-specification` on how to select different script per platform. - -.. conf:: commands_pre ^ ARGVLIST - - .. versionadded:: 3.4 - - Commands to run before running the :conf:`commands`. - All evaluation and configuration logic applies from :conf:`commands`. - -.. conf:: commands_post ^ ARGVLIST - - .. versionadded:: 3.4 - - Commands to run after running the :conf:`commands`. Execute regardless of the outcome of - both :conf:`commands` and :conf:`commands_pre`. - All evaluation and configuration logic applies from :conf:`commands`. - -.. conf:: install_command ^ ARGV ^ python -m pip install {opts} {packages} - - .. versionadded:: 1.6 - - Determines the command used for installing packages into the virtual environment; - both the package under test and its dependencies (defined with :conf:`deps`). - Must contain the substitution key ``{packages}`` which will be replaced by the package(s) to - install. You should also accept ``{opts}`` if you are using pip -- it will contain index server options - such as ``--pre`` (configured as ``pip_pre``) and potentially index-options from the - deprecated :conf:`indexserver` option. - - .. note:: - - You can also provide a single arbitrary command to the ``install_command``. Please take care that this command can be - executed on the supported operating systems. When executing shell scripts we recommend to not specify the script - directly but instead pass it to the appropriate shell as argument (e.g. prefer ``bash script.sh`` over - ``script.sh``). - -.. conf:: list_dependencies_command ^ ARGV ^ python -m pip freeze - - .. versionadded:: 2.4 - - The ``list_dependencies_command`` setting is used for listing - the packages installed into the virtual environment. - -.. conf:: ignore_errors ^ true|false ^ false - - .. versionadded:: 2.0 - - If ``false``, a non-zero exit code from one command will abort execution of - commands for that environment. - If ``true``, a non-zero exit code from one command will be ignored and - further commands will be executed. The overall status will be - "commands failed", i.e. tox will exit non-zero in case any command failed. - - It may be helpful to note that this setting is analogous to the ``-k`` or - ``--keep-going`` option of GNU Make. - - Note that in tox 2.0, the default behavior of tox with respect to treating - errors from commands changed. tox < 2.0 would ignore errors by default. tox - >= 2.0 will abort on an error by default, which is safer and more typical - of CI and command execution tools, as it doesn't make sense to run tests if - installing some prerequisite failed and it doesn't make sense to try to - deploy if tests failed. - -.. conf:: pip_pre ^ true|false ^ false + deps = pytest + commands = pytest tests - .. versionadded:: 1.9 + [testenv:type] + deps = mypy + commands = mypy src - If ``true``, adds ``--pre`` to the ``opts`` passed to - :conf:`install_command`. If :conf:`install_command` uses pip, this - will cause it to install the latest available pre-release of any - dependencies without a specified version. If ``false``, pip - will only install final releases of unpinned dependencies. +``pyproject.toml`` +~~~~~~~~~~~~~~~~~~ +You can inline a ``tox.ini`` style configuration under the ``tool:tox`` section and ``legacy_tox_ini`` key. - Passing the ``--pre`` command-line option to tox will force this to - ``true`` for all testenvs. - - Don't set this option if your :conf:`install_command` does not use pip. - -.. conf:: allowlist_externals ^ MULTI-LINE-LIST - - .. versionadded:: 3.18 - - Each line specifies a command name (in glob-style pattern format) - which can be used in the ``commands`` section without triggering - a "not installed in virtualenv" warning. Example: if you use the - unix ``make`` for running tests you can list ``allowlist_externals=make`` - or ``allowlist_externals=/usr/bin/make`` if you want more precision. - If you don't want tox to issue a warning in any case, just use - ``allowlist_externals=*`` which will match all commands (not recommended). - - .. note:: - - ``whitelist_externals`` has the same meaning and usage as ``allowlist_externals`` - but it is now deprecated. - -.. conf:: changedir ^ PATH ^ {toxinidir} - - Change the current working directory when executing the test command. - - .. note:: - - If the directory does not exist yet, it will be created. - -.. conf:: deps ^ MULTI-LINE-LIST - - Environment dependencies - installed into the environment (see :conf:`install_command`) prior - to project after environment creation. One dependency (a file, a URL or a package name) per - line. Must be PEP-508_ compliant. All installer commands are executed using the toxinidir_ as the - current working directory. - - .. code-block:: ini - - [testenv] - deps = - pytest - pytest-cov >= 3.5 - pywin32 >=1.0 ; sys_platform == 'win32' - octomachinery==0.0.13 # pyup: < 0.1.0 # disable feature updates - - - .. versionchanged:: 2.3 - - Support for index servers is now deprecated, and its usage discouraged. - - .. versionchanged:: 3.9 - - Comment support on the same line as the dependency. When feeding the content to the install - tool we'll strip off content (including) from the first comment marker (``#``) - preceded by one or more space. For example, if a dependency is - ``octomachinery==0.0.13 # pyup: < 0.1.0 # disable feature updates`` it will be turned into - just ``octomachinery==0.0.13``. - -.. conf:: platform ^ REGEX - - .. versionadded:: 2.0 - - A testenv can define a new ``platform`` setting as a regular expression. - If a non-empty expression is defined and does not match against the - ``sys.platform`` string the entire test environment will be skipped and - none of the commands will be executed. Running ``tox -e `` - will run commands for a particular platform and skip the rest. - -.. conf:: setenv ^ MULTI-LINE-LIST - - .. versionadded:: 0.9 - - Each line contains a NAME=VALUE environment variable setting which - will be used for all test command invocations as well as for installing - the sdist package into a virtual environment. - - Notice that when updating a path variable, you can consider the use of - variable substitution for the current value and to handle path separator. - - .. code-block:: ini - - [testenv] - setenv = - PYTHONPATH = {env:PYTHONPATH}{:}{toxinidir} - - .. versionadded:: 3.20 - - Support for comments. Lines starting with ``#`` are ignored. - - Support for environment files. Lines starting with the ``file|`` contain path to a environment - file to load. Rules within the environment file are the same as within the ``setenv`` - (same replacement and comment support). - -.. conf:: passenv ^ SPACE-SEPARATED-GLOBNAMES - - .. versionadded:: 2.0 - - A list of wildcard environment variable names which - shall be copied from the tox invocation environment to the test - environment when executing test commands. If a specified environment - variable doesn't exist in the tox invocation environment it is ignored. - You can use ``*`` and ``?`` to match multiple environment variables with - one name. The list of environment variable names is not case sensitive, and - all variables that match when upper cased will be passed. For example, passing - ``A`` will pass both ``A`` and ``a``. - - Some variables are always passed through to ensure the basic functionality - of standard library functions or tooling like pip. - This is also not case sensitive on all platforms except Windows: - - * passed through on all platforms: ``CURL_CA_BUNDLE``, ``PATH``, - ``LANG``, ``LANGUAGE``, - ``LD_LIBRARY_PATH``, ``PIP_INDEX_URL``, ``PIP_EXTRA_INDEX_URL``, - ``REQUESTS_CA_BUNDLE``, ``SSL_CERT_FILE``, - ``HTTP_PROXY``, ``HTTPS_PROXY``, ``NO_PROXY`` - * Windows: ``APPDATA``, ``SYSTEMDRIVE``, ``SYSTEMROOT``, ``PATHEXT``, ``TEMP``, ``TMP`` - ``NUMBER_OF_PROCESSORS``, ``USERPROFILE``, ``MSYSTEM``, - ``PROGRAMFILES``, ``PROGRAMFILES(X86)``, ``PROGRAMDATA`` - * Others (e.g. UNIX, macOS): ``TMPDIR`` - - You can override these variables with the ``setenv`` option. - - If defined the ``TOX_TESTENV_PASSENV`` environment variable (in the tox - invocation environment) can define additional space-separated variable - names that are to be passed down to the test command environment. - - .. versionchanged:: 2.7 - - ``PYTHONPATH`` will be passed down if explicitly defined. If - ``PYTHONPATH`` exists in the host environment but is **not** declared - in ``passenv`` a warning will be emitted. - -.. conf:: recreate ^ true|false ^ false - - Always recreate virtual environment if this option is true. - If this option is false, ``tox``'s resolution mechanism will be used to - determine whether to recreate the environment. - -.. conf:: downloadcache ^ PATH - - **IGNORED** -- Since pip-8 has caching by default this option is now - ignored. Please remove it from your configs as a future tox version might - bark on it. - -.. conf:: sitepackages ^ true|false ^ false - - Set to ``true`` if you want to create virtual environments that also - have access to globally installed packages. - - .. warning:: - - In cases where a command line tool is also installed globally you have - to make sure that you use the tool installed in the virtualenv by using - ``python -m `` (if supported by the tool) or - ``{envbindir}/``. - - If you forget to do that you will get a warning like this:: - - WARNING: test command found but not installed in testenv - cmd: /path/to/parent/interpreter/bin/ - env: /foo/bar/.tox/python - Maybe you forgot to specify a dependency? See also the allowlist_externals envconfig setting. - - -.. conf:: alwayscopy ^ true|false ^ false - - Set to ``true`` if you want virtualenv to always copy files rather than - symlinking. - - This is useful for situations where hardlinks don't work (e.g. running in - VMS with Windows guests). - -.. conf:: download ^ true|false ^ false - - .. versionadded:: 3.10 - - Set to ``true`` if you want virtualenv to upgrade pip/wheel/setuptools to - the latest version. If (and only if) you want to choose a specific version - (not necessarily the latest) then you can add e.g. ``VIRTUALENV_PIP=20.3.3`` - to your setenv. - -.. conf:: args_are_paths ^ true|false ^ true - - Treat positional arguments passed to ``tox`` as file system paths - and - if they exist on the filesystem - rewrite them according - to the ``changedir``. Default is true due to the exists-on-filesystem check it's - usually safe to try rewriting. - -.. conf:: envtmpdir ^ PATH ^ {envdir}/tmp - - Defines a temporary directory for the virtualenv which will be cleared - each time before the group of test commands is invoked. - -.. conf:: envlogdir ^ PATH ^ {envdir}/log - - Defines a directory for logging where tox will put logs of tool - invocation. - -.. conf:: indexserver ^ URL - - .. versionadded:: 0.9 - - (DEPRECATED, will be removed in a future version) Use :conf:`setenv` - to configure PIP_INDEX_URL environment variable, see below. - - Multi-line ``name = URL`` definitions of python package servers. - You can specify an alternative index server for dependencies by applying the - ``:indexservername:depname`` pattern. The ``default`` indexserver - definition determines where unscoped dependencies and the sdist install - installs from. Example: +Below you find the specification for the *ini-style* format, but you might want to skim some +examples first and use this page as a reference. - .. code-block:: ini +.. code-block:: toml + [tool.tox] + legacy_tox_ini = """ [tox] - indexserver = - default = https://mypypi.org - - will make tox install all dependencies from this PyPI index server - (including when installing the project sdist package). - - The recommended way to set a custom index server URL is to use :conf:`setenv`: - - .. code-block:: ini + min_version = 4.0 + env_list = + py310 + py39 + type [testenv] - setenv = - PIP_INDEX_URL = {env:PIP_INDEX_URL:https://pypi.org/simple/} - - This will ensure the desired index server gets used for virtual environment - creation and allow overriding the index server URL with an environment variable. + deps = pytest + commands = pytest tests -.. conf:: envdir ^ PATH ^ {toxworkdir}/{envname} + [testenv:type] + deps = mypy + commands = mypy src + """ - .. versionadded:: 1.5 +Core +---- - User can set specific path for environment. If path would not be absolute - it would be treated as relative to ``{toxinidir}``. +.. conf:: + :keys: requires + :default: + :version_added: 3.2.0 -.. conf:: usedevelop ^ true|false ^ false + Specify a list of :pep:`508` compliant dependencies that must be satisfied in the Python environment hosting tox when + running the tox command. If any of these dependencies are not satisfied will automatically create a provisioned tox + environment that does not have this issue, and run the tox command within that environment. See + :ref:`provision_tox_env` for more details. - .. versionadded:: 1.6 + .. code-block:: ini - Install the current package in development mode with "setup.py - develop" instead of installing from the ``sdist`` package. (This - uses pip's ``-e`` option, so should be avoided if you've specified a - custom :conf:`install_command` that does not support ``-e``). Note that - changes to the build/install process (including changes in dependencies) - are only detected when using setuptools with setup.py. + [tox] + requires = + tox>4 + virtualenv>20.2 -.. conf:: skip_install ^ true|false ^ false +.. conf:: + :keys: min_version, minversion + :default: - .. versionadded:: 1.9 + A string to define the minimal tox version required to run. If the host's tox version is less than this, it will + automatically create a provisioned tox environment that satisfies this requirement. See :ref:`provision_tox_env` + for more details. - Do not install the current package. This can be used when you need the - virtualenv management but do not want to install the current package - into that environment. +.. conf:: + :keys: provision_tox_env + :default: .tox + :version_added: 3.8.0 -.. conf:: ignore_outcome ^ true|false ^ false + Name of the tox environment used to provision a valid tox run environment. - .. versionadded:: 2.2 + .. versionchanged:: 3.23.0 - If set to true a failing result of this testenv will not make tox fail, - only a warning will be produced. + When tox is invoked with the ``--no-provision`` flag, the provision won't be attempted, tox will fail instead. -.. conf:: extras ^ MULTI-LINE-LIST +.. conf:: + :keys: env_list, envlist + :default: - .. versionadded:: 2.4 + A list of environments to run by default (when the user does not specify anything during the invocation). - A list of "extras" to be installed with the sdist or develop install. - For example, ``extras = testing`` is equivalent to ``[testing]`` in a - ``pip install`` command. These are not installed if ``skip_install`` is - ``true``. + .. versionchanged:: 3.4.0 -.. conf:: description ^ SINGLE-LINE-TEXT ^ no description + Which tox environments are run during the tox invocation can be further filtered via the operating system + environment variable ``TOX_SKIP_ENV`` regular expression (e.g. ``py27.*`` means **don't** evaluate environments + that start with the key ``py27``). Skipped environments will be logged at level two verbosity level. - A short description of the environment, this will be used to explain - the environment to the user upon listing environments for the command - line with any level of verbosity higher than zero. +.. conf:: + :keys: skip_missing_interpreters + :default: config + :version_added: 1.7.2 -.. conf:: parallel_show_output ^ bool ^ false + Setting this to ``true`` will force ``tox`` to return success even if some of the specified environments were + missing. This is useful for some CI systems or when running on a developer box, where you might only have a subset + of all your supported interpreters installed but don't want to mark the build as failed because of it. As expected, + the command line switch always overrides this setting if passed on the invocation. Setting it to ``config`` means + that the value is read from the config file. - .. versionadded:: 3.7.0 +.. conf:: + :keys: tox_root, toxinidir - If set to True the content of the output will always be shown when running in parallel mode. + The root directory for the tox project (where the configuration file is found). -.. conf:: depends ^ comma separated values +.. conf:: + :keys: work_dir, toxworkdir + :default: {tox_root}/.tox - .. versionadded:: 3.7.0 + Directory for tox to generate its environments into, will be created if it does not exist. - tox environments this depends on. tox will try to run all dependent environments before running this - environment. Format is same as :conf:`envlist` (allows factor usage). +.. conf:: + :keys: temp_dir + :default: {tox_root}/.temp - .. warning:: + Directory where to put tox temporary files. For example: we create a hard link (if possible, otherwise new copy) in + this directory for the project package. This ensures tox works correctly when having parallel runs (as each session + will have its own copy of the project package - e.g. the source distribution). - ``depends`` does not pull in dependencies into the run target, for example if you select ``py27,py36,coverage`` - via the ``-e`` tox will only run those three (even if ``coverage`` may specify as ``depends`` other targets too - - such as ``py27, py35, py36, py37``). +.. conf:: + :keys: no_package, skipsdist + :default: false -.. conf:: suicide_timeout ^ float ^ 0.0 + Flag indicating to perform the packaging operation or not. Set it to ``true`` when using tox for an application, + instead of a library. - .. versionadded:: 3.15.2 +.. conf:: + :keys: package_env, isolated_build_env + :default: .pkg + :version_added: 3.3.0 - When an interrupt is sent via Ctrl+C or the tox process is killed with a SIGTERM, - a SIGINT is sent to all foreground processes. The :conf:`suicide_timeout` gives - the running process time to cleanup and exit before receiving (in some cases, a duplicate) SIGINT from - tox. + Default name of the virtual environment used to create a source distribution from the source tree. -.. conf:: interrupt_timeout ^ float ^ 0.3 +.. conf:: + :keys: package_root, setupdir + :default: {tox_root} - .. versionadded:: 3.15.0 + Indicates where the packaging root file exists (historically setup.py file or pyproject.toml now). - When tox is interrupted, it propagates the signal to the child process - after :conf:`suicide_timeout` seconds. If the process still hasn't exited - after :conf:`interrupt_timeout` seconds, its sends a SIGTERM. +.. conf:: + :keys: labels + :default: -.. conf:: terminate_timeout ^ float ^ 0.2 + A mapping of label names to environments it applies too. For example: - .. versionadded:: 3.15.0 + .. code-block:: ini - When tox is interrupted, after waiting :conf:`interrupt_timeout` seconds, - it propagates the signal to the child process, waits - :conf:`interrupt_timeout` seconds, sends it a SIGTERM, waits - :conf:`terminate_timeout` seconds, and sends it a SIGKILL if it hasn't - exited. + [tox] + labels = + test = py310, py39 + static = flake8, mypy -Substitutions -------------- +Python language core options +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Any ``key=value`` setting in an ini-file can make use -of value substitution through the ``{...}`` string-substitution pattern. +.. conf:: + :keys: ignore_base_python_conflict, ignore_basepython_conflict + :default: True -You can escape curly braces with the ``\`` character if you need them, for example:: + .. versionadded:: 3.1.0 - commands = echo "\{posargs\}" = {posargs} + tox allows setting the Python version for an environment via the :ref:`basepython` setting. If that's not set tox + can set a default value from the environment name (e.g. ``py310`` implies Python 3.10). Matching up the Python + version with the environment name has became expected at this point, leading to surprises when some configs don't + do so. To help with sanity of users a error will be raised whenever the environment name version does not matches + up with this expectation. + Furthermore, we allow hard enforcing this rule by setting this flag to ``true``. In such cases we ignore the + :ref:`base_python` and instead always use the base Python implied from the Python name. This allows you to configure + :ref:`base_python` in the :ref:`base` section without affecting environments that have implied base Python versions. -Note some substitutions (e.g. ``posargs``, ``env``) may have addition values attached to it, -via the ``:`` character (e.g. ``posargs`` - default value, ``env`` - key). -Such substitutions cannot have a space after the ``:`` character -(e.g. ``{posargs: magic}`` while being at the start of a line -inside the ini configuration (this would be parsed as factorial ``{posargs``, -having value magic). -Globally available substitutions -++++++++++++++++++++++++++++++++ -.. _`toxinidir`: +tox environment +--------------- -``{toxinidir}`` - the directory where ``tox.ini`` is located +Base options +~~~~~~~~~~~~ -.. _`toxworkdir`: +.. conf:: + :keys: envname, env_name + :constant: -``{toxworkdir}`` - the directory where virtual environments are created and sub directories - for packaging reside. + The name of the tox environment. -``{temp_dir}`` - the directory where tox temporary files live. +.. conf:: + :keys: env_dir, envdir + :default: {work_dir}/{env_name} + :version_added: 1.5 - .. versionadded:: 3.16.1 + Directory assigned to the tox environment. If not absolute it would be treated as relative to :ref:`tox_root`. -``{homedir}`` - the user-home directory path. +.. conf:: + :keys: env_tmp_dir, envtmpdir + :default: {work_dir}/{env_name}/tmp -``{distdir}`` - the directory where sdist-packages will be created in + A folder that is always reset at the start of the run. -``{distshare}`` - (DEPRECATED) the directory where sdist-packages will be copied to so that - they may be accessed by other processes or tox runs. +.. conf:: + :keys: env_log_dir, envlogdir + :default: {work_dir}/{env_name}/log -``{:}`` - OS-specific path separator (``:`` on \*nix family, ``;`` on Windows). May be used in ``setenv``, - when target variable is path variable (e.g. PATH or PYTHONPATH). + A folder containing log files about tox runs. It's always reset at the start of the run. Currently contains every + process invocation in the format of ``-.log``, and details the execution request (command, + environment variables, current working directory, etc.) and its outcome (exit code and standard output/error + content). -``{/}`` - OS-specific directory separator (``/`` on \*nix family, ``\\`` on Windows). - Useful for deriving filenames from preset paths, as arguments for commands - that requires ``\\`` on Windows. e.g. ``{distdir}{/}file.txt``. - It is not usually needed when using commands written in Python. +.. conf:: + :keys: platform -Substitutions for virtualenv-related sections -+++++++++++++++++++++++++++++++++++++++++++++ + Run on platforms that match this regular expression (empty means any platform). If a non-empty expression is defined + and does not match against the ``sys.platform`` string the entire test environment will be skipped and none of the + commands will be executed. Running ``tox -e `` will run commands for a particular platform and skip + the rest. -``{envname}`` - the name of the virtual environment -``{envpython}`` - path to the virtual Python interpreter -``{envdir}`` - directory of the virtualenv hierarchy -``{envbindir}`` - directory where executables are located -``{envsitepackagesdir}`` - directory where packages are installed. - Note that architecture-specific files may appear in a different directory. -``{envtmpdir}`` - the environment temporary directory -``{envlogdir}`` - the environment log directory +.. conf:: + :keys: pass_env, passenv + :default: + Environment variables to pass on to the tox environment. The values are evaluated as UNIX shell-style wildcards, see + `fnmatch `_ If a specified environment variable doesn't exist in the + tox invocation environment it is ignored. The list of environment variable names is not case sensitive, for example: + passing ``A`` or ``a`` will pass through both ``A`` and ``a``. -Environment variable substitutions -++++++++++++++++++++++++++++++++++ +.. conf:: + :keys: set_env, setenv -If you specify a substitution string like this:: + A dictionary of environment variables to set when running commands in the tox environment. Lines starting with a + ``file|`` prefix define the location of environment file. - {env:KEY} + .. note:: -then the value will be retrieved as ``os.environ['KEY']`` -and raise an Error if the environment variable -does not exist. + Environment files are processed using the following rules: + - blank lines are ignored, + - lines starting with the ``#`` character are ignored, + - each line is in KEY=VALUE format; both the key and the value are stripped, + - there is no special handling of quotation marks, they are part of the key or value. -Environment variable substitutions with default values -++++++++++++++++++++++++++++++++++++++++++++++++++++++ +.. conf:: + :keys: parallel_show_output + :default: False + :version_added: 3.7 -If you specify a substitution string like this:: + If set to ``True`` the content of the output will always be shown when running in parallel mode. - {env:KEY:DEFAULTVALUE} +.. conf:: + :keys: recreate + :default: False -then the value will be retrieved as ``os.environ['KEY']`` -and replace with DEFAULTVALUE if the environment variable does not -exist. + Always recreate virtual environment if this option is true, otherwise leave it up to tox. -If you specify a substitution string like this:: +.. conf:: + :keys: allowlist_externals + :default: - {env:KEY:} + Each line specifies a command name (in glob-style pattern format) which can be used in the commands section even if + it's located outside of the tox environment. For example: if you use the unix *rm* command for running tests you can + list ``allowlist_externals=rm`` or ``allowlist_externals=/usr/bin/rm``. If you want to allow all external + commands you can use ``allowlist_externals=*`` which will match all commands (not recommended). -then the value will be retrieved as ``os.environ['KEY']`` -and replace with an empty string if the environment variable does not -exist. +.. conf:: + :keys: labels + :default: + :ref_suffix: env -Substitutions can also be nested. In that case they are expanded starting -from the innermost expression:: + A list of labels to apply for this environment. For example: - {env:KEY:{env:DEFAULT_OF_KEY}} + .. code-block:: ini -the above example is roughly equivalent to -``os.environ.get('KEY', os.environ['DEFAULT_OF_KEY'])`` + [testenv] + labels = test, core + [testenv:flake8] + labels = mypy -.. _`command positional substitution`: -.. _`positional substitution`: +Execute +~~~~~~~ -Interactive shell substitution -++++++++++++++++++++++++++++++ +.. conf:: + :keys: suicide_timeout + :default: 0.0 + :version_added: 3.15.2 -.. versionadded:: 3.4.0 + When an interrupt is sent via Ctrl+C or the tox process is killed with a SIGTERM, a SIGINT is sent to all foreground + processes. The :ref:`suicide_timeout` gives the running process time to cleanup and exit before receiving (in some + cases, a duplicate) SIGINT from tox. -It's possible to inject a config value only when tox is running in interactive shell (standard input):: +.. conf:: + :keys: interrupt_timeout + :default: 0.3 + :version_added: 3.15 - {tty:ON_VALUE:OFF_VALUE} + When tox is interrupted, it propagates the signal to the child process after :ref:`suicide_timeout` seconds. If the + process still hasn't exited after :ref:`interrupt_timeout` seconds, its sends a SIGTERM. -The first value is the value to inject when the interactive terminal is available, -the second value is the value to use when it's not. The later on is optional. A good use case -for this is e.g. passing in the ``--pdb`` flag for pytest. +.. conf:: + :keys: terminate_timeout + :default: 0.2 + :version_added: 3.15 -Substitutions for positional arguments in commands -++++++++++++++++++++++++++++++++++++++++++++++++++ + When tox is interrupted, after waiting :ref:`interrupt_timeout` seconds, it propagates the signal to the child + process, waits :ref:`interrupt_timeout` seconds, sends it a SIGTERM, waits :ref:`terminate_timeout` seconds, and + sends it a SIGKILL if it hasn't exited. -.. versionadded:: 1.0 +Run +~~~ -If you specify a substitution string like this:: +.. conf:: + :keys: base + :default: testenv + :version_added: 4.0.0 - {posargs:DEFAULTS} + Inherit missing keys from these sections. -then the value will be replaced with positional arguments as provided -to the tox command:: +.. conf:: + :keys: runner + :default: + :version_added: 4.0.0 - tox arg1 arg2 + The tox execute used to evaluate this environment. Defaults to Python virtual environments, however may be + overwritten by plugins. -In this instance, the positional argument portion will be replaced with -``arg1 arg2``. If no positional arguments were specified, the value of -DEFAULTS will be used instead. If DEFAULTS contains other substitution -strings, such as ``{env:*}``, they will be interpreted., +.. conf:: + :keys: description + :default: -Use a double ``--`` if you also want to pass options to an underlying -test command, for example:: + A short description of the environment, this will be used to explain the environment to the user upon listing + environments. - tox -- --opt1 ARG1 +.. conf:: + :keys: depends + :default: -will make the ``--opt1 ARG1`` appear in all test commands where ``[]`` or -``{posargs}`` was specified. By default (see ``args_are_paths`` -setting), ``tox`` rewrites each positional argument if it is a relative -path and exists on the filesystem to become a path relative to the -``changedir`` setting. + tox environments that this environment depends on (must be run after those). -Previous versions of tox supported the ``[.*]`` pattern to denote -positional arguments with defaults. This format has been deprecated. -Use ``{posargs:DEFAULTS}`` to specify those. + .. warning:: + ``depends`` does not pull in dependencies into the run target, for example if you select ``py310,py39,coverage`` + via the ``-e`` tox will only run those three (even if ``coverage`` may specify as ``depends`` other targets too - + such as ``py310, py39, py38``). This is solely meant to specify dependencies and order in between a target run + set. -Substitution for values from other sections -+++++++++++++++++++++++++++++++++++++++++++ +.. conf:: + :keys: commands_pre + :default: + :version_added: 3.4 -.. versionadded:: 1.4 + Commands to run before running the :ref:`commands`. All evaluation and configuration logic applies from + :ref:`commands`. -Values from other sections can be referred to via:: +.. conf:: + :keys: commands + :default: - {[sectionname]valuename} + The commands to be called for testing. Only execute if :ref:`commands_pre` succeed. Each line is interpreted as one + command; however a command can be split over multiple lines by ending the line with the ``\`` character. -which you can use to avoid repetition of config values. -You can put default values in one section and reference them in others to avoid repeating the same values: + Commands will execute one by one in sequential fashion until one of them fails (their exit code is non-zero) or all + of them succeed. The exit code of a command may be ignored (meaning they are always considered successful) by + prefixing the command with a dash (``-``) - this is similar to how ``make`` recipe lines work. The outcome of the + environment is considered successful only if all commands (these + setup + teardown) succeeded (exit code ignored + via the ``-`` or success exit code value of zero). -.. code-block:: ini + .. note:: - [base] - deps = - pytest - mock - pytest-xdist + The virtual environment binary path (see :ref:`env_bin_dir`) is prepended to the ``PATH`` environment variable, + meaning commands will first try to resolve to an executable from within the virtual environment, and only after + that outside of it. Therefore ``python`` translates as the virtual environments ``python`` (having the same + runtime version as the :ref:`base_python`), and ``pip`` translates as the virtual environments ``pip``. - [testenv:dulwich] - deps = - dulwich - {[base]deps} + .. note:: - [testenv:mercurial] - deps = - mercurial - {[base]deps} + Inline scripts can be used, however note these are discovered from the project root directory, and is not + influenced by :ref:`change_dir` (this only affects the runtime current working directory). To make this behaviour + explicit we recommend that you make inline scripts absolute paths by prepending ``{tox_root}``, instead of + ``path/to/my_script`` prefer ``{tox_root}{/}path{/}to{/}my_script``. If your inline script is platform dependent + refer to :ref:`platform-specification` on how to select different script per platform. +.. conf:: + :keys: commands_post + :default: -.. _generating-environments: + Commands to run after running the :ref:`commands`. Execute regardless of the outcome of both :ref:`commands` and + :ref:`commands_pre`. All evaluation and configuration logic applies from :ref:`commands`. -Generating environments, conditional settings ---------------------------------------------- +.. conf:: + :keys: change_dir, changedir + :default: {tox root} -.. versionadded:: 1.8 + Change to this working directory when executing the test command. If the directory does not exist yet, it will be + created (required for Windows to be able to execute any command). -Suppose you want to test your package against python2.7, python3.6 and against -several versions of a dependency, say Django 1.5 and Django 1.6. You can -accomplish that by writing down 2*2 = 4 ``[testenv:*]`` sections and then -listing all of them in ``envlist``. +.. conf:: + :keys: args_are_paths + :default: False -However, a better approach looks like this: + Treat positional arguments passed to tox as file system paths and - if they exist on the filesystem and are in + relative format - rewrite them according to the current and :ref:`change_dir` working directory. This handles + automatically transforming relative paths specified on the CLI to relative paths respective of the commands executing + directory. -.. code-block:: ini +.. conf:: + :keys: ignore_errors + :default: False - [tox] - envlist = {py27,py36}-django{15,16} + When executing the commands keep going even if a sub-command exits with non-zero exit code. The overall status will + be "commands failed", i.e. tox will exit non-zero in case any command failed. It may be helpful to note that this + setting is analogous to the ``-k`` or ``--keep-going`` option of GNU Make. - [testenv] - deps = - pytest - django15: Django>=1.5,<1.6 - django16: Django>=1.6,<1.7 - py36: unittest2 - commands = pytest +.. conf:: + :keys: ignore_outcome + :default: False -This uses two new facilities of tox-1.8: + If set to true a failing result of this test environment will not make tox fail (instead just warn). -- generative envlist declarations where each envname - consists of environment parts or "factors" +.. conf:: + :keys: skip_install + :default: False + :version_added: 1.9 -- "factor" specific settings + Skip installation of the package. This can be used when you need the virtualenv management but do not want to + install the current package into that environment. -Let's go through this step by step. +.. conf:: + :keys: package_env + :default: {package_env} + :version_added: 4.0.0 + :ref_suffix: env + Name of the virtual environment used to create a source distribution from the source tree for this environment. -.. _generative-envlist: +.. conf:: + :keys: package_tox_env_type + :version_added: 4.0.0 + :default: virtualenv-pep-517 -Generative envlist -++++++++++++++++++ + tox package type used to package. -:: +Packaging +~~~~~~~~~ +.. conf:: + :keys: package_root, setupdir + :default: {package_root} + :ref_suffix: env - envlist = {py36,py27}-django{15,16} + Indicates where the packaging root file exists (historically setup.py file or pyproject.toml now). -This is bash-style syntax and will create ``2*2=4`` environment names -like this:: +.. _python-options: - py27-django15 - py27-django16 - py36-django15 - py36-django16 +Python options +~~~~~~~~~~~~~~ +.. conf:: + :keys: base_python, basepython + :default: {package_root} -You can still list environments explicitly along with generated ones:: + Name or path to a Python interpreter which will be used for creating the virtual environment, first one found wins. + This determines in practice the Python for what we'll create a virtual isolated environment. Use this to specify the + Python version for a tox environment. If not specified, the virtual environments factors (e.g. name part) will be + used to automatically set one. For example, ``py310`` means ``python3.10``, ``py3`` means ``python3`` and ``py`` + means ``python``. If the name does not match this pattern the same Python version tox is installed into will be used. - envlist = {py27,py36}-django{15,16}, docs, flake + .. versionchanged:: 3.1 -Keep in mind that whitespace characters (except newline) within ``{}`` -are stripped, so the following line defines the same environment names:: + After resolving this value if the interpreter reports back a different version number than implied from the name + a warning will be printed by default. However, if :ref:`ignore_basepython_conflict` is set, the value is + ignored and we force the :ref:`base_python` implied from the factor name. - envlist = {py27,py36}-django{ 15, 16 }, docs, flake + .. note:: -.. note:: + Leaving this unset will cause an error if the package under test has a different Python requires than tox itself + and tox is installed into a Python that's not supported by the package. For example, if your package requires + Python 3.10 or later, and you install tox in Python 3.9, when you run a tox environment that has left this + unspecified tox will use Python 3.9 to build and install your package which will fail given it requires 3.10. - To help with understanding how the variants will produce section values, - you can ask tox to show their expansion with a new option:: +.. conf:: + :keys: env_site_packages_dir, envsitepackagesdir + :constant: - $ tox -l - py27-django15 - py27-django16 - py36-django15 - py36-django16 - docs - flake + The Python environments site package - where packages are installed (the purelib folder path). +.. conf:: + :keys: env_bin_dir, envbindir + :constant: -.. _generative-sections: + The binary folder where console/gui scripts are generated during installation. -Generative section names -++++++++++++++++++++++++ +.. conf:: + :keys: env_python, envpython + :constant: -.. versionadded:: 3.15 + The Python executable from within the tox environment. -Using similar syntax, it is possible to generate sections:: +Python run +~~~~~~~~~~ +.. conf:: + :keys: deps + :default: - [testenv:py{27,36}-flake] + Name of the Python dependencies. Installed into the environment prior to project after environment creation, but + before package installation. All installer commands are executed using the :ref:`tox_root` as the current working + directory. Each value must be one of: -This is equivalent to defining distinct sections:: + - a Python dependency as specified by :pep:`440`, + - a `requirement file `_ when the value starts with + ``-r`` (followed by a file path), + - a `constraint file `_ when the value starts with + ``-c`` (followed by a file path). - $ tox -a - py27-flake - py36-flake + For example: -It is useful when you need an environment different from the default one, -but still want to take advantage of factor-conditional settings. + .. code-block:: ini + [testenv] + deps = + pytest>=7,<8 + -r requirements.txt + -c constraints.txt -.. _factors: +.. conf:: + :keys: use_develop, usedevelop + :default: false + :version_added: 1.6 -Factors and factor-conditional settings -++++++++++++++++++++++++++++++++++++++++ + Install the current package in development mode with develop mode. For pip this uses ``-e`` option, so should be + avoided if you've specified a custom :ref:`install_command` that does not support ``-e``. -As discussed previously, parts of an environment name delimited by hyphens are -called factors and can be used to set values conditionally. In list settings -such as ``deps`` or ``commands`` you can freely intermix optional lines with -unconditional ones: +.. conf:: + :keys: package + :version_added: 4.0 -.. code-block:: ini + When option can be one of ``skip``, ``dev-legacy``, ``sdist``, ``wheel`` or ``external``. If :ref:`use_develop` is + set this becomes a constant of ``dev-legacy``. If :ref:`skip_install` is set this becomes a constant of ``skip``. - [testenv] - deps = - pytest - django15: Django>=1.5,<1.6 - django16: Django>=1.6,<1.7 - py36: unittest2 -Reading it line by line: +.. conf:: + :keys: wheel_build_env + :version_added: 4.0 + :default: - -- ``pytest`` will be included unconditionally, -- ``Django>=1.5,<1.6`` will be included for environments containing - ``django15`` factor, -- ``Django>=1.6,<1.7`` similarly depends on ``django16`` factor, -- ``unittest2`` will be loaded for Python 3.6 environments. + If :ref:`wheel_build_env` is set to ``wheel`` this will be the tox Python environment in which the wheel will be + built. The value is generated to be unique per Python flavor and version, and prefixed with :ref:`package_env` value. + This is to ensure the target interpreter and the generated wheel will be compatible. If you have a wheel that can be + reused across multiple Python versions set this value to the same across them (to avoid building a new wheel for + each one of them). -tox provides a number of default factors corresponding to Python interpreter -versions. The conditional setting above will lead to either ``python3.6`` or -``python2.7`` used as base python, e.g. ``python3.6`` is selected if current -environment contains ``py36`` factor. +.. conf:: + :keys: extras + :version_added: 2.4 + :default: -.. note:: + A list of "extras" from the package to be installed. For example, ``extras = testing`` is equivalent to ``[testing]`` + in a ``pip install`` command. - Configuring :conf:`basepython` for environments using default factors - will result in a warning. Configure :conf:`ignore_basepython_conflict` - if you wish to explicitly ignore these conflicts, allowing you to define a - global :conf:`basepython` for all environments *except* those with - default factors. +.. _external-package-builder: -Complex factor conditions -+++++++++++++++++++++++++ +External package builder +~~~~~~~~~~~~~~~~~~~~~~~~ -Sometimes you need to specify the same line for several factors or create a -special case for a combination of factors. Here is how you do it: +tox supports operating with externally built packages. External packages might be provided in two wayas: -.. code-block:: ini +- explicitly via the :ref:`--installpkg ` CLI argument, +- setting the :ref:`package` to ``external`` and using a tox packaging environment named ``_external`` + (see :ref:`package_env`) to build the package. The tox packaging environment takes all configuration flags of a + :ref:`python environment `, plus the following: - [tox] - envlist = py{27,34,36}-django{15,16}-{sqlite,mysql} +.. conf:: + :keys: deps + :default: + :ref_suffix: external - [testenv] - deps = - py34-mysql: PyMySQL # use if both py34 and mysql are in the env name - py27,py36: urllib3 # use if either py36 or py27 are in the env name - py{27,36}-sqlite: mock # mocking sqlite in python 2.x & 3.6 - !py34-sqlite: mock # mocking sqlite, except in python 3.4 - sqlite-!py34: mock # (same as the line above) - !py34-!py36: enum34 # use if neither py34 nor py36 are in the env name + Name of the Python dependencies as specified by :pep:`440`. Installed into the environment prior running the build + commands. All installer commands are executed using the :ref:`tox_root` as the current working directory. -Take a look at the first ``deps`` line. It shows how you can special case -something for a combination of factors, by just hyphenating the combining -factors together. This particular line states that ``PyMySQL`` will be loaded -for python 3.4, mysql environments, e.g. ``py34-django15-mysql`` and -``py34-django16-mysql``. +.. conf:: + :keys: commands + :default: + :ref_suffix: external -The second line shows how you use the same setting for several factors - by -listing them delimited by commas. It's possible to list not only simple factors, -but also their combinations like ``py27-sqlite,py36-sqlite``. + Commands to run that will build the package. If any command fails the packaging operation is considered failed and + will fail all environments using that package. -The remaining lines all have the same effect and use conditions equivalent to -``py27-sqlite,py36-sqlite``. They have all been added only to help demonstrate -the following: +.. conf:: + :keys: ignore_errors + :default: False + :ref_suffix: external -- how factor expressions get expanded the same way as in envlist -- how to use negated factor conditions by prefixing negated factors with ``!`` -- that the order in which factors are hyphenated together does not matter + When executing the commands keep going even if a sub-command exits with non-zero exit code. The overall status will + be "commands failed", i.e. tox will exit non-zero in case any command failed. It may be helpful to note that this + setting is analogous to the ``-k`` or ``--keep-going`` option of GNU Make. -.. note:: +.. conf:: + :keys: change_dir, changedir + :default: {tox root} + :ref_suffix: external - Factors don't do substring matching against env name, instead every - hyphenated expression is split by ``-`` and if ALL of its non-negated - factors and NONE of its negated ones are also factors of an env then that - condition is considered to hold for that env. + Change to this working directory when executing the package build command. If the directory does not exist yet, it + will be created (required for Windows to be able to execute any command). - For example, environment ``py36-mysql-!dev``: +.. conf:: + :keys: package_glob + :default: {envtmpdir}{/}dist{/}* - - would be matched by expressions ``py36``, ``py36-mysql`` or - ``mysql-py36``, - - but not ``py2``, ``py36-sql`` or ``py36-mysql-dev``. + A glob that should match the wheel/sdist file to install. If no file or multiple files is matched the packaging + operation is considered failed and will raise an error. -Factors and values substitution are compatible -++++++++++++++++++++++++++++++++++++++++++++++ -It is possible to mix both values substitution and factor expressions. -For example:: +Python virtual environment +~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. conf:: + :keys: system_site_packages, sitepackages + :default: False - [tox] - envlist = py27,py36,coverage + Create virtual environments that also have access to globally installed packages. Note the default value may be + overwritten by the ``VIRTUALENV_SYSTEM_SITE_PACKAGES`` environment variable. - [testenv] - deps = - flake8 - coverage: coverage + .. warning:: - [testenv:py27] - deps = - {[testenv]deps} - pytest + In cases where a command line tool is also installed globally you have to make sure that you use the tool installed + in the virtualenv by using ``python -m `` (if supported by the tool) or + ``{env_bin_dir}/``. If you forget to do that you will get an error. -With the previous configuration, it will install: +.. conf:: + :keys: always_copy, alwayscopy + :default: False -- ``flake8`` and ``pytest`` packages for ``py27`` environment. -- ``flake8`` package for ``py36`` environment. -- ``flake8`` and ``coverage`` packages for ``coverage`` environment. + Force virtualenv to always copy rather than symlink. Note the default value may be overwritten by the + ``VIRTUALENV_COPIES`` or ``VIRTUALENV_ALWAYS_COPY`` (in that order) environment variables. This is useful for + situations where hardlinks don't work (e.g. running in VMS with Windows guests). -Advanced settings ------------------ +.. conf:: + :keys: download + :version_added: 3.10 + :default: False -.. _`long interpreter directives`: + True if you want virtualenv to upgrade pip/wheel/setuptools to the latest version. Note the default value may be + overwritten by the ``VIRTUALENV_DOWNLOAD`` environment variable. If (and only if) you want to choose a specific + version (not necessarily the latest) then you can add ``VIRTUALENV_PIP=20.3.3`` (and similar) to your :ref:`set_env`. -Handle interpreter directives with long lengths -+++++++++++++++++++++++++++++++++++++++++++++++ -For systems supporting executable text files (scripts with a shebang), the -system will attempt to parse the interpreter directive to determine the program -to execute on the target text file. When ``tox`` prepares a virtual environment -in a file container which has a large length (e.g. using Jenkins Pipelines), the -system might not be able to invoke shebang scripts which define interpreters -beyond system limits (e.g. Linux has a limit of 128; ``BINPRM_BUF_SIZE``). To -workaround an environment which suffers from an interpreter directive limit, a -user can bypass the system's interpreter parser by defining the -``TOX_LIMITED_SHEBANG`` environment variable before invoking ``tox``:: +Python virtual environment packaging +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. conf:: + :keys: meta_dir + :version_added: 4.0.0 + :default: {env_dir}/.meta - export TOX_LIMITED_SHEBANG=1 + Directory where to put the project metadata files. -When the workaround is enabled, all tox-invoked text file executables will have -their interpreter directive parsed by and explicitly executed by ``tox``. +.. conf:: + :keys: pkg_dir + :version_added: 4.0.0 + :default: {env_dir}/.dist -Environment variables ---------------------- -tox will treat the following environment variables: + Directory where to put project packages. -- ``TOX_DISCOVER`` for python discovery first try the python executables under these paths -- ``TOXENV`` see :conf:`envlist`. -- ``TOX_LIMITED_SHEBANG`` see :ref:`long interpreter directives`. -- ``TOX_PARALLEL_NO_SPINNER`` see :ref:`parallel_mode`. -- ``_TOX_PARALLEL_ENV`` lets tox know that it is invoked in the parallel mode. -- ``TOX_PROVISION`` is only intended to be used internally. -- ``TOX_REPORTER_TIMESTAMP`` enables showing for each output line its delta since the tox startup when set to ``1``. -- ``TOX_SKIP_ENV`` see :conf:`envlist`. -- ``TOX_TESTENV_PASSENV`` see :conf:`passenv`. +Pip installer +~~~~~~~~~~~~~ -Injected environment variables -++++++++++++++++++++++++++++++ -tox will inject the following environment variables that you can use to test that your command is running within tox: +.. conf:: + :keys: install_command + :default: python -I -m pip install + :version_added: 1.6 -.. versionadded:: 3.4 + Determines the command used for installing packages into the virtual environment; both the package under test and its + dependencies (defined with :ref:`deps`). Must contain the substitution key ``{packages}`` which will be replaced by + the package(s) to install. You should also accept ``{opts}`` -- it will contain index server options such as + ``--pre`` (configured as ``pip_pre``). -- ``TOX_WORK_DIR`` env var is set to the tox work directory -- ``TOX_ENV_NAME`` is set to the current running tox environment name -- ``TOX_ENV_DIR`` is set to the current tox environments working dir. -- ``TOX_PACKAGE`` the packaging phases outcome path (useful to inspect and make assertion of the built package itself). -- ``TOX_PARALLEL_ENV`` is set to the current running tox environment name, only when running in parallel mode. + .. note:: -:note: this applies for all tox envs (isolated packaging too) and all external - commands called (e.g. install command - pip). + You can also provide arbitrary commands to the ``install_command``. Please take care that these commands can be + executed on the supported operating systems. When executing shell scripts we recommend to not specify the script + directly but instead pass it to the appropriate shell as argument (e.g. prefer ``bash script.sh`` over + ``script.sh``). -Other Rules and notes ---------------------- +.. conf:: + :keys: list_dependencies_command + :default: python -m pip freeze --all + :version_added: 2.4 -* ``path`` specifications: if a specified ``path`` is a relative path - it will be considered as relative to the ``toxinidir``, the directory - where the configuration file resides. + The ``list_dependencies_command`` setting is used for listing the packages installed into the virtual environment. -CLI -=== -.. autoprogram:: tox.cli:cli - :prog: tox +.. conf:: + :keys: pip_pre + :default: false + :version_added: 1.9 -.. include:: links.rst + If ``true``, adds ``--pre`` to the ``opts`` passed to :ref:`install_command`. This will cause it to install the + latest available pre-release of any dependencies without a specified version. If ``false``, pip will only install + final releases of unpinned dependencies. diff --git a/docs/developers.rst b/docs/developers.rst deleted file mode 100644 index 7592e8e26..000000000 --- a/docs/developers.rst +++ /dev/null @@ -1,56 +0,0 @@ -.. _developers: - -Developers FAQ -============== -This section contains information for users who want to extend the tox source code. - -.. contents:: - :local: - -PyCharm -------- -1. To generate the **project interpreter** you can use ``tox -rvvve dev``. -2. For tests we use **pytest**, therefore change the `Default test runner `_ to ``pytest``. -3. In order to be able to **debug** tests which create - a virtual environment (the ones in ``test_z_cmdline.py``) one needs to disable the PyCharm feature - `Attach to subprocess automatically while debugging `_ - (because virtualenv creation calls via subprocess to the ``pip`` executable, and PyCharm rewrites all calls to - Python interpreters to attach to its debugger - however, this rewrite for pip makes it to have bad arguments: - ``no such option --port``). - -Multiple Python versions on Windows ------------------------------------ -In order to run the unit tests locally all Python versions enlisted in ``tox.ini`` need to be installed. - -.. note:: For a nice Windows terminal take a look at `cmder`_. - -.. _cmder: http://cmder.net/ - -One solution for this is to install the latest conda, and then install all Python versions via conda envs. This will -create separate folders for each Python version. - -.. code-block:: bat - - conda create -n python2.7 python=2.7 anaconda - -For tox to find them you'll need to: - -- add the main installation version to the systems ``PATH`` variable (e.g. ``D:\Anaconda`` - you can use `WindowsPathEditor`_) -- for other versions create a BAT scripts into the main installation folder to delegate the call to the correct Python - interpreter: - - .. code-block:: bat - - @echo off - REM python2.7.bat - @D:\Anaconda\pkgs\python-2.7.13-1\python.exe %* - -.. _WindowsPathEditor: https://rix0rrr.github.io/WindowsPathEditor/ - -This way you can also directly call from cli the matching Python version if you need to(similarly to UNIX systems), for -example: - - .. code-block:: bat - - python2.7 main.py - python3.6 main.py diff --git a/docs/development.rst b/docs/development.rst new file mode 100644 index 000000000..fa0664abd --- /dev/null +++ b/docs/development.rst @@ -0,0 +1,230 @@ +Development +=========== + +Getting started +--------------- + + +``tox`` is a volunteer maintained open source project and we welcome contributions of all forms. The sections +below will help you get started with development, testing, and documentation. We’re pleased that you are interested in +working on tox. This document is meant to get you setup to work on tox and to act as a guide and reference +to the development setup. If you face any issues during this process, please +:issue:`new?title=Trouble+with+development+environment` about it on the issue tracker. + +Setup +~~~~~ + +tox is a command line application written in Python. To work on it, you'll need: + +- **Source code**: available on :gh_repo:`GitHub `. You can use ``git`` to clone the repository: + + .. code-block:: shell + + git clone https://github.com/tox-dev/tox + cd tox + +- **Python interpreter**: We recommend using ``CPython``. You can use + `this guide `_ to set it up. + +- :pypi:`tox`: to automatically get the projects development dependencies and run the test suite. We recommend + installing it using `pipx `_. + +Running from source tree +~~~~~~~~~~~~~~~~~~~~~~~~ + +The easiest way to do this is to generate the development tox environment, and then invoke tox from under the +``.tox/dev`` folder + +.. code-block:: shell + + tox -e dev + .tox/dev/bin/tox # on Linux + .tox/dev/Scripts/tox # on Windows + +Running tests +~~~~~~~~~~~~~ + +tox's tests are written using the :pypi:`pytest` test framework. :pypi:`tox` is used to automate the setup +and execution of tox's tests. + +To run tests locally execute: + +.. code-block:: shell + + tox -e py + +This will run the test suite for the same Python version as under which ``tox`` is installed. Alternatively you can +specify a specific version of Python by using the ``pyNN`` format, such as: ``py38``, ``pypy3``, etc. + +``tox`` has been configured to forward any additional arguments it is given to ``pytest``. +This enables the use of pytest's +`rich CLI `_. As an example, you can +select tests using the various ways that pytest provides: + +.. code-block:: shell + + # Using markers + tox -e py -- -m "not slow" + # Using keywords + tox -e py -- -k "test_extra" + +Some tests require additional dependencies to be run, such is the various shell activators (``bash``, ``fish``, +``powershell``, etc). The tests will be skipped automatically if the dependencies are not present. Please note however that in CI +all tests are run; so even if all tests succeed locally for you, they may still fail in the CI. + +Running linters +~~~~~~~~~~~~~~~ + +tox uses :pypi:`pre-commit` for managing linting of the codebase. ``pre-commit`` performs various checks on all +files in tox and uses tools that help following a consistent code style within the codebase. To use linters locally, +run: + +.. code-block:: shell + + tox -e fix + +.. note:: + + Avoid using ``# noqa`` comments to suppress linter warnings - wherever possible, warnings should be fixed instead. + ``# noqa`` comments are reserved for rare cases where the recommended style causes severe readability problems or + sidestep bugs within the linters. + +Code style guide +~~~~~~~~~~~~~~~~ + +- First and foremost, the linters configured for the project must pass; this generally means following PEP-8 rules, + as codified by: ``flake8``, ``black``, ``isort``, ``pyupgrade``. +- The supported Python versions (and the code syntax to use) are listed in the ``setup.cfg`` file + in the ``options/python_requires`` entry. However, there are some files that have to be kept compatible + with Python 2.7 to allow and test for running Python 2 envs from tox. They are listed in ``.pre-commit-config.yaml`` + under ``repo: https://github.com/asottile/pyupgrade`` under ``hooks/exclude``. + Please do not attempt to modernize them to Python 3.x. +- Packaging options should be specified within ``setup.cfg``; ``setup.py`` is only kept for editable installs. +- All code (tests too) must be type annotated as much as required by ``mypy``. +- We use a line length of 120. +- Exception messages should only be capitalized (and ended with a period/exclamation mark) if they are multi-sentenced, + which should be avoided. Otherwise, use statements that start with lowercase. +- All function (including test) names must follow PEP-8, so they must be fully snake cased. All classes are upper + camel-cased. +- Prefer f-strings instead of the ``str.format`` method. +- Tests should contain as little information as possible but do use descriptive variable names within it. + +Building documentation +~~~~~~~~~~~~~~~~~~~~~~ + +tox's documentation is built using :pypi:`Sphinx`. The documentation is written in reStructuredText. To build it +locally, run: + +.. code-block:: shell + + tox -e docs + +The built documentation can be found in the ``.tox/docs_out`` folder and may be viewed by opening ``index.html`` within +that folder. + + +Contributing +------------- + +Submitting pull requests +~~~~~~~~~~~~~~~~~~~~~~~~ + +Submit pull requests (PRs) against the ``master`` branch, providing a good description of what you're doing and why. You must +have legal permission to distribute any code you contribute to tox and it must be available under the MIT +License. Provide tests that cover your changes and run the tests locally first. tox +:ref:`supports ` multiple Python versions and operating systems. Any pull request must +consider and work on all these platforms. + +Pull requests should be small to facilitate review. Keep them self-contained, and limited in scope. `Studies have shown +`_ that review quality falls off as patch size +grows. Sometimes this will result in many small PRs to land a single large feature. In particular, pull requests must +not be treated as "feature branches", with ongoing development work happening within the PR. Instead, the feature should +be broken up into smaller, independent parts which can be reviewed and merged individually. + +Additionally, avoid including "cosmetic" changes to code that is unrelated to your change, as these make reviewing the +PR more difficult. Examples include re-flowing text in comments or documentation, or addition or removal of blank lines +or whitespace within lines. Such changes can be made separately, as a "formatting cleanup" PR, if needed. + +Automated testing +~~~~~~~~~~~~~~~~~ + +All pull requests and merges to the ``master`` branch are tested using :gh:`GitHub Actions ` +(configured by ``check.yml`` file inside the ``.github/workflows`` directory). You can find the status and the results +to the CI runs for your PR on GitHub's Web UI for the pull request. You can also find links to the CI services' pages +for the specific builds in the form of "Details" links, in case the CI run fails and you wish to view the output. + +To trigger CI to run again for a pull request, you can close and open the pull request or submit another change to the +pull request. If needed, project maintainers can manually trigger a restart of a job/build. + +Changelog entries +~~~~~~~~~~~~~~~~~ + +The ``changelog.rst`` file is managed using :pypi:`towncrier` and all changes must be accompanied by a +changelog entry. To add an entry to the changelog, first you need to have created an issue describing the +change you want to make. A pull request itself *may* function as such, but it is preferred to have a dedicated issue +(for example, in case the PR ends up rejected due to code quality reasons). + +There is no need to create an issue for trivial changes, e.g. for typo fixes. + +Once you have an issue or pull request, you take the number and you create a file inside of the ``docs/changelog`` +directory named after that issue number with an extension of: + +- ``feature.rst``, +- ``bugfix.rst``, +- ``doc.rst``, +- ``removal.rst``, +- ``misc.rst``. + +Thus if your issue or PR number is ``1234`` and this change is fixing a bug, then you would create a file +``docs/changelog/1234.bugfix.rst``. PRs can span multiple categories by creating multiple files (for instance, if you +added a feature and deprecated/removed the old feature at the same time, you would create +``docs/changelog/1234.bugfix.rst`` and ``docs/changelog/1234.remove.rst``). Likewise if a PR touches multiple issues/PRs +you may create a file for each of them with the same contents and :pypi:`towncrier` will deduplicate them. + +Contents of a changelog entry +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The content of this file is reStructuredText formatted text that will be used as the content of the changelog entry. +You do not need to reference the issue or PR numbers here as towncrier will automatically add a reference to all of +the affected issues when rendering the changelog. + +In order to maintain a consistent style in the ``changelog.rst`` file, it is preferred to keep the entries to the +point, in sentence case, shorter than 120 characters and in an imperative tone -- an entry should complete the sentence +``This change will …``. In rare cases, where one line is not enough, use a summary line in an imperative tone followed +by a blank line separating it from a description of the feature/change in one or more paragraphs, each wrapped +at 120 characters. Remember that a changelog entry is meant for end users and should only contain details relevant to an +end user. + + +Becoming a maintainer +~~~~~~~~~~~~~~~~~~~~~ + +If you want to become an official maintainer, start by helping out. As a first step, we welcome you to triage issues on +tox's issue tracker. tox maintainers provide triage abilities to contributors once they have been around +for some time and contributed positively to the project. This is optional and highly recommended for becoming a +tox maintainer. Later, when you think you're ready, get in touch with one of the maintainers and they will +initiate a vote among the existing maintainers. + +.. note:: + + Upon becoming a maintainer, a person should be given access to various tox-related tooling across + multiple platforms. These are noted here for future reference by the maintainers: + + - GitHub Push Access (provides also CI administration capabilities) + - PyPI Publishing Access + - ReadTheDocs Administration capabilities (the root domain `tox.wiki `_ is currently + owned and maintained by the primary maintainer and author ``Bernat Gabor``; bought via + `Porkbun `_ + -- reach out to him directly for any changes). + + +.. _current-maintainers: + +Current maintainers +^^^^^^^^^^^^^^^^^^^ + +- :user:`Anthony Sottile ` +- :user:`Bernát Gábor ` +- :user:`Jürgen Gmach ` +- :user:`Miroslav Šedivý ` +- :user:`Oliver Bestwalter ` diff --git a/docs/drafts/extend-envs-and-packagebuilds.md b/docs/drafts/extend-envs-and-packagebuilds.md deleted file mode 100644 index ed8379117..000000000 --- a/docs/drafts/extend-envs-and-packagebuilds.md +++ /dev/null @@ -1,168 +0,0 @@ -# Extension of environment handling and building packages - -Issue reference: #338 - -*Notes from a discussion at the pytest sprint 2016* - -Goal: drive building of packages and the environments needed to test them, exercising the tests and report the results for more than just virtualenvs and python virtualenvs - -### Problems - -* No concept of mapping environments to specific packages (versioned packages) -* no control over when it happens for specific environment -* no control over how it happens (e.g. which python interpreter is used to create the package) -* No way of triggering build only if there is an environment that needs a specific build trigger it only if an environment actually needs it -* package definition that might match on everything might be a problem for which environments test? Not clear? - -### Solution - -It should be possible to build other kinds of packages than just the standard sdist and it should also be possible to create different kinds of builds that can be used from different environments. To make this possible there has to be some concept of factorized package definitions and a way to match these factorized builds to environments with a similar way of matching like what is in place already to generate environments. sdist would for example would match to a "sdist" factor to only be matched against virtualenvs as the default. - -This could then be used to have virtualenv, conda, nixos, docker, pyenv, rpm, deb, etc. builds and tie them to concrete test environments. - -To summarize - we would need a: - - * packagedef (how to build a package) - * envdef (how to build an environment) - * way of matching envs to concrete packages (at package definition level) (e.g `{py27,py34}-{win32,linux}-{venv,conda,pyenv}-[...]`) - -## Beginnings of configuration examples (not thought out yet) - - [tox] - envlist={py,27,py34}-{win32, linux}-{conda,virtualenv} - - [packagedef:sdist] - # how to build (e.g. {py27,py34}-{sdist}) - # how to match (e.g. {py27,py34}-{sdist}) - - [packagedef:conda] - # how to build (e.g. {py27,py34}-{conda}) - # how to match (e.g. {py27,py34}-{conda}) - - [packagedef:wheel] - # how to build - # how to match - -#### integrate detox - -* reporting in detox is minimal (would need to improve) -* restricting processes would be necessary depending on power of the machine - (creating 16 processes on a dual-core machine might be overkill) -* port it from eventlets to threads? - -### Concrete use case conda integration (started by Bruno) - -* Asynchronicity / detox not taken into account yet -* Conda activation might do anything (change filesys, start DBs) -* Can I activate environments in parallel -* Packages would need to be created (from conda.yml) -* Activation is a problem - - -### Unsorted discussion notes - -* Simplify for the common case: most packages are universal, so it should be simple -one to one relationship from environment to directory -* Floris: metadata driven. Package has metadata to the env with what env it is compatible -* Holger: configuration driven. explicitly configuring which packages should be used (default sdist to be used, overridable by concrete env) -* Ronny: "package definitions" (this package, this setup command) + matching definitions (matching packages (with wildcards) for environments) - - -## Proposal - -This feature shall allow one to specify how plugins can specify new types of package formats and environments to run test -commands in. - -Such plugins would take care of setting up the environment, create packages and run test commands using hooks provided -by tox. The actual knowledge how to create a certain package format is implement in the plugin. - -Plugin decides which is the required python interpreter to use in order to create the relevant package format. - - -```ini -[tox] -plugins=conda # virtualenv plugin is builtin; intention here is to bail out early in case the specified plugins - # are not installed -envlist=py27,py35 - -[testenv] -package_formats= # new option to specify wanted package formats for test environment using tox factors feature - # defaults to "sdist" if not set - py35: sdist wheel conda # names here are provided by plugins (reserved keywords) - py27: sdist conda -commands = py.test -``` - -Listing tox environments (`tox --list`) would display the following output: - -``` -(sdist) py27 -(conda) py27 -(sdist) py35 -(wheel) py35 -(conda) py35 -``` - -To remain backward-compatible, the package format will not be displayed if only a single package format is specified. - - - -How to skip building a package for a specific factor? - -Illustrate how to exclude a certain package format for a factor: - -```ini -[tox] -plugins=conda -envlist=py27,py35,py27-xdist - -[testenv] -commands = py.test -package_formats=sdist wheel conda -exclude_package_formats= # new option which filters out packages - py27-xdist: wheel -``` - -or possibly using the negated factor condition support: - -```ini -[tox] -plugins=conda -envlist=py27,py35,py27-xdist - -[testenv] -commands = py.test -package_formats= - sdist - !py27,!xdist: wheel - conda -``` - -Output of `tox --list`: - -``` -(sdist) py27 -(wheel) py27 -(conda) py27 -(sdist) py35 -(wheel) py35 -(conda) py35 -(sdist) py27-xdist -(conda) py27-xdist -``` - - -### Implementation Details - -``` -tox_package_formats() -> ['conda'] # ['sdist', 'wheel'] -tox_testenv_create(env_meta, package_type) -> # creates an environment for given package, using - # information from env_meta (like .envdir) - # returns: an "env" object which is forwarded to the next hooks -tox_testenv_install(env_meta, package_type, env) -> # installs deps and package into environment -tox_testenv_runtest(env_meta, package_type, env) -> # activates environment and runs test commands - -tox_testenv_updated(env_meta, package_type) -> # returns True if the environment is already up to date - # otherwise, tox will remove the environment completely and - # create a new one -``` diff --git a/docs/drafts/tox_conda_notes_niccodemus.md b/docs/drafts/tox_conda_notes_niccodemus.md deleted file mode 100644 index c570948e7..000000000 --- a/docs/drafts/tox_conda_notes_niccodemus.md +++ /dev/null @@ -1,89 +0,0 @@ -``` -[tox] -envlist=py27,py35 - -[testenv] -commands= py.test --timeout=180 {posargs:tests} -deps=pytest>=2.3.5 - pytest-timeout - -# USE CASE 1: plain conda, with deps on tox.ini -create_env_command = conda create --prefix {envdir} python={python_version} -install_command = conda install --prefix {envdir} {opts} {packages} -list_dependencies_command = conda list --prefix {envdir} - -# deprecated: see tox_create_popen hook -linux:env_activate_command=source activate {envdir} -win:env_activate_command=activate.bat {envdir} - -# USE CASE 2: plain conda, using requirements.txt -install_command = conda install --prefix {envdir} {opts} --file requirements.txt - -# USE CASE 3: conda env -create_env_command = conda env create --prefix {envdir} python={python_version} --file environment.yml -install_command = - -[testenv] -type=virtualenv -type=venv -type=conda -type=conda-reqs -type=conda-env -``` - -1. Create a new ``create_env_command`` option. -2. Create a new ``env_activate_command`` option (also consider how to make that platform dependent). -2. New substitution variable: {python_version} ('3.5', '2.7', etc') -3. env type concept: different types change the default options. - -1. tox_addoption can now add new "testenv" sections to tox.ini: -``` -[virtualenv] -[conda] -[venv] -``` -2. extend hooks: -``` - * tox_addoption - * tox_configure - for each requested env in config: - tox_testenv_up_to_date(envmeta) - tox_testenv_create(envmeta) - tox_testenv_install_deps(envmeta, env) - tox_runtest_pre(envmeta, env) - tox_runtest(envmeta, env, popen) - tox_runtest_post(envmeta, env) -``` - -3. separate virtualenv details from "VirtualEnv" class into a plugin. - -``` -[tox] -envlist={py27,py35}-{sdist,wheel,conda} - -[package-sdist] -command = python setup.py sdist - -[package-wheel] -command = python setup.py bdist_wheel - -[package-conda] -command = conda build ./conda-recipe - -[testenv:{sdist,wheel}] -commands = py.test - -[testenv:conda] -packages = sdist,wheel -commands = py.test --conda-only -``` - -* tox_addoption -* tox_get_python_executable -* tox_configure -for each requested env in config: - tox_testenv_create(envmeta) - tox_testenv_install_deps(envmeta, env) - tox_runtest_pre(envmeta, env) - tox_runtest(envmeta, env, popen) - tox_runtest_post(envmeta, env) diff --git a/docs/example/basic.rst b/docs/example/basic.rst deleted file mode 100644 index 8e69289b7..000000000 --- a/docs/example/basic.rst +++ /dev/null @@ -1,476 +0,0 @@ -Basic usage -============================================= - -A simple tox.ini / default environments ------------------------------------------------ - -Put basic information about your project and the test environments you -want your project to run in into a ``tox.ini`` file that should -reside next to your ``setup.py`` file: - -.. code-block:: ini - - # content of: tox.ini , put in same dir as setup.py - [tox] - envlist = py27,py36 - [testenv] - # install testing framework - # ... or install anything else you might need here - deps = pytest - # run the tests - # ... or run any other command line tool you need to run here - commands = pytest - -To sdist-package, install and test your project, you can -now type at the command prompt: - -.. code-block:: shell - - tox - -This will sdist-package your current project, create two virtualenv_ -Environments, install the sdist-package into the environments and run -the specified command in each of them. With: - -.. code-block:: shell - - tox -e py36 - -you can restrict the test run to the python3.6 environment. - -Tox currently understands the following patterns: - -.. code-block:: shell - - py: The current Python version tox is using - pypy: Whatever available PyPy there is - jython: Whatever available Jython there is - pyN: Python of version N. for example py2 or py3 ... etc - pyNM: Python of version N.M. for example py27 or py38 ... etc - pypyN: PyPy of version N. for example pypy2 or pypy3 ... etc - pypyNM: PyPy version N.M. for example pypy27 or pypy35 ... etc - -However, you can also create your own test environment names, -see some of the examples in :doc:`examples <../examples>`. - -pyproject.toml tox legacy ini ------------------------------ - -The tox configuration can also be in ``pyproject.toml`` (if you want to avoid an extra file). - -Currently only the old format is supported via ``legacy_tox_ini``, a native implementation is planned though. - -.. code-block:: toml - - [build-system] - requires = [ "setuptools >= 35.0.2", "wheel >= 0.29.0"] - build-backend = "setuptools.build_meta" - - [tool.tox] - legacy_tox_ini = """ - [tox] - envlist = py27,py36 - - [testenv] - deps = pytest >= 3.0.0, <4 - commands = pytest - """ - -Note that when you define a ``pyproject.toml`` you must define the ``build-system`` section per PEP-518. - -Specifying a platform ------------------------------------------------ - -.. versionadded:: 2.0 - -If you want to specify which platform(s) your test environment -runs on you can set a platform regular expression like this: - -.. code-block:: ini - - [testenv] - platform = linux2|darwin - -If the expression does not match against ``sys.platform`` -the test environment will be skipped. - -Allowing non-virtualenv commands ------------------------------------------------ - -.. versionadded:: 1.5 - -Sometimes you may want to use tools not contained in your -virtualenv such as ``make``, ``bash`` or others. To avoid -warnings you can use the ``allowlist_externals`` testenv -configuration: - -.. code-block:: ini - - # content of tox.ini - [testenv] - allowlist_externals = make - /bin/bash - - -.. _virtualenv: https://pypi.org/project/virtualenv - -.. _multiindex: - -Depending on requirements.txt or defining constraints ------------------------------------------------------ - -.. versionadded:: 1.6.1 - -(experimental) If you have a ``requirements.txt`` file you can add it to your ``deps`` variable like this: - -.. code-block:: ini - - [testenv] - deps = -rrequirements.txt - -This is actually a side effect that all elements of the dependency list is directly passed to ``pip``. - -If you have a ``constraints.txt`` file you could add it to your ``deps`` like the ``requirements.txt`` file above. -However, then it would not be applied to - -* build time dependencies when using isolated builds (https://github.com/pypa/pip/issues/8439) -* run time dependencies not already listed in ``deps``. - -A better method may be to use ``setenv`` like this: - -.. code-block:: ini - - [testenv] - setenv = PIP_CONSTRAINT=constraints.txt - -Make sure that all dependencies, including transient dependencies, are listed in your ``constraints.txt`` file or the version used may vary. - -It should be noted that ``pip``, ``setuptools`` and ``wheel`` are often not part of the dependency tree and will be left at whatever version ``virtualenv`` used to seed the environment. - -All installation commands are executed using ``{toxinidir}`` (the directory where ``tox.ini`` resides) as the current working directory. -Therefore, the underlying ``pip`` installation will assume ``requirements.txt`` or ``constraints.txt`` to exist at ``{toxinidir}/requirements.txt`` or ``{toxinidir}/constraints.txt``. - - -For more details on ``requirements.txt`` files or ``constraints.txt`` files please see: - -* https://pip.pypa.io/en/stable/user_guide/#requirements-files -* https://pip.pypa.io/en/stable/user_guide/#constraints-files - -Using a different default PyPI URL ----------------------------------- - -To install dependencies and packages from a different -default PyPI server you can type interactively: - -.. code-block:: shell - - tox -i https://pypi.my-alternative-index.org - -This causes tox to install dependencies and the sdist install step -to use the specified URL as the index server. - -You can cause the same effect by using a ``PIP_INDEX_URL`` environment variable. -This variable can be also set in ``tox.ini``: - -.. code-block:: ini - - [testenv] - setenv = - PIP_INDEX_URL = https://pypi.my-alternative-index.org - -Alternatively, a configuration where ``PIP_INDEX_URL`` could be overridden from environment: - -.. code-block:: ini - - [testenv] - setenv = - PIP_INDEX_URL = {env:PIP_INDEX_URL:https://pypi.my-alternative-index.org} - -Installing dependencies from multiple PyPI servers --------------------------------------------------- - -You can instrument tox to install dependencies from -multiple PyPI servers, using ``PIP_EXTRA_INDEX_URL`` environment variable: - -.. code-block:: ini - - [testenv] - setenv = - PIP_EXTRA_INDEX_URL = https://mypypiserver.org - deps = - # docutils will be installed directly from PyPI - docutils - # mypackage missing at PyPI will be installed from custom PyPI URL - mypackage - -This configuration will install ``docutils`` from the default -Python PyPI server and will install the ``mypackage`` from -our index server at ``https://mypypiserver.org`` URL. - -.. warning:: - - Using an extra PyPI index for installing private packages may cause security issues. - For example, if ``mypackage`` is registered with the default PyPI index, pip will install ``mypackage`` - from the default PyPI index, not from the custom one. - -Further customizing installation ---------------------------------- - -.. versionadded:: 1.6 - -By default tox uses `pip`_ to install packages, both the -package-under-test and any dependencies you specify in ``tox.ini``. -You can fully customize tox's install-command through the -testenv-specific :conf:`install_command = ARGV ` setting. -For instance, to use pip's ``--find-links`` and ``--no-index`` options to specify -an alternative source for your dependencies: - -.. code-block:: ini - - [testenv] - install_command = pip install --pre --find-links https://packages.example.com --no-index {opts} {packages} - -.. _pip: https://pip.pypa.io/en/stable/ - -Forcing re-creation of virtual environments ------------------------------------------------ - -.. versionadded:: 0.9 - -To force tox to recreate a (particular) virtual environment: - -.. code-block:: shell - - tox --recreate -e py27 - -would trigger a complete reinstallation of the existing py27 environment -(or create it afresh if it doesn't exist). - -Passing down environment variables -------------------------------------------- - -.. versionadded:: 2.0 - -By default tox will only pass the ``PATH`` environment variable (and on -windows ``SYSTEMROOT`` and ``PATHEXT``) from the tox invocation to the -test environments. If you want to pass down additional environment -variables you can use the ``passenv`` option: - -.. code-block:: ini - - [testenv] - passenv = LANG - -When your test commands execute they will execute with -the same LANG setting as the one with which tox was invoked. - -Setting environment variables -------------------------------------------- - -.. versionadded:: 1.0 - -If you need to set an environment variable like ``PYTHONPATH`` you -can use the ``setenv`` directive: - -.. code-block:: ini - - [testenv] - setenv = PYTHONPATH = {toxinidir}/subdir - -When your test commands execute they will execute with -a PYTHONPATH setting that will lead Python to also import -from the ``subdir`` below the directory where your ``tox.ini`` -file resides. - -Special handling of PYTHONHASHSEED -------------------------------------------- - -.. versionadded:: 1.6.2 - -By default, tox sets PYTHONHASHSEED_ for test commands to a random integer -generated when ``tox`` is invoked. This mimics Python's hash randomization -enabled by default starting `in Python 3.3`_. To aid in reproducing test -failures, tox displays the value of ``PYTHONHASHSEED`` in the test output. - -You can tell tox to use an explicit hash seed value via the ``--hashseed`` -command-line option to ``tox``. You can also override the hash seed value -per test environment in ``tox.ini`` as follows: - -.. code-block:: ini - - [testenv] - setenv = PYTHONHASHSEED = 100 - -If you wish to disable this feature, you can pass the command line option -``--hashseed=noset`` when ``tox`` is invoked. You can also disable it from the -``tox.ini`` by setting ``PYTHONHASHSEED = 0`` as described above. - -.. _`in Python 3.3`: https://docs.python.org/3/whatsnew/3.3.html#builtin-functions-and-types -.. _PYTHONHASHSEED: https://docs.python.org/3/using/cmdline.html#envvar-PYTHONHASHSEED - -Integration with "setup.py test" command ----------------------------------------------------- - -.. warning:: - - ``setup.py test`` is `deprecated - `_ - and will be removed in a future version. - -.. _`ignoring exit code`: - -Ignoring a command exit code ----------------------------- - -In some cases, you may want to ignore a command exit code. For example: - -.. code-block:: ini - - [testenv:py27] - commands = coverage erase - {envbindir}/python setup.py develop - coverage run -p setup.py test - coverage combine - - coverage html - {envbindir}/flake8 loads - -By using the ``-`` prefix, similar to a ``make`` recipe line, you can ignore -the exit code for that command. - -Compressing dependency matrix ------------------------------ - -If you have a large matrix of dependencies, python versions and/or environments you can -use :ref:`generative-envlist` and :ref:`conditional settings ` to express that in a concise form: - -.. code-block:: ini - - [tox] - envlist = py{36,37,38}-django{22,30}-{sqlite,mysql} - - [testenv] - deps = - django22: Django>=2.2,<2.3 - django30: Django>=3.0,<3.1 - # use PyMySQL if factors "py37" and "mysql" are present in env name - py38-mysql: PyMySQL - # use urllib3 if any of "py36" or "py37" are present in env name - py36,py37: urllib3 - # mocking sqlite on 3.6 and 3.7 if factor "sqlite" is present - py{36,37}-sqlite: mock - - -Using generative section names ------------------------------- - -Suppose you have some binary packages, and need to run tests both in 32 and 64 bits. -You also want an environment to create your virtual env for the developers. - -.. code-block:: ini - - [testenv] - basepython = - py38-x86: python3.8-32 - py38-x64: python3.8-64 - commands = pytest - - [testenv:py38-{x86,x64}-venv] - usedevelop = true - envdir = - x86: .venv-x86 - x64: .venv-x64 - commands = - - -Prevent symbolic links in virtualenv ------------------------------------- -By default virtualenv will use symlinks to point to the system's python files, modules, etc. -If you want the files to be copied instead, possibly because your filesystem is not capable -of handling symbolic links, you can instruct virtualenv to use the "--always-copy" argument -meant exactly for that purpose, by setting the ``alwayscopy`` directive in your environment: - -.. code-block:: ini - - [testenv] - alwayscopy = True - -.. _`parallel_mode`: - -Parallel mode -------------- -``tox`` allows running environments in parallel: - -- Invoke by using the ``--parallel`` or ``-p`` flag. After the packaging phase completes tox will run in parallel - processes tox environments (spins a new instance of the tox interpreter, but passes through all host flags and - environment variables). -- ``-p`` takes an argument specifying the degree of parallelization, defaulting to ``auto``: - - - ``all`` to run all invoked environments in parallel, - - ``auto`` to limit it to CPU count, - - or pass an integer to set that limit. -- Parallel mode displays a progress spinner while running tox environments in parallel, and reports outcome of - these as soon as completed with a human readable duration timing attached. This spinner can be disabled by - setting the environment variable ``TOX_PARALLEL_NO_SPINNER`` to the value ``1``. -- Parallel mode by default shows output only of failed environments and ones marked as :conf:`parallel_show_output` - ``=True``. -- There's now a concept of dependency between environments (specified via :conf:`depends`), tox will re-order the - environment list to be run to satisfy these dependencies (in sequential run too). Furthermore, in parallel mode, - will only schedule a tox environment to run once all of its dependencies finished (independent of their outcome). - - .. warning:: - - ``depends`` does not pull in dependencies into the run target, for example if you select ``py27,py36,coverage`` - via the ``-e`` tox will only run those three (even if ``coverage`` may specify as ``depends`` other targets too - - such as ``py27, py35, py36, py37``). - -- ``--parallel-live``/``-o`` allows showing the live output of the standard output and error, also turns off reporting - described above. -- Note: parallel evaluation disables standard input. Use non parallel invocation if you need standard input. - -Example final output: - -.. code-block:: bash - - $ tox -e py27,py36,coverage -p all - ✔ OK py36 in 9.533 seconds - ✔ OK py27 in 9.96 seconds - ✔ OK coverage in 2.0 seconds - ___________________________ summary ______________________________________________________ - py27: commands succeeded - py36: commands succeeded - coverage: commands succeeded - congratulations :) - - -Example progress bar, showing a rotating spinner, the number of environments running and their list (limited up to \ -120 characters): - -.. code-block:: bash - - ⠹ [2] py27 | py36 - -.. _`auto-provision`: - -tox auto-provisioning ---------------------- -In case the host tox does not satisfy either the :conf:`minversion` or the :conf:`requires`, tox will now automatically -create a virtual environment under :conf:`provision_tox_env` that satisfies those constraints and delegate all calls -to this meta environment. This should allow automatically satisfying constraints on your tox environment, -given you have at least version ``3.8.0`` of tox. - -For example given: - -.. code-block:: ini - - [tox] - minversion = 3.10.0 - requires = tox_venv >= 1.0.0 - -if the user runs it with tox ``3.8.0`` or later installed tox will automatically ensured that both the minimum version -and requires constraints are satisfied, by creating a virtual environment under ``.tox`` folder, and then installing -into it ``tox >= 3.10.0`` and ``tox_venv >= 1.0.0``. Afterwards all tox invocations are forwarded to the tox installed -inside ``.tox\.tox`` folder (referred to as meta-tox or auto-provisioned tox). - -This allows tox to automatically setup itself with all its plugins for the current project. If the host tox satisfies -the constraints expressed with the :conf:`requires` and :conf:`minversion` no such provisioning is done (to avoid -setup cost when it's not explicitly needed). diff --git a/docs/example/devenv.rst b/docs/example/devenv.rst deleted file mode 100644 index 317e3cb1a..000000000 --- a/docs/example/devenv.rst +++ /dev/null @@ -1,117 +0,0 @@ -======================= -Development environment -======================= - -tox can be used for just preparing different virtual environments required by a -project. - -This feature can be used by deployment tools when preparing deployed project -environments. It can also be used for setting up normalized project development -environments and thus help reduce the risk of different team members using -mismatched development environments. - - -Creating development environments using the ``--devenv`` option -=============================================================== - -The easiest way to set up a development environment is to use the ``--devenv`` -option along with your existing configured ``testenv``\ s. The ``--devenv`` -option accepts a single argument, the location you want to create a development -environment at. - -For example, if I wanted to replicate the ``py36`` environment, I could run:: - - $ tox --devenv venv-py36 -e py36 - ... - $ source venv-py36/bin/activate - (venv-py36) $ python --version - Python 3.6.7 - -The ``--devenv`` option skips the ``commands=`` section of that configured -test environment and always sets ``usedevelop=true`` for the environment that -is created. - -If you don't specify an environment with ``-e``, the devenv feature will -default to ``-e py`` -- usually taking the interpreter you're running ``tox`` -with and the default ``[testenv]`` configuration. - -It is possible to use the ``--devenv`` option without a tox configuration file, -however the configuration file is respected if present. - -Creating development environments using configuration -===================================================== - -Here are some examples illustrating how to set up a project's development -environment using tox. For illustration purposes, let us call the development -environment ``dev``. - - -Example 1: Basic scenario -------------------------- - -Step 1 - Configure the development environment -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -First, we prepare the tox configuration for our development environment by -defining a ``[testenv:dev]`` section in the project's ``tox.ini`` -configuration file: - -.. code-block:: ini - - [testenv:dev] - basepython = python2.7 - usedevelop = True - -In it we state: - -- what Python executable to use in the environment, -- that our project should be installed into the environment using ``setup.py - develop``, as opposed to building and installing its source distribution using - ``setup.py install``. - -The development environment will reside in ``toxworkdir`` (default is ``.tox``) just -like the other tox environments. - -We can configure a lot more, if we want to. For example, we can add the -following to our configuration, telling tox not to reuse ``commands`` or -``deps`` settings from the base ``[testenv]`` -configuration: - -.. code-block:: ini - - [testenv:dev] - commands = - deps = - - -Step 2 - Create the development environment -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Once the ``[testenv:dev]`` configuration section has been defined, we create -the actual development environment by running the following: - -.. code-block:: shell - - tox -e dev - -This creates the environment at the path specified by the environment's -``envdir`` configuration value. - - -Example 2: A more complex scenario ----------------------------------- - -Let us say we want our project development environment to: - -- use Python executable ``python2.7``, -- pull packages from ``requirements.txt``, located in the same directory as - ``tox.ini``. - -Here is an example configuration for the described scenario: - -.. code-block:: ini - - [testenv:dev] - basepython = python2.7 - usedevelop = True - deps = -rrequirements.txt diff --git a/docs/example/documentation.rst b/docs/example/documentation.rst deleted file mode 100644 index 4b25411f0..000000000 --- a/docs/example/documentation.rst +++ /dev/null @@ -1,53 +0,0 @@ -Generate documentation -====================== - -It's possible to generate the projects documentation with tox itself. The advantage of this -path is that now generating the documentation can be part of the CI, and whenever any -validations/checks/operations fail while generating the documentation you'll catch it -within tox. - -Sphinx ------- - -No need to use the cryptic make file to generate a sphinx documentation. One can use tox -to ensure all right dependencies are available within a virtual environment, and -even specify the python version needed to perform the build. For example if the sphinx -file structure is under the ``doc`` folder the following configuration will generate -the documentation under ``{toxworkdir}/docs_out`` and print out a link to the generated -documentation: - -.. code-block:: ini - - [testenv:docs] - description = invoke sphinx-build to build the HTML docs - basepython = python3.7 - deps = sphinx >= 1.7.5, < 2 - commands = sphinx-build -d "{toxworkdir}/docs_doctree" doc "{toxworkdir}/docs_out" --color -W -bhtml {posargs} - python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' - -Note here we say we also require python 3.7, allowing us to use f-strings within the sphinx -``conf.py``. Now one can specify a separate test environment that will validate that the -links are correct. - -mkdocs ------- - -Define one environment to write/generate the documentation, and another to deploy it. Use -the config substitution logic to avoid defining dependencies multiple time: - -.. code-block:: ini - - [testenv:docs] - description = Run a development server for working on documentation - basepython = python3.7 - deps = mkdocs >= 1.7.5, < 2 - mkdocs-material - commands = mkdocs build --clean - python -c 'print("###### Starting local server. Press Control+C to stop server ######")' - mkdocs serve -a localhost:8080 - - [testenv:docs-deploy] - description = built fresh docs and deploy them - deps = {[testenv:docs]deps} - basepython = {[testenv:docs]basepython} - commands = mkdocs gh-deploy --clean diff --git a/docs/example/general.rst b/docs/example/general.rst deleted file mode 100644 index 2687caa9f..000000000 --- a/docs/example/general.rst +++ /dev/null @@ -1,264 +0,0 @@ -.. be in -*- rst -*- mode! - -General tips and tricks -================================ - -Interactively passing positional arguments ------------------------------------------------ - -If you invoke ``tox`` like this: - -.. code-block:: shell - - tox -- -x tests/test_something.py - -the arguments after the ``--`` will be substituted -everywhere where you specify ``{posargs}`` in your -test commands, for example using ``pytest``: - -.. code-block:: ini - - [testenv] - # Could also be in a specific ``[testenv:]`` section - commands = pytest {posargs} - -or using ``nosetests``: - -.. code-block:: ini - - [testenv] - commands = nosetests {posargs} - -the above ``tox`` invocation will trigger the test runners to -stop after the first failure and to only run a particular test file. - -You can specify defaults for the positional arguments using this -syntax: - -.. code-block:: ini - - [testenv] - commands = nosetests {posargs:--with-coverage} - -.. _recreate: - -Dependency changes and tracking -------------------------------- - -Creating virtual environments and installing dependencies is an expensive operation. -Therefore tox tries to avoid it whenever possible, meaning it will never perform this -unless it detects with absolute certainty that it needs to perform an update. A tox -environment creation is made up of: - -- create the virtual environment -- install dependencies specified inside deps -- if it's a library project (has build package phase), install library dependencies - (with potential extras) - -These three steps are only performed once (given they all succeeded). Subsequent calls -that don't detect changes to the traits of that step will not alter the virtual -environment in any way. When a change is detected for any of the steps, the entire -virtual environment is removed and the operation starts from scratch (this is -because it's very hard to determine what would the delta changes would be needed - -e.g. a dependency could migrate from one dependency to another, and in this case -we would need to install the new while removing the old one). - -Here's what traits we track at the moment for each steps: - -- virtual environment trait is tied to the python path the :conf:`basepython` - resolves too (if this config changes, the virtual environment will be recreated), -- :conf:`deps` sections changes (meaning any string-level change for the entries, note - requirement file content changes are not tracked), -- library dependencies are tracked at :conf:`extras` level (because there's no - Python API to enquire about the actual dependencies in a non-tool specific way, - e.g. setuptools has one way, flit something else, and poetry another). - -Whenever you change traits that are not tracked we recommend you to manually trigger a -rebuild of the tox environment by passing the ``-r`` flag for the tox invocation. For -instance, for a setuptools project whenever you modify the ``install_requires`` keyword -at the next run force the recreation of the tox environment by passing the recreate cli -tox flag. - -.. _`TOXENV`: - -Selecting one or more environments to run tests against --------------------------------------------------------- - -Using the ``-e ENV[,ENV36,...]`` option you explicitly list -the environments where you want to run tests against. For -example, given the previous sphinx example you may call: - -.. code-block:: shell - - tox -e docs - -which will make ``tox`` only manage the ``docs`` environment -and call its test commands. You may specify more than -one environment like this: - -.. code-block:: shell - - tox -e py27,py36 - -which would run the commands of the ``py27`` and ``py36`` testenvironments -respectively. The special value ``ALL`` selects all environments. - -You can also specify an environment list in your ``tox.ini``: - -.. code-block:: ini - - [tox] - envlist = py27,py36 - -or override it from the command line or from the environment variable -``TOXENV``: - -.. code-block:: shell - - export TOXENV=py27,py36 # in bash style shells - -.. _artifacts: - -Access package artifacts between multiple tox-runs --------------------------------------------------------- - -If you have multiple projects using tox you can make use of -a ``distshare`` directory where ``tox`` will copy in sdist-packages so -that another tox run can find the "latest" dependency. This feature -allows you to test a package against an unreleased development version -or even an uncommitted version on your own machine. - -By default, ``{homedir}/.tox/distshare`` will be used for -copying in and copying out artifacts (i.e. Python packages). - -For project ``two`` to depend on the ``one`` package you use -the following entry: - -.. code-block:: ini - - # example two/tox.ini - [testenv] - # install latest package from "one" project - deps = {distshare}/one-*.zip - -That's all. tox running on project ``one`` will copy the sdist-package -into the ``distshare`` directory after which a ``tox`` run on project -``two`` will grab it because ``deps`` contain an entry with the -``one-*.zip`` pattern. If there is more than one matching package the -highest version will be taken. ``tox`` uses verlib_ to compare version -strings which must be compliant with :pep:`386`. - -If you want to use this with Jenkins_, also checkout the :ref:`jenkins artifact example`. - -.. _verlib: https://bitbucket.org/tarek/distutilsversion/ - -basepython defaults, overriding -+++++++++++++++++++++++++++++++ - -For any ``pyXY`` test environment name the underlying ``pythonX.Y`` executable -will be searched in your system ``PATH``. Similarly, for ``jython`` and -``pypy`` the respective ``jython`` and ``pypy-c`` names will be looked for. -The executable must exist in order to successfully create *virtualenv* -environments. On Windows a ``pythonX.Y`` named executable will be searched in -typical default locations using the ``C:\PythonXY\python.exe`` pattern. - -All other targets will use the system ``python`` instead. You can override any -of the default settings by defining the :conf:`basepython` variable in a -specific test environment section, for example: - -.. code-block:: ini - - [testenv:docs] - basepython = python2.7 - -Avoiding expensive sdist ------------------------- - -Some projects are large enough that running an sdist, followed by -an install every time can be prohibitively costly. To solve this, -there are two different options you can add to the ``tox`` section. First, -you can simply ask tox to please not make an sdist: - -.. code-block:: ini - - [tox] - skipsdist=True - -If you do this, your local software package will not be installed into -the virtualenv. You should probably be okay with that, or take steps -to deal with it in your commands section: - -.. code-block:: ini - - [testenv] - commands = python setup.py develop - pytest - -Running ``setup.py develop`` is a common enough model that it has its own -option: - -.. code-block:: ini - - [testenv] - usedevelop=True - -And a corresponding command line option ``--develop``, which will set -``skipsdist`` to True and then perform the ``setup.py develop`` step at the -place where ``tox`` normally performs the installation of the sdist. -Specifically, it actually runs ``pip install -e .`` behind the scenes, which -itself calls ``setup.py develop``. - -There is an optimization coded in to not bother re-running the command if -``$projectname.egg-info`` is newer than ``setup.py`` or ``setup.cfg``. - -.. include:: ../links.rst - - -Understanding ``InvocationError`` exit codes --------------------------------------------- - -When a command (defined by ``commands =`` in ``tox.ini``) fails, -it has a non-zero exit code, -and an ``InvocationError`` exception is raised by ``tox``: - -.. code-block:: shell - - ERROR: InvocationError for command - '' (exited with code 1) - -If the command starts with ``pytest`` or ``python setup.py test`` for instance, -then the `pytest exit codes`_ are relevant. - -On unix systems, there are some rather `common exit codes`_. -This is why for exit codes larger than 128, -if a signal with number equal to `` - 128`` is found -in the :py:mod:`signal` module, an additional hint is given: - -.. code-block:: shell - - ERROR: InvocationError for command - '' (exited with code 139) - Note: this might indicate a fatal error signal (139 - 128 = 11: SIGSEGV) - -where ```` is the command defined in ``tox.ini``, with quotes removed. - -The signal numbers (e.g. 11 for a segmentation fault) can be found in the -"Standard signals" section of the `signal man page`_. -Their meaning is described in `POSIX signals`_. - -Beware that programs may issue custom exit codes with any value, -so their documentation should be consulted. - - -Sometimes, no exit code is given at all. -An example may be found in `pytest-qt issue #170`_, -where Qt was calling ``abort()`` instead of ``exit()``. - -.. seealso:: :ref:`ignoring exit code`. - -.. _`pytest exit codes`: https://docs.pytest.org/en/latest/usage.html#possible-exit-codes -.. _`common exit codes`: http://www.faqs.org/docs/abs/HTML/exitcodes.html -.. _`abort()``: http://www.unix.org/version2/sample/abort.html -.. _`pytest-qt issue #170`: https://github.com/pytest-dev/pytest-qt/issues/170 -.. _`signal man page`: http://man7.org/linux/man-pages/man7/signal.7.html -.. _`POSIX signals`: https://en.wikipedia.org/wiki/Signal_(IPC)#POSIX_signals diff --git a/docs/example/jenkins.rst b/docs/example/jenkins.rst deleted file mode 100644 index bf8e3d426..000000000 --- a/docs/example/jenkins.rst +++ /dev/null @@ -1,215 +0,0 @@ -Using tox with the Jenkins Integration Server -================================================= - -Using Jenkins multi-configuration jobs -------------------------------------------- - -The Jenkins_ continuous integration server allows you to define "jobs" with -"build steps" which can be test invocations. If you :doc:`install <../install>` ``tox`` on your -default Python installation on each Jenkins agent, you can easily create -a Jenkins multi-configuration job that will drive your tox runs from the CI-server side, -using these steps: - -* install the Python plugin for Jenkins under "manage jenkins" -* create a "multi-configuration" job, give it a name of your choice -* configure your repository so that Jenkins can pull it -* (optional) configure multiple nodes so that tox-runs are performed - on multiple hosts -* configure ``axes`` by using :ref:`TOXENV ` as an axis - name and as values provide space-separated test environment names - you want Jenkins/tox to execute. - -* add a **Python-build step** with this content (see also next example): - - .. code-block:: python - - import tox - - os.chdir(os.getenv("WORKSPACE")) - tox.cmdline() # environment is selected by ``TOXENV`` env variable - -* check ``Publish JUnit test result report`` and enter - ``**/junit-*.xml`` as the pattern so that Jenkins collects - test results in the JUnit XML format. - -The last point requires that your test command creates JunitXML files, -for example with ``pytest`` it is done like this: - -.. code-block:: ini - - [testenv] - commands = pytest --junitxml=junit-{envname}.xml - - - -**zero-installation** for agents -------------------------------------------------------------- - -.. note:: - - This feature is broken currently because "toxbootstrap.py" - has been removed. Please file an issue if you'd like to - see it back. - -If you manage many Jenkins agents and want to use the latest officially -released tox (or latest development version) and want to skip manually -installing ``tox`` then substitute the above **Python build step** code -with this: - -.. code-block:: python - - import urllib, os - - url = "https://bitbucket.org/hpk42/tox/raw/default/toxbootstrap.py" - # os.environ['USETOXDEV']="1" # use tox dev version - d = dict(__file__="toxbootstrap.py") - exec(urllib.urlopen(url).read(), globals=d) - d["cmdline"](["--recreate"]) - -The downloaded ``toxbootstrap.py`` file downloads all necessary files to -install ``tox`` in a virtual sub environment. Notes: - -* uncomment the line containing ``USETOXDEV`` to use the latest - development-release version of tox instead of the - latest released version. - -* adapt the options in the last line as needed (the example code - will cause tox to reinstall all virtual environments all the time - which is often what one wants in CI server contexts) - - -Integrating "sphinx" documentation checks in a Jenkins job ----------------------------------------------------------------- - -If you are using a multi-configuration Jenkins job which collects -JUnit Test results you will run into problems using the previous -method of running the sphinx-build command because it will not -generate JUnit results. To accommodate this issue one solution -is to have ``pytest`` wrap the sphinx-checks and create a -JUnit result file which wraps the result of calling sphinx-build. -Here is an example: - -1. create a ``docs`` environment in your ``tox.ini`` file like this: - - .. code-block:: ini - - [testenv:docs] - basepython = python - # change to ``doc`` dir if that is where your sphinx-docs live - changedir = doc - deps = sphinx - pytest - commands = pytest --tb=line -v --junitxml=junit-{envname}.xml check_sphinx.py - -2. create a ``doc/check_sphinx.py`` file like this: - - .. code-block:: python - - import subprocess - - - def test_linkcheck(tmpdir): - doctrees = tmpdir.join("doctrees") - htmldir = tmpdir.join("html") - subprocess.check_call( - ["sphinx-build", "-W", "-blinkcheck", "-d", str(doctrees), ".", str(htmldir)] - ) - - - def test_build_docs(tmpdir): - doctrees = tmpdir.join("doctrees") - htmldir = tmpdir.join("html") - subprocess.check_call( - ["sphinx-build", "-W", "-bhtml", "-d", str(doctrees), ".", str(htmldir)] - ) - -3. run ``tox -e docs`` and then you may integrate this environment - along with your other environments into Jenkins. - -Note that ``pytest`` is only installed into the docs environment -and does not need to be in use or installed with any other environment. - -.. _`jenkins artifact example`: - -Access package artifacts between Jenkins jobs --------------------------------------------------------- - -.. _`Jenkins Copy Artifact plugin`: https://wiki.jenkins.io/display/JENKINS/Copy+Artifact+Plugin - -In an extension to :ref:`artifacts` you can also configure Jenkins jobs to -access each others artifacts. ``tox`` uses the ``distshare`` directory -to access artifacts and in a Jenkins context (detected via existence -of the environment variable ``HUDSON_URL``); it defaults to -to ``{toxworkdir}/distshare``. - -This means that each workspace will have its own ``distshare`` -directory and we need to configure Jenkins to perform artifact copying. -The recommend way to do this is to install the `Jenkins Copy Artifact plugin`_ -and for each job which "receives" artifacts you add a **Copy artifacts from another project** build step -using roughly this configuration: - - - .. code-block:: shell - - Project-name: name of the other (tox-managed) job you want the artifact from - Artifacts to copy: .tox/dist/*.zip # where tox jobs create artifacts - Target directory: .tox/distshare # where we want it to appear for us - Flatten Directories: CHECK # create no subdir-structure - -You also need to configure the "other" job to archive artifacts; This -is done by checking ``Archive the artifacts`` and entering: - - .. code-block:: shell - - Files to archive: .tox/dist/*.zip - -So our "other" job will create an sdist-package artifact and -the "copy-artifacts" plugin will copy it to our ``distshare`` area. -Now everything proceeds as :ref:`artifacts` shows it. - -So if you are using defaults you can re-use and debug exactly the -same ``tox.ini`` file and make use of automatic sharing of -your artifacts between runs or Jenkins jobs. - - -Avoiding the "path too long" error with long shebang lines ---------------------------------------------------------------- - -When using ``tox`` on a Jenkins instance, there may be a scenario where ``tox`` -can not invoke ``pip`` because the shebang (Unix) line is too long. Some systems -only support a limited amount of characters for an interpreter directive (e.x. -Linux as a limit of 128). There are two methods to workaround this issue: - - 1. Invoke ``tox`` with the ``--workdir`` option which tells ``tox`` to use a - specific directory for its virtual environments. Using a unique and short - path can prevent this issue. - 2. Use the environment variable ``TOX_LIMITED_SHEBANG`` to deal with - environments with interpreter directive limitations (consult - :ref:`long interpreter directives` for more information). - - -Running tox environments in parallel ------------------------------------- - -Jenkins has parallel stages allowing you to run commands in parallel, however tox package -building it is not parallel safe. Use the ``--parallel--safe-build`` flag to enable parallel safe -builds (this will generate unique folder names for ``distdir``, ``distshare`` and ``log``. -Here's a generic stage definition demonstrating how to use this inside Jenkins: - -.. code-block:: groovy - - stage('run tox envs') { - steps { - script { - def envs = sh(returnStdout: true, script: "tox -l").trim().split('\n') - def cmds = envs.collectEntries({ tox_env -> - [tox_env, { - sh "tox --parallel--safe-build -vve $tox_env" - }] - }) - parallel(cmds) - } - } - } - -.. include:: ../links.rst diff --git a/docs/example/nose.rst b/docs/example/nose.rst deleted file mode 100644 index bd4339e4a..000000000 --- a/docs/example/nose.rst +++ /dev/null @@ -1,38 +0,0 @@ -nose and tox -================================= - -It is easy to integrate `nosetests`_ runs with tox. -For starters here is a simple ``tox.ini`` config to configure your project -for running with nose: - -Basic nosetests example --------------------------- - -Assuming the following layout: - -.. code-block:: shell - - tox.ini # see below for content - setup.py # a classic distutils/setuptools setup.py file - -and the following ``tox.ini`` content: - -.. code-block:: ini - - [testenv] - deps = nose - # ``{posargs}`` will be substituted with positional arguments from command line - commands = nosetests {posargs} - -you can invoke ``tox`` in the directory where your ``tox.ini`` resides. -``tox`` will sdist-package your project create two virtualenv environments -with the ``python2.7`` and ``python3.6`` interpreters, respectively, and will -then run the specified test command. - - -More examples? ------------------------------------------- - -Also you might want to checkout :doc:`general` and :doc:`documentation`. - -.. include:: ../links.rst diff --git a/docs/example/package.rst b/docs/example/package.rst deleted file mode 100644 index 66a037321..000000000 --- a/docs/example/package.rst +++ /dev/null @@ -1,91 +0,0 @@ -Packaging -========= - -Although one can use tox to develop and test applications one of its most popular -usage is to help library creators. Libraries need first to be packaged, so then -they can be installed inside a virtual environment for testing. To help with this -tox implements PEP-517_ and PEP-518_. This means that by default -tox will build source distribution out of source trees. Before running test commands -``pip`` is used to install the source distribution inside the build environment. - -To create a source distribution there are multiple tools out there and with PEP-517_ and PEP-518_ -you can easily use your favorite one with tox. Historically tox -only supported ``setuptools``, and always used the tox host environment to build -a source distribution from the source tree. This is still the default behavior. -To opt out of this behaviour you need to set isolated builds to true. - -setuptools ----------- -Using the ``pyproject.toml`` file at the root folder (alongside ``setup.py``) one can specify -build requirements. - -.. code-block:: toml - - [build-system] - requires = [ - "setuptools >= 35.0.2", - "setuptools_scm >= 2.0.0, <3" - ] - build-backend = "setuptools.build_meta" - -.. code-block:: ini - - # tox.ini - [tox] - isolated_build = True - -flit ----- -flit_ requires ``Python 3``, however the generated source -distribution can be installed under ``python 2``. Furthermore it does not require a ``setup.py`` -file as that information is also added to the ``pyproject.toml`` file. - -.. code-block:: toml - - [build-system] - requires = ["flit_core >=2,<4"] - build-backend = "flit_core.buildapi" - - [tool.flit.metadata] - module = "package_toml_flit" - author = "Happy Harry" - author-email = "happy@harry.com" - home-page = "https://github.com/happy-harry/is" - -.. code-block:: ini - - # tox.ini - [tox] - isolated_build = True - -poetry ------- -poetry_ requires ``Python 3``, however the generated source -distribution can be installed under ``python 2``. Furthermore it does not require a ``setup.py`` -file as that information is also added to the ``pyproject.toml`` file. - -.. code-block:: toml - - [build-system] - requires = ["poetry_core>=1.0.0"] - build-backend = "poetry.core.masonry.api" - - [tool.poetry] - name = "package_toml_poetry" - version = "0.1.0" - description = "" - authors = ["Name "] - -.. code-block:: ini - - # tox.ini - [tox] - isolated_build = True - - [tox:.package] - # note tox will use the same python version as under what tox is installed to package - # so unless this is python 3 you can require a given python version for the packaging - # environment via the basepython key - basepython = python3 - -.. include:: ../links.rst diff --git a/docs/example/platform.rst b/docs/example/platform.rst deleted file mode 100644 index a2944e90e..000000000 --- a/docs/example/platform.rst +++ /dev/null @@ -1,44 +0,0 @@ -.. _platform-specification: - -Platform specification -============================ - -Basic multi-platform example ----------------------------- - -Assuming the following layout: - -.. code-block:: shell - - tox.ini # see below for content - setup.py # a classic distutils/setuptools setup.py file - -and the following ``tox.ini`` content: - -.. code-block:: ini - - [tox] - # platform specification support is available since version 2.0 - minversion = 2.0 - envlist = py{27,36}-{mylinux,mymacos,mywindows} - - [testenv] - # environment will be skipped if regular expression does not match against the sys.platform string - platform = mylinux: linux - mymacos: darwin - mywindows: win32 - - # you can specify dependencies and their versions based on platform filtered environments - deps = mylinux,mymacos: py==1.4.32 - mywindows: py==1.4.30 - - # upon tox invocation you will be greeted according to your platform - commands= - mylinux: python -c 'print("Hello, Linus!")' - mymacos: python -c 'print("Hello, Steve!")' - mywindows: python -c 'print("Hello, Bill!")' - -you can invoke ``tox`` in the directory where your ``tox.ini`` resides. -``tox`` creates two virtualenv environments with the ``python2.7`` and -``python3.6`` interpreters, respectively, and will then run the specified -command according to platform you invoke ``tox`` at. diff --git a/docs/example/pytest.rst b/docs/example/pytest.rst deleted file mode 100644 index 00d8cdae1..000000000 --- a/docs/example/pytest.rst +++ /dev/null @@ -1,126 +0,0 @@ -pytest and tox -================================= - -It is easy to integrate `pytest`_ runs with tox. If you encounter -issues, please check if they are `listed as a known issue`_ and/or use -the :doc:`support channels <../support>`. - -Basic example --------------------------- - -Assuming the following layout: - -.. code-block:: shell - - tox.ini # see below for content - setup.py # a classic distutils/setuptools setup.py file - -and the following ``tox.ini`` content: - -.. code-block:: ini - - [tox] - envlist = py35,py36 - - [testenv] - deps = pytest # PYPI package providing pytest - commands = pytest {posargs} # substitute with tox' positional arguments - -you can now invoke ``tox`` in the directory where your ``tox.ini`` resides. -``tox`` will sdist-package your project, create two virtualenv environments -with the ``python3.5`` and ``python3.6`` interpreters, respectively, and will -then run the specified test command in each of them. - -Extended example: change dir before test and use per-virtualenv tempdir --------------------------------------------------------------------------- - -Assuming the following layout: - -.. code-block:: shell - - tox.ini # see below for content - setup.py # a classic distutils/setuptools setup.py file - tests # the directory containing tests - -and the following ``tox.ini`` content: - -.. code-block:: ini - - [tox] - envlist = py35,py36 - - [testenv] - changedir = tests - deps = pytest - # change pytest tempdir and add posargs from command line - commands = pytest --basetemp="{envtmpdir}" {posargs} - -you can invoke ``tox`` in the directory where your ``tox.ini`` resides. -Differently than in the previous example the ``pytest`` command -will be executed with a current working directory set to ``tests`` -and the test run will use the per-virtualenv temporary directory. - -.. _`passing positional arguments`: - -Using multiple CPUs for test runs ------------------------------------ - -``pytest`` supports distributing tests to multiple processes and hosts -through the `pytest-xdist`_ plugin. Here is an example configuration -to make ``tox`` use this feature: - -.. code-block:: ini - - [testenv] - deps = pytest-xdist - changedir = tests - # use three sub processes - commands = pytest --basetemp="{envtmpdir}" \ - --confcutdir=.. \ - -n 3 \ - {posargs} - -.. _`listed as a known issue`: - -Known issues and limitations ------------------------------ - -**Too long filenames**. you may encounter "too long filenames" for temporarily -created files in your pytest run. Try to not use the "--basetemp" parameter. - -**installed-versus-checkout version**. ``pytest`` collects test -modules on the filesystem and then tries to import them under their -`fully qualified name`_. This means that if your test files are -importable from somewhere then your ``pytest`` invocation may end up -importing the package from the checkout directory rather than the -installed package. - -This issue may be characterised by pytest test-collection error messages, in python 3.x environments, that look like: - -.. code-block:: shell - - import file mismatch: - imported module 'myproj.foo.tests.test_foo' has this __file__ attribute: - /home/myuser/repos/myproj/build/lib/myproj/foo/tests/test_foo.py - which is not the same as the test file we want to collect: - /home/myuser/repos/myproj/myproj/foo/tests/test_foo.py - HINT: remove __pycache__ / .pyc files and/or use a unique basename for your test file modules - -There are a few ways to prevent this. - -With installed tests (the tests packages are known to ``setup.py``), a -safe and explicit option is to give the explicit path -``{envsitepackagesdir}/mypkg`` to pytest. -Alternatively, it is possible to use ``changedir`` so that checked-out -files are outside the import path, then pass ``--pyargs mypkg`` to -pytest. - -With tests that won't be installed, the simplest way to run them -against your installed package is to avoid ``__init__.py`` files in test -directories; pytest will still find and import them by adding their -parent directory to ``sys.path`` but they won't be copied to -other places or be found by Python's import system outside of pytest. - -.. _`fully qualified name`: https://docs.pytest.org/en/latest/goodpractices.html#test-package-name - -.. include:: ../links.rst diff --git a/docs/example/result.rst b/docs/example/result.rst deleted file mode 100644 index 575cbedf3..000000000 --- a/docs/example/result.rst +++ /dev/null @@ -1,47 +0,0 @@ -Writing a JSON result file --------------------------------------------------------- - -.. versionadded: 1.6 - -You can instruct tox to write a json-report file via: - -.. code-block:: shell - - - tox --result-json=PATH - -This will create a json-formatted result file using this schema: - -.. code-block:: json - - { - "testenvs": { - "py27": { - "python": { - "executable": "/home/hpk/p/tox/.tox/py27/bin/python", - "version": "2.7.3 (default, Aug 1 2012, 05:14:39) \n[GCC 4.6.3]", - "version_info": [ 2, 7, 3, "final", 0 ] - }, - "test": [ - { - "output": "...", - "command": [ - "/home/hpk/p/tox/.tox/py27/bin/pytest", - "--instafail", - "--junitxml=/home/hpk/p/tox/.tox/py27/log/junit-py27.xml", - "tests/test_config.py" - ], - "retcode": "0" - } - ], - "setup": [] - } - }, - "platform": "linux2", - "installpkg": { - "basename": "tox-1.6.0.dev1.zip", - "sha256": "b6982dde5789a167c4c35af0d34ef72176d0575955f5331ad04aee9f23af4326" - }, - "toxversion": "1.6.0.dev1", - "reportversion": "1" - } diff --git a/docs/example/unittest.rst b/docs/example/unittest.rst deleted file mode 100644 index 26e0e5457..000000000 --- a/docs/example/unittest.rst +++ /dev/null @@ -1,98 +0,0 @@ -unittest2, discover and tox -=============================== - -Running unittests with 'discover' ------------------------------------------- - -The discover_ project allows you to discover and run unittests -that you can easily integrate it in a ``tox`` run. As an example, -perform a checkout of `Pygments `_: - -.. code-block:: shell - - hg clone https://bitbucket.org/birkenfeld/pygments-main - -and add the following ``tox.ini`` to it: - -.. code-block:: ini - - [tox] - envlist = py27,py35,py36 - - [testenv] - changedir = tests - commands = discover - deps = discover - -If you now invoke ``tox`` you will see the creation of -three virtual environments and a unittest-run performed -in each of them. - -Running unittest2 and sphinx tests in one go ------------------------------------------------------ - -.. _`Michael Foord`: http://www.voidspace.org.uk/ - -`Michael Foord`_ has contributed a ``tox.ini`` file that -allows you to run all tests for his mock_ project, -including some sphinx-based doctests. If you checkout -its repository with: - -.. code-block:: shell - - git clone https://github.com/testing-cabal/mock.git - -The checkout has a `tox.ini file `_ -that looks like this: - -.. code-block:: ini - - [tox] - envlist = py27,py35,py36,py37 - - [testenv] - deps = unittest2 - commands = unit2 discover [] - - [testenv:py36] - commands = - unit2 discover [] - sphinx-build -b doctest docs html - sphinx-build docs html - deps = - unittest2 - sphinx - - [testenv:py27] - commands = - unit2 discover [] - sphinx-build -b doctest docs html - sphinx-build docs html - deps = - unittest2 - sphinx - -mock uses unittest2_ to run the tests. Invoking ``tox`` starts test -discovery by executing the ``unit2 discover`` -commands on Python 2.7, 3.5, 3.6 and 3.7 respectively. Against -Python3.6 and Python2.7 it will additionally run sphinx-mediated -doctests. If building the docs fails, due to a reST error, or -any of the doctests fails, it will be reported by the tox run. - -The ``[]`` parentheses in the commands provide :ref:`positional substitution` which means -you can e.g. type: - -.. code-block:: shell - - tox -- -f -s SOMEPATH - -which will ultimately invoke: - -.. code-block:: shell - - unit2 discover -f -s SOMEPATH - -in each of the environments. This allows you to customize test discovery -in your ``tox`` runs. - -.. include:: ../links.rst diff --git a/docs/examples.rst b/docs/examples.rst deleted file mode 100644 index 7c86dda08..000000000 --- a/docs/examples.rst +++ /dev/null @@ -1,16 +0,0 @@ -tox configuration and usage examples -============================================================================== - -.. toctree:: - :maxdepth: 2 - - example/basic.rst - example/package.rst - example/pytest.rst - example/unittest - example/nose.rst - example/documentation.rst - example/general.rst - example/jenkins.rst - example/devenv.rst - example/platform.rst diff --git a/docs/faq.rst b/docs/faq.rst new file mode 100644 index 000000000..5d43bf413 --- /dev/null +++ b/docs/faq.rst @@ -0,0 +1,371 @@ +FAQ +=== + +Here you'll find answers to some frequently asked questions. + +Breaking changes in tox 4 +------------------------- +Version 4 of tox should be mostly backwards compatible with version 3, with the following exceptions: + +tox 4 - Python support +++++++++++++++++++++++ +- tox now requires Python ``3.7`` or later and is tested only against CPython. You can still create test environments + for earlier Python versions or different Python interpreters. PyPy support is best effort, meaning we do not test it + as part of our CI runs, however if you discover issues under PyPy we will accept PRs addressing it. + +tox 4 - known regressions ++++++++++++++++++++++++++ + +- With tox 4 the tty trait of the caller environment is no longer passed through. The most notable impact of this is + that some tools no longer print colored output. A PR to address this is welcomed, in the meantime you can use the + ``tty`` substitution to force color mode for these tools, see for example tox itself with pytest and mypy + `here in tox.ini `_. + +tox 4 - new plugin system ++++++++++++++++++++++++++ + +tox 4 is a grounds up rewrite of the code base, and while we kept the configuration layer compatibility no such effort +has been made for the programmatic API. Therefore, all plugins will need to redo their integration against the new code +base. If you're a plugin developer refer to the `plugin documentation `_ for +more information. + +tox 4 - removed tox.ini keys +++++++++++++++++++++++++++++ + ++--------------------------+---------------------------------------------+ +| Configuration key | Migration path | ++==========================+=============================================+ +| ``indexserver`` | See `Using a custom PyPI server`_. | ++--------------------------+---------------------------------------------+ +| ``whitelist_externals`` | Use :ref:`allowlist_externals` key instead. | ++--------------------------+---------------------------------------------+ +| ``isolated_build`` | Isolated builds are now always used. | ++--------------------------+---------------------------------------------+ + +tox 4 - substitutions removed ++++++++++++++++++++++++++++++ +- The ``distshare`` substitution has been removed. + +tox 4 - disallowed env names +++++++++++++++++++++++++++++ +- Environment names that contain multiple Python variants, such as ``name-py39-pypy`` or ``py39-py310`` will now raise + an error, previously this only warned, you can use :ref:`ignore_basepython_conflict` to disable this error, but we + recommend changing the name to avoid this name that can be confusing. + +tox 4 - CLI arguments changed ++++++++++++++++++++++++++++++ +- The ``--parallel--safe-build`` CLI argument has been removed, no longer needed. +- When you want to pass an option to a test command, e.g. to ``pytest``, now you must use ``--`` as a separator, this + worked with version 3 also, but any unknown trailing arguments were automatically passed through, while now this is + no longer the case. + +tox 4 - packaging changes ++++++++++++++++++++++++++ +- We use isolated builds (always) as specified by :pep:`518` and use :pep:`517` to communicate with the build backend. +- The ``--develop`` CLI flag or the :ref:`use_develop` settings now enables editable installations via the :pep:`660` + mechanism rather than the legacy ``pip install -e`` behaviour. The old functionality can still be forced by setting + the :ref:`package` setting for the run environment to ``editable-legacy``. + +tox 4 -- output changes ++++++++++++++++++++++++ +- We now use colors for reporting, to help make the output easier to read for humans. This can be disabled via the + ``TERM=dumb`` or ``NO_COLOR=1`` environment variables, or the ``--colored no`` CLI argument. + +New features in tox 4 +--------------------- +Here is a non-exhaustive list of these. + +- You can now build wheel(s) instead of a source distribution during the packaging phase by using the ``wheel`` setting + for the :ref:`package` setting. If your package is a universal wheel you'll likely want to set the + :ref:`wheel_build_env` to ``.pkg`` to avoid building a wheel for every Python version you target. +- Editable wheel support was added as defined by :pep:`660` via the :ref:`package` setting to ``editable``. +- We redesigned our CLI interface, we no longer try to squeeze everything under single command, instead now we have + multiple sub-commands. For backwards compatibility if you do not specify a subcommand we'll assume you want the tox 3 + legacy interface (available under the legacy subcommand), for now the list of available commands are: + + .. code-block:: bash + + subcommands: + tox command to execute (by default legacy) + + {run,r,run-parallel,p,depends,de,list,l,devenv,d,config,c,quickstart,q,exec,e,legacy,le} + run (r) run environments + run-parallel (p) run environments in parallel + depends (de) visualize tox environment dependencies + list (l) list environments + devenv (d) sets up a development environment at ENVDIR based on the tox configuration specified + config (c) show tox configuration + quickstart (q) Command line script to quickly create a tox config file for a Python project + exec (e) execute an arbitrary command within a tox environment + legacy (le) legacy entry-point command + + The ``exec`` and ``depends`` are brand new features. Other subcommands are a more powerful versions of previously + existing single flags (e.g. ``-av`` is now succeeded by the ``list`` subcommand). All subcommands have a one or two + character shortcuts for less typing on the CLI (e.g. ``tox run`` can be abbreviated to ``tox r``). For more details + see :ref:`cli`. +- Startup times should be improved because now we no longer eagerly load all configurations for all environments, but + instead these are performed lazily when needed. Side-effect of this is that if you have an invalid configuration will + not be picked up until you try to use it. +- We now discover your package dependency changes (either via :pep:`621` or otherwise via :pep:`517` + ``prepare_metadata_for_build_wheel``/``build_wheel`` metadata). If new dependencies are added these will be installed + on the next run. If a dependency is removed we'll recreate the entire environment. This works for ``requirements`` + files within the :ref:`deps`. This means that you should never need to use ``--recreate`` flag, tox should be smart + enough to figure out when things change and automatically apply it. +- All tox defaults can now be changed via the user level config-file (see help message output for its location, can be + changed via ``TOX_CONFIG_FILE`` environment variable). +- All tox defaults can now be changed via an environment variable: ``TOX_`` prefix followed by the settings key, + e.g. ``TOX_PACKAGE=wheel``. +- Any configuration can be overwritten via the CLI ``-x`` or ``--override`` flag, e.g. + ``tox run -e py311 -x testenv:py310.package=editable`` would force the packaging of environment ``py311`` to be an + editable install independent what's in the configuration file. +- :ref:`basepython` is now a list, the first successfully detected python will be used to generate python environment. +- We now have support for inline tox plugins via the ``toxfile.py`` at the root of your project. At a later time this + will allow using Python only configuration, as seen with nox. +- You can now group tox environments via :ref:`labels` configuration, and you can invoke all tox environments within a + label by using the ``-m label`` CLI flag (instead of the ``-e list_of_envs``). +- You can now invoke all tox environments within a given factor via the ``-f factor`` CLI flag. + +Using a custom PyPI server +-------------------------- +By default tox uses pip to install Python dependencies. Therefore to change the index server you should configure pip +directly. pip accepts environment variables as configuration flags, therefore the easiest way to do this is to set the +``PIP_INDEX_URL`` environment variable: + +.. code-block:: ini + + set_env = + PIP_INDEX_URL = https://tox.wiki/pypi/simple + +It's considered a best practice to allow the user to change the index server rather than hard code it, allowing them +to use for example a local cache when they are offline. Therefore, a better form of this would be: + +.. code-block:: ini + + set_env = + PIP_INDEX_URL = {env:PIP_INDEX_URL:https://tox.wiki/pypi/simple} + +Here we use an environment substitution to set the index URL if not set by the user, but otherwise default to our target +URI. + +Using two PyPI servers +---------------------- + +When you want to use two PyPI index servers because not all dependencies are found in either of them use the +``PIP_EXTRA_INDEX_URL`` environment variable: + +.. code-block:: ini + + set_env = + PIP_INDEX_URL = {env:PIP_INDEX_URL:https://tox.wiki/pypi/simple-first} + PIP_EXTRA_INDEX_URL = {env:PIP_EXTRA_INDEX_URL:https://tox.wiki/pypi/simple-second} + +If the index server defined under ``PIP_INDEX_URL`` does not contain a package, pip will attempt to resolve it also from +the URI from ``PIP_EXTRA_INDEX_URL``. + +.. warning:: + + Using an extra PyPI index for installing private packages may cause security issues. For example, if ``package1`` is + registered with the default PyPI index, pip will install ``package1`` from the default PyPI index, not from the extra + one. + +Using constraint files +---------------------- +`Constraint files `_ are a type of artifact, supported by +pip, that define not what requirements to install but instead what version constraints should be applied for the +otherwise specified requirements. The constraint file must always be specified together with the requirement(s) to +install. While creating a test environment tox will invoke pip multiple times, in separate phases: + +1. If :ref:`deps` is specified, it will install a set of dependencies before installing the package. +2. If the target environment contains a package (the project does not have :ref:`package` ``skip`` or + :ref:`skip_install` is ``true``), it will: + + 1. install the dependencies of the package. + 2. install the package itself. + +Some solutions and their drawbacks: + +- specify the constraint files within :ref:`deps` (these constraints will not be applied when installing package + dependencies), +- use ``PIP_CONSTRAINT`` inside :ref:`set_env` (tox will not know about the content of the constraint file and such + will not trigger a rebuild of the environment when its content changes), +- specify the constraint file by extending the :ref:`install_command` as in the following example + (tox will not know about the content of the constraint file and such will not trigger a rebuild of the environment + when its content changes). + +.. code-block:: ini + + [testenv:py39] + install_command = python -m pip install {opts} {packages} -c constraints.txt + extras = test + +Note constraint files are a subset of requirement files. Therefore, it's valid to pass a constraint file wherever you +can specify a requirement file. + +.. _platform-specification: + +Platform specification +---------------------- + +Assuming the following layout: + +.. code-block:: shell + + tox.ini # see below for content + setup.py # a classic distutils/setuptools setup.py file + +and the following ``tox.ini`` content: + +.. code-block:: ini + + [tox] + min_version = 2.0 # platform specification support is available since version 2.0 + envlist = py{310,39}-{lin,mac,win} + + [testenv] + # environment will be skipped if regular expression does not match against the sys.platform string + platform = lin: linux + mac: darwin + win: win32 + + # you can specify dependencies and their versions based on platform filtered environments + deps = lin,mac: platformdirs==3 + win: platformdirs==2 + + # upon tox invocation you will be greeted according to your platform + commands= + lin: python -c 'print("Hello, Linus!")' + mac: python -c 'print("Hello, Tim!")' + win: python -c 'print("Hello, Satya!")' + +You can invoke ``tox`` in the directory where your ``tox.ini`` resides. ``tox`` creates two virtualenv environments +with the ``python3.10`` and ``python3.9`` interpreters, respectively, and will then run the specified command according +to platform you invoke ``tox`` at. + +Ignoring the exit code of a given command +----------------------------------------- + +When multiple commands are defined within the :ref:`commands` configuration field tox will run them sequentially until +one of them fails (by exiting with non zero exit code) or all of them are run. If you want to ignore the status code of +a given command add a ``-`` prefix to that line (similar syntax to how the GNU ``make`` handles this): + +.. code-block:: ini + + + [testenv] + commands = + - python -c 'import sys; sys.exit(1)' + python --version + +Customizing virtual environment creation +---------------------------------------- + +By default tox uses the :pypi:`virtualenv` to create Python virtual environments to run your tools in. To change how tox +creates virtual environments you can set environment variables to customize virtualenv. For example, to provision a given +pip version in the virtual environment you can set ``VIRTUALENV_PIP`` or to enable system site packages use the +``VIRTUALENV_SYSTEM_SITE_PACKAGES``: + + +.. code-block:: ini + + + [testenv] + setenv = + VIRTUALENV_PIP==22.1 + VIRTUALENV_SYSTEM_SITE_PACKAGES=true + +Consult the :pypi:`virtualenv` project for supported values (any CLI flag for virtualenv, in all upper case, prefixed +by the ``VIRTUALENV_`` key). + +Building documentation with Sphinx +---------------------------------- + +It's possible to orchestrate the projects documentation with tox. The advantage of this is that now generating the +documentation can be part of the CI, and whenever any validations/checks/operations fail while generating the +documentation you'll catch it within tox. + +We don't recommend using the Make and Batch file generated by Sphinx, as this makes your documentation generation +platform specific. A better solution is to use tox to setup a documentation build environment and invoke sphinx inside +it. This solution is cross platform. + +For example if the sphinx file structure is under the ``docs`` folder the following configuration will generate +the documentation under ``.tox/docs_out/index.html`` and print out a link to the generated documentation: + +.. code-block:: ini + + [testenv:docs] + description = build documentation + basepython = python3.10 + deps = + sphinx>=4 + commands = + sphinx-build -d "{envtmpdir}{/}doctree" docs "{toxworkdir}{/}docs_out" --color -b html + python -c 'print(r"documentation available under file://{toxworkdir}{/}docs_out{/}index.html")' + +Note here we also require Python 3.10, allowing us to use f-strings within the sphinx ``conf.py``. + +Building documentation with mkdocs +---------------------------------- + +It's possible to orchestrate the projects documentation with tox. The advantage of this is that now generating the +documentation can be part of the CI, and whenever any validations/checks/operations fail while generating the +documentation you'll catch it within tox. + +It's best to define one environment to write/generate the documentation, and another to deploy it. Use the config +substitution logic to avoid duplication: + +.. code-block:: ini + + [testenv:docs] + description = Run a development server for working on documentation + deps = + mkdocs>=1.3 + mkdocs-material + commands = + mkdocs build --clean + python -c 'print("###### Starting local server. Press Control+C to stop server ######")' + mkdocs serve -a localhost:8080 + + [testenv:docs-deploy] + description = built fresh docs and deploy them + deps = {[testenv:docs]deps} + commands = mkdocs gh-deploy --clean + +Understanding ``InvocationError`` exit codes +-------------------------------------------- + +When a command executed by tox fails, it always has a non-zero exit code and an ``InvocationError`` exception is +raised: + +.. code-block:: shell + + ERROR: InvocationError for command + '' (exited with code 1) + +Generally always check the documentation for the command executed to understand what the code means. For example for +:pypi:`pytest` you'd read `here `_. On unix +systems, there are some rather `common exit codes `_. This is why for +exit codes larger than 128, if a signal with number equal to `` - 128`` is found in the :py:mod:`signal` +module, an additional hint is given: + +.. code-block:: shell + + ERROR: InvocationError for command + '' (exited with code 139) + Note: this might indicate a fatal error signal (139 - 128 = 11: SIGSEGV) + + +The signal numbers (e.g. 11 for a segmentation fault) can be found in the "Standard signals" section of the +`signal man page `_. +Their meaning is described in `POSIX signals `_. Beware +that programs may issue custom exit codes with any value, so their documentation should be consulted. + + +Sometimes, no exit code is given at all. An example may be found in +:gh:`pytest-qt issue #170 `, where Qt was calling +`abort() `_ instead of ``exit()``. + +Access full logs +---------------- + +If you want to access the full logs you need to write ``-q`` and ``-v`` as +individual tox arguments and avoid combining them into a single one. diff --git a/docs/img/overview.mermaidjs b/docs/img/overview.mermaidjs new file mode 100644 index 000000000..3bd5db100 --- /dev/null +++ b/docs/img/overview.mermaidjs @@ -0,0 +1,40 @@ +stateDiagram-v2 +%%{init:{'state':{'nodeSpacing': 0, 'rankSpacing': 20}}}%% + +[*] --> conf +conf --> tox_env + +state tox_env { + state hdi <> + state hpi <> + state fpi <> + + [*] --> create + create --> hdi : has (new) project dependencies (deps) + hdi --> deps: yes + hdi --> hpi: no, has package + deps --> hpi: has package + hpi --> fpi: yes, built package in this run + hpi --> commands : no + fpi --> install_deps: yes + fpi --> package: no + package --> install_deps + install_deps --> install + install --> commands + commands --> commands: for each entry
in commands* + commands --> [*] : pass outcome to report +} +tox_env --> tox_env :for each tox environment + +tox_env --> report +report --> report :for each tox environment +report --> [*] + +conf: build configuration (CLI + files)
identify environments to run +create: create an isolated tox environment
the other steps executed within this +deps: install project dependencies (if has deps) +package: build package +install: install package without dependencies +install_deps: install (new) package dependencies +commands: run command +report: report the outcome of the run diff --git a/docs/img/overview_dark.svg b/docs/img/overview_dark.svg new file mode 100644 index 000000000..2b488ab51 --- /dev/null +++ b/docs/img/overview_dark.svg @@ -0,0 +1,563 @@ +
for each tox environment
for each tox environment
build configuration (CLI + files)
identify environments to run
tox_env
has (new) project dependencies (deps)
yes
no, has package
has package
yes, built package in this run
no
yes
no
for each entry
in commands*
pass outcome to report
create an isolated tox environment
the other steps executed within this
install project dependencies (if has deps)
run command
install (new) package dependencies
build package
install package without dependencies
report the outcome of the run
diff --git a/docs/img/overview_light.svg b/docs/img/overview_light.svg new file mode 100644 index 000000000..698a04290 --- /dev/null +++ b/docs/img/overview_light.svg @@ -0,0 +1,563 @@ +
for each tox environment
for each tox environment
build configuration (CLI + files)
identify environments to run
tox_env
has (new) project dependencies (deps)
yes
no, has package
has package
yes, built package in this run
no
yes
no
for each entry
in commands*
pass outcome to report
create an isolated tox environment
the other steps executed within this
install project dependencies (if has deps)
run command
install (new) package dependencies
build package
install package without dependencies
report the outcome of the run
diff --git a/docs/img/tox_flow.png b/docs/img/tox_flow.png deleted file mode 100644 index 4f13b8bc7..000000000 Binary files a/docs/img/tox_flow.png and /dev/null differ diff --git a/docs/index.rst b/docs/index.rst index e101336ba..000638165 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,189 +1,78 @@ -Welcome to the tox automation project -=============================================== - -Vision: standardize testing in Python ---------------------------------------------- - -``tox`` aims to automate and standardize testing in Python. It is part -of a larger vision of easing the packaging, testing and release process -of Python software. - -What is tox? --------------------- - -tox is a generic virtualenv_ management and test command line tool you can use for: - -* checking that your package installs correctly with different Python versions and - interpreters - -* running your tests in each of the environments, configuring your test tool of choice - -* acting as a frontend to Continuous Integration servers, greatly - reducing boilerplate and merging CI and shell-based testing. - - -Basic example ------------------ - -First, install ``tox`` with ``pip install tox``. -Then put basic information about your project and the test environments you -want your project to run in into a ``tox.ini`` file residing -right next to your ``setup.py`` file: - -.. code-block:: ini - - # content of: tox.ini , put in same dir as setup.py - [tox] - envlist = py27,py36 - - [testenv] - # install pytest in the virtualenv where commands will be executed - deps = pytest - commands = - # NOTE: you can run any command line tool here - not just tests - pytest - -You can also try generating a ``tox.ini`` file automatically, by running -``tox-quickstart`` and then answering a few simple questions. - -To sdist-package, install and test your project against Python2.7 and Python3.6, just type:: - - tox - -and watch things happen (you must have python2.7 and python3.6 installed in your -environment otherwise you will see errors). When you run ``tox`` a second time -you'll note that it runs much faster because it keeps track of virtualenv details -and will not recreate or re-install dependencies. You also might want to -checkout :doc:`examples` to get some more ideas. - -System overview ---------------- - -.. figure:: img/tox_flow.png - :align: center - :width: 800px - - tox workflow diagram - -.. - The above image raw can be found and edited by using the toxdevorg Google role account under - https://www.lucidchart.com/documents/edit/5d921f32-f2e1-4618-a265-7f9e30503dc6/0 - -tox roughly follows the following phases: - -1. **configuration:** load ``tox.ini`` and merge it with options from the command line and the - operating system environment variables. -2. **packaging** (optional): create a source distribution of the current project by invoking - - .. code-block:: bash - - python setup.py sdist - - Note that for this operation the same Python environment will be used as the one tox is - installed into (therefore you need to make sure that it contains your build dependencies). - Skip this step for application projects that don't have a ``setup.py``. - -3. **environment** - for each tox environment (e.g. ``py27``, ``py36``) do: - - 1. **environment creation**: create a fresh environment, by default virtualenv_ is used. tox will - automatically try to discover a valid Python interpreter version by using the environment name - (e.g. ``py27`` means Python 2.7 and the ``basepython`` configuration value) and the current - operating system ``PATH`` value. This is created at first run only to be re-used at subsequent - runs. If certain aspects of the project change, a re-creation of the environment is - automatically triggered. To force the recreation tox can be invoked with ``-r``/``--recreate``. - - 2. **install** (optional): install the environment dependencies specified inside the - :conf:`deps` configuration section, and then the earlier packaged source distribution. - By default ``pip`` is used to install packages, however one can customise this via - :conf:`install_command`. Note ``pip`` will not update project dependencies (specified either - in the ``install_requires`` or the ``extras`` section of the ``setup.py``) if any version already - exists in the virtual environment; therefore we recommend to recreate your environments - whenever your project dependencies change. - - 3. **commands**: run the specified commands in the specified order. Whenever the exit code of - any of them is not zero stop, and mark the environment failed. Note, starting a command with a - single dash character means ignore exit code. - -4. **report** print out a report of outcomes for each tox environment: - - .. code:: bash - - ____________________ summary ____________________ - py27: commands succeeded - ERROR: py36: commands failed - - Only if all environments ran successfully tox will return exit code ``0`` (success). In this - case you'll also see the message ``congratulations :)``. - -tox will take care of environment isolation for you: it will strip away all operating system -environment variables not specified via :conf:`passenv`. Furthermore, it will also alter the -``PATH`` variable so that your commands resolve first and foremost within the current active -tox environment. In general all executables in the path are available in ``commands``, but tox will -emit a warning if it was not explicitly allowed via :conf:`allowlist_externals`. - -Current features -------------------- - -* **automation of tedious Python related test activities** - -* **test your Python package against many interpreter and dependency configs** - - - automatic customizable (re)creation of virtualenv_ test environments - - - installs your ``setup.py`` based project into each virtual environment - - - test-tool agnostic: runs pytest, nose or unittests in a uniform manner - -* :doc:`plugin system ` to modify tox execution with simple hooks. - -* uses pip_ and setuptools_ by default. Support for configuring the installer command - through :conf:`install_command = ARGV `. - -* **cross-Python compatible**: CPython-2.7, 3.5 and higher, Jython and pypy_. - -* **cross-platform**: Windows and Unix style environments - -* **integrates with continuous integration servers** like Jenkins_ - (formerly known as Hudson) and helps you to avoid boilerplatish - and platform-specific build-step hacks. - -* **full interoperability with devpi**: is integrated with and - is used for testing in the devpi_ system, a versatile PyPI - index server and release managing tool. - -* **driven by a simple ini-style config file** - -* **documented** :doc:`examples ` and :doc:`configuration ` - -* **concise reporting** about tool invocations and configuration errors - -* **professionally** :doc:`supported ` - -* supports :ref:`using different / multiple PyPI index servers ` - - -Related projects ----------------- - -tox has influenced several other projects in the Python test automation space. If tox doesn't quite fit your needs or you want to do more research, we recommend taking a look at these projects: - -- `Invoke `__ is a general-purpose task execution library, similar to Make. Invoke is far more general-purpose than tox but it does not contain the Python testing-specific features that tox specializes in. -- `Nox `__ is a project similar in spirit to tox but different in approach. Nox's key difference is that it uses Python scripts instead of a configuration file. Nox might be useful if you find tox's configuration too limiting but aren't looking to move to something as general-purpose as Invoke or Make. - - +tox - automation project +======================== + +``tox`` aims to automate and standardize testing in Python. It is part of a larger vision of easing the packaging, +testing and release process of Python software (alongside `pytest `_ +and `devpi `_). + +.. image:: https://img.shields.io/pypi/v/tox?style=flat-square + :target: https://pypi.org/project/tox/#history + :alt: Latest version on PyPI +.. image:: https://img.shields.io/pypi/implementation/tox?style=flat-square + :alt: PyPI - Implementation +.. image:: https://img.shields.io/pypi/pyversions/tox?style=flat-square + :alt: PyPI - Python Version +.. image:: https://readthedocs.org/projects/tox/badge/?version=latest&style=flat-square + :target: https://tox.wiki/en/latest/ + :alt: Documentation status +.. image:: https://img.shields.io/discord/802911963368783933?style=flat-square + :target: https://discord.com/invite/tox + :alt: Discord +.. image:: https://img.shields.io/pypi/dm/tox?style=flat-square + :target: https://pypistats.org/packages/tox + :alt: PyPI - Downloads +.. image:: https://img.shields.io/pypi/l/tox?style=flat-square + :target: https://opensource.org/licenses/MIT + :alt: PyPI - License +.. image:: https://img.shields.io/github/issues/tox-dev/tox?style=flat-square + :target: https://github.com/tox-dev/tox/issues + :alt: Open issues +.. image:: https://img.shields.io/github/issues-pr/tox-dev/tox?style=flat-square + :target: https://github.com/tox-dev/tox/pulls + :alt: Open pull requests +.. image:: https://img.shields.io/github/stars/tox-dev/tox?style=flat-square + :target: https://pypistats.org/packages/tox + :alt: Package popularity + +tox is a generic virtual environment management and test command line tool you can use for: + +* checking your package builds and installs correctly under different environments (such as different Python + implementations, versions or installation dependencies), +* running your tests in each of the environments with the test tool of choice, +* acting as a frontend to continuous integration servers, greatly reducing boilerplate and merging CI and + shell-based testing. + +Useful links +------------ + +**Related projects** + +tox has influenced several other projects in the Python test automation space. If tox doesn't quite fit your needs or +you want to do more research, we recommend taking a look at these projects: + +- `Invoke `_ is a general-purpose task execution library, similar to Make. Invoke is far more + general-purpose than tox but it does not contain the Python testing-specific features that tox specializes in. + +- `nox `_ is a project similar in spirit to tox but different in approach. Nox's key + difference is that it uses Python scripts instead of a configuration file. Nox might be useful if you find tox's + configuration too limiting but aren't looking to move to something as general-purpose as Invoke or Make. + +**Tutorials** + +* `Oliver Bestwalter - Automating Build, Test and Release Workflows with tox `_ +* `Bernat Gabor - Standardize Testing in Python `_ + +.. comment: split here .. toctree:: :hidden: - install - examples + installation + user_guide + cli_interface config - support - changelog + faq plugins - developers - example/result - announce/changelog-only - - -.. include:: links.rst + plugins_api + development + changelog diff --git a/docs/install.rst b/docs/install.rst deleted file mode 100644 index 14af4f6cc..000000000 --- a/docs/install.rst +++ /dev/null @@ -1,57 +0,0 @@ -tox installation -================================== - -Install info in a nutshell ----------------------------------- - -**Pythons**: CPython 2.7 and 3.5 or later, Jython-2.5.1, pypy-1.9ff - -**Operating systems**: Linux, Windows, OSX, Unix - -**Installer Requirements**: setuptools_ - -**License**: MIT license - -**git repository**: https://github.com/tox-dev/tox - -Installation with pip --------------------------------------- - -Use the following command: - -.. code-block:: shell - - pip install tox - -It is fine to install ``tox`` itself into a virtualenv_ environment. - -Install from clone -------------------------- - -Consult the GitHub page how to clone the git repository: - - https://github.com/tox-dev/tox - -and then install in your environment with something like: - -.. code-block:: shell - - $ cd - $ pip install . - -or install it `editable `_ if you want code changes to propagate automatically: - -.. code-block:: shell - - $ cd - $ pip install --editable . - -so that you can do changes and submit patches. - - -[Linux/macOS] Install via your package manager ----------------------------------------------- - -You can also find tox packaged for many Linux distributions and Homebrew for macOs - usually under the name of **python-tox** or simply **tox**. Be aware though that there also other projects under the same name (most prominently a `secure chat client `_ with no affiliation to this project), so make sure you install the correct package. - -.. include:: links.rst diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 000000000..a843ad4d1 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,72 @@ +Installation +============ + +via pipx +-------- + +:pypi:`tox` is a CLI tool that needs a Python interpreter (version 3.7 or higher) to run. We recommend :pypi:`pipx` to +install tox into an isolated environment. This has the added benefit that later you'll be able to upgrade tox without +affecting other parts of the system. + +.. code-block:: bash + + python -m pip install pipx-in-pipx --user + pipx install tox + tox --help + +via pip +------- + +Alternatively you can install it within the global Python interpreter itself (perhaps as a user package via the +``--user`` flag). Be cautious if you are using a Python installation that is managed by your operating system or +another package manager. ``pip`` might not coordinate with those tools, and may leave your system in an inconsistent +state. Note, if you go down this path you need to ensure pip is new enough per the subsections below: + +.. code-block:: bash + + python -m pip install --user tox + python -m tox --help + +wheel +~~~~~ +Installing tox via a wheel (default with pip) requires an installer that can understand the ``python-requires`` tag (see +:pep:`503`), with pip this is version ``9.0.0`` (released in November 2016). Furthermore, in case you're not installing +it via PyPI you need to use a mirror that correctly forwards the ``python-requires`` tag (notably the OpenStack mirrors +don't do this, or older :gh_repo:`devpi/devpi` versions - added with version ``4.7.0``). + +.. _sdist: + +sdist +~~~~~ +When installing via a source distribution you need an installer that handles the :pep:`517` specification. In case of +``pip`` this is version ``18.0.0`` or later (released in July 2018). If you cannot upgrade your pip to support this you +need to ensure that the build requirements from :gh:`pyproject.toml ` are +satisfied before triggering the installation. + +via ``setup.py`` +---------------- +We don't recommend and officially support this method. You should prefer using an installer that supports :pep:`517` +interface, such as pip ``19.0.0`` or later. That being said you might be able to still install a package via this method +if you satisfy build dependencies before calling the installation command (as described under :ref:`sdist`). + +latest unreleased +----------------- +Installing an unreleased version is discouraged and should be only done for testing purposes. If you do so you'll need +a pip version of at least ``18.0.0`` and use the following command: + + +.. code-block:: console + + pip install git+https://github.com/tox-dev/tox.git@rewrite + +.. _compatibility-requirements: + +Python and OS Compatibility +--------------------------- + +tox works with the following Python interpreter implementations: + +- `CPython `_ versions 3.7, 3.8, 3.9, 3.10 + +This means tox works on the latest patch version of each of these minor versions. Previous patch versions are supported +on a best effort approach. diff --git a/docs/links.rst b/docs/links.rst deleted file mode 100644 index d51b21c69..000000000 --- a/docs/links.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. _`Cookiecutter`: https://cookiecutter.readthedocs.io -.. _`pluggy`: https://pluggy.readthedocs.io -.. _`cookiecutter-tox-plugin`: https://github.com/tox-dev/cookiecutter-tox-plugin -.. _devpi: https://doc.devpi.net -.. _Python: https://www.python.org -.. _virtualenv: https://pypi.org/project/virtualenv -.. _`pytest`: https://pytest.org -.. _nosetests: -.. _`nose`: https://pypi.org/project/nose -.. _`Holger Krekel`: https://twitter.com/hpk42 -.. _`pytest-xdist`: https://pypi.org/project/pytest-xdist -.. _ConfigParser: https://docs.python.org/3/library/configparser.html - -.. _`easy_install`: http://peak.telecommunity.com/DevCenter/EasyInstall -.. _pip: https://pypi.org/project/pip -.. _setuptools: https://pypi.org/project/setuptools -.. _`jenkins`: https://jenkins.io/index.html -.. _sphinx: https://pypi.org/project/Sphinx -.. _discover: https://pypi.org/project/discover -.. _unittest2: https://pypi.org/project/unittest2 -.. _mock: https://pypi.org/project/mock/ -.. _flit: https://flit.readthedocs.io/en/latest/ -.. _poetry: https://python-poetry.org/ -.. _pypy: https://pypy.org - -.. _`Python Packaging Guide`: https://packaging.python.org/tutorials/packaging-projects/ -.. _`tox.ini`: :doc:configfile - -.. _`PEP-508`: https://www.python.org/dev/peps/pep-0508/ -.. _`PEP-517`: https://www.python.org/dev/peps/pep-0517/ -.. _`PEP-518`: https://www.python.org/dev/peps/pep-0518/ diff --git a/docs/plugins.rst b/docs/plugins.rst index 877a611d5..2a74dd3cc 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -1,226 +1,60 @@ -.. be in -*- rst -*- mode! +Extending tox +============= -tox plugins -=========== +Extensions points +~~~~~~~~~~~~~~~~~ -.. versionadded:: 2.0 +.. automodule:: tox.plugin + :members: + :exclude-members: impl -A growing number of hooks make tox modifiable in different phases of execution by writing plugins. +.. autodata:: tox.plugin.impl + :no-value: -tox - like `pytest`_ and `devpi`_ - uses `pluggy`_ to provide an extension mechanism for pip-installable internal or devpi/PyPI-published plugins. +.. automodule:: tox.plugin.spec + :members: -Using plugins -------------- +Adoption of a plugin under tox-dev Github organization +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To start using a plugin you need to install it in the same environment where the tox host -is installed. +You're free to host your plugin on your favorite platform, however the core tox development is happening on Github, +under the ``tox-dev`` org organization. We are happy to adopt tox plugins under the ``tox-dev`` organization if: -e.g.: +- we determine it's trying to solve a valid use case and it's not malicious (e.g. no plugin that deletes the users home + directory), +- it's released on PyPI with at least 100 downloads per month (to ensure it's a plugin used by people). -.. code-block:: shell +What's in for you in this: - $ pip install tox-travis +- you get owner rights on the repository under the tox-dev organization, +- exposure of your plugin under the core umbrella, +- backup maintainers from other tox plugin development. -You can search for available plugins on PyPI by visiting `PyPI `_ and -searching for packages that are prefixed ``tox-`` or contain the word "plugin" in the description. -Examples include:: +How to apply: - tox-ansible - Plugin for generating tox environments for tools like ansible-test - tox-asdf - A tox plugin that finds python executables using asdf - tox-backticks - Allows backticks within setenv blocks for populating - environment variables - tox-bindep - Runs bindep checks prior to tests - tox-bitbucket-status - Update bitbucket status for each env - tox-cmake - Build CMake projects using tox - tox-conda - Provides integration with the conda package manager - tox-current-env - Run tests in the current python environment - tox-docker - Launch a docker instance around test runs - tox-direct - Run everything directly without tox venvs - tox-envlist - Allows selection of a different tox envlist - tox-envreport - A tox-plugin to document the setup of used virtual - tox-factor - Runs a subset of tox test environments - tox-globinterpreter - tox plugin to allow specification of interpreter - tox-gh-actions - A plugin for helping to run tox in GitHub actions. - tox-ltt - Light-the-torch integration - tox-no-internet - Workarounds for using tox with no internet connection - tox-pdm - Utilizes PDM as the package manager and installer - tox-pip-extensions - Augment tox with different installation methods via - progressive enhancement. - tox-pipenv - A pipenv plugin for tox - tox-pipenv-install - Install packages from Pipfile - tox-poetry - Install packages using poetry - tox-py-backwards - tox plugin for py-backwards - tox-pyenv - tox plugin that makes tox use ``pyenv which`` to find - python executables - tox-pytest-summary - tox + Py.test summary - tox-run-before - tox plugin to run shell commands before the test - environments are created. - tox-run-command - tox plugin to run arbitrary commands in a virtualenv - tox-tags - Allows running subsets of environments based on tags - tox-travis - Seamless integration of tox into Travis CI - tox-venv - Use python3 venvs for python3 tox testenvs environments. - tox-virtualenv-no-download - Disable virtualenv's download-by-default in tox +- create an issue under the ``tox-dev/tox`` Github repository with the title + :gh:`Adopt plugin \ `, +- wait for the green light by one of our maintainers (see :ref:`current-maintainers`), +- follow the `guidance by Github + `_, +- (optionally) add at least one other people as co-maintainer on PyPI. +Migration from tox 3 +~~~~~~~~~~~~~~~~~~~~ +This section explains how the plugin interface changed between tox 3 and 4, and provides guidance for plugin developers +on how to migrate. -There might also be some plugins not (yet) available from PyPI that could be installed directly -from source hosters like Github or Bitbucket (or from a local clone). See the associated `pip documentation `_. +``tox_get_python_executable`` +----------------------------- +With tox 4 the Python discovery is performed ``tox.tox_env.python.virtual_env.api._get_python`` that delegates the job +to ``virtualenv``. Therefore first `define a new virtualenv discovery mechanism +`_ and then set that by setting the +``VIRTUALENV_DISCOVERY`` environment variable. -To see what is installed you can call ``tox --version`` to get the version of the host and names -and locations of all installed plugins:: +``tox_package`` +--------------- +Register new packager types via :func:`tox_register_tox_env `. - 3.0.0 imported from /home/ob/.virtualenvs/tmp/lib/python3.6/site-packages/tox/__init__.py - registered plugins: - tox-travis-0.10 at /home/ob/.virtualenvs/tmp/lib/python3.6/site-packages/tox_travis/hooks.py - -.. warning:: - - If the `tox.ini` used specifies :conf:`minversion` or :conf:`requires` options then registered plugins may not have any effect. - See :conf:`provision_tox_env` for details. - - -Creating a plugin +``tox_addoption`` ----------------- - -Start from a template - -You can create a new tox plugin with all the bells and whistles via a `Cookiecutter`_ template -(see `cookiecutter-tox-plugin`_ - this will create a complete PyPI-releasable, documented -project with license, documentation and CI. - -.. code-block:: shell - - $ pip install -U cookiecutter - $ cookiecutter gh:tox-dev/cookiecutter-tox-plugin - - -Tutorial: a minimal tox plugin ------------------------------- - -.. note:: - - This is the minimal implementation to demonstrate what is absolutely necessary to have a - working plugin for internal use. To move from something like this to a publishable plugin - you could apply ``cookiecutter -f cookiecutter-tox-plugin`` and adapt the code to the - package based structure used in the cookiecutter. - -Let us consider you want to extend tox behaviour by displaying fireworks at the end of a -successful tox run (we won't go into the details of how to display fireworks though). - -To create a working plugin you need at least a python project with a tox entry point and a python -module implementing one or more of the pluggy-based hooks tox specifies (using the -``@tox.hookimpl`` decorator as marker). - -minimal structure: - -.. code-block:: shell - - $ mkdir tox-fireworks - $ cd tox-fireworks - $ touch tox_fireworks.py - $ touch setup.py - -contents of ``tox_fireworks.py``: - -.. code-block:: python - - import pluggy - - hookimpl = pluggy.HookimplMarker("tox") - - - @hookimpl - def tox_addoption(parser): - """Add command line option to display fireworks on request.""" - - - @hookimpl - def tox_configure(config): - """Post process config after parsing.""" - - - @hookimpl - def tox_runenvreport(config): - """Display fireworks if all was fine and requested.""" - -.. note:: - - See :ref:`toxHookSpecsApi` for details - -contents of ``setup.py``: - -.. code-block:: python - - from setuptools import setup - - setup( - name="tox-fireworks", - py_modules=["tox_fireworks"], - entry_points={"tox": ["fireworks = tox_fireworks"]}, - classifiers=["Framework:: tox"], - ) - -Using the **tox-** prefix in ``tox-fireworks`` is an established convention to be able to -see from the project name that this is a plugin for tox. It also makes it easier to find with -e.g. ``pip search 'tox-'`` once it is released on PyPI. - -To make your new plugin discoverable by tox, you need to install it. During development you should -install it with ``-e`` or ``--editable``, so that changes to the code are immediately active: - -.. code-block:: shell - - $ pip install -e - - -Publish your plugin to PyPI ---------------------------- - -If you think the rest of the world could profit using your plugin, you can publish it to PyPI. - -You need to add some more meta data to ``setup.py`` (see `cookiecutter-tox-plugin`_ for a complete -example or consult the `setup.py docs `_). - - -.. note:: - - Make sure your plugin project name is prefixed by ``tox-`` to be easy to find via e.g. - ``pip search tox-`` - -You can and publish it like: - -.. code-block:: shell - - $ cd - $ python setup.py sdist bdist_wheel upload - -.. note:: - - You could also use `twine `_ for secure uploads. - - For more information about packaging and deploying Python projects see the - `Python Packaging Guide`_. - -.. _toxHookSpecsApi: - - -Hook specifications and related API ------------------------------------ - -.. automodule:: tox.hookspecs - :members: - -.. autoclass:: tox.config.Parser() - :members: - -.. autoclass:: tox.config.Config() - :members: - -.. autoclass:: tox.config.TestenvConfig() - :members: - -.. autoclass:: tox.venv.VirtualEnv() - :members: - -.. autoclass:: tox.session.Session() - :members: - -.. include:: links.rst +Renamed to :func:`tox_add_option `. diff --git a/docs/plugins_api.rst b/docs/plugins_api.rst new file mode 100644 index 000000000..99f8094c1 --- /dev/null +++ b/docs/plugins_api.rst @@ -0,0 +1,144 @@ +API +=== + +tox objects +~~~~~~~~~~~ + +register +-------- + +.. automodule:: tox.tox_env.register + :members: + :exclude-members: REGISTER + +.. autodata:: REGISTER + :no-value: + +config +------ +.. autoclass:: tox.config.cli.parser.ArgumentParserWithEnvAndConfig + :members: + +.. autoclass:: tox.config.cli.parser.ToxParser + :members: + +.. autoclass:: tox.config.cli.parser.Parsed + :members: + +.. autoclass:: tox.config.main.Config + :members: + :exclude-members: __init__, make + +.. autoclass:: tox.config.loader.section.Section + :members: + +.. autoclass:: tox.config.loader.api.ConfigLoadArgs + :members: + +.. autoclass:: tox.config.sets.ConfigSet + :members: + :special-members: __iter__, __contains__ + +.. autoclass:: tox.config.sets.CoreConfigSet + :members: + +.. autoclass:: tox.config.sets.EnvConfigSet + :members: + +.. autoclass:: tox.config.of_type.ConfigDefinition + :members: + +.. autoclass:: tox.config.of_type.ConfigDynamicDefinition + :members: + +.. autoclass:: tox.config.of_type.ConfigConstantDefinition + :members: + +.. autoclass:: tox.config.source.api.Source + :members: + +.. autoclass:: tox.config.loader.api.Override + :members: + +.. autoclass:: tox.config.loader.api.Loader + :members: + +.. autoclass:: tox.config.loader.convert.Convert + :members: + +.. autoclass:: tox.config.types.EnvList + :members: + :special-members: __bool__, __iter__ + +.. autoclass:: tox.config.types.Command + :members: + +.. autoclass:: tox.config.loader.convert.Factory + :members: + +environments +------------ +.. autoclass:: tox.tox_env.api.ToxEnv + :members: + +.. autoclass:: tox.tox_env.runner.RunToxEnv + :members: + +.. autoclass:: tox.tox_env.package.PackageToxEnv + :members: + +.. autoclass:: tox.tox_env.package.Package + :members: + +journal +------- +.. autoclass:: tox.journal.env.EnvJournal + :members: + :exclude-members: __init__ + :special-members: __bool__, __setitem__ + +report +------ +.. autoclass:: tox.report.ToxHandler + :members: + :exclude-members: stream, format, patch_thread, write_out_err, suspend_out_err + +execute +------- +.. autoclass:: tox.execute.request.ExecuteRequest + :members: + +.. autoclass:: tox.execute.request.StdinSource + :members: + +.. autoclass:: tox.execute.api.Outcome + :members: + +.. autoclass:: tox.execute.api.Execute + :members: + +.. autoclass:: tox.execute.api.ExecuteStatus + :members: + +.. autoclass:: tox.execute.api.ExecuteInstance + :members: + +.. autoclass:: tox.execute.stream.SyncWrite + :members: + +installer +--------- + +.. autoclass:: tox.tox_env.installer.Installer + :members: + +session +------- +.. autoclass:: tox.session.state.State + :members: + +.. autoclass:: tox.session.env_select.EnvSelector + :members: + +.. autoclass:: tox.tox_env.info.Info + :members: diff --git a/docs/support.rst b/docs/support.rst deleted file mode 100644 index b470a9b96..000000000 --- a/docs/support.rst +++ /dev/null @@ -1,23 +0,0 @@ -.. _support: - -Support and contact channels -===================================== - -Getting in contact: - -* file a `report on the issue tracker`_ -* hang out on the tox discord server channel at https://discord.gg/tox -* `fork the github repository`_ and submit merge/pull requests (see the developers help page -- :ref:`developers`) - -Paid professional support ----------------------------- - -Contact holger at `merlinux.eu`_, an association of -experienced well-known Python developers. - -.. _`Testing In Python (TIP) mailing list`: http://lists.idyll.org/listinfo/testing-in-python -.. _`holger's twitter presence`: https://twitter.com/hpk42 -.. _`merlinux.eu`: https://merlinux.eu -.. _`report on the issue tracker`: https://github.com/tox-dev/tox/issues -.. _`tetamap blog`: https://holgerkrekel.net -.. _`fork the github repository`: https://github.com/tox-dev/tox diff --git a/docs/tox_conf.py b/docs/tox_conf.py new file mode 100644 index 000000000..e5134ff84 --- /dev/null +++ b/docs/tox_conf.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import cast + +from docutils.nodes import Element, Node, Text, container, fully_normalize_name, literal, paragraph, reference, strong +from docutils.parsers.rst.directives import flag, unchanged, unchanged_required +from docutils.parsers.rst.states import RSTState, RSTStateMachine +from docutils.statemachine import StringList, string2lines +from sphinx.domains.std import StandardDomain +from sphinx.locale import __ +from sphinx.util.docutils import SphinxDirective +from sphinx.util.logging import getLogger + +LOGGER = getLogger(__name__) + + +class ToxConfig(SphinxDirective): + name = "conf" + has_content = True + option_spec = { + "keys": unchanged_required, + "version_added": unchanged, + "version_changed": unchanged, + "default": unchanged, + "constant": flag, + "ref_suffix": unchanged, + } + + def __init__( + self, + name: str, + arguments: list[str], + options: dict[str, str], + content: StringList, + lineno: int, + content_offset: int, + block_text: str, + state: RSTState, + state_machine: RSTStateMachine, + ): + super().__init__( + name, + arguments, + options, + content, + lineno, + content_offset, + block_text, + state, + state_machine, + ) + self._std_domain: StandardDomain = cast(StandardDomain, self.env.get_domain("std")) + + def run(self) -> list[Node]: + self.env.note_reread() # this document needs to be always updated + + line = paragraph() + line += Text("■" if "constant" in self.options else "⚙️") + for key in (i.strip() for i in self.options["keys"].split(",")): + line += Text(" ") + self._mk_key(line, key) + if "default" in self.options: + default = self.options["default"] + line += Text(" with default value of ") + line += literal(default, default) + if "version_added" in self.options: + line += Text(" 📢 added in ") + ver = self.options["version_added"] + line += literal(ver, ver) + + p = container("") + self.state.nested_parse(StringList(string2lines("\n".join(f" {i}" for i in self.content))), 0, p) + line += p + + return [line] + + def _mk_key(self, line: paragraph, key: str) -> None: + ref_id = key if "ref_suffix" not in self.options else f"{key}-{self.options['ref_suffix']}" + ref = reference("", refid=ref_id, reftitle=key) + line.attributes["ids"].append(ref_id) + st = strong() + st += literal(text=key) + ref += st + self._register_ref(ref_id, ref_id, ref) + line += ref + + def _register_ref(self, ref_name: str, ref_title: str, node: Element) -> None: + of_name, doc_name = fully_normalize_name(ref_name), self.env.docname + if of_name in self._std_domain.labels: + LOGGER.warning( + __("duplicate label %s, other instance in %s"), + of_name, + self.env.doc2path(self._std_domain.labels[of_name][0]), + location=node, + type="sphinx-argparse-cli", + subtype=self.env.docname, + ) + self._std_domain.anonlabels[of_name] = doc_name, ref_name + self._std_domain.labels[of_name] = doc_name, ref_name, ref_title diff --git a/docs/user_guide.rst b/docs/user_guide.rst new file mode 100644 index 000000000..2be96faf7 --- /dev/null +++ b/docs/user_guide.rst @@ -0,0 +1,437 @@ +User Guide +========== + +Basic example +------------- + +tox is an environment orchestrator. Use it to define how to setup and execute various tools on your projects. The +tool can be: + +- a test runner (such as :pypi:`pytest`), +- a linter (e.g., :pypi:`flake8`), +- a formatter (for example :pypi:`black` or :pypi:`isort`), +- a documentation generator (e.g., :pypi:`Sphinx`), +- library builder and publisher (e.g., :pypi:`build` with :pypi:`twine`), +- or anything else you may need to execute. + +First, in a configuration file you need to define what tools you need to run and how to provision a test environment for +these. The canonical file for this is the ``tox.ini`` file, let's take a look at an example of this (this needs to live +at the root of your project): + +.. note:: + + You can also generate a ``tox.ini`` file automatically by running ``tox quickstart`` and then answering a few + questions. + +.. code-block:: ini + + [tox] + envlist = + format + py310 + + [testenv:format] + description = install black in a virtual environment and invoke it on the current folder + deps = black==22.3.0 + skip_install = true + commands = black . + + [testenv:py310] + description = install pytest in a virtual environment and invoke it on the tests folder + deps = + pytest>=7 + pytest-sugar + commands = pytest tests {posargs} + + +The configuration is split into two type of configuration: core settings are hosted under the ``tox`` section and per run +environment settings hosted under ``testenv:``. Under the core section we define that this project has two +run environments named ``format`` and ``py310`` respectively (we use the ``envlist`` configuration key to do so). + +Then we define separately what should the formatting environment (``testenv:format`` section) and the test environment +(``testenv:py310`` section). For example to format the project we: + +- add a description (visible when you type ``tox list`` into the command line), +- we define that it requires the ``black`` PyPI dependency with version ``22.3.0``, +- the black tool does not need the project we are testing to be installed into the test environment so we disable this + default behaviour via the ``skip_install`` configuration, +- and we define that the tool should be invoked as we'd type ``black .`` into the command line. + +For testing the project we use the ``py310`` environment, for which we: + +- define a text description of the environment, +- specify that requires ``pytest`` ``7`` ot later together with the :pypi:`pytest-sugar` project, +- and that the tool should be invoked via the ``pytest tests`` CLI command. + +``{posargs}`` is a place holder part for the CLI command that allows us to pass additional flags to the pytest +invocation, for example if we'd want to run ``pytest tests -v`` as a one off, instead of ``tox run -e py310`` we'd type +``tox run -e py310 -- -v``. The ``--`` delimits flags for the tox tool and what should be forwarded to the tool within. + +tox, by default, always creates a fresh virtual environment for every run environment. The Python version to use for a +given environment can be controlled via the :ref:`base_python` configuration, however if not set will try to use the +environment name to determine something sensible: if the name is in the format of ``pyxy`` then tox will create an environment with CPython +with version ``x.y`` (for example ``py310`` means CPython ``3.10``). If the name does not match this pattern it will +use a virtual environment with the same Python version as the one tox is installed into (this is the case for +``format``). + +tox environments are reused between runs, so while the first ``tox run -e py310`` will take a while as tox needs to +create a virtual environment and install ``pytest`` and ``pytest-sugar`` in it, subsequent runs only need to reinstall +your project, as long as the environments dependency list does not change. + +Almost every step and aspect of virtual environments and command execution can be customized. You'll find +an exhaustive list of configuration flags (together with what it does and detailed explanation of what values are +accepted) at our :ref:`configuration page `. + +System overview +--------------- + +Below is a graphical representation of the tox states and transition pathways between them: + +.. image:: img/overview_light.svg + :align: center + :class: only-light + +.. image:: img/overview_dark.svg + :align: center + :class: only-dark + + +The primary tox states are: + +#. **Configuration:** load tox configuration files (such as ``tox.ini``, ``pyproject.toml`` and ``toxfile.py``) and + merge it with options from the command line plus the operating system environment variables. + +#. **Environment**: for each selected tox environment (e.g. ``py310``, ``format``) do: + + #. **Creation**: create a fresh environment; by default :pypi:`virtualenv` is used, but configurable via + :ref:`runner`. For `virtualenv` tox will use the `virtualenv discovery logic + `_ where the python specification is + defined by the tox environments :ref:`base_python` (if not set will default to the environments name). This is + created at first run only to be re-used at subsequent runs. If certain aspects of the project change (python + version, dependencies removed, etc.), a re-creation of the environment is automatically triggered. To force the + recreation tox can be invoked with the :ref:`recreate` flag (``-r``). + + #. **Install dependencies** (optional): install the environment dependencies specified inside the ``deps`` + configuration section, and then the earlier packaged source distribution. By default ``pip`` is used to install + packages, however one can customize this via ``install_command``. Note ``pip`` will not update project + dependencies (specified either in the ``install_requires`` or the ``extras`` section of the ``setup.py``) if any + version already exists in the virtual environment; therefore we recommend to recreate your environments whenever + your project dependencies change. + + #. **Packaging** (optional): create a distribution of the current project. + + #. **Build**: If the tox environment has a package configured tox will build a package from the current source + tree. If multiple tox environments are run and the package built are compatible in between them then it will be + reused. This is to ensure that we build the package as rare as needed. By default for Python a source + distribution is built as defined via the ``pyproject.toml`` style build (see PEP-517 and PEP-518). + + #. **Install the package dependencies**. If this has not changed since the last run this step will be skipped. + + #. **Install the package**. This operation will force reinstall the package without its dependencies. + + #. **Commands**: run the specified commands in the specified order. Whenever the exit code of any of them is not + zero, stop and mark the environment failed. When you start a command with a dash character, the exit code will be + ignored. + +#. **Report** print out a report of outcomes for each tox environment: + + .. code:: bash + + ____________________ summary ____________________ + py37: commands succeeded + ERROR: py38: commands failed + + Only if all environments ran successfully tox will return exit code ``0`` (success). In this case you'll also see the + message ``congratulations :)``. + +tox will take care of environment variable isolation for you. That means it will remove system environment variables not specified via +``passenv``. Furthermore, it will also alter the ``PATH`` variable so that your commands resolve within the current +active tox environment. In general, all executables outside of the tox environment are available in ``commands``, but +external commands need to be explicitly allowed via the :ref:`allowlist_externals` configuration. + +Main features +------------- + +* **automation of tedious Python related test activities** +* **test your Python package against many interpreter and dependency configurations** + + - automatic customizable (re)creation of :pypi:`virtualenv` test environments + - installs your project into each virtual environment + - test-tool agnostic: runs pytest, nose or unittest in a uniform manner + +* ``plugin system`` to modify tox execution with simple hooks. +* uses :pypi:`pip` and :pypi:`virtualenv` by default. Support for plugins replacing it with their own. +* **cross-Python compatible**: tox requires CPython 3.7 and higher, but it can create environments 2.7 or later +* **cross-platform**: Windows, macOS and Unix style environments +* **full interoperability with devpi**: is integrated with and is used for testing in the :pypi:`devpi` system, a + versatile PyPI index server and release managing tool +* **driven by a simple (but flexible to allow expressing more complicated variants) ini-style config file** +* **documented** examples and configuration +* **concise reporting** about tool invocations and configuration errors +* supports using different / multiple PyPI index servers + +Related projects +---------------- + +tox has influenced several other projects in the Python test automation space. If tox doesn't quite fit your needs or +you want to do more research, we recommend taking a look at these projects: + +- `nox `__ is a project similar in spirit to tox but different in approach. The + primary key difference is that it uses Python scripts instead of a configuration file. It might be useful if you + find tox configuration too limiting but aren't looking to move to something as general-purpose as ``Invoke`` or + ``make``. Please note that tox will support defining configuration in a Python file soon, too. +- `Invoke `__ is a general-purpose task execution library, similar to Make. Invoke is far + more general-purpose than tox but it does not contain the Python testing-specific features that tox specializes in. + + +Auto-provisioning +----------------- +In case the installed tox version does not satisfy either the :ref:`min_version` or the :ref:`requires`, tox will automatically +create a virtual environment under :ref:`provision_tox_env` name that satisfies those constraints and delegate all +calls to this meta environment. This should allow satisfying constraints on your tox environment automatically, +given you have at least version ``3.8.0`` of tox. + +For example given: + +.. code-block:: ini + + [tox] + min_version = 4 + requires = tox-docker>=1 + +if the user runs it with tox ``3.8`` or later the installed tox application will automatically ensure that both the minimum version and +requires constraints are satisfied, by creating a virtual environment under ``.tox`` folder, and then installing into it +``tox>=4`` and ``tox-docker>=1``. Afterwards all tox invocations are forwarded to the tox installed inside ``.tox\.tox`` +folder (referred to as meta-tox or auto-provisioned tox). + +This allows tox to automatically setup itself with all its plugins for the current project. If the host tox satisfies +the constraints expressed with the :ref:`requires` and :ref:`min_version` no such provisioning is done (to avoid +setup cost and indirection when it's not explicitly needed). + +Cheat sheet +------------ + +This section details information that you'll use most often in short form. + +CLI +~~~ +- Each tox subcommand has a 1 (or 2) letter shortcut form too, e.g. ``tox run`` can also be written as ``tox r`` or + ``tox config`` can be shortened to ``tox c``. +- To run all tox environments defined in the :ref:`env_list` run tox without any flags: ``tox``. +- To run a single tox environment use the ``-e`` flag for the ``run`` sub-command as in ``tox run -e py310``. +- To run two or more tox environment pass comma separated values, e.g. ``tox run -e format,py310``. The run command will + run the tox environments sequentially, one at a time, in the specified order. +- To run two or more tox environment in parallel use the ``parallel`` sub-command , e.g. ``tox parallel -e py39,py310``. + The ``--parallel`` flag for this sub-command controls the degree of parallelism. +- To view the configuration value for a given environment and a given configuration key use the config sub-command with + the ``-k`` flag to filter for targeted configuration values: ``tox config -e py310 -k pass_env``. +- tox tries to automatically detect changes to your project dependencies and force a recreation when needed. + Unfortunately the detection is not always accurate, and it also won't detect changes on the PyPI index server. You can + force a fresh start for the tox environments by passing the ``-r`` flag to your run command. Whenever you see + something that should work but fails with some esoteric error it's recommended to use this flag to make sure you don't + have a stale Python environment; e.g. ``tox run -e py310 -r`` would clean the run environment and recreate it from + scratch. + +Configuration +~~~~~~~~~~~~~ + +- Every tox environment has its own configuration section (e.g. in case of ``tox.ini`` configuration method the + ``py310`` tox environments configuration is read from the ``testenv:py310`` section). If the section is missing or does + not contain that configuration value, it will fall back to the section defined by the :ref:`base` configuration (for + ``tox.ini`` this is the ``testenv`` section). For example: + + .. code-block:: ini + + [testenv] + commands = pytest tests + + [testenv:test] + description = run the test suite with pytest + + Here the environment description for ``test`` is taken from ``testenv:test``. As ``commands`` is not specified, + the value defined under the ``testenv`` section will be used. If the base environment is also missing a + configuration value then the configuration default will be used (e.g. in case of the ``pass_env`` configuration here). + +- To change the current working directory for the commands run use :ref:`change_dir` (note this will make the change for + all install commands too - watch out if you have relative paths in your project dependencies). + +- Environment variables: + - To view environment variables set and passed down use ``tox c -e py310 -k set_env pass_env``. + - To pass through additional environment variables use :ref:`pass_env`. + - To set environment variables use :ref:`set_env`. +- Setup operation can be configured via the :ref:`commands_pre`, while teardown commands via the :ref:`commands_post`. +- Configurations may be set conditionally within the ``tox.ini`` file. If a line starts with an environment name + or names, separated by a comma, followed by ``:`` the configuration will only be used if the + environment name(s) matches the executed tox environment. For example: + + .. code-block:: ini + + [testenv] + deps = + pip + format: black + py310,py39: pytest + + Here pip will be always installed as the configuration value is not conditional. black is only used for the ``format`` + environment, while ``pytest`` is only installed for the ``py310`` and ``py39`` environments. + +.. _`parallel_mode`: + +Parallel mode +------------- +``tox`` allows running environments in parallel mode via the ``parallel`` sub-command: + +- After the packaging phase completes tox will run the tox environments in parallel processes (multi-thread based). +- the ``--parallel`` flag takes an argument specifying the degree of parallelization, defaulting to ``auto``: + + - ``all`` to run all invoked environments in parallel, + - ``auto`` to limit it to CPU count, + - or pass an integer to set that limit. +- Parallel mode displays a progress spinner while running tox environments in parallel, and reports outcome of these as + soon as they have been completed with a human readable duration timing attached. This spinner can be disabled via the + ``--parallel-no-spinner`` flag. +- Parallel mode by default shows output only of failed environments and ones marked as :ref:`parallel_show_output` + ``=True``. +- There's now a concept of dependency between environments (specified via :ref:`depends`), tox will re-order the + environment list to be run to satisfy these dependencies, also for sequential runs. Furthermore, in parallel mode, + tox will only schedule a tox environment to run once all of its dependencies have finished (independent of their outcome). + + .. warning:: + + ``depends`` does not pull in dependencies into the run target, for example if you select ``py310,py39,coverage`` + via the ``-e`` tox will only run those three (even if ``coverage`` may specify as ``depends`` other targets too - + such as ``py310, py39, py38, py37``). + +- ``--parallel-live``/``-o`` allows showing the live output of the standard output and error, also turns off reporting + as described above. +- Note: parallel evaluation disables standard input. Use non parallel invocation if you need standard input. + +Example final output: + +.. code-block:: bash + + $ tox -e py310,py39,coverage -p all + ✔ OK py39 in 9.533 seconds + ✔ OK py310 in 9.96 seconds + ✔ OK coverage in 2.0 seconds + ___________________________ summary ______________________________________________________ + py310: commands succeeded + py39: commands succeeded + coverage: commands succeeded + congratulations :) + + +Example progress bar, showing a rotating spinner, the number of environments running and their list (limited up to +120 characters): + +.. code-block:: bash + + ⠹ [2] py310 | py39 + +Packaging +--------- + +tox always builds projects in a PEP-518 compatible virtual environment and communicates with the build backend according +to the interface defined in PEP-517 and PEP-660. To define package build dependencies and specify the build backend to +use create a ``pyproject.toml`` at the root of the project. For example to use hatch: + +.. code-block:: toml + + [build-system] + build-backend = "hatchling.build" + requires = ["hatchling>=0.22", "hatch-vcs>=0.2"] + +By default tox will create and install a source distribution. You can configure to build a wheel instead by setting +the :ref:`package` configuration to ``wheel``. Wheels are much faster to install than source distributions. + +To query the projects dependencies tox will use a virtual environment whose name is defined under the :ref:`package_env` +configuration (by default ``.pkg``). The virtual environment used for building the package depends on the artifact +built: + +- for source distribution the :ref:`package_env`, +- for wheels the name defined under :ref:`wheel_build_env` (this depends on the Python version defined by the target tox + environment under :ref:`base_python`, if the environment targets CPython 3.10 it will be ``.pkg-cpython310`` or + for PyPy 3.9 it will be ``.pkg-pypy39``). + +For pure Python projects (non C-Extension ones) it's recommended to set :ref:`wheel_build_env` to the same as the +:ref:`package_env`. This way you'll build the wheel once and install the same wheel for all tox environments. + +Advanced features +----------------- + +tox supports these features that 90 percent of the time you'll not need, but are very useful the other ten percent. + +Generative environments +~~~~~~~~~~~~~~~~~~~~~~~ + +Generative environment list ++++++++++++++++++++++++++++ + +If you have a large matrix of dependencies, python versions and/or environments you can use a generative +:ref:`env_list` and conditional settings to express that in a concise form: + +.. code-block:: ini + + [tox] + env_list = py{311,310,39}-django{41,40}-{sqlite,mysql} + + [testenv] + deps = + django41: Django>=4.1,<4.2 + django40: Django>=4.0,<4.1 + # use PyMySQL if factors "py311" and "mysql" are present in env name + py311-mysql: PyMySQL + # use urllib3 if any of "py311" or "py310" are present in env name + py311,py310: urllib3 + # mocking sqlite on 3.11 and 3.10 if factor "sqlite" is present + py{311,310}-sqlite: mock + +This will generate the following tox environments: + +.. code-block:: shell + + > tox l + default environments: + py311-django41-sqlite -> [no description] + py311-django41-mysql -> [no description] + py311-django40-sqlite -> [no description] + py311-django40-mysql -> [no description] + py310-django41-sqlite -> [no description] + py310-django41-mysql -> [no description] + py310-django40-sqlite -> [no description] + py310-django40-mysql -> [no description] + py39-django41-sqlite -> [no description] + py39-django41-mysql -> [no description] + py39-django40-sqlite -> [no description] + py39-django40-mysql -> [no description] + +Generative section names +++++++++++++++++++++++++ + +Suppose you have some binary packages, and need to run tests both in 32 and 64 bits. You also want an environment to +create your virtual env for the developers. + +.. code-block:: ini + + [testenv] + base_python = + py311-x86: python3.11-32 + py311-x64: python3.11-64 + commands = pytest + + [testenv:py311-{x86,x64}-venv] + envdir = + x86: .venv-x86 + x64: .venv-x64 + +.. code-block:: shell + + > tox l + default environments: + py -> [no description] + + additional environments: + py310-black -> [no description] + py310-lint -> [no description] + py311-black -> [no description] + py311-lint -> [no description] diff --git a/pyproject.toml b/pyproject.toml index 6aa5a1b15..3bf5ca996 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,52 +1,159 @@ [build-system] -requires = [ - "setuptools >= 40.0.4", - "setuptools_scm >= 2.0.0", - "wheel >= 0.29.0", +build-backend = "hatchling.build" +requires = ["hatchling>=1.11.1", "hatch-vcs>=0.2.1"] + +[project] +name = "tox" +description = "tox is a generic virtualenv management and test command line tool" +readme.file = "README.md" +readme.content-type = "text/markdown" +keywords = ["virtual", "environments", "isolated", "testing"] +license = "MIT" +urls.Homepage = "http://tox.readthedocs.org/" +urls.Source = "https://github.com/tox-dev/tox" +urls.Tracker = "https://github.com/tox-dev/tox/issues" +authors = [{ name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }] +maintainers = [ + { name = "Anthony Sottile", email = "asottile@umich.edu" }, + { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, + { name = "Jürgen Gmach", email = "juergen.gmach@googlemail.com" }, + { name = "Oliver Bestwalter", email = "oliver@bestwalter.de" }, +] +requires-python = ">=3.7" +dependencies = [ + "cachetools>=5.2", + "chardet>=5.1", + "colorama>=0.4.6", + "packaging>=22", + "platformdirs>=2.6", + "pluggy>=1", + "pyproject-api>=1.2.1", + 'tomli>=2.0.1; python_version < "3.11"', + "virtualenv>=20.17.1", + "filelock>=3.8.2", + 'importlib-metadata>=5.1; python_version < "3.8"', + 'typing-extensions>=4.4; python_version < "3.8"', +] +optional-dependencies.docs = [ + "furo>=2022.12.7", + "sphinx>=5.3", + "sphinx-argparse-cli>=1.10", + "sphinx-autodoc-typehints>=1.19.5", + "sphinx-copybutton>=0.5.1", + "sphinx-inline-tabs>=2022.1.2b11", + "sphinxcontrib-towncrier>=0.2.1a0", + "towncrier>=22.8", +] +optional-dependencies.testing = [ + "build[virtualenv]>=0.9", + "covdefaults>=2.2.2", + "devpi-process>=0.3", + "diff-cover>=7.2", + "distlib>=0.3.6", + "flaky>=3.7", + "hatch-vcs>=0.2.1", + "hatchling>=1.11.1", + "psutil>=5.9.4", + "pytest>=7.2", + "pytest-cov>=4", + "pytest-mock>=3.10", + "pytest-xdist>=3.1", + "re-assert>=1.1", + "time-machine>=2.8.2", +] +scripts.tox = "tox.run:run" +dynamic = ["version"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: tox", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", ] -build-backend = 'setuptools.build_meta' -[tool.towncrier] - package = "tox" - filename = "docs/changelog.rst" - directory = "docs/changelog" - template = "docs/changelog/template.jinja2" - title_format = "v{version} ({project_date})" - issue_format = "`#{issue} `_" - underlines = ["-", "^"] - - [[tool.towncrier.section]] - path = "" - - [[tool.towncrier.type]] - directory = "bugfix" - name = "Bugfixes" - showcontent = true - - [[tool.towncrier.type]] - directory = "feature" - name = "Features" - showcontent = true - - [[tool.towncrier.type]] - directory = "deprecation" - name = "Deprecations (removal in next major release)" - showcontent = true - - [[tool.towncrier.type]] - directory = "breaking" - name = "Backward incompatible changes" - showcontent = true - - [[tool.towncrier.type]] - directory = "doc" - name = "Documentation" - showcontent = true - - [[tool.towncrier.type]] - directory = "misc" - name = "Miscellaneous" - showcontent = true +[tool.hatch] +build.dev-mode-dirs = ["src"] +build.hooks.vcs.version-file = "src/tox/version.py" +build.targets.sdist.include = ["/src", "/tests"] +version.source = "vcs" [tool.black] -line-length = 99 +line-length = 120 + +[tool.coverage] +html.show_contexts = true +html.skip_covered = false +paths.source = [ + "src", + ".tox*/*/lib/python*/site-packages", + ".tox*/pypy*/site-packages", + ".tox*\\*\\Lib\\site-packages", + "*/src", + "*\\src", +] +report.fail_under = 88 +report.omit = ["src/tox/config/cli/for_docs.py", "tests/execute/local_subprocess/bad_process.py", "tests/type_check/*"] +run.parallel = true +run.plugins = ["covdefaults"] + +[tool.isort] +known_first_party = ["tox", "tests"] +profile = "black" +line_length = 120 + +[tool.mypy] +python_version = "3.7" +show_error_codes = true +strict = true +overrides = [ + { module = [ + "colorama.*", + "coverage.*", + "distlib.*", + "flaky.*", + "importlib_metadata.*", + "pluggy.*", + "psutil.*", + "re_assert.*", + "virtualenv.*", + ], ignore_missing_imports = true }, +] + +[tool.pep8] +max-line-length = "120" + +[tool.flake8] +max-complexity = 22 +max-line-length = 120 +unused-arguments-ignore-abstract-functions = true +noqa-require-code = true +dictionaries = ["en_US", "python", "technical", "django"] +ignore = [ + "E203", # whitespace before ':' + "W503", # line break before binary operator +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--tb=auto -ra --showlocals --no-success-flaky-report" + +[tool.towncrier] +name = "tox" +filename = "docs/changelog.rst" +directory = "docs/changelog" +title_format = false +issue_format = ":issue:`{issue}`" +template = "docs/changelog/template.jinja2" +# possible types, all default: feature, bugfix, doc, removal, misc diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 37013e356..000000000 --- a/setup.cfg +++ /dev/null @@ -1,74 +0,0 @@ -[metadata] -name = tox -description = tox is a generic virtualenv management and test command line tool -long_description = file: README.md -long_description_content_type = text/markdown -url = https://tox.readthedocs.io -author = Holger Krekel, Oliver Bestwalter, Bernát Gábor and others -maintainer = Bernát Gábor, Oliver Bestwalter, Anthony Sottile, Jürgen Gmach -maintainer_email = gaborjbernat@gmail.com -license = MIT -license_file = LICENSE -platforms = any -classifiers = - Development Status :: 5 - Production/Stable - Framework :: tox - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Operating System :: MacOS :: MacOS X - Operating System :: Microsoft :: Windows - Operating System :: POSIX - Programming Language :: Python :: 2 - Programming Language :: Python :: 3 - Programming Language :: Python :: Implementation :: CPython - Programming Language :: Python :: Implementation :: PyPy - Topic :: Software Development :: Libraries - Topic :: Software Development :: Testing - Topic :: Utilities -keywords = virtual, environments, isolated, testing -project_urls = - Source=https://github.com/tox-dev/tox - Tracker=https://github.com/tox-dev/tox/issues - Changelog=https://tox.readthedocs.io/en/latest/changelog.html - -[options] -packages = find: -install_requires = - filelock>=3.0.0 - packaging>=14 - pluggy>=0.12.0 - py>=1.4.17 - six>=1.14.0 # required when virtualenv>=20 - virtualenv!=20.0.0,!=20.0.1,!=20.0.2,!=20.0.3,!=20.0.4,!=20.0.5,!=20.0.6,!=20.0.7,>=16.0.0 - colorama>=0.4.1 ;platform_system=="Windows" - importlib-metadata>=0.12;python_version<"3.8" - toml>=0.10.2;python_version<="3.6" - tomli>=2.0.1;python_version>="3.7" and python_version<"3.11" -python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* - -[options.packages.find] -where = src - -[options.entry_points] -console_scripts = - tox=tox:cmdline - tox-quickstart=tox._quickstart:main - -[options.extras_require] -docs = - pygments-github-lexers>=0.0.5 - sphinx>=2.0.0 - sphinxcontrib-autoprogram>=0.1.5 - towncrier>=18.5.0 -testing = - flaky>=3.4.0 - freezegun>=0.3.11 - pytest>=4.0.0 - pytest-cov>=2.5.1 - pytest-mock>=1.10.0 - pytest-randomly>=1.0.0 - pathlib2>=2.3.3;python_version<"3.4" - psutil>=5.6.1;platform_python_implementation=="cpython" - -[bdist_wheel] -universal = 1 diff --git a/setup.py b/setup.py deleted file mode 100644 index d6efbf575..000000000 --- a/setup.py +++ /dev/null @@ -1,18 +0,0 @@ -import textwrap - -from setuptools import setup - -setup( - use_scm_version={ - "write_to": "src/tox/version.py", - "write_to_template": textwrap.dedent( - """ - # coding: utf-8 - from __future__ import unicode_literals - - __version__ = {version!r} - """, - ).lstrip(), - }, - package_dir={"": "src"}, -) diff --git a/src/tox/__init__.py b/src/tox/__init__.py index b3df3d5f8..867c86605 100644 --- a/src/tox/__init__.py +++ b/src/tox/__init__.py @@ -1,32 +1,9 @@ -"""Everything made explicitly available via `__all__` can be considered as part of the tox API. +from __future__ import annotations -We will emit deprecation warnings for one minor release before making changes to these objects. - -If objects are marked experimental they might change between minor versions. - -To override/modify tox behaviour via plugins see `tox.hookspec` and its use with pluggy. -""" -import pluggy - -from . import exception -from .constants import INFO, PIP, PYTHON -from .hookspecs import hookspec -from .version import __version__ +from .run import main +from .version import version as __version__ __all__ = ( - "__version__", # tox version - "cmdline", # run tox as part of another program/IDE (same behaviour as called standalone) - "hookimpl", # Hook implementation marker to be imported by plugins - "exception", # tox specific exceptions - # EXPERIMENTAL CONSTANTS API - "PYTHON", - "INFO", - "PIP", - # DEPRECATED - will be removed from API in tox 4 - "hookspec", + "__version__", + "main", ) - -hookimpl = pluggy.HookimplMarker("tox") - -# NOTE: must come last due to circular import -from .session import cmdline # isort:skip diff --git a/src/tox/__main__.py b/src/tox/__main__.py index 821fa4800..0c06369a1 100644 --- a/src/tox/__main__.py +++ b/src/tox/__main__.py @@ -1,4 +1,6 @@ -import tox +from __future__ import annotations + +from tox.run import run if __name__ == "__main__": - tox.cmdline() + run() diff --git a/src/tox/_pytestplugin.py b/src/tox/_pytestplugin.py deleted file mode 100644 index d0c870335..000000000 --- a/src/tox/_pytestplugin.py +++ /dev/null @@ -1,619 +0,0 @@ -from __future__ import print_function, unicode_literals - -import os -import subprocess -import sys -import textwrap -import time -import traceback -from collections import OrderedDict -from fnmatch import fnmatch - -import py -import pytest -import six - -import tox -import tox.session -from tox import venv -from tox.config import parseconfig -from tox.config.parallel import ENV_VAR_KEY_PRIVATE as PARALLEL_ENV_VAR_KEY_PRIVATE -from tox.config.parallel import ENV_VAR_KEY_PUBLIC as PARALLEL_ENV_VAR_KEY_PUBLIC -from tox.reporter import update_default_reporter -from tox.venv import CreationConfig, VirtualEnv, getdigest - -mark_dont_run_on_windows = pytest.mark.skipif(os.name == "nt", reason="non windows test") -mark_dont_run_on_posix = pytest.mark.skipif(os.name == "posix", reason="non posix test") - - -def pytest_configure(): - if "TOXENV" in os.environ: - del os.environ["TOXENV"] - if "HUDSON_URL" in os.environ: - del os.environ["HUDSON_URL"] - - -def pytest_addoption(parser): - parser.addoption( - "--no-network", - action="https://github.com/store_true", - dest="no_network", - help="don't run tests requiring network", - ) - - -def pytest_report_header(): - return "tox comes from: {!r}".format(tox.__file__) - - -@pytest.fixture -def work_in_clean_dir(tmpdir): - with tmpdir.as_cwd(): - yield - - -@pytest.fixture(autouse=True) -def check_cwd_not_changed_by_test(): - old = os.getcwd() - yield - new = os.getcwd() - if old != new: - pytest.fail("test changed cwd: {!r} => {!r}".format(old, new)) - - -@pytest.fixture(autouse=True) -def check_os_environ_stable(): - old = os.environ.copy() - - to_clean = { - k: os.environ.pop(k, None) - for k in { - PARALLEL_ENV_VAR_KEY_PRIVATE, - PARALLEL_ENV_VAR_KEY_PUBLIC, - str("TOX_WORK_DIR"), - str("PYTHONPATH"), - } - } - - yield - - for key, value in to_clean.items(): - if value is not None: - os.environ[key] = value - - new = os.environ - extra = {k: new[k] for k in set(new) - set(old)} - miss = {k: old[k] for k in set(old) - set(new)} - diff = { - "{} = {} vs {}".format(k, old[k], new[k]) - for k in set(old) & set(new) - if old[k] != new[k] and not (k.startswith("PYTEST_") or k.startswith("COV_")) - } - if extra or miss or diff: - msg = "test changed environ" - if extra: - msg += " extra {}".format(extra) - if miss: - msg += " miss {}".format(miss) - if diff: - msg += " diff {}".format(diff) - pytest.fail(msg) - - -@pytest.fixture(name="newconfig") -def create_new_config_file(tmpdir): - def create_new_config_file_(args, source=None, plugins=(), filename="tox.ini"): - if source is None: - source = args - args = [] - s = textwrap.dedent(source) - p = tmpdir.join(filename) - p.write(s) - tox.session.setup_reporter(args) - with tmpdir.as_cwd(): - return parseconfig(args, plugins=plugins) - - return create_new_config_file_ - - -@pytest.fixture -def cmd(request, monkeypatch, capfd): - if request.config.option.no_network: - pytest.skip("--no-network was specified, test cannot run") - request.addfinalizer(py.path.local().chdir) - - def run(*argv): - reset_report() - with RunResult(argv, capfd) as result: - _collect_session(result) - - # noinspection PyBroadException - try: - tox.session.main([str(x) for x in argv]) - assert False # this should always exist with SystemExit - except SystemExit as exception: - result.ret = exception.code - except OSError as e: - traceback.print_exc() - result.ret = e.errno - except Exception: - traceback.print_exc() - result.ret = 1 - return result - - def _collect_session(result): - prev_build = tox.session.build_session - - def build_session(config): - result.session = prev_build(config) - return result.session - - monkeypatch.setattr(tox.session, "build_session", build_session) - - yield run - - -class RunResult: - def __init__(self, args, capfd): - self.args = args - self.ret = None - self.duration = None - self.out = None - self.err = None - self.session = None - self.capfd = capfd - - def __enter__(self): - self._start = time.time() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.duration = time.time() - self._start - self.out, self.err = self.capfd.readouterr() - - def _read(self, out, pos): - out.buffer.seek(pos) - return out.buffer.read().decode(out.encoding, errors=out.errors) - - @property - def outlines(self): - out = [] if self.out is None else self.out.splitlines() - err = [] if self.err is None else self.err.splitlines() - return err + out - - def __repr__(self): - res = "RunResult(ret={}, args={!r}, out=\n{}\n, err=\n{})".format( - self.ret, - self.args, - self.out, - self.err, - ) - if six.PY2: - return res.encode("UTF-8") - else: - return res - - def output(self): - return "{}\n{}\n{}".format(self.ret, self.err, self.out) - - def assert_success(self, is_run_test_env=True): - msg = self.output() - assert self.ret == 0, msg - if is_run_test_env: - assert any(" congratulations :)" == line for line in reversed(self.outlines)), msg - - def assert_fail(self, is_run_test_env=True): - msg = self.output() - assert self.ret, msg - if is_run_test_env: - assert not any(" congratulations :)" == line for line in reversed(self.outlines)), msg - - -class ReportExpectMock: - def __init__(self): - from tox import reporter - - self.instance = reporter._INSTANCE - self.clear() - self._index = -1 - - def clear(self): - self._index = -1 - if not six.PY2: - self.instance.reported_lines.clear() - else: - del self.instance.reported_lines[:] - - def getnext(self, cat): - __tracebackhide__ = True - newindex = self._index + 1 - while newindex < len(self.instance.reported_lines): - call = self.instance.reported_lines[newindex] - lcat = call[0] - if fnmatch(lcat, cat): - self._index = newindex - return call - newindex += 1 - raise LookupError( - "looking for {!r}, no reports found at >={:d} in {!r}".format( - cat, - self._index + 1, - self.instance.reported_lines, - ), - ) - - def expect(self, cat, messagepattern="*", invert=False): - __tracebackhide__ = True - if not messagepattern.startswith("*"): - messagepattern = "*{}".format(messagepattern) - while self._index < len(self.instance.reported_lines): - try: - call = self.getnext(cat) - except LookupError: - break - for lmsg in call[1:]: - lmsg = str(lmsg).replace("\n", " ") - if fnmatch(lmsg, messagepattern): - if invert: - raise AssertionError( - "found {}({!r}), didn't expect it".format(cat, messagepattern), - ) - return - if not invert: - raise AssertionError( - "looking for {}({!r}), no reports found at >={:d} in {!r}".format( - cat, - messagepattern, - self._index + 1, - self.instance.reported_lines, - ), - ) - - def not_expect(self, cat, messagepattern="*"): - return self.expect(cat, messagepattern, invert=True) - - -class pcallMock: - def __init__(self, args, cwd, env, stdout, stderr, shell): - self.arg0 = args[0] - self.args = args - self.cwd = cwd - self.env = env - self.stdout = stdout - self.stderr = stderr - self.shell = shell - self.pid = os.getpid() - self.returncode = 0 - - @staticmethod - def communicate(): - return "", "" - - def wait(self): - pass - - -@pytest.fixture(name="mocksession") -def create_mocksession(request): - config = request.getfixturevalue("newconfig")([], "") - - class MockSession(tox.session.Session): - def __init__(self, config): - self.logging_levels(config.option.quiet_level, config.option.verbose_level) - super(MockSession, self).__init__(config, popen=self.popen) - self._pcalls = [] - self.report = ReportExpectMock() - - def _clearmocks(self): - if not six.PY2: - self._pcalls.clear() - else: - del self._pcalls[:] - self.report.clear() - - def popen(self, args, cwd, shell=None, stdout=None, stderr=None, env=None, **_): - process_call_mock = pcallMock(args, cwd, env, stdout, stderr, shell) - self._pcalls.append(process_call_mock) - return process_call_mock - - def new_config(self, config): - self.logging_levels(config.option.quiet_level, config.option.verbose_level) - self.config = config - self.venv_dict.clear() - self.existing_venvs.clear() - - def logging_levels(self, quiet, verbose): - update_default_reporter(quiet, verbose) - if hasattr(self, "config"): - self.config.option.quiet_level = quiet - self.config.option.verbose_level = verbose - - return MockSession(config) - - -@pytest.fixture -def newmocksession(mocksession, newconfig): - def newmocksession_(args, source, plugins=()): - config = newconfig(args, source, plugins=plugins) - mocksession._reset(config, mocksession.popen) - return mocksession - - return newmocksession_ - - -def getdecoded(out): - try: - return out.decode("utf-8") - except UnicodeDecodeError: - return "INTERNAL not-utf8-decodeable, truncated string:\n{}".format(py.io.saferepr(out)) - - -@pytest.fixture -def initproj(tmpdir): - """Create a factory function for creating example projects. - - Constructed folder/file hierarchy examples: - - with `src_root` other than `.`: - - tmpdir/ - name/ # base - src_root/ # src_root - name/ # package_dir - __init__.py - name.egg-info/ # created later on package build - setup.py - - with `src_root` given as `.`: - - tmpdir/ - name/ # base, src_root - name/ # package_dir - __init__.py - name.egg-info/ # created later on package build - setup.py - """ - - def initproj_(nameversion, filedefs=None, src_root=".", add_missing_setup_py=True): - if filedefs is None: - filedefs = {} - if not src_root: - src_root = "." - if isinstance(nameversion, six.string_types): - parts = nameversion.rsplit(str("-"), 1) - if len(parts) == 1: - parts.append("0.1") - name, version = parts - else: - name, version = nameversion - base = tmpdir.join(name) - src_root_path = _path_join(base, src_root) - assert base == src_root_path or src_root_path.relto( - base, - ), "`src_root` must be the constructed project folder or its direct or indirect subfolder" - - base.ensure(dir=1) - create_files(base, filedefs) - if not _filedefs_contains(base, filedefs, "setup.py") and add_missing_setup_py: - create_files( - base, - { - "setup.py": """ - from setuptools import setup, find_packages - setup( - name='{name}', - description='{name} project', - version='{version}', - license='MIT', - platforms=['unix', 'win32'], - packages=find_packages('{src_root}'), - package_dir={{'':'{src_root}'}}, - ) - """.format( - **locals() - ), - }, - ) - if not _filedefs_contains(base, filedefs, src_root_path.join(name)): - create_files( - src_root_path, - { - name: { - "__init__.py": textwrap.dedent( - ''' - """ module {} """ - __version__ = {!r}''', - ) - .strip() - .format(name, version), - }, - }, - ) - manifestlines = [ - "include {}".format(p.relto(base)) for p in base.visit(lambda x: x.check(file=1)) - ] - create_files(base, {"MANIFEST.in": "\n".join(manifestlines)}) - base.chdir() - return base - - with py.path.local().as_cwd(): - yield initproj_ - - -def _path_parts(path): - path = path and str(path) # py.path.local support - parts = [] - while path: - folder, name = os.path.split(path) - if folder == path: # root folder - folder, name = name, folder - if name: - parts.append(name) - path = folder - parts.reverse() - return parts - - -def _path_join(base, *args): - # workaround for a py.path.local bug on Windows (`path.join('/x', abs=1)` - # should be py.path.local('X:\\x') where `X` is the current drive, when in - # fact it comes out as py.path.local('\\x')) - return py.path.local(base.join(*args, abs=1)) - - -def _filedefs_contains(base, filedefs, path): - """ - whether `filedefs` defines a file/folder with the given `path` - - `path`, if relative, will be interpreted relative to the `base` folder, and - whether relative or not, must refer to either the `base` folder or one of - its direct or indirect children. The base folder itself is considered - created if the filedefs structure is not empty. - - """ - unknown = object() - base = py.path.local(base) - path = _path_join(base, path) - - path_rel_parts = _path_parts(path.relto(base)) - for part in path_rel_parts: - if not isinstance(filedefs, dict): - return False - filedefs = filedefs.get(part, unknown) - if filedefs is unknown: - return False - return path_rel_parts or path == base and filedefs - - -def create_files(base, filedefs): - for key, value in filedefs.items(): - if isinstance(value, dict): - create_files(base.ensure(key, dir=1), value) - elif isinstance(value, six.string_types): - s = textwrap.dedent(value) - - if not isinstance(s, six.text_type): - if not isinstance(s, six.binary_type): - s = str(s) - else: - s = six.ensure_text(s) - - base.join(key).write_text(s, encoding="UTF-8") - - -@pytest.fixture() -def mock_venv(monkeypatch): - """This creates a mock virtual environment (e.g. will inherit the current interpreter). - Note: because we inherit, to keep things sane you must call the py environment and only that; - and cannot install any packages.""" - - # first ensure we have a clean python path - monkeypatch.delenv(str("PYTHONPATH"), raising=False) - - # object to collect some data during the execution - class Result(object): - def __init__(self, session): - self.popens = popen_list - self.session = session - - res = OrderedDict() - - # convince tox that the current running virtual environment is already the env we would create - class ProxyCurrentPython: - @classmethod - def readconfig(cls, path): - if path.dirname.endswith("{}py".format(os.sep)): - return CreationConfig( - base_resolved_python_sha256=getdigest(sys.executable), - base_resolved_python_path=sys.executable, - tox_version=tox.__version__, - sitepackages=False, - usedevelop=False, - deps=[], - alwayscopy=False, - ) - elif path.dirname.endswith("{}.package".format(os.sep)): - return CreationConfig( - base_resolved_python_sha256=getdigest(sys.executable), - base_resolved_python_path=sys.executable, - tox_version=tox.__version__, - sitepackages=False, - usedevelop=False, - deps=[(getdigest(""), "setuptools >= 35.0.2"), (getdigest(""), "wheel")], - alwayscopy=False, - ) - assert False # pragma: no cover - - monkeypatch.setattr(CreationConfig, "readconfig", ProxyCurrentPython.readconfig) - - # provide as Python the current python executable - def venv_lookup(venv, name): - assert name == "python" - venv.envconfig.envdir = py.path.local(sys.executable).join("..", "..") - return sys.executable - - monkeypatch.setattr(VirtualEnv, "_venv_lookup", venv_lookup) - - # don't allow overriding the tox config data for the host Python - def finish_venv(self): - return - - monkeypatch.setattr(VirtualEnv, "finish", finish_venv) - - # we lie that it's an environment with no packages in it - @tox.hookimpl - def tox_runenvreport(venv, action): - return [] - - monkeypatch.setattr(venv, "tox_runenvreport", tox_runenvreport) - - # intercept the build session to save it and we intercept the popen invocations - # collect all popen calls - popen_list = [] - - def popen(cmd, **kwargs): - # we don't want to perform installation of new packages, - # just replace with an always ok cmd - if "pip" in cmd and "install" in cmd: - cmd = ["python", "-c", "print({!r})".format(cmd)] - ret = None - try: - ret = subprocess.Popen(cmd, **kwargs) - except tox.exception.InvocationError as exception: # pragma: no cover - ret = exception # pragma: no cover - finally: - popen_list.append((kwargs.get("env"), ret, cmd)) - return ret - - def build_session(config): - session = tox.session.Session(config, popen=popen) - res[id(session)] = Result(session) - return session - - monkeypatch.setattr(tox.session, "build_session", build_session) - return res - - -@pytest.fixture(scope="session") -def current_tox_py(): - """generate the current (test runners) python versions key - e.g. py37 when running under Python 3.7""" - return "{}{}{}".format("pypy" if tox.INFO.IS_PYPY else "py", *sys.version_info) - - -def pytest_runtest_setup(item): - reset_report() - - -def pytest_runtest_teardown(item): - reset_report() - - -def pytest_pyfunc_call(pyfuncitem): - reset_report() - - -def reset_report(quiet=0, verbose=0): - from tox.reporter import _INSTANCE - - _INSTANCE._reset(quiet_level=quiet, verbose_level=verbose) diff --git a/src/tox/_quickstart.py b/src/tox/_quickstart.py deleted file mode 100644 index 175d97083..000000000 --- a/src/tox/_quickstart.py +++ /dev/null @@ -1,285 +0,0 @@ -# -*- coding: utf-8 -*- -""" - tox._quickstart - ~~~~~~~~~~~~~~~~~ - - Command-line script to quickly setup a configuration for a Python project - - This file was heavily inspired by and uses code from ``sphinx-quickstart`` - in the BSD-licensed `Sphinx project`_. - - .. Sphinx project_: http://sphinx.pocoo.org/ - - License for Sphinx - ================== - - Copyright (c) 2007-2011 by the Sphinx team (see AUTHORS file). - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -""" -import argparse -import codecs -import os -import sys -import textwrap - -import six - -import tox - -ALTERNATIVE_CONFIG_NAME = "tox-generated.ini" -QUICKSTART_CONF = """\ -# tox (https://tox.readthedocs.io/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. - -[tox] -envlist = {envlist} - -[testenv] -deps = - {deps} -commands = - {commands} -""" - - -class ValidationError(Exception): - """Raised for validation errors.""" - - -def nonempty(x): - if not x: - raise ValidationError("Please enter some text.") - return x - - -def choice(*line): - def val(x): - if x not in line: - raise ValidationError("Please enter one of {}.".format(", ".join(line))) - return x - - return val - - -def boolean(x): - if x.upper() not in ("Y", "YES", "N", "NO"): - raise ValidationError("Please enter either 'y' or 'n'.") - return x.upper() in ("Y", "YES") - - -def list_modificator(answer, existing=None): - if not existing: - existing = [] - if not isinstance(existing, list): - existing = [existing] - if not answer: - return existing - existing.extend([t.strip() for t in answer.split(",") if t.strip()]) - return existing - - -def do_prompt(map_, key, text, default=None, validator=nonempty, modificator=None): - while True: - prompt = "> {} [{}]: ".format(text, default) if default else "> {}: ".format(text) - answer = six.moves.input(prompt) - if default and not answer: - answer = default - # FIXME use six instead of self baked solution - # noinspection PyUnresolvedReferences - if sys.version_info < (3,) and not isinstance(answer, unicode): # noqa - # for Python 2.x, try to get a Unicode string out of it - if answer.decode("ascii", "replace").encode("ascii", "replace") != answer: - term_encoding = getattr(sys.stdin, "encoding", None) - if term_encoding: - answer = answer.decode(term_encoding) - else: - print( - "* Note: non-ASCII characters entered but terminal encoding unknown" - " -> assuming UTF-8 or Latin-1.", - ) - try: - answer = answer.decode("utf-8") - except UnicodeDecodeError: - answer = answer.decode("latin1") - if validator: - try: - answer = validator(answer) - except ValidationError as exception: - print("* {}".format(exception)) - continue - break - map_[key] = modificator(answer, map_.get(key)) if modificator else answer - - -def ask_user(map_): - """modify *map_* in place by getting info from the user.""" - print("Welcome to the tox {} quickstart utility.".format(tox.__version__)) - print( - "This utility will ask you a few questions and then generate a simple configuration " - "file to help get you started using tox.\n" - "Please enter values for the following settings (just press Enter to accept a " - "default value, if one is given in brackets).\n", - ) - print( - textwrap.dedent( - """What Python versions do you want to test against? - [1] {} - [2] py27, {} - [3] (All versions) {} - [4] Choose each one-by-one""", - ).format( - tox.PYTHON.CURRENT_RELEASE_ENV, - tox.PYTHON.CURRENT_RELEASE_ENV, - ", ".join(tox.PYTHON.QUICKSTART_PY_ENVS), - ), - ) - do_prompt( - map_, - "canned_pyenvs", - "Enter the number of your choice", - default="3", - validator=choice("1", "2", "3", "4"), - ) - if map_["canned_pyenvs"] == "1": - map_[tox.PYTHON.CURRENT_RELEASE_ENV] = True - elif map_["canned_pyenvs"] == "2": - for pyenv in ("py27", tox.PYTHON.CURRENT_RELEASE_ENV): - map_[pyenv] = True - elif map_["canned_pyenvs"] == "3": - for pyenv in tox.PYTHON.QUICKSTART_PY_ENVS: - map_[pyenv] = True - elif map_["canned_pyenvs"] == "4": - for pyenv in tox.PYTHON.QUICKSTART_PY_ENVS: - if pyenv not in map_: - do_prompt( - map_, - pyenv, - "Test your project with {} (Y/n)".format(pyenv), - "Y", - validator=boolean, - ) - print( - textwrap.dedent( - """What command should be used to test your project? Examples:\ - - pytest\n" - - python -m unittest discover - - python setup.py test - - trial package.module""", - ), - ) - do_prompt( - map_, - "commands", - "Type the command to run your tests", - default="pytest", - modificator=list_modificator, - ) - print("What extra dependencies do your tests have?") - map_["deps"] = get_default_deps(map_["commands"]) - if map_["deps"]: - print("default dependencies are: {}".format(map_["deps"])) - do_prompt( - map_, - "deps", - "Comma-separated list of dependencies", - validator=None, - modificator=list_modificator, - ) - - -def get_default_deps(commands): - if commands and any(c in str(commands) for c in ["pytest", "py.test"]): - return ["pytest"] - if "trial" in commands: - return ["twisted"] - return [] - - -def post_process_input(map_): - envlist = [env for env in tox.PYTHON.QUICKSTART_PY_ENVS if map_.get(env) is True] - map_["envlist"] = ", ".join(envlist) - map_["commands"] = "\n ".join(cmd.strip() for cmd in map_["commands"]) - map_["deps"] = "\n ".join(dep.strip() for dep in set(map_["deps"])) - - -def generate(map_): - """Generate project based on values in *d*.""" - dpath = map_.get("path", os.getcwd()) - altpath = os.path.join(dpath, ALTERNATIVE_CONFIG_NAME) - while True: - name = map_.get("name", tox.INFO.DEFAULT_CONFIG_NAME) - targetpath = os.path.join(dpath, name) - if not os.path.isfile(targetpath): - break - do_prompt(map_, "name", "{} exists - choose an alternative".format(targetpath), altpath) - with codecs.open(targetpath, "w", encoding="utf-8") as f: - f.write(prepare_content(QUICKSTART_CONF.format(**map_))) - print( - "Finished: {} has been created. For information on this file, " - "see https://tox.readthedocs.io/en/latest/config.html\n" - "Execute `tox` to test your project.".format(targetpath), - ) - - -def prepare_content(content): - return "\n".join(line.rstrip() for line in content.split("\n")) - - -def parse_args(): - parser = argparse.ArgumentParser( - description="Command-line script to quickly tox config file for a Python project.", - ) - parser.add_argument( - "root", - type=str, - nargs="?", - default=".", - help="Custom root directory to write config to. Defaults to current directory.", - ) - parser.add_argument( - "--version", - action="https://github.com/version", - version="%(prog)s {}".format(tox.__version__), - ) - return parser.parse_args() - - -def main(): - args = parse_args() - map_ = {"path": args.root} - try: - ask_user(map_) - except (KeyboardInterrupt, EOFError): - print("\n[Interrupted.]") - return 1 - post_process_input(map_) - generate(map_) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/tox/action.py b/src/tox/action.py deleted file mode 100644 index 07c6084f4..000000000 --- a/src/tox/action.py +++ /dev/null @@ -1,297 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import os -import signal -import subprocess -import sys -import time -from contextlib import contextmanager -from threading import Thread - -import py - -from tox import reporter -from tox.constants import INFO -from tox.exception import InvocationError -from tox.reporter import Verbosity -from tox.util.lock import get_unique_file -from tox.util.stdlib import is_main_thread - -if sys.version_info >= (3, 3): - from shlex import quote as shlex_quote -else: - from pipes import quote as shlex_quote - - -class Action(object): - """Action is an effort to group operations with the same goal (within reporting)""" - - def __init__( - self, - name, - msg, - args, - log_dir, - generate_tox_log, - command_log, - popen, - python, - suicide_timeout, - interrupt_timeout, - terminate_timeout, - ): - self.name = name - self.args = args - self.msg = msg - self.activity = self.msg.split(" ", 1)[0] - self.log_dir = log_dir - self.generate_tox_log = generate_tox_log - self.via_popen = popen - self.command_log = command_log - self._timed_report = None - self.python = python - self.suicide_timeout = suicide_timeout - self.interrupt_timeout = interrupt_timeout - self.terminate_timeout = terminate_timeout - if is_main_thread(): - # python allows only main thread to install signal handlers - # see https://docs.python.org/3/library/signal.html#signals-and-threads - self._install_sigterm_handler() - - def __enter__(self): - msg = "{} {}".format(self.msg, " ".join(map(str, self.args))) - self._timed_report = reporter.timed_operation(self.name, msg) - self._timed_report.__enter__() - - return self - - def __exit__(self, type, value, traceback): - self._timed_report.__exit__(type, value, traceback) - - def setactivity(self, name, msg): - self.activity = name - if msg: - reporter.verbosity0("{} {}: {}".format(self.name, name, msg), bold=True) - else: - reporter.verbosity1("{} {}: {}".format(self.name, name, msg), bold=True) - - def info(self, name, msg): - reporter.verbosity1("{} {}: {}".format(self.name, name, msg), bold=True) - - def popen( - self, - args, - cwd=None, - env=None, - redirect=True, - returnout=False, - ignore_ret=False, - capture_err=True, - callback=None, - report_fail=True, - ): - """this drives an interaction with a subprocess""" - cwd = py.path.local() if cwd is None else cwd - cmd_args = [str(x) for x in self._rewrite_args(cwd, args)] - cmd_args_shell = " ".join(shlex_quote(i) for i in cmd_args) - stream_getter = self._get_standard_streams( - capture_err, - cmd_args_shell, - redirect, - returnout, - cwd, - ) - exit_code, output = None, None - with stream_getter as (fin, out_path, stderr, stdout): - try: - process = self.via_popen( - cmd_args, - stdout=stdout, - stderr=stderr, - cwd=str(cwd), - env=os.environ.copy() if env is None else env, - universal_newlines=True, - shell=False, - creationflags=( - subprocess.CREATE_NEW_PROCESS_GROUP - if sys.platform == "win32" - else 0 - # needed for Windows signal send ability (CTRL+C) - ), - ) - except OSError as exception: - exit_code = exception.errno - else: - if callback is not None: - callback(process) - reporter.log_popen(cwd, out_path, cmd_args_shell, process.pid) - output = self.evaluate_cmd(fin, process, redirect) - exit_code = process.returncode - finally: - if out_path is not None and out_path.exists(): - lines = out_path.read_text("UTF-8").split("\n") - # first three lines are the action, cwd, and cmd - remove it - output = "\n".join(lines[3:]) - try: - if exit_code and not ignore_ret: - if report_fail: - msg = "invocation failed (exit code {:d})".format(exit_code) - if out_path is not None: - msg += ", logfile: {}".format(out_path) - if not out_path.exists(): - msg += " warning log file missing" - reporter.error(msg) - if out_path is not None and out_path.exists(): - reporter.separator("=", "log start", Verbosity.QUIET) - reporter.quiet(output) - reporter.separator("=", "log end", Verbosity.QUIET) - raise InvocationError(cmd_args_shell, exit_code, output) - finally: - self.command_log.add_command(cmd_args, output, exit_code) - return output - - def evaluate_cmd(self, input_file_handler, process, redirect): - try: - if self.generate_tox_log and not redirect: - if process.stderr is not None: - # prevent deadlock - raise ValueError("stderr must not be piped here") - # we read binary from the process and must write using a binary stream - buf = getattr(sys.stdout, "buffer", sys.stdout) - last_time = time.time() - while True: - # we have to read one byte at a time, otherwise there - # might be no output for a long time with slow tests - data = input_file_handler.read(1) - if data: - buf.write(data) - if b"\n" in data or (time.time() - last_time) > 1: - # we flush on newlines or after 1 second to - # provide quick enough feedback to the user - # when printing a dot per test - buf.flush() - last_time = time.time() - elif process.poll() is not None: - if process.stdout is not None: - process.stdout.close() - break - else: - time.sleep(0.1) - # the seek updates internal read buffers - input_file_handler.seek(0, 1) - input_file_handler.close() - out, _ = process.communicate() # wait to finish - except KeyboardInterrupt as exception: - reporter.error("got KeyboardInterrupt signal") - main_thread = is_main_thread() - while True: - try: - if main_thread: - # spin up a new thread to disable further interrupt on main thread - stopper = Thread(target=self.handle_interrupt, args=(process,)) - stopper.start() - stopper.join() - else: - self.handle_interrupt(process) - except KeyboardInterrupt: - continue - break - raise exception - return out - - def handle_interrupt(self, process): - """A three level stop mechanism for children - INT -> TERM -> KILL""" - msg = "from {} {{}} pid {}".format(os.getpid(), process.pid) - if self._wait(process, self.suicide_timeout) is None: - self.info("KeyboardInterrupt", msg.format("SIGINT")) - process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT) - if self._wait(process, self.interrupt_timeout) is None: - self.info("KeyboardInterrupt", msg.format("SIGTERM")) - process.terminate() - if self._wait(process, self.terminate_timeout) is None: - self.info("KeyboardInterrupt", msg.format("SIGKILL")) - process.kill() - process.communicate() - - @staticmethod - def _wait(process, timeout): - if sys.version_info >= (3, 3): - # python 3 has timeout feature built-in - try: - process.communicate(timeout=timeout) - except subprocess.TimeoutExpired: - pass - else: - # on Python 2 we need to simulate it - delay = 0.01 - while process.poll() is None and timeout > 0: - time.sleep(delay) - timeout -= delay - return process.poll() - - @contextmanager - def _get_standard_streams(self, capture_err, cmd_args_shell, redirect, returnout, cwd): - stdout = out_path = input_file_handler = None - stderr = subprocess.STDOUT if capture_err else None - - if self.generate_tox_log or redirect: - out_path = self.get_log_path(self.name) - with out_path.open("wt") as stdout, out_path.open("rb") as input_file_handler: - msg = "action: {}, msg: {}\ncwd: {}\ncmd: {}\n".format( - self.name.replace("\n", " "), - self.msg.replace("\n", " "), - str(cwd).replace("\n", " "), - cmd_args_shell.replace("\n", " "), - ) - stdout.write(msg) - stdout.flush() - input_file_handler.read() # read the header, so it won't be written to stdout - yield input_file_handler, out_path, stderr, stdout - return - - if returnout: - stdout = subprocess.PIPE - - yield input_file_handler, out_path, stderr, stdout - - def get_log_path(self, actionid): - log_file = get_unique_file(self.log_dir, prefix=actionid, suffix=".log") - return log_file - - def _rewrite_args(self, cwd, args): - - executable = None - if INFO.IS_WIN: - # shebang lines are not adhered on Windows so if it's a python script - # pre-pend the interpreter - ext = os.path.splitext(str(args[0]))[1].lower() - if ext == ".py": - executable = str(self.python) - if executable is None: - executable = args[0] - args = args[1:] - - new_args = [executable] - - # to make the command shorter try to use relative paths for all subsequent arguments - # note the executable cannot be relative as the Windows applies cwd after invocation - for arg in args: - if arg and os.path.isabs(str(arg)): - arg_path = py.path.local(arg) - if arg_path.exists() and arg_path.common(cwd) is not None: - potential_arg = cwd.bestrelpath(arg_path) - if len(potential_arg.split("..")) < 2: - # just one parent directory accepted as relative path - arg = potential_arg - new_args.append(str(arg)) - - return new_args - - def _install_sigterm_handler(self): - """Handle sigterm as if it were a keyboardinterrupt""" - - def sigterm_handler(signum, frame): - reporter.error("Got SIGTERM, handling it as a KeyboardInterrupt") - raise KeyboardInterrupt() - - signal.signal(signal.SIGTERM, sigterm_handler) diff --git a/src/tox/cli.py b/src/tox/cli.py deleted file mode 100644 index 2fe755cf1..000000000 --- a/src/tox/cli.py +++ /dev/null @@ -1,11 +0,0 @@ -from tox.config import Parser, get_plugin_manager - - -def cli_parser(): - parser = Parser() - pm = get_plugin_manager(tuple()) - pm.hook.tox_addoption(parser=parser) - return parser.argparser - - -cli = cli_parser() diff --git a/src/tox/config/__init__.py b/src/tox/config/__init__.py index 75b9ab92f..e69de29bb 100644 --- a/src/tox/config/__init__.py +++ b/src/tox/config/__init__.py @@ -1,2146 +0,0 @@ -from __future__ import print_function - -import argparse -import io -import itertools -import json -import os -import random -import re -import shlex -import string -import sys -import traceback -import warnings -from collections import OrderedDict -from fnmatch import fnmatchcase -from subprocess import list2cmdline -from threading import Thread - -import pluggy -import py -import six - -if sys.version_info >= (3, 11): - import tomllib as toml_loader - - toml_mode = "rb" - toml_encoding = None -elif sys.version_info >= (3, 7): - import tomli as toml_loader - - toml_mode = "rb" - toml_encoding = None -else: - import toml as toml_loader - - toml_mode = "r" - toml_encoding = "UTF-8" - - -from packaging import requirements -from packaging.utils import canonicalize_name -from packaging.version import Version - -import tox -from tox.constants import INFO -from tox.exception import MissingDependency -from tox.interpreters import Interpreters, NoInterpreterInfo -from tox.reporter import ( - REPORTER_TIMESTAMP_ON_ENV, - error, - update_default_reporter, - using, - verbosity1, -) -from tox.util.path import ensure_empty_dir -from tox.util.stdlib import importlib_metadata - -from .parallel import ENV_VAR_KEY_PRIVATE as PARALLEL_ENV_VAR_KEY_PRIVATE -from .parallel import ENV_VAR_KEY_PUBLIC as PARALLEL_ENV_VAR_KEY_PUBLIC -from .parallel import add_parallel_config, add_parallel_flags -from .reporter import add_verbosity_commands - -if sys.version_info >= (3, 3): - from shlex import quote as shlex_quote -else: - from pipes import quote as shlex_quote - - -hookimpl = tox.hookimpl -# DEPRECATED - REMOVE - left for compatibility with plugins importing from here. -# Import hookimpl directly from tox instead. - - -WITHIN_PROVISION = os.environ.get(str("TOX_PROVISION")) == "1" - -SUICIDE_TIMEOUT = 0.0 -INTERRUPT_TIMEOUT = 0.3 -TERMINATE_TIMEOUT = 0.2 - -_FACTOR_LINE_PATTERN = re.compile(r"^([\w{}.!,-]+):\s+(.+)") -_ENVSTR_SPLIT_PATTERN = re.compile(r"((?:{[^}]+})+)|,") -_ENVSTR_EXPAND_PATTERN = re.compile(r"{([^}]+)}") -_WHITESPACE_PATTERN = re.compile(r"\s+") - - -def get_plugin_manager(plugins=()): - # initialize plugin manager - import tox.venv - - pm = pluggy.PluginManager("tox") - pm.add_hookspecs(tox.hookspecs) - pm.register(tox.config) - pm.register(tox.interpreters) - pm.register(tox.venv) - pm.register(tox.session) - from tox import package - - pm.register(package) - pm.load_setuptools_entrypoints("tox") - for plugin in plugins: - pm.register(plugin) - pm.check_pending() - return pm - - -class Parser: - """Command line and ini-parser control object.""" - - def __init__(self): - class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter): - def __init__(self, prog): - super(HelpFormatter, self).__init__(prog, max_help_position=35, width=190) - - self.argparser = argparse.ArgumentParser( - description="tox options", - add_help=False, - prog="tox", - formatter_class=HelpFormatter, - ) - self._testenv_attr = [] - - def add_argument(self, *args, **kwargs): - """add argument to command line parser. This takes the - same arguments that ``argparse.ArgumentParser.add_argument``. - """ - return self.argparser.add_argument(*args, **kwargs) - - def add_testenv_attribute(self, name, type, help, default=None, postprocess=None): - """add an ini-file variable for "testenv" section. - - Types are specified as strings like "bool", "line-list", "string", "argv", "path", - "argvlist". - - The ``postprocess`` function will be called for each testenv - like ``postprocess(testenv_config=testenv_config, value=value)`` - where ``value`` is the value as read from the ini (or the default value) - and ``testenv_config`` is a :py:class:`tox.config.TestenvConfig` instance - which will receive all ini-variables as object attributes. - - Any postprocess function must return a value which will then be set - as the final value in the testenv section. - """ - self._testenv_attr.append(VenvAttribute(name, type, default, help, postprocess)) - - def add_testenv_attribute_obj(self, obj): - """add an ini-file variable as an object. - - This works as the ``add_testenv_attribute`` function but expects - "name", "type", "help", and "postprocess" attributes on the object. - """ - assert hasattr(obj, "name") - assert hasattr(obj, "type") - assert hasattr(obj, "help") - assert hasattr(obj, "postprocess") - self._testenv_attr.append(obj) - - def parse_cli(self, args, strict=False): - args, argv = self.argparser.parse_known_args(args) - if argv and (strict or WITHIN_PROVISION): - self.argparser.error("unrecognized arguments: {}".format(" ".join(argv))) - return args - - def _format_help(self): - return self.argparser.format_help() - - -class VenvAttribute: - def __init__(self, name, type, default, help, postprocess): - self.name = name - self.type = type - self.default = default - self.help = help - self.postprocess = postprocess - - -class DepOption: - name = "deps" - type = "line-list" - help = "each line specifies a dependency in pip/setuptools format." - default = () - - def postprocess(self, testenv_config, value): - deps = [] - config = testenv_config.config - for depline in value: - m = re.match(r":(\w+):\s*(\S+)", depline) - if m: - iname, name = m.groups() - ixserver = config.indexserver[iname] - else: - name = depline.strip() - ixserver = None - # we need to process options, in case they contain a space, - # as the subprocess call to pip install will otherwise fail. - # in case of a short option, we remove the space - for option in tox.PIP.INSTALL_SHORT_OPTIONS_ARGUMENT: - if name.startswith(option): - name = "{}{}".format(option, name[len(option) :].strip()) - # in case of a long option, we add an equal sign - for option in tox.PIP.INSTALL_LONG_OPTIONS_ARGUMENT: - name_start = "{} ".format(option) - if name.startswith(name_start): - name = "{}={}".format(option, name[len(option) :].strip()) - name = self._cut_off_dep_comment(name) - name = self._replace_forced_dep(name, config) - deps.append(DepConfig(name, ixserver)) - return deps - - def _replace_forced_dep(self, name, config): - """Override given dependency config name. Take ``--force-dep-version`` option into account. - - :param name: dep config, for example ["pkg==1.0", "other==2.0"]. - :param config: ``Config`` instance - :return: the new dependency that should be used for virtual environments - """ - if not config.option.force_dep: - return name - for forced_dep in config.option.force_dep: - if self._is_same_dep(forced_dep, name): - return forced_dep - return name - - @staticmethod - def _cut_off_dep_comment(name): - return re.sub(r"\s+#.*", "", name).strip() - - @classmethod - def _is_same_dep(cls, dep1, dep2): - """Definitions are the same if they refer to the same package, even if versions differ.""" - dep1_name = canonicalize_name(requirements.Requirement(dep1).name) - try: - dep2_name = canonicalize_name(requirements.Requirement(dep2).name) - except requirements.InvalidRequirement: - # we couldn't parse a version, probably a URL - return False - return dep1_name == dep2_name - - -class PosargsOption: - name = "args_are_paths" - type = "bool" - default = True - help = "treat positional args in commands as paths" - - def postprocess(self, testenv_config, value): - config = testenv_config.config - args = config.option.args - if args: - if value: - args = [] - for arg in config.option.args: - if arg and not os.path.isabs(arg): - origpath = os.path.join(config.invocationcwd.strpath, arg) - if os.path.exists(origpath): - arg = os.path.relpath(origpath, testenv_config.changedir.strpath) - args.append(arg) - testenv_config._reader.addsubstitutions(args) - return value - - -class InstallcmdOption: - name = "install_command" - type = "argv_install_command" - default = r"python -m pip install \{opts\} \{packages\}" - help = "install command for dependencies and package under test." - - def postprocess(self, testenv_config, value): - if "{packages}" not in value: - raise tox.exception.ConfigError( - "'install_command' must contain '{packages}' substitution", - ) - return value - - -def parseconfig(args, plugins=()): - """Parse the configuration file and create a Config object. - - :param plugins: - :param list[str] args: list of arguments. - :rtype: :class:`Config` - :raise SystemExit: toxinit file is not found - """ - pm = get_plugin_manager(plugins) - config, option = parse_cli(args, pm) - update_default_reporter(config.option.quiet_level, config.option.verbose_level) - - for config_file in propose_configs(option.configfile): - config_type = config_file.basename - - content = None - if config_type == "pyproject.toml": - toml_content = get_py_project_toml(config_file) - try: - content = toml_content["tool"]["tox"]["legacy_tox_ini"] - except KeyError: - continue - try: - ParseIni(config, config_file, content) - except SkipThisIni: - continue - pm.hook.tox_configure(config=config) # post process config object - break - else: - parser = Parser() - pm.hook.tox_addoption(parser=parser) - # if no tox config file, now we need do a strict argument evaluation - # raise on unknown args - parser.parse_cli(args, strict=True) - if option.help or option.helpini: - return config - if option.devenv: - # To load defaults, we parse an empty config - ParseIni(config, py.path.local(), "") - pm.hook.tox_configure(config=config) - return config - msg = "tox config file (either {}) not found" - candidates = ", ".join(INFO.CONFIG_CANDIDATES) - feedback(msg.format(candidates), sysexit=not (option.help or option.helpini)) - return config - - -def get_py_project_toml(path): - with io.open(str(path), mode=toml_mode, encoding=toml_encoding) as file_handler: - config_data = toml_loader.load(file_handler) - return config_data - - -def propose_configs(cli_config_file): - from_folder = py.path.local() - if cli_config_file is not None: - if os.path.isfile(cli_config_file): - yield py.path.local(cli_config_file) - return - if os.path.isdir(cli_config_file): - from_folder = py.path.local(cli_config_file) - else: - print( - "ERROR: {} is neither file or directory".format(cli_config_file), - file=sys.stderr, - ) - return - for basename in INFO.CONFIG_CANDIDATES: - if from_folder.join(basename).isfile(): - yield from_folder.join(basename) - for path in from_folder.parts(reverse=True): - ini_path = path.join(basename) - if ini_path.check(): - yield ini_path - - -def parse_cli(args, pm): - parser = Parser() - pm.hook.tox_addoption(parser=parser) - option = parser.parse_cli(args) - if option.version: - print(get_version_info(pm)) - raise SystemExit(0) - interpreters = Interpreters(hook=pm.hook) - config = Config( - pluginmanager=pm, - option=option, - interpreters=interpreters, - parser=parser, - args=args, - ) - return config, option - - -def feedback(msg, sysexit=False): - print("ERROR: {}".format(msg), file=sys.stderr) - if sysexit: - raise SystemExit(1) - - -def get_version_info(pm): - out = ["{} imported from {}".format(tox.__version__, tox.__file__)] - plugin_dist_info = pm.list_plugin_distinfo() - if plugin_dist_info: - out.append("registered plugins:") - for mod, egg_info in plugin_dist_info: - source = getattr(mod, "__file__", repr(mod)) - out.append(" {}-{} at {}".format(egg_info.project_name, egg_info.version, source)) - return "\n".join(out) - - -class SetenvDict(object): - _DUMMY = object() - - def __init__(self, definitions, reader): - self.definitions = definitions - self.reader = reader - self.resolved = {} - self._lookupstack = [] - - def __repr__(self): - return "{}: {}".format(self.__class__.__name__, self.definitions) - - def __contains__(self, name): - return name in self.definitions - - def get(self, name, default=None): - try: - return self.resolved[name] - except KeyError: - try: - if name in self._lookupstack: - raise KeyError(name) - val = self.definitions[name] - except KeyError: - return os.environ.get(name, default) - self._lookupstack.append(name) - try: - self.resolved[name] = res = self.reader._replace(val, name="setenv") - finally: - self._lookupstack.pop() - return res - - def __getitem__(self, name): - x = self.get(name, self._DUMMY) - if x is self._DUMMY: - raise KeyError(name) - return x - - def keys(self): - return self.definitions.keys() - - def __setitem__(self, name, value): - self.definitions[name] = value - self.resolved[name] = value - - def items(self): - return ((name, self[name]) for name in self.definitions) - - def export(self): - # post-process items to avoid internal syntax/semantics - # such as {} being escaped using \{\}, suitable for use with - # os.environ . - return { - name: Replacer._unescape(value) - for name, value in self.items() - if value is not self._DUMMY - } - - -@tox.hookimpl -def tox_addoption(parser): - parser.add_argument( - "--version", - action="https://github.com/store_true", - help="report version information to stdout.", - ) - parser.add_argument("-h", "--help", action="https://github.com/store_true", help="show help about options") - parser.add_argument( - "--help-ini", - "--hi", - action="https://github.com/store_true", - dest="helpini", - help="show help about ini-names", - ) - add_verbosity_commands(parser) - parser.add_argument( - "--showconfig", - action="https://github.com/store_true", - help="show live configuration (by default all env, with -l only default targets," - " specific via TOXENV/-e)", - ) - parser.add_argument( - "-l", - "--listenvs", - action="https://github.com/store_true", - help="show list of test environments (with description if verbose)", - ) - parser.add_argument( - "-a", - "--listenvs-all", - action="https://github.com/store_true", - help="show list of all defined environments (with description if verbose)", - ) - parser.add_argument( - "-c", - dest="configfile", - help="config file name or directory with 'tox.ini' file.", - ) - parser.add_argument( - "-e", - action="https://github.com/append", - dest="env", - metavar="envlist", - help="work against specified environments (ALL selects all).", - ) - parser.add_argument( - "--devenv", - metavar="ENVDIR", - help=( - "sets up a development environment at ENVDIR based on the env's tox " - "configuration specified by `-e` (-e defaults to py)." - ), - ) - parser.add_argument("--notest", action="https://github.com/store_true", help="skip invoking test commands.") - parser.add_argument( - "--sdistonly", - action="https://github.com/store_true", - help="only perform the sdist packaging activity.", - ) - parser.add_argument( - "--skip-pkg-install", - action="https://github.com/store_true", - help="skip package installation for this run", - ) - add_parallel_flags(parser) - parser.add_argument( - "--parallel--safe-build", - action="https://github.com/store_true", - dest="parallel_safe_build", - help="(deprecated) ensure two tox builds can run in parallel " - "(uses a lock file in the tox workdir with .lock extension)", - ) - parser.add_argument( - "--installpkg", - metavar="PATH", - help="use specified package for installation into venv, instead of creating an sdist.", - ) - parser.add_argument( - "--develop", - action="https://github.com/store_true", - help="install package in the venv using 'setup.py develop' via 'pip -e .'", - ) - parser.add_argument( - "-i", - "--index-url", - action="https://github.com/append", - dest="indexurl", - metavar="URL", - help="set indexserver url (if URL is of form name=url set the " - "url for the 'name' indexserver, specifically)", - ) - parser.add_argument( - "--pre", - action="https://github.com/store_true", - help="install pre-releases and development versions of dependencies. " - "This will pass the --pre option to install_command " - "(pip by default).", - ) - parser.add_argument( - "-r", - "--recreate", - action="https://github.com/store_true", - help="force recreation of virtual environments", - ) - parser.add_argument( - "--result-json", - dest="resultjson", - metavar="PATH", - help="write a json file with detailed information " - "about all commands and results involved.", - ) - parser.add_argument( - "--discover", - dest="discover", - nargs="+", - metavar="PATH", - help="for python discovery first try the python executables under these paths", - default=[], - ) - - # We choose 1 to 4294967295 because it is the range of PYTHONHASHSEED. - parser.add_argument( - "--hashseed", - metavar="SEED", - help="set PYTHONHASHSEED to SEED before running commands. " - "Defaults to a random integer in the range [1, 4294967295] " - "([1, 1024] on Windows). " - "Passing 'noset' suppresses this behavior.", - ) - parser.add_argument( - "--force-dep", - action="https://github.com/append", - metavar="REQ", - help="Forces a certain version of one of the dependencies " - "when configuring the virtual environment. REQ Examples " - "'pytest<2.7' or 'django>=1.6'.", - ) - parser.add_argument( - "--sitepackages", - action="https://github.com/store_true", - help="override sitepackages setting to True in all envs", - ) - parser.add_argument( - "--alwayscopy", - action="https://github.com/store_true", - help="override alwayscopy setting to True in all envs", - ) - parser.add_argument( - "--no-provision", - action="https://github.com/store", - nargs="?", - default=False, - const=True, - metavar="REQUIRES_JSON", - help="do not perform provision, but fail and if a path was provided " - "write provision metadata as JSON to it", - ) - - cli_skip_missing_interpreter(parser) - parser.add_argument("--workdir", metavar="PATH", help="tox working directory") - - parser.add_argument( - "args", - nargs="*", - help="additional arguments available to command positional substitution", - ) - - def _set_envdir_from_devenv(testenv_config, value): - if ( - testenv_config.config.option.devenv is not None - and testenv_config.envname != testenv_config.config.provision_tox_env - ): - return py.path.local(testenv_config.config.option.devenv) - else: - return value - - parser.add_testenv_attribute( - name="envdir", - type="path", - default="{toxworkdir}/{envname}", - help="set venv directory -- be very careful when changing this as tox " - "will remove this directory when recreating an environment", - postprocess=_set_envdir_from_devenv, - ) - - # add various core venv interpreter attributes - def setenv(testenv_config, value): - setenv = value - config = testenv_config.config - if "PYTHONHASHSEED" not in setenv and config.hashseed is not None: - setenv["PYTHONHASHSEED"] = config.hashseed - - setenv["TOX_ENV_NAME"] = str(testenv_config.envname) - setenv["TOX_ENV_DIR"] = str(testenv_config.envdir) - return setenv - - parser.add_testenv_attribute( - name="setenv", - type="dict_setenv", - postprocess=setenv, - help="list of X=Y lines with environment variable settings", - ) - - def basepython_default(testenv_config, value): - """either user set or proposed from the factor name - - in both cases we check that the factor name implied python version and the resolved - python interpreter version match up; if they don't we warn, unless ignore base - python conflict is set in which case the factor name implied version if forced - """ - for factor in testenv_config.factors: - match = tox.PYTHON.PY_FACTORS_RE.match(factor) - if match: - base_exe = {"py": "python"}.get(match.group(1), match.group(1)) - version_s = match.group(2) - if not version_s: - version_info = () - elif len(version_s) == 1: - version_info = (version_s,) - else: - version_info = (version_s[0], version_s[1:]) - implied_version = ".".join(version_info) - implied_python = "{}{}".format(base_exe, implied_version) - break - else: - implied_python, version_info, implied_version = None, (), "" - - if testenv_config.config.ignore_basepython_conflict and implied_python is not None: - return implied_python - - proposed_python = (implied_python or sys.executable) if value is None else str(value) - if implied_python is not None and implied_python != proposed_python: - testenv_config.basepython = proposed_python - python_info_for_proposed = testenv_config.python_info - if not isinstance(python_info_for_proposed, NoInterpreterInfo): - proposed_version = ".".join( - str(x) for x in python_info_for_proposed.version_info[: len(version_info)] - ) - if proposed_version != implied_version: - # TODO(stephenfin): Raise an exception here in tox 4.0 - warnings.warn( - "conflicting basepython version (set {}, should be {}) for env '{}';" - "resolve conflict or set ignore_basepython_conflict".format( - proposed_version, - implied_version, - testenv_config.envname, - ), - ) - - return proposed_python - - parser.add_testenv_attribute( - name="basepython", - type="basepython", - default=None, - postprocess=basepython_default, - help="executable name or path of interpreter used to create a virtual test environment.", - ) - - def merge_description(testenv_config, value): - """the reader by default joins generated description with new line, - replace new line with space""" - return value.replace("\n", " ") - - parser.add_testenv_attribute( - name="description", - type="string", - default="", - postprocess=merge_description, - help="short description of this environment", - ) - - parser.add_testenv_attribute( - name="envtmpdir", - type="path", - default="{envdir}/tmp", - help="venv temporary directory", - ) - - parser.add_testenv_attribute( - name="envlogdir", - type="path", - default="{envdir}/log", - help="venv log directory", - ) - - parser.add_testenv_attribute( - name="downloadcache", - type="string", - default=None, - help="(ignored) has no effect anymore, pip-8 uses local caching by default", - ) - - parser.add_testenv_attribute( - name="changedir", - type="path", - default="{toxinidir}", - help="directory to change to when running commands", - ) - - parser.add_testenv_attribute_obj(PosargsOption()) - - def skip_install_default(testenv_config, value): - return value is True or testenv_config.config.option.skip_pkg_install is True - - parser.add_testenv_attribute( - name="skip_install", - type="bool", - default=False, - postprocess=skip_install_default, - help="Do not install the current package. This can be used when you need the virtualenv " - "management but do not want to install the current package", - ) - - parser.add_testenv_attribute( - name="ignore_errors", - type="bool", - default=False, - help="if set to True all commands will be executed irrespective of their result error " - "status.", - ) - - def recreate(testenv_config, value): - if testenv_config.config.option.recreate: - return True - return value - - parser.add_testenv_attribute( - name="recreate", - type="bool", - default=False, - postprocess=recreate, - help="always recreate this test environment.", - ) - - def passenv(testenv_config, value): - # Flatten the list to deal with space-separated values. - value = list(itertools.chain.from_iterable([x.split(" ") for x in value])) - - passenv = { - "CURL_CA_BUNDLE", - "LANG", - "LANGUAGE", - "LC_ALL", - "LD_LIBRARY_PATH", - "PATH", - "PIP_INDEX_URL", - "PIP_EXTRA_INDEX_URL", - "REQUESTS_CA_BUNDLE", - "SSL_CERT_FILE", - "TOX_WORK_DIR", - "HTTP_PROXY", - "HTTPS_PROXY", - "NO_PROXY", - str(REPORTER_TIMESTAMP_ON_ENV), - str(PARALLEL_ENV_VAR_KEY_PUBLIC), - } - - # read in global passenv settings - p = os.environ.get("TOX_TESTENV_PASSENV", None) - if p is not None: - env_values = [x for x in p.split() if x] - value.extend(env_values) - - # we ensure that tmp directory settings are passed on - # we could also set it to the per-venv "envtmpdir" - # but this leads to very long paths when run with jenkins - # so we just pass it on by default for now. - if tox.INFO.IS_WIN: - passenv.add("APPDATA") # needed to find user site-packages location - passenv.add("SYSTEMDRIVE") # needed for pip6 - passenv.add("SYSTEMROOT") # needed for python's crypto module - passenv.add("PATHEXT") # needed for discovering executables - passenv.add("COMSPEC") # needed for distutils cygwincompiler - passenv.add("TEMP") - passenv.add("TMP") - # for `multiprocessing.cpu_count()` on Windows (prior to Python 3.4). - passenv.add("NUMBER_OF_PROCESSORS") - passenv.add("PROCESSOR_ARCHITECTURE") # platform.machine() - passenv.add("USERPROFILE") # needed for `os.path.expanduser()` - passenv.add("MSYSTEM") # fixes #429 - # PROGRAM* required for compiler tool discovery #2382 - passenv.add("PROGRAMFILES") - passenv.add("PROGRAMFILES(X86)") - passenv.add("PROGRAMDATA") - else: - passenv.add("TMPDIR") - - # add non-uppercased variables to passenv if present (only necessary for UNIX) - passenv.update(name for name in os.environ if name.upper() in passenv) - - for spec in value: - for name in os.environ: - if fnmatchcase(name.upper(), spec.upper()): - passenv.add(name) - return passenv - - parser.add_testenv_attribute( - name="passenv", - type="line-list", - postprocess=passenv, - help="environment variables needed during executing test commands (taken from invocation " - "environment). Note that tox always passes through some basic environment variables " - "which are needed for basic functioning of the Python system. See --showconfig for the " - "eventual passenv setting.", - ) - - parser.add_testenv_attribute( - name="whitelist_externals", - type="line-list", - help="DEPRECATED: use allowlist_externals", - ) - - parser.add_testenv_attribute( - name="allowlist_externals", - type="line-list", - help="each lines specifies a path or basename for which tox will not warn " - "about it coming from outside the test environment.", - ) - - parser.add_testenv_attribute( - name="platform", - type="string", - default=".*", - help="regular expression which must match against ``sys.platform``. " - "otherwise testenv will be skipped.", - ) - - def sitepackages(testenv_config, value): - return testenv_config.config.option.sitepackages or value - - def alwayscopy(testenv_config, value): - return testenv_config.config.option.alwayscopy or value - - parser.add_testenv_attribute( - name="sitepackages", - type="bool", - default=False, - postprocess=sitepackages, - help="Set to ``True`` if you want to create virtual environments that also " - "have access to globally installed packages.", - ) - - parser.add_testenv_attribute( - "download", - type="bool", - default=False, - help="download the latest pip, setuptools and wheel when creating the virtual" - "environment (default is to use the one bundled in virtualenv)", - ) - - parser.add_testenv_attribute( - name="alwayscopy", - type="bool", - default=False, - postprocess=alwayscopy, - help="Set to ``True`` if you want virtualenv to always copy files rather " - "than symlinking.", - ) - - def pip_pre(testenv_config, value): - return testenv_config.config.option.pre or value - - parser.add_testenv_attribute( - name="pip_pre", - type="bool", - default=False, - postprocess=pip_pre, - help="If ``True``, adds ``--pre`` to the ``opts`` passed to the install command. ", - ) - - def develop(testenv_config, value): - option = testenv_config.config.option - return not option.installpkg and (value or option.develop or option.devenv is not None) - - parser.add_testenv_attribute( - name="usedevelop", - type="bool", - postprocess=develop, - default=False, - help="install package in develop/editable mode", - ) - - parser.add_testenv_attribute_obj(InstallcmdOption()) - - parser.add_testenv_attribute( - name="list_dependencies_command", - type="argv", - default="python -m pip freeze", - help="list dependencies for a virtual environment", - ) - - parser.add_testenv_attribute_obj(DepOption()) - - parser.add_testenv_attribute( - name="suicide_timeout", - type="float", - default=SUICIDE_TIMEOUT, - help="timeout to allow process to exit before sending SIGINT", - ) - - parser.add_testenv_attribute( - name="interrupt_timeout", - type="float", - default=INTERRUPT_TIMEOUT, - help="timeout before sending SIGTERM after SIGINT", - ) - - parser.add_testenv_attribute( - name="terminate_timeout", - type="float", - default=TERMINATE_TIMEOUT, - help="timeout before sending SIGKILL after SIGTERM", - ) - - parser.add_testenv_attribute( - name="commands", - type="argvlist", - default="", - help="each line specifies a test command and can use substitution.", - ) - - parser.add_testenv_attribute( - name="commands_pre", - type="argvlist", - default="", - help="each line specifies a setup command action and can use substitution.", - ) - - parser.add_testenv_attribute( - name="commands_post", - type="argvlist", - default="", - help="each line specifies a teardown command and can use substitution.", - ) - - parser.add_testenv_attribute( - "ignore_outcome", - type="bool", - default=False, - help="if set to True a failing result of this testenv will not make " - "tox fail, only a warning will be produced", - ) - - parser.add_testenv_attribute( - "extras", - type="line-list", - help="list of extras to install with the source distribution or develop install", - ) - - add_parallel_config(parser) - - -def cli_skip_missing_interpreter(parser): - class SkipMissingInterpreterAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - value = "true" if values is None else values - if value not in ("config", "true", "false"): - raise argparse.ArgumentTypeError("value must be config, true or false") - setattr(namespace, self.dest, value) - - parser.add_argument( - "-s", - "--skip-missing-interpreters", - default="config", - metavar="val", - nargs="?", - action=SkipMissingInterpreterAction, - help="don't fail tests for missing interpreters: {config,true,false} choice", - ) - - -class Config(object): - """Global Tox config object.""" - - def __init__(self, pluginmanager, option, interpreters, parser, args): - self.envconfigs = OrderedDict() - """Mapping envname -> envconfig""" - self.invocationcwd = py.path.local() - self.interpreters = interpreters - self.pluginmanager = pluginmanager - self.option = option - self._parser = parser - self._testenv_attr = parser._testenv_attr - self.args = args - - """option namespace containing all parsed command line options""" - - @property - def homedir(self): - homedir = get_homedir() - if homedir is None: - homedir = self.toxinidir # FIXME XXX good idea? - return homedir - - -class TestenvConfig: - """Testenv Configuration object. - - In addition to some core attributes/properties this config object holds all - per-testenv ini attributes as attributes, see "tox --help-ini" for an overview. - """ - - def __init__(self, envname, config, factors, reader): - #: test environment name - self.envname = envname - #: global tox config object - self.config = config - #: set of factors - self.factors = factors - self._reader = reader - self._missing_subs = {} - """Holds substitutions that could not be resolved. - - Pre 2.8.1 missing substitutions crashed with a ConfigError although this would not be a - problem if the env is not part of the current testrun. So we need to remember this and - check later when the testenv is actually run and crash only then. - """ - - # Python 3 only, as __getattribute__ is ignored for old-style types on Python 2 - def __getattribute__(self, name): - rv = object.__getattribute__(self, name) - if isinstance(rv, Exception): - raise rv - return rv - - if six.PY2: - - def __getattr__(self, name): - if name in self._missing_subs: - raise self._missing_subs[name] - raise AttributeError(name) - - def get_envbindir(self): - """Path to directory where scripts/binaries reside.""" - is_bin = ( - isinstance(self.python_info, NoInterpreterInfo) - or tox.INFO.IS_WIN is False - or self.python_info.implementation == "Jython" - or ( - # this combination is MSYS2 - tox.INFO.IS_WIN - and self.python_info.os_sep == "/" - ) - or ( - tox.INFO.IS_WIN - and self.python_info.implementation == "PyPy" - and self.python_info.extra_version_info < (7, 3, 1) - ) - ) - return self.envdir.join("bin" if is_bin else "Scripts") - - @property - def envbindir(self): - return self.get_envbindir() - - @property - def envpython(self): - """Path to python executable.""" - return self.get_envpython() - - def get_envpython(self): - """path to python/jython executable.""" - if "jython" in str(self.basepython): - name = "jython" - else: - name = "python" - return self.envbindir.join(name) - - def get_envsitepackagesdir(self): - """Return sitepackagesdir of the virtualenv environment. - - NOTE: Only available during execution, not during parsing. - """ - x = self.config.interpreters.get_sitepackagesdir(info=self.python_info, envdir=self.envdir) - return x - - @property - def python_info(self): - """Return sitepackagesdir of the virtualenv environment.""" - return self.config.interpreters.get_info(envconfig=self) - - def getsupportedinterpreter(self): - if tox.INFO.IS_WIN and self.basepython and "jython" in self.basepython: - raise tox.exception.UnsupportedInterpreter( - "Jython/Windows does not support installing scripts", - ) - info = self.config.interpreters.get_info(envconfig=self) - if not info.executable: - raise tox.exception.InterpreterNotFound(self.basepython) - if not info.version_info: - raise tox.exception.InvocationError( - "Failed to get version_info for {}: {}".format(info.name, info.err), - ) - return info.executable - - -testenvprefix = "testenv:" - - -def get_homedir(): - try: - return py.path.local._gethomedir() - except Exception: - return None - - -def make_hashseed(): - max_seed = 4294967295 - if tox.INFO.IS_WIN: - max_seed = 1024 - return str(random.randint(1, max_seed)) - - -class SkipThisIni(Exception): - """Internal exception to indicate the parsed ini file should be skipped""" - - -class ParseIni(object): - def __init__(self, config, ini_path, ini_data): # noqa - config.toxinipath = ini_path - using("tox.ini: {} (pid {})".format(config.toxinipath, os.getpid())) - config.toxinidir = config.toxinipath.dirpath() if ini_path.check(file=True) else ini_path - - self._cfg = py.iniconfig.IniConfig(config.toxinipath, ini_data) - - if ini_path.basename == "setup.cfg" and "tox:tox" not in self._cfg: - verbosity1("Found no [tox:tox] section in setup.cfg, skipping.") - raise SkipThisIni() - - previous_line_of = self._cfg.lineof - - self.expand_section_names(self._cfg) - - def line_of_default_to_zero(section, name=None): - at = previous_line_of(section, name=name) - if at is None: - at = 0 - return at - - self._cfg.lineof = line_of_default_to_zero - config._cfg = self._cfg - self.config = config - - prefix = "tox" if ini_path.basename == "setup.cfg" else None - fallbacksection = "tox:tox" if ini_path.basename == "setup.cfg" else "tox" - - context_name = getcontextname() - if context_name == "jenkins": - reader = SectionReader( - "tox:jenkins", - self._cfg, - prefix=prefix, - fallbacksections=[fallbacksection], - ) - dist_share_default = "{toxworkdir}/distshare" - elif not context_name: - reader = SectionReader("tox", self._cfg, prefix=prefix) - dist_share_default = "{homedir}/.tox/distshare" - else: - raise ValueError("invalid context") - - if config.option.hashseed is None: - hash_seed = make_hashseed() - elif config.option.hashseed == "noset": - hash_seed = None - else: - hash_seed = config.option.hashseed - config.hashseed = hash_seed - - reader.addsubstitutions(toxinidir=config.toxinidir, homedir=config.homedir) - - if config.option.workdir is None: - config.toxworkdir = reader.getpath("toxworkdir", "{toxinidir}/.tox") - else: - config.toxworkdir = config.toxinidir.join(config.option.workdir, abs=True) - - if os.path.exists(str(config.toxworkdir)): - config.toxworkdir = config.toxworkdir.realpath() - - reader.addsubstitutions(toxworkdir=config.toxworkdir) - config.ignore_basepython_conflict = reader.getbool("ignore_basepython_conflict", False) - - config.distdir = reader.getpath("distdir", "{toxworkdir}/dist") - - reader.addsubstitutions(distdir=config.distdir) - config.distshare = reader.getpath("distshare", dist_share_default) - reader.addsubstitutions(distshare=config.distshare) - config.temp_dir = reader.getpath("temp_dir", "{toxworkdir}/.tmp") - reader.addsubstitutions(temp_dir=config.temp_dir) - config.sdistsrc = reader.getpath("sdistsrc", None) - config.setupdir = reader.getpath("setupdir", "{toxinidir}") - config.logdir = config.toxworkdir.join("log") - within_parallel = PARALLEL_ENV_VAR_KEY_PRIVATE in os.environ - if not within_parallel and not WITHIN_PROVISION: - ensure_empty_dir(config.logdir) - - # determine indexserver dictionary - config.indexserver = {"default": IndexServerConfig("default")} - prefix = "indexserver" - for line in reader.getlist(prefix): - name, url = map(lambda x: x.strip(), line.split("=", 1)) - config.indexserver[name] = IndexServerConfig(name, url) - - if config.option.skip_missing_interpreters == "config": - val = reader.getbool("skip_missing_interpreters", False) - config.option.skip_missing_interpreters = "true" if val else "false" - - override = False - if config.option.indexurl: - for url_def in config.option.indexurl: - m = re.match(r"\W*(\w+)=(\S+)", url_def) - if m is None: - url = url_def - name = "default" - else: - name, url = m.groups() - if not url: - url = None - if name != "ALL": - config.indexserver[name].url = url - else: - override = url - # let ALL override all existing entries - if override: - for name in config.indexserver: - config.indexserver[name] = IndexServerConfig(name, override) - - self.handle_provision(config, reader) - - self.parse_build_isolation(config, reader) - res = self._getenvdata(reader, config) - config.envlist, all_envs, config.envlist_default, config.envlist_explicit = res - - # factors used in config or predefined - known_factors = self._list_section_factors("testenv") - known_factors.update({"py", "python"}) - - # factors stated in config envlist - stated_envlist = reader.getstring("envlist", replace=False) - if stated_envlist: - for env in _split_env(stated_envlist): - known_factors.update(env.split("-")) - - # configure testenvs - to_do = [] - failures = OrderedDict() - results = {} - cur_self = self - - def run(name, section, subs, config): - try: - results[name] = cur_self.make_envconfig(name, section, subs, config) - except Exception as exception: - failures[name] = (exception, traceback.format_exc()) - - order = [] - for name in all_envs: - section = "{}{}".format(testenvprefix, name) - factors = set(name.split("-")) - if ( - section in self._cfg - or factors <= known_factors - or all( - tox.PYTHON.PY_FACTORS_RE.match(factor) for factor in factors - known_factors - ) - ): - order.append(name) - thread = Thread(target=run, args=(name, section, reader._subs, config)) - thread.daemon = True - thread.start() - to_do.append(thread) - for thread in to_do: - while thread.is_alive(): - thread.join(timeout=20) - if failures: - raise tox.exception.ConfigError( - "\n".join( - "{} failed with {} at {}".format(key, exc, trace) - for key, (exc, trace) in failures.items() - ), - ) - for name in order: - config.envconfigs[name] = results[name] - all_develop = all( - name in config.envconfigs and config.envconfigs[name].usedevelop - for name in config.envlist - ) - - config.skipsdist = reader.getbool("skipsdist", all_develop) - - if config.option.devenv is not None: - config.option.notest = True - - if config.option.devenv is not None and len(config.envlist) != 1: - feedback("--devenv requires only a single -e", sysexit=True) - - def handle_provision(self, config, reader): - config.requires = reader.getlist("requires") - config.minversion = reader.getstring("minversion", None) - config.provision_tox_env = name = reader.getstring("provision_tox_env", ".tox") - min_version = "tox >= {}".format(config.minversion or Version(tox.__version__).public) - deps = self.ensure_requires_satisfied(config, config.requires, min_version) - if config.run_provision: - section_name = "testenv:{}".format(name) - if section_name not in self._cfg.sections: - self._cfg.sections[section_name] = {} - self._cfg.sections[section_name]["description"] = "meta tox" - env_config = self.make_envconfig( - name, - "{}{}".format(testenvprefix, name), - reader._subs, - config, - ) - env_config.deps = deps - config.envconfigs[config.provision_tox_env] = env_config - raise tox.exception.MissingRequirement(config) - # if provisioning is not on, now we need do a strict argument evaluation - # raise on unknown args - self.config._parser.parse_cli(args=self.config.args, strict=True) - - @classmethod - def ensure_requires_satisfied(cls, config, requires, min_version): - missing_requirements = [] - failed_to_parse = False - deps = [] - exists = set() - for require in requires + [min_version]: - # noinspection PyBroadException - try: - package = requirements.Requirement(require) - # check if the package even applies - if package.marker and not package.marker.evaluate({"extra": ""}): - continue - package_name = canonicalize_name(package.name) - if package_name not in exists: - deps.append(DepConfig(require, None)) - exists.add(package_name) - dist = importlib_metadata.distribution(package.name) - if not package.specifier.contains(dist.version, prereleases=True): - raise MissingDependency(package) - except requirements.InvalidRequirement as exception: - failed_to_parse = True - error("failed to parse {!r}".format(exception)) - except Exception as exception: - verbosity1("could not satisfy requires {!r}".format(exception)) - missing_requirements.append(str(requirements.Requirement(require))) - if failed_to_parse: - raise tox.exception.BadRequirement() - if config.option.no_provision and missing_requirements: - msg = "provisioning explicitly disabled within {}, but missing {}" - if config.option.no_provision is not True: # it's a path - msg += " and wrote to {}" - cls.write_requires_to_json_file(config) - raise tox.exception.Error( - msg.format(sys.executable, missing_requirements, config.option.no_provision) - ) - if WITHIN_PROVISION and missing_requirements: - msg = "break infinite loop provisioning within {} missing {}" - raise tox.exception.Error(msg.format(sys.executable, missing_requirements)) - config.run_provision = bool(len(missing_requirements)) - return deps - - @staticmethod - def write_requires_to_json_file(config): - requires_dict = { - "minversion": config.minversion, - "requires": config.requires, - } - try: - with open(config.option.no_provision, "w", encoding="utf-8") as outfile: - json.dump(requires_dict, outfile, indent=4) - except TypeError: # Python 2 - with open(config.option.no_provision, "w") as outfile: - json.dump(requires_dict, outfile, indent=4, encoding="utf-8") - - def parse_build_isolation(self, config, reader): - config.isolated_build = reader.getbool("isolated_build", False) - config.isolated_build_env = reader.getstring("isolated_build_env", ".package") - if config.isolated_build is True: - name = config.isolated_build_env - section_name = "testenv:{}".format(name) - if section_name not in self._cfg.sections: - self._cfg.sections[section_name] = {} - self._cfg.sections[section_name]["deps"] = "" - self._cfg.sections[section_name]["sitepackages"] = "False" - self._cfg.sections[section_name]["description"] = "isolated packaging environment" - config.envconfigs[name] = self.make_envconfig( - name, - "{}{}".format(testenvprefix, name), - reader._subs, - config, - ) - - def _list_section_factors(self, section): - factors = set() - if section in self._cfg: - for _, value in self._cfg[section].items(): - exprs = re.findall(r"^([\w{}.!,-]+):\s+", value, re.M) - factors.update(*mapcat(_split_factor_expr_all, exprs)) - return factors - - def make_envconfig(self, name, section, subs, config, replace=True): - factors = set(name.split("-")) - reader = SectionReader(section, self._cfg, fallbacksections=["testenv"], factors=factors) - tc = TestenvConfig(name, config, factors, reader) - reader.addsubstitutions( - envname=name, - envbindir=tc.get_envbindir, - envsitepackagesdir=tc.get_envsitepackagesdir, - envpython=tc.get_envpython, - **subs - ) - for env_attr in config._testenv_attr: - atype = env_attr.type - try: - if atype in ( - "bool", - "float", - "path", - "string", - "dict", - "dict_setenv", - "argv", - "argvlist", - "argv_install_command", - ): - meth = getattr(reader, "get{}".format(atype)) - res = meth(env_attr.name, env_attr.default, replace=replace) - elif atype == "basepython": - no_fallback = name in (config.provision_tox_env,) - res = reader.getstring( - env_attr.name, - env_attr.default, - replace=replace, - no_fallback=no_fallback, - ) - elif atype == "space-separated-list": - res = reader.getlist(env_attr.name, sep=" ") - elif atype == "line-list": - res = reader.getlist(env_attr.name, sep="\n") - elif atype == "env-list": - res = reader.getstring(env_attr.name, replace=False) - res = tuple(_split_env(res)) - else: - raise ValueError("unknown type {!r}".format(atype)) - if env_attr.postprocess: - res = env_attr.postprocess(testenv_config=tc, value=res) - except tox.exception.MissingSubstitution as e: - tc._missing_subs[env_attr.name] = res = e - # On Python 2, exceptions are handled in __getattr__ - if not six.PY2 or not isinstance(res, Exception): - setattr(tc, env_attr.name, res) - if atype in ("path", "string", "basepython"): - reader.addsubstitutions(**{env_attr.name: res}) - return tc - - def _getallenvs(self, reader, config, extra_env_list=None): - extra_env_list = extra_env_list or [] - env_str = reader.getstring("envlist", replace=False) - env_list = _split_env(env_str) - for env in extra_env_list: - if env not in env_list: - env_list.append(env) - - all_envs = OrderedDict((i, None) for i in env_list) - package_env = config.isolated_build_env if config.isolated_build is True else None - for section in self._cfg: - if section.name.startswith(testenvprefix): - section_env = section.name[len(testenvprefix) :] - if section_env != package_env: - all_envs[section_env] = None - if not all_envs: - all_envs["python"] = None - return list(all_envs.keys()) - - def _getenvdata(self, reader, config): - from_option = self.config.option.env - from_environ = os.environ.get("TOXENV") - from_config = reader.getstring("envlist", replace=False) - - env_list = [] - envlist_explicit = False - if ( - (from_option and "ALL" in from_option) - or (not from_option and from_environ and "ALL" in from_environ.split(",")) - ) and PARALLEL_ENV_VAR_KEY_PRIVATE not in os.environ: - all_envs = self._getallenvs(reader, config) - else: - candidates = ( - (os.environ.get(PARALLEL_ENV_VAR_KEY_PRIVATE), True), - (from_option, True), - (from_environ, True), - ("py" if self.config.option.devenv is not None else None, False), - (from_config, False), - ) - env_str, envlist_explicit = next(((i, e) for i, e in candidates if i), ([], False)) - env_list = _split_env(env_str) - all_envs = self._getallenvs(reader, config, env_list) - - if not env_list: - env_list = all_envs - - provision_tox_env = config.provision_tox_env - if config.provision_tox_env in env_list: - msg = "provision_tox_env {} cannot be part of envlist".format(provision_tox_env) - raise tox.exception.ConfigError(msg) - - package_env = config.isolated_build_env - if config.isolated_build is True and package_env in env_list: - msg = "isolated_build_env {} cannot be part of envlist".format(package_env) - raise tox.exception.ConfigError(msg) - - return env_list, all_envs, _split_env(from_config), envlist_explicit - - @staticmethod - def expand_section_names(config): - """Generative section names. - - Allow writing section as [testenv:py{36,37}-cov] - The parser will see it as two different sections: [testenv:py36-cov], [testenv:py37-cov] - - """ - factor_re = re.compile(r"{\s*([\w\s,-]+)\s*}") - split_re = re.compile(r"\s*,\s*") - to_remove = set() - for section in list(config.sections): - split_section = factor_re.split(section) - for parts in itertools.product(*map(split_re.split, split_section)): - section_name = "".join(parts) - if section_name not in config.sections: - config.sections[section_name] = config.sections[section] - to_remove.add(section) - - for section in to_remove: - del config.sections[section] - - -def _split_env(env): - """if handed a list, action="https://github.com/append" was used for -e""" - if env is None: - return [] - if not isinstance(env, list): - env = [e.split("#", 1)[0].strip() for e in env.split("\n")] - env = ",".join(e for e in env if e) - env = [env] - return mapcat(_expand_envstr, env) - - -def _is_negated_factor(factor): - return factor.startswith("!") - - -def _base_factor_name(factor): - return factor[1:] if _is_negated_factor(factor) else factor - - -def _split_factor_expr(expr): - def split_single(e): - raw = e.split("-") - included = {_base_factor_name(factor) for factor in raw if not _is_negated_factor(factor)} - excluded = {_base_factor_name(factor) for factor in raw if _is_negated_factor(factor)} - return included, excluded - - partial_envs = _expand_envstr(expr) - return [split_single(e) for e in partial_envs] - - -def _split_factor_expr_all(expr): - partial_envs = _expand_envstr(expr) - return [{_base_factor_name(factor) for factor in e.split("-")} for e in partial_envs] - - -def _expand_envstr(envstr): - # split by commas not in groups - tokens = _ENVSTR_SPLIT_PATTERN.split(envstr) - envlist = ["".join(g).strip() for k, g in itertools.groupby(tokens, key=bool) if k] - - def expand(env): - tokens = _ENVSTR_EXPAND_PATTERN.split(env) - parts = [_WHITESPACE_PATTERN.sub("", token).split(",") for token in tokens] - return ["".join(variant) for variant in itertools.product(*parts)] - - return mapcat(expand, envlist) - - -def mapcat(f, seq): - return list(itertools.chain.from_iterable(map(f, seq))) - - -class DepConfig: - def __init__(self, name, indexserver=None): - self.name = name - self.indexserver = indexserver - - def __repr__(self): - if self.indexserver: - if self.indexserver.name == "default": - return self.name - return ":{}:{}".format(self.indexserver.name, self.name) - return str(self.name) - - -class IndexServerConfig: - def __init__(self, name, url=None): - self.name = name - self.url = url - - def __repr__(self): - return "IndexServerConfig(name={}, url={})".format(self.name, self.url) - - -is_section_substitution = re.compile(r"{\[[^{}\s]+\]\S+?}").match -# Check value matches substitution form of referencing value from other section. -# E.g. {[base]commands} - - -class SectionReader: - def __init__( - self, - section_name, - cfgparser, - fallbacksections=None, - factors=(), - prefix=None, - posargs="", - ): - if prefix is None: - self.section_name = section_name - else: - self.section_name = "{}:{}".format(prefix, section_name) - self._cfg = cfgparser - self.fallbacksections = fallbacksections or [] - self.factors = factors - self._subs = {} - self._subststack = [] - self._setenv = None - self.posargs = posargs - - def get_environ_value(self, name): - if self._setenv is None: - return os.environ.get(name) - return self._setenv.get(name) - - def addsubstitutions(self, _posargs=None, **kw): - self._subs.update(kw) - if _posargs: - self.posargs = _posargs - - def getpath(self, name, defaultpath, replace=True): - path = self.getstring(name, defaultpath, replace=replace) - if path is not None: - toxinidir = self._subs["toxinidir"] - return toxinidir.join(path, abs=True) - - def getlist(self, name, sep="\n"): - s = self.getstring(name, None) - if s is None: - return [] - return [x.strip() for x in s.split(sep) if x.strip()] - - def getdict(self, name, default=None, sep="\n", replace=True): - value = self.getstring(name, None, replace=replace) - return self._getdict(value, default=default, sep=sep, replace=replace) - - def getdict_setenv(self, name, default=None, sep="\n", replace=True): - value = self.getstring(name, None, replace=replace, crossonly=True) - definitions = self._getdict(value, default=default, sep=sep, replace=replace) - self._setenv = SetenvDict(definitions, reader=self) - return self._setenv - - def _getdict(self, value, default, sep, replace=True): - if value is None or not replace: - return default or {} - - env_values = {} - for line in value.split(sep): - if line.strip(): - if line.startswith("#"): # comment lines are ignored - pass - elif line.startswith("file|"): # file markers contain paths to env files - file_path = line[5:].strip() - if os.path.exists(file_path): - with open(file_path, "rt") as file_handler: - content = file_handler.read() - env_values.update(self._getdict(content, "", sep, replace)) - else: - name, value = line.split("=", 1) - env_values[name.strip()] = value.strip() - return env_values - - def getfloat(self, name, default=None, replace=True): - s = self.getstring(name, default, replace=replace) - if not s or not replace: - s = default - if s is None: - raise KeyError("no config value [{}] {} found".format(self.section_name, name)) - - if not isinstance(s, float): - try: - s = float(s) - except ValueError: - raise tox.exception.ConfigError("{}: invalid float {!r}".format(name, s)) - return s - - def getbool(self, name, default=None, replace=True): - s = self.getstring(name, default, replace=replace) - if not s or not replace: - s = default - if s is None: - raise KeyError("no config value [{}] {} found".format(self.section_name, name)) - - if not isinstance(s, bool): - if s.lower() == "true": - s = True - elif s.lower() == "false": - s = False - else: - raise tox.exception.ConfigError( - "{}: boolean value {!r} needs to be 'True' or 'False'".format(name, s), - ) - return s - - def getargvlist(self, name, default="", replace=True): - s = self.getstring(name, default, replace=False) - return _ArgvlistReader.getargvlist(self, s, replace=replace, name=name) - - def getargv(self, name, default="", replace=True): - return self.getargvlist(name, default, replace=replace)[0] - - def getargv_install_command(self, name, default="", replace=True): - s = self.getstring(name, default, replace=False) - if not s: - # This occurs when factors are used, and a testenv doesn't have - # a factorised value for install_command, most commonly occurring - # if setting platform is also used. - # An empty value causes error install_command must contain '{packages}'. - s = default - - if "{packages}" in s: - s = s.replace("{packages}", r"\{packages\}") - if "{opts}" in s: - s = s.replace("{opts}", r"\{opts\}") - - return _ArgvlistReader.getargvlist(self, s, replace=replace, name=name)[0] - - def getstring(self, name, default=None, replace=True, crossonly=False, no_fallback=False): - x = None - sections = [self.section_name] + ([] if no_fallback else self.fallbacksections) - for s in sections: - try: - x = self._cfg[s][name] - break - except KeyError: - continue - - if x is None: - x = default - else: - # It is needed to apply factors before unwrapping - # dependencies, otherwise it can break the substitution - # process. Once they are unwrapped, we call apply factors - # again for those new dependencies. - x = self._apply_factors(x) - x = self._replace_if_needed(x, name, replace, crossonly) - x = self._apply_factors(x) - - x = self._replace_if_needed(x, name, replace, crossonly) - return x - - def getposargs(self, default=None): - if self.posargs: - posargs = self.posargs - if sys.platform.startswith("win"): - posargs_string = list2cmdline([x for x in posargs if x]) - else: - posargs_string = " ".join(shlex_quote(x) for x in posargs if x) - return posargs_string - else: - return default or "" - - def _replace_if_needed(self, x, name, replace, crossonly): - if replace and x and hasattr(x, "replace"): - x = self._replace(x, name=name, crossonly=crossonly) - return x - - def _apply_factors(self, s): - def factor_line(line): - m = _FACTOR_LINE_PATTERN.search(line) - if not m: - return line - - expr, line = m.groups() - if any( - included <= self.factors and not any(x in self.factors for x in excluded) - for included, excluded in _split_factor_expr(expr) - ): - return line - - lines = s.strip().splitlines() - return "\n".join(filter(None, map(factor_line, lines))) - - def _replace(self, value, name=None, section_name=None, crossonly=False): - if "{" not in value: - return value - - section_name = section_name if section_name else self.section_name - assert name - self._subststack.append((section_name, name)) - try: - replaced = Replacer(self, crossonly=crossonly).do_replace(value) - assert self._subststack.pop() == (section_name, name) - except tox.exception.MissingSubstitution: - if not section_name.startswith(testenvprefix): - raise tox.exception.ConfigError( - "substitution env:{!r}: unknown or recursive definition in" - " section {!r}.".format(value, section_name), - ) - raise - return replaced - - -class Replacer: - RE_ITEM_REF = re.compile( - r""" - (?[^[:{}]+):)? # optional sub_type for special rules - (?P(?:\[[^,{}]*\])?[^:,{}]*) # substitution key - (?::(?P([^{}]|\\{|\\})*))? # default value - [}] - """, - re.VERBOSE, - ) - - def __init__(self, reader, crossonly=False): - self.reader = reader - self.crossonly = crossonly - - def do_replace(self, value): - """ - Recursively expand substitutions starting from the innermost expression - """ - - def substitute_once(x): - return self.RE_ITEM_REF.sub(self._replace_match, x) - - expanded = substitute_once(value) - - while expanded != value: # substitution found - value = expanded - expanded = substitute_once(value) - - return expanded - - @staticmethod - def _unescape(s): - return s.replace("\\{", "{").replace("\\}", "}") - - def _replace_match(self, match): - g = match.groupdict() - sub_value = g["substitution_value"] - if self.crossonly: - if sub_value.startswith("["): - return self._substitute_from_other_section(sub_value) - # in crossonly we return all other hits verbatim - start, end = match.span() - return match.string[start:end] - - full_match = match.group(0) - # ":" is swallowed by the regex, so the raw matched string is checked - if full_match.startswith("{:"): - if full_match != "{:}": - raise tox.exception.ConfigError( - "Malformed substitution with prefix ':': {}".format(full_match), - ) - - return os.pathsep - - default_value = g["default_value"] - # special case: opts and packages. Leave {opts} and - # {packages} intact, they are replaced manually in - # _venv.VirtualEnv.run_install_command. - if sub_value in ("opts", "packages"): - return "{{{}}}".format(sub_value) - - if sub_value == "posargs": - return self.reader.getposargs(default_value) - - sub_type = g["sub_type"] - if sub_type == "posargs": - if default_value: - value = "{}:{}".format(sub_value, default_value) - else: - value = sub_value - return self.reader.getposargs(value) - - if not sub_type and not sub_value: - raise tox.exception.ConfigError( - "Malformed substitution; no substitution type provided. " - "If you were using `{}` for `os.pathsep`, please use `{:}`.", - ) - - if not sub_type and not default_value and sub_value == "/": - return os.sep - - if sub_type == "env": - return self._replace_env(sub_value, default_value) - if sub_type == "tty": - if is_interactive(): - return match.group("substitution_value") - return match.group("default_value") - if sub_type == "posargs": - return self.reader.getposargs(sub_value) - if sub_type is not None: - raise tox.exception.ConfigError( - "No support for the {} substitution type".format(sub_type), - ) - return self._replace_substitution(sub_value) - - def _replace_env(self, key, default): - if not key: - raise tox.exception.ConfigError("env: requires an environment variable name") - value = self.reader.get_environ_value(key) - if value is not None: - return value - if default is not None: - return default - raise tox.exception.MissingSubstitution(key) - - def _substitute_from_other_section(self, key): - if key.startswith("[") and "]" in key: - i = key.find("]") - section, item = key[1:i], key[i + 1 :] - cfg = self.reader._cfg - if section in cfg and item in cfg[section]: - if (section, item) in self.reader._subststack: - raise tox.exception.SubstitutionStackError( - "{} already in {}".format((section, item), self.reader._subststack), - ) - x = str(cfg[section][item]) - return self.reader._replace( - x, - name=item, - section_name=section, - crossonly=self.crossonly, - ) - - raise tox.exception.ConfigError("substitution key {!r} not found".format(key)) - - def _replace_substitution(self, sub_key): - val = self.reader._subs.get(sub_key, None) - if val is None: - val = self._substitute_from_other_section(sub_key) - if callable(val): - val = val() - return str(val) - - -def is_interactive(): - return sys.stdin.isatty() - - -class _ArgvlistReader: - @classmethod - def getargvlist(cls, reader, value, replace=True, name=None): - """Parse ``commands`` argvlist multiline string. - - :param SectionReader reader: reader to be used. - :param str value: Content stored by key. - - :rtype: list[list[str]] - :raise :class:`tox.exception.ConfigError`: - line-continuation ends nowhere while resolving for specified section - """ - commands = [] - current_command = "" - for line in value.splitlines(): - line = line.rstrip() - if not line: - continue - if line.endswith("\\"): - current_command += " {}".format(line[:-1]) - continue - current_command += line - - if is_section_substitution(current_command): - replaced = reader._replace(current_command, crossonly=True, name=name) - commands.extend(cls.getargvlist(reader, replaced, name=name)) - else: - commands.append(cls.processcommand(reader, current_command, replace, name=name)) - current_command = "" - else: - if current_command: - raise tox.exception.ConfigError( - "line-continuation ends nowhere while resolving for [{}] {}".format( - reader.section_name, - "commands", - ), - ) - return commands - - @classmethod - def processcommand(cls, reader, command, replace=True, name=None): - # Iterate through each word of the command substituting as - # appropriate to construct the new command string. This - # string is then broken up into exec argv components using - # shlex. - if replace: - newcommand = "" - for word in CommandParser(command).words(): - if word == "[]": - newcommand += reader.getposargs() - continue - - new_arg = "" - new_word = reader._replace(word, name=name) - new_word = reader._replace(new_word, name=name) - new_word = Replacer._unescape(new_word) - new_arg += new_word - newcommand += new_arg - else: - newcommand = command - - # Construct shlex object that will not escape any values, - # use all values as is in argv. - shlexer = shlex.shlex(newcommand, posix=True) - shlexer.whitespace_split = True - shlexer.escape = "" - return list(shlexer) - - -class CommandParser(object): - class State(object): - def __init__(self): - self.word = "" - self.depth = 0 - self.yield_words = [] - - def __init__(self, command): - self.command = command - - def words(self): - ps = CommandParser.State() - - def word_has_ended(): - return ( - ( - cur_char in string.whitespace - and ps.word - and ps.word[-1] not in string.whitespace - ) - or (cur_char == "{" and ps.depth == 0 and not ps.word.endswith("\\")) - or (ps.depth == 0 and ps.word and ps.word[-1] == "}") - or (cur_char not in string.whitespace and ps.word and ps.word.strip() == "") - ) - - def yield_this_word(): - yieldword = ps.word - ps.word = "" - if yieldword: - ps.yield_words.append(yieldword) - - def yield_if_word_ended(): - if word_has_ended(): - yield_this_word() - - def accumulate(): - ps.word += cur_char - - def push_substitution(): - ps.depth += 1 - - def pop_substitution(): - ps.depth -= 1 - - for cur_char in self.command: - if cur_char in string.whitespace: - if ps.depth == 0: - yield_if_word_ended() - accumulate() - elif cur_char == "{": - yield_if_word_ended() - accumulate() - push_substitution() - elif cur_char == "}": - accumulate() - pop_substitution() - else: - yield_if_word_ended() - accumulate() - - if ps.word.strip(): - yield_this_word() - return ps.yield_words - - -def getcontextname(): - if any(env in os.environ for env in ["JENKINS_URL", "HUDSON_URL"]): - return "jenkins" - return None diff --git a/src/tox/helper/__init__.py b/src/tox/config/cli/__init__.py similarity index 100% rename from src/tox/helper/__init__.py rename to src/tox/config/cli/__init__.py diff --git a/src/tox/config/cli/env_var.py b/src/tox/config/cli/env_var.py new file mode 100644 index 000000000..5492070e2 --- /dev/null +++ b/src/tox/config/cli/env_var.py @@ -0,0 +1,41 @@ +""" +Provides configuration values from the environment variables. +""" +from __future__ import annotations + +import logging +import os +from typing import Any + +from tox.config.loader.str_convert import StrConvert + +CONVERT = StrConvert() + + +def get_env_var(key: str, of_type: type[Any]) -> tuple[Any, str] | None: + """Get the environment variable option. + + :param key: the config key requested + :param of_type: the type we would like to convert it to + :return: + """ + key_upper = key.upper() + for environ_key in (f"TOX_{key_upper}", f"TOX{key_upper}"): + if environ_key in os.environ: + value = os.environ[environ_key] + try: + source = f"env var {environ_key}" + result = CONVERT.to(raw=value, of_type=of_type, factory=None) + return result, source + except Exception as exception: + logging.warning( + "env var %s=%r cannot be transformed to %r because %r", + environ_key, + value, + of_type, + exception, + ) + return None + + +__all__ = ("get_env_var",) diff --git a/src/tox/config/cli/ini.py b/src/tox/config/cli/ini.py new file mode 100644 index 000000000..8839f4307 --- /dev/null +++ b/src/tox/config/cli/ini.py @@ -0,0 +1,77 @@ +""" +Provides configuration values from tox.ini files. +""" +from __future__ import annotations + +import logging +import os +from configparser import ConfigParser +from pathlib import Path +from typing import Any + +from platformdirs import user_config_dir + +from tox.config.loader.api import ConfigLoadArgs +from tox.config.loader.ini import IniLoader +from tox.config.source.ini_section import CORE + +DEFAULT_CONFIG_FILE = Path(user_config_dir("tox")) / "config.ini" + + +class IniConfig: + TOX_CONFIG_FILE_ENV_VAR = "TOX_CONFIG_FILE" + STATE = {None: "failed to parse", True: "active", False: "missing"} + + def __init__(self) -> None: + config_file = os.environ.get(self.TOX_CONFIG_FILE_ENV_VAR, None) + self.is_env_var = config_file is not None + self.config_file = Path(config_file if config_file is not None else DEFAULT_CONFIG_FILE) + self._cache: dict[tuple[str, type[Any]], Any] = {} + self.has_config_file: bool | None = self.config_file.exists() + self.ini: IniLoader | None = None + + if self.has_config_file: + self.config_file = self.config_file.absolute() + try: + + parser = ConfigParser(interpolation=None) + with self.config_file.open() as file_handler: + parser.read_file(file_handler) + self.has_tox_section = parser.has_section(CORE.key) + if self.has_tox_section: + self.ini = IniLoader(CORE, parser, overrides=[], core_section=CORE) + except Exception as exception: + logging.error("failed to read config file %s because %r", config_file, exception) + self.has_config_file = None + + def get(self, key: str, of_type: type[Any]) -> Any: + cache_key = key, of_type + if cache_key in self._cache: + result = self._cache[cache_key] + else: + try: + if self.ini is None: # pragma: no cover # this can only happen if we don't call __bool__ firsts + result = None + else: + source = "file" + args = ConfigLoadArgs(chain=[key], name=CORE.prefix, env_name=None) + value = self.ini.load(key, of_type=of_type, conf=None, factory=None, args=args) + result = value, source + except KeyError: # just not found + result = None + except Exception as exception: + logging.warning("%s key %s as type %r failed with %r", self.config_file, key, of_type, exception) + result = None + self._cache[cache_key] = result + return result + + def __bool__(self) -> bool: + return bool(self.has_config_file) and bool(self.has_tox_section) + + @property + def epilog(self) -> str: + # text to show within the parsers epilog + return ( + f"{os.linesep}config file {str(self.config_file)!r} {self.STATE[self.has_config_file]} " + f"(change{'d' if self.is_env_var else ''} via env var {self.TOX_CONFIG_FILE_ENV_VAR})" + ) diff --git a/src/tox/config/cli/parse.py b/src/tox/config/cli/parse.py new file mode 100644 index 000000000..59e61532a --- /dev/null +++ b/src/tox/config/cli/parse.py @@ -0,0 +1,96 @@ +""" +This module pulls together this package: create and parse CLI arguments for tox. +""" +from __future__ import annotations + +import os +from contextlib import redirect_stderr +from pathlib import Path +from typing import TYPE_CHECKING, Callable, NamedTuple, Sequence, cast + +from tox.config.source import Source, discover_source +from tox.report import ToxHandler, setup_report + +from .parser import Parsed, ToxParser + +if TYPE_CHECKING: + from tox.session.state import State + + +class Options(NamedTuple): + parsed: Parsed + pos_args: Sequence[str] | None + source: Source + cmd_handlers: dict[str, Callable[[State], int]] + log_handler: ToxHandler + + +def get_options(*args: str) -> Options: + pos_args: tuple[str, ...] | None = None + try: # remove positional arguments passed to parser if specified, they are pulled directly from sys.argv + pos_arg_at = args.index("--") + except ValueError: + pass + else: + pos_args = tuple(args[pos_arg_at + 1 :]) + args = args[:pos_arg_at] + + guess_verbosity, log_handler, source = _get_base(args) + parsed, cmd_handlers = _get_all(args) + if guess_verbosity != parsed.verbosity: + log_handler.update_verbosity(parsed.verbosity) + return Options(parsed, pos_args, source, cmd_handlers, log_handler) + + +def _get_base(args: Sequence[str]) -> tuple[int, ToxHandler, Source]: + """First just load the base options (verbosity+color) to setup the logging framework.""" + tox_parser = ToxParser.base() + parsed = Parsed() + try: + with open(os.devnull, "w") as file_handler: + with redirect_stderr(file_handler): + tox_parser.parse_known_args(args, namespace=parsed) + except SystemExit: + ... # ignore parse errors, such as -va raises ignored explicit argument 'a' + guess_verbosity = parsed.verbosity + handler = setup_report(guess_verbosity, parsed.is_colored) + from tox.plugin.manager import MANAGER # load the plugin system right after we set up report + + source = discover_source(parsed.config_file, parsed.root_dir) + + MANAGER.load_plugins(source.path) + + return guess_verbosity, handler, source + + +def _get_all(args: Sequence[str]) -> tuple[Parsed, dict[str, Callable[[State], int]]]: + """Parse all the options.""" + tox_parser = _get_parser() + parsed = cast(Parsed, tox_parser.parse_args(args)) + handlers = {k: p for k, (_, p) in tox_parser.handlers.items()} + return parsed, handlers + + +def _get_parser() -> ToxParser: + tox_parser = ToxParser.core() # load the core options + # plus options setup by plugins + from tox.plugin.manager import MANAGER + + MANAGER.tox_add_option(tox_parser) + tox_parser.fix_defaults() + return tox_parser + + +def _get_parser_doc() -> ToxParser: + # trigger register of tox env types (during normal run we call this later to handle plugins) + from tox.plugin.manager import MANAGER # pragma: no cover + + MANAGER.load_plugins(Path.cwd()) + + return _get_parser() # pragma: no cover + + +__all__ = ( + "get_options", + "Options", +) diff --git a/src/tox/config/cli/parser.py b/src/tox/config/cli/parser.py new file mode 100644 index 000000000..ffd52d377 --- /dev/null +++ b/src/tox/config/cli/parser.py @@ -0,0 +1,360 @@ +""" +Customize argparse logic for tox (also contains the base options). +""" +from __future__ import annotations + +import argparse +import logging +import os +import sys +from argparse import SUPPRESS, Action, ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, TypeVar, cast + +from tox.config.loader.str_convert import StrConvert +from tox.plugin import NAME + +from .env_var import get_env_var +from .ini import IniConfig + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from typing import Literal +else: # pragma: no cover (py38+) + from typing_extensions import Literal + +if TYPE_CHECKING: + from tox.session.state import State + + +class ArgumentParserWithEnvAndConfig(ArgumentParser): + """ + Argument parser which updates its defaults by checking the configuration files and environmental variables. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + # sub-parsers also construct an instance of the parser, but they don't get their own file config, but inherit + self.file_config = kwargs.pop("file_config") if "file_config" in kwargs else IniConfig() + kwargs["epilog"] = self.file_config.epilog + super().__init__(*args, **kwargs) + + def fix_defaults(self) -> None: + for action in self._actions: + self.fix_default(action) + + def fix_default(self, action: Action) -> None: + if hasattr(action, "default") and hasattr(action, "dest") and action.default != SUPPRESS: + of_type = self.get_type(action) + key = action.dest + outcome = get_env_var(key, of_type=of_type) + if outcome is None and self.file_config: + outcome = self.file_config.get(key, of_type=of_type) + if outcome is not None: + action.default, default_value = outcome + action.default_source = default_value # type: ignore[attr-defined] + if isinstance(action, argparse._SubParsersAction): + for values in action.choices.values(): + if not isinstance(values, ToxParser): # pragma: no cover + raise RuntimeError("detected sub-parser added without using our own add command") + values.fix_defaults() + + @staticmethod + def get_type(action: Action) -> type[Any]: + of_type: type[Any] | None = getattr(action, "of_type", None) + if of_type is None: + if isinstance(action, argparse._AppendAction): + of_type = List[action.type] # type: ignore[name-defined] + elif isinstance(action, argparse._StoreAction) and action.choices: + loc = locals() + loc["Literal"] = Literal + as_literal = f"Literal[{', '.join(repr(i) for i in action.choices)}]" + of_type = eval(as_literal, globals(), loc) + elif action.default is not None: + of_type = type(action.default) + elif isinstance(action, argparse._StoreConstAction) and action.const is not None: + of_type = type(action.const) + else: + raise TypeError(action) + return of_type + + def parse_args( # type: ignore # avoid defining all overloads + self, + args: Sequence[str] | None = None, + namespace: Namespace | None = None, + ) -> Namespace: + res, argv = self.parse_known_args(args, namespace) + if argv: + self.error( + f'unrecognized arguments: {" ".join(argv)}\n' + "hint: if you tried to pass arguments to a command use -- to separate them from tox ones", + ) + return res + + +class HelpFormatter(ArgumentDefaultsHelpFormatter): + """ + A help formatter that provides the default value and the source it comes from. + """ + + def __init__(self, prog: str) -> None: + super().__init__(prog, max_help_position=30, width=240) + + def _get_help_string(self, action: Action) -> str | None: + text: str = super()._get_help_string(action) or "" + if hasattr(action, "default_source"): + default = " (default: %(default)s)" + if text.endswith(default): # pragma: no branch + text = f"{text[: -len(default)]} (default: %(default)s -> from %(default_source)s)" + return text + + def add_raw_text(self, text: str | None) -> None: + def keep(content: str) -> str: + return content + + if text is not SUPPRESS and text is not None: + self._add_item(keep, [text]) + + +ToxParserT = TypeVar("ToxParserT", bound="ToxParser") +DEFAULT_VERBOSITY = 2 + + +class Parsed(Namespace): + """CLI options""" + + @property + def verbosity(self) -> int: + """:return: reporting verbosity""" + result: int = max(self.verbose - self.quiet, 0) + return result + + @property + def is_colored(self) -> bool: + """:return: flag indicating if the output is colored or not""" + return cast(bool, self.colored == "yes") + + exit_and_dump_after: int + + +ArgumentArgs = Tuple[Tuple[str, ...], Optional[Type[Any]], Dict[str, Any]] + + +class ToxParser(ArgumentParserWithEnvAndConfig): + """Argument parser for tox.""" + + def __init__(self, *args: Any, root: bool = False, add_cmd: bool = False, **kwargs: Any) -> None: + self.of_cmd: str | None = None + self.handlers: dict[str, tuple[Any, Callable[[State], int]]] = {} + self._arguments: list[ArgumentArgs] = [] + self._groups: list[tuple[Any, dict[str, Any], list[tuple[dict[str, Any], list[ArgumentArgs]]]]] = [] + super().__init__(*args, **kwargs) + if root is True: + self._add_base_options() + if add_cmd is True: + msg = "tox command to execute (by default legacy)" + self._cmd: Any | None = self.add_subparsers(title="subcommands", description=msg, dest="command") + self._cmd.required = False + self._cmd.default = "legacy" + else: + self._cmd = None + + def add_command( + self, + cmd: str, + aliases: Sequence[str], + help_msg: str, + handler: Callable[[State], int], + ) -> ArgumentParser: + if self._cmd is None: + raise RuntimeError("no sub-command group allowed") + sub_parser: ToxParser = self._cmd.add_parser( + cmd, + help=help_msg, + aliases=aliases, + formatter_class=HelpFormatter, + file_config=self.file_config, + ) + sub_parser.of_cmd = cmd # mark it as parser for a sub-command + content = sub_parser, handler + self.handlers[cmd] = content + for alias in aliases: + self.handlers[alias] = content + for (args, of_type, kwargs) in self._arguments: + sub_parser.add_argument(*args, of_type=of_type, **kwargs) + for (args, kwargs, excl) in self._groups: + group = sub_parser.add_argument_group(*args, **kwargs) + for (e_kwargs, arguments) in excl: + excl_group = group.add_mutually_exclusive_group(**e_kwargs) + for (a_args, _, a_kwargs) in arguments: + excl_group.add_argument(*a_args, **a_kwargs) + return sub_parser + + def add_argument_group(self, *args: Any, **kwargs: Any) -> Any: + result = super().add_argument_group(*args, **kwargs) + if self.of_cmd is None: + if args not in (("positional arguments",), ("optional arguments",)): + + def add_mutually_exclusive_group(**e_kwargs: Any) -> Any: + def add_argument(*a_args: str, of_type: type[Any] | None = None, **a_kwargs: Any) -> Action: + res_args: Action = prev_add_arg(*a_args, **a_kwargs) # type: ignore[has-type] + arguments.append((a_args, of_type, a_kwargs)) + return res_args + + arguments: list[ArgumentArgs] = [] + excl.append((e_kwargs, arguments)) + res_excl = prev_excl(**kwargs) + prev_add_arg = res_excl.add_argument + res_excl.add_argument = add_argument # type: ignore[assignment] + return res_excl + + prev_excl = result.add_mutually_exclusive_group + result.add_mutually_exclusive_group = add_mutually_exclusive_group # type: ignore[assignment] + excl: list[tuple[dict[str, Any], list[ArgumentArgs]]] = [] + self._groups.append((args, kwargs, excl)) + return result + + def add_argument(self, *args: str, of_type: type[Any] | None = None, **kwargs: Any) -> Action: + result = super().add_argument(*args, **kwargs) + if self.of_cmd is None and result.dest != "help": + self._arguments.append((args, of_type, kwargs)) + if hasattr(self, "_cmd") and self._cmd is not None and hasattr(self._cmd, "choices"): + for parser in {id(v): v for k, v in self._cmd.choices.items()}.values(): + parser.add_argument(*args, of_type=of_type, **kwargs) + if of_type is not None: + result.of_type = of_type # type: ignore[attr-defined] + return result + + @classmethod + def base(cls: type[ToxParserT]) -> ToxParserT: + return cls(add_help=False, root=True) + + @classmethod + def core(cls: type[ToxParserT]) -> ToxParserT: + return cls( + prog=NAME, + formatter_class=HelpFormatter, + add_cmd=True, + root=True, + description="create and set up environments to run command(s) in them", + ) + + def _add_base_options(self) -> None: + """Argument options that always make sense.""" + add_core_arguments(self) + self.fix_defaults() + + def parse_known_args( # type: ignore[override] + self, + args: Sequence[str] | None = None, + namespace: Parsed | None = None, + ) -> tuple[Parsed, list[str]]: + if args is None: + args = sys.argv[1:] + cmd_at: int | None = None + if self._cmd is not None and args: + for at, arg in enumerate(args): + if arg in self._cmd.choices: + cmd_at = at + break + else: + cmd_at = None + if cmd_at is not None: # if we found a command move it to the start + args = args[cmd_at], *args[:cmd_at], *args[cmd_at + 1 :] + elif args not in (("--help",), ("-h",)) and (self._cmd is not None and "legacy" in self._cmd.choices): + # on help no mangling needed, and we also want to insert once we have legacy to insert + args = "legacy", *args + result = Parsed() if namespace is None else namespace + _, args = super().parse_known_args(args, namespace=result) + return result, args + + +def add_verbosity_flags(parser: ArgumentParser) -> None: + from tox.report import LEVELS + + level_map = "|".join(f"{c}={logging.getLevelName(l)}" for c, l in sorted(LEVELS.items())) + verbosity_group = parser.add_argument_group("verbosity") + verbosity_group.description = ( + f"every -v increases, every -q decreases verbosity level, " + f"default {logging.getLevelName(LEVELS[3])}, map {level_map}" + ) + verbosity = verbosity_group.add_mutually_exclusive_group() + verbosity.add_argument( + "-v", + "--verbose", + action="https://github.com/count", + dest="verbose", + help="increase verbosity", + default=DEFAULT_VERBOSITY, + ) + verbosity.add_argument("-q", "--quiet", action="https://github.com/count", dest="quiet", help="decrease verbosity", default=0) + + +def add_color_flags(parser: ArgumentParser) -> None: + converter = StrConvert() + if converter.to_bool(os.environ.get("NO_COLOR", "")): + color = "no" + elif converter.to_bool(os.environ.get("FORCE_COLOR", "")): + color = "yes" + elif os.environ.get("TERM", "") == "dumb": + color = "no" + else: + color = "yes" if sys.stdout.isatty() else "no" + + parser.add_argument( + "--colored", + default=color, + choices=["yes", "no"], + help="should output be enriched with colors, default is yes unless TERM=dumb or NO_COLOR is defined.", + ) + + +def add_exit_and_dump_after(parser: ArgumentParser) -> None: + parser.add_argument( + "--exit-and-dump-after", + dest="exit_and_dump_after", + metavar="seconds", + default=0, + type=int, + help="dump tox threads after n seconds and exit the app - useful to debug when tox hangs, 0 means disabled", + ) + + +def add_core_arguments(parser: ArgumentParser) -> None: + add_color_flags(parser) + add_verbosity_flags(parser) + add_exit_and_dump_after(parser) + parser.add_argument( + "-c", + "--conf", + dest="config_file", + metavar="file", + default=None, + type=Path, + of_type=Optional[Path], + help="configuration file/folder for tox (if not specified will discover one)", + ) + parser.add_argument( + "--workdir", + dest="work_dir", + metavar="dir", + default=None, + type=Path, + of_type=Optional[Path], + help="tox working directory (if not specified will be the folder of the config file)", + ) + parser.add_argument( + "--root", + dest="root_dir", + metavar="dir", + default=None, + type=Path, + of_type=Optional[Path], + help="project root directory (if not specified will be the folder of the config file)", + ) + + +__all__ = ( + "DEFAULT_VERBOSITY", + "Parsed", + "ToxParser", + "HelpFormatter", +) diff --git a/src/tox/session/commands/__init__.py b/src/tox/config/loader/__init__.py similarity index 100% rename from src/tox/session/commands/__init__.py rename to src/tox/config/loader/__init__.py diff --git a/src/tox/config/loader/api.py b/src/tox/config/loader/api.py new file mode 100644 index 000000000..fcac1f839 --- /dev/null +++ b/src/tox/config/loader/api.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from abc import abstractmethod +from argparse import ArgumentTypeError +from concurrent.futures import Future +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, Generator, List, Mapping, TypeVar + +from tox.plugin import impl + +from .convert import Convert, Factory +from .section import Section +from .str_convert import StrConvert + +if TYPE_CHECKING: + from tox.config.cli.parser import ToxParser + from tox.config.main import Config + + +class Override: + """ + An override for config definitions. + """ + + def __init__(self, value: str) -> None: + key, equal, self.value = value.partition("=") + if not equal: + raise ArgumentTypeError(f"override {value} has no = sign in it") + self.namespace, _, self.key = key.rpartition(".") + + def __repr__(self) -> str: + return f"{self.__class__.__name__}('{self}')" + + def __str__(self) -> str: + return f"{self.namespace}{'.' if self.namespace else ''}{self.key}={self.value}" + + def __eq__(self, other: Any) -> bool: + if type(self) != type(other): + return False + return (self.namespace, self.key, self.value) == (other.namespace, other.key, other.value) + + def __ne__(self, other: Any) -> bool: + return not (self == other) + + +class ConfigLoadArgs: + """Arguments that help loading a configuration value.""" + + def __init__(self, chain: list[str] | None, name: str | None, env_name: str | None): + """ + :param chain: the configuration chain (useful to detect circular references) + :param name: the name of the configuration + :param env_name: the tox environment this load is for + """ + self.chain: list[str] = chain or [] + self.name = name + self.env_name = env_name + + def copy(self) -> ConfigLoadArgs: + """:return: create a copy of the object""" + return ConfigLoadArgs(self.chain.copy(), self.name, self.env_name) + + +OverrideMap = Mapping[str, List[Override]] + +T = TypeVar("T") +V = TypeVar("V") + + +class Loader(Convert[T]): + """Loader loads a configuration value and converts it.""" + + def __init__(self, section: Section, overrides: list[Override]) -> None: + self._section = section + self.overrides = {o.key: o for o in overrides} + self.parent: Loader[Any] | None = None + + @property + def section(self) -> Section: + return self._section + + @abstractmethod + def load_raw(self, key: str, conf: Config | None, env_name: str | None) -> T: + """ + Load the raw object from the config store. + + :param key: the key under what we want the configuration + :param env_name: load for env name + :param conf: the global config object + """ + raise NotImplementedError + + @abstractmethod + def found_keys(self) -> set[str]: + """A list of configuration keys found within the configuration.""" + raise NotImplementedError + + def __repr__(self) -> str: + return f"{type(self).__name__}" + + def __contains__(self, item: str) -> bool: + return item in self.found_keys() + + def load( + self, + key: str, + of_type: type[V], + factory: Factory[V], + conf: Config | None, + args: ConfigLoadArgs, + ) -> V: + """ + Load a value (raw and then convert). + + :param key: the key under it lives + :param of_type: the type to convert to + :param factory: factory method to build the object + :param conf: the configuration object of this tox session (needed to manifest the value) + :param args: the config load arguments + :return: the converted type + """ + if key in self.overrides: + return _STR_CONVERT.to(self.overrides[key].value, of_type, factory) + raw = self.load_raw(key, conf, args.env_name) + future: Future[V] = Future() + with self.build(future, key, of_type, conf, raw, args) as prepared: + converted = self.to(prepared, of_type, factory) + future.set_result(converted) + return converted + + @contextmanager + def build( + self, + future: Future[V], # noqa: U100 + key: str, # noqa: U100 + of_type: type[V], # noqa: U100 + conf: Config | None, # noqa: U100 + raw: T, + args: ConfigLoadArgs, # noqa: U100 + ) -> Generator[T, None, None]: + """ + Materialize the raw configuration value from the loader. + + :param future: a future which when called will provide the converted config value + :param key: the config key + :param of_type: the config type + :param conf: the global config + :param raw: the raw value + :param args: env args + """ + yield raw + + +@impl +def tox_add_option(parser: ToxParser) -> None: + parser.add_argument( + "-x", + "--override", + action="https://github.com/append", + type=Override, + default=[], + dest="override", + help="configuration override(s)", + ) + + +_STR_CONVERT = StrConvert() diff --git a/src/tox/config/loader/convert.py b/src/tox/config/loader/convert.py new file mode 100644 index 000000000..b9259b05b --- /dev/null +++ b/src/tox/config/loader/convert.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import sys +from abc import ABC, abstractmethod +from collections import OrderedDict +from pathlib import Path +from typing import Any, Callable, Dict, Generic, Iterator, List, Optional, Set, TypeVar, Union, cast + +from ..types import Command, EnvList + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from typing import Literal +else: # pragma: no cover (py38+) + from typing_extensions import Literal + + +_NO_MAPPING = object() +T = TypeVar("T") +V = TypeVar("V") + +Factory = Optional[Callable[[object], T]] # note the argument is anything, due e.g. memory loader can inject anything + + +class Convert(ABC, Generic[T]): + """A class that converts a raw type to a given tox (python) type""" + + def to(self, raw: T, of_type: type[V], factory: Factory[V]) -> V: + """ + Convert given raw type to python type + + :param raw: the raw type + :param of_type: python type + :param factory: factory method to build the object + :return: the converted type + """ + from_module = getattr(of_type, "__module__", None) + if from_module in ("typing", "typing_extensions"): + return self._to_typing(raw, of_type, factory) + if issubclass(of_type, Path): + return self.to_path(raw) # type: ignore[return-value] + if issubclass(of_type, bool): + return self.to_bool(raw) # type: ignore[return-value] + if issubclass(of_type, Command): + return self.to_command(raw) # type: ignore[return-value] + if issubclass(of_type, EnvList): + return self.to_env_list(raw) # type: ignore[return-value] + if issubclass(of_type, str): + return self.to_str(raw) # type: ignore[return-value] + if isinstance(raw, of_type): # already target type no need to transform it + # do it this late to allow normalization - e.g. string strip + return raw + if factory: + return factory(raw) + return of_type(raw) # type: ignore[call-arg] + + def _to_typing(self, raw: T, of_type: type[V], factory: Factory[V]) -> V: + origin = getattr(of_type, "__origin__", of_type.__class__) + result: Any = _NO_MAPPING + if origin in (list, List): + entry_type = of_type.__args__[0] # type: ignore[attr-defined] + result = [self.to(i, entry_type, factory) for i in self.to_list(raw, entry_type)] + elif origin in (set, Set): + entry_type = of_type.__args__[0] # type: ignore[attr-defined] + result = {self.to(i, entry_type, factory) for i in self.to_set(raw, entry_type)} + elif origin in (dict, Dict): + key_type, value_type = of_type.__args__[0], of_type.__args__[1] # type: ignore[attr-defined] + result = OrderedDict( + (self.to(k, key_type, factory), self.to(v, value_type, factory)) + for k, v in self.to_dict(raw, (key_type, value_type)) + ) + elif origin == Union: # handle Optional values + args: list[type[Any]] = of_type.__args__ # type: ignore[attr-defined] + none = type(None) + if len(args) == 2 and none in args: + if isinstance(raw, str): + raw = raw.strip() # type: ignore[assignment] + if not raw: + result = None + else: + new_type = next(i for i in args if i != none) # pragma: no cover # this will always find a element + result = self.to(raw, new_type, factory) + elif origin in (Literal, type(Literal)): + choice = of_type.__args__ # type: ignore[attr-defined] + if raw not in choice: + raise ValueError(f"{raw} must be one of {choice}") + result = raw + if result is not _NO_MAPPING: + return cast(V, result) + raise TypeError(f"{raw} cannot cast to {of_type!r}") + + @staticmethod + @abstractmethod + def to_str(value: T) -> str: + """ + Convert to string. + + :param value: the value to convert + :returns: a string representation of the value + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_bool(value: T) -> bool: + """ + Convert to boolean. + + :param value: the value to convert + :returns: a boolean representation of the value + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_list(value: T, of_type: type[Any]) -> Iterator[T]: + """ + Convert to list. + + :param value: the value to convert + :param of_type: the type of elements in the list + :returns: a list representation of the value + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_set(value: T, of_type: type[Any]) -> Iterator[T]: + """ + Convert to set. + + :param value: the value to convert + :param of_type: the type of elements in the set + :returns: a set representation of the value + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_dict(value: T, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[T, T]]: + """ + Convert to dictionary. + + :param value: the value to convert + :param of_type: a tuple indicating the type of the key and the value + :returns: a iteration of key-value pairs that gets populated into a dict + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_path(value: T) -> Path: + """ + Convert to path. + + :param value: the value to convert + :returns: path representation of the value + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_command(value: T) -> Command: + """ + Convert to a command to execute. + + :param value: the value to convert + :returns: command representation of the value + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def to_env_list(value: T) -> EnvList: + """ + Convert to a tox EnvList. + + :param value: the value to convert + :returns: a list of tox environments from the value + """ + raise NotImplementedError + + +__all__ = [ + "Convert", + "Factory", +] diff --git a/src/tox/config/loader/ini/__init__.py b/src/tox/config/loader/ini/__init__.py new file mode 100644 index 000000000..270dc13bb --- /dev/null +++ b/src/tox/config/loader/ini/__init__.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import inspect +import re +from concurrent.futures import Future +from configparser import ConfigParser, SectionProxy +from contextlib import contextmanager +from typing import TYPE_CHECKING, Generator, TypeVar + +from tox.config.loader.api import ConfigLoadArgs, Loader, Override +from tox.config.loader.ini.factor import filter_for_env +from tox.config.loader.ini.replace import replace +from tox.config.loader.section import Section +from tox.config.loader.str_convert import StrConvert +from tox.config.set_env import SetEnv +from tox.report import HandledError + +if TYPE_CHECKING: + from tox.config.main import Config + +V = TypeVar("V") +_COMMENTS = re.compile(r"(\s)*(? None: + self._section_proxy: SectionProxy = parser[section_key or section.key] + self._parser = parser + self.core_section = core_section + super().__init__(section, overrides) + + def load_raw(self, key: str, conf: Config | None, env_name: str | None) -> str: + return self.process_raw(conf, env_name, self._section_proxy[key]) + + @staticmethod + def process_raw(conf: Config | None, env_name: str | None, value: str) -> str: + # strip comments + elements: list[str] = [] + for line in value.split("\n"): + if not line.startswith("#"): + part = _COMMENTS.sub("", line) + elements.append(part.replace("\\#", "#")) + strip_comments = "\n".join(elements) + if conf is None: # conf is None when we're loading the global tox configuration file for the CLI + factor_filtered = strip_comments # we don't support factor and replace functionality there + else: + factor_filtered = filter_for_env(strip_comments, env_name) # select matching factors + collapsed = factor_filtered.replace("\r", "").replace("\\\n", "") # collapse explicit new-line escape + return collapsed + + @contextmanager + def build( + self, + future: Future[V], + key: str, + of_type: type[V], + conf: Config | None, + raw: str, + args: ConfigLoadArgs, + ) -> Generator[str, None, None]: + delay_replace = inspect.isclass(of_type) and issubclass(of_type, SetEnv) + + def replacer(raw_: str, args_: ConfigLoadArgs) -> str: + if conf is None: + replaced = raw_ # no replacement supported in the core section + else: + try: + replaced = replace(conf, self, raw_, args_) # do replacements + except Exception as exception: + if isinstance(exception, HandledError): + raise + name = self.core_section.key if args_.env_name is None else args_.env_name + msg = f"replace failed in {name}.{key} with {exception!r}" + raise HandledError(msg) from exception + return replaced + + if not delay_replace: + raw = replacer(raw, args) + yield raw + if delay_replace: + converted = future.result() + converted.use_replacer(replacer, args) # type: ignore[attr-defined] # this can be only set_env that has it + + def found_keys(self) -> set[str]: + return set(self._section_proxy.keys()) + + def get_section(self, name: str) -> SectionProxy | None: + # needed for non tox environment replacements + if self._parser.has_section(name): + return self._parser[name] + return None + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(section={self._section.key}, overrides={self.overrides!r})" diff --git a/src/tox/config/loader/ini/factor.py b/src/tox/config/loader/ini/factor.py new file mode 100644 index 000000000..1f5dcfb90 --- /dev/null +++ b/src/tox/config/loader/ini/factor.py @@ -0,0 +1,96 @@ +""" +Expand tox factor expressions to tox environment list. +""" +from __future__ import annotations + +import re +from itertools import chain, groupby, product +from typing import Iterator + + +def filter_for_env(value: str, name: str | None) -> str: + current = ( + set(chain.from_iterable([(i for i, _ in a) for a in find_factor_groups(name)])) if name is not None else set() + ) + overall = [] + for factors, content in expand_factors(value): + if factors is None: + if content: + overall.append(content) + else: + for group in factors: + if all((a_name in current) ^ negate for a_name, negate in group): + overall.append(content) + result = "\n".join(overall) + return result + + +def find_envs(value: str) -> Iterator[str]: + seen = set() + for factors, _ in expand_factors(value): + if factors is not None: + for group in factors: + env = explode_factor(group) + if env not in seen: + yield env + seen.add(env) + + +def extend_factors(value: str) -> Iterator[str]: + for group in find_factor_groups(value): + yield explode_factor(group) + + +def explode_factor(group: list[tuple[str, bool]]) -> str: + return "-".join([name for name, _ in group]) + + +def expand_factors(value: str) -> Iterator[tuple[Iterator[list[tuple[str, bool]]] | None, str]]: + for line in value.split("\n"): + match = re.match(r"^((?P[\w{}.!,-]+):\s+)?(?P.*?)$", line) + if match is None: # pragma: no cover + raise RuntimeError("for a valid factor regex this cannot happen") + groups = match.groupdict() + factor_expr, content = groups["factor_expr"], groups["content"] + if factor_expr is not None: + factors = find_factor_groups(factor_expr) + yield factors, content + else: + yield None, content + + +def find_factor_groups(value: str) -> Iterator[list[tuple[str, bool]]]: + """transform '{py,!pi}-{a,b},c' to [{'py', 'a'}, {'py', 'b'}, {'pi', 'a'}, {'pi', 'b'}, {'c'}]""" + for env in expand_env_with_negation(value): + result = [name_with_negate(f) for f in env.split("-")] + yield result + + +def expand_env_with_negation(value: str) -> Iterator[str]: + """transform '{py,!pi}-{a,b},c' to ['py-a', 'py-b', '!pi-a', '!pi-b', 'c']""" + for key, group in groupby(re.split(r"((?:{[^}]+})+)|,", value), key=bool): + if key: + group_str = "".join(group).strip() + elements = re.split(r"{([^}]+)}", group_str) + parts = [re.sub(r"\s+", "", elem).split(",") for elem in elements] + for variant in product(*parts): + variant_str = "".join(variant) + yield variant_str + + +def name_with_negate(factor: str) -> tuple[str, bool]: + negated = is_negated(factor) + result = factor[1:] if negated else factor + return result, negated + + +def is_negated(factor: str) -> bool: + return factor.startswith("!") + + +__all__ = ( + "filter_for_env", + "find_envs", + "expand_factors", + "extend_factors", +) diff --git a/src/tox/config/loader/ini/replace.py b/src/tox/config/loader/ini/replace.py new file mode 100644 index 000000000..cb0174e3e --- /dev/null +++ b/src/tox/config/loader/ini/replace.py @@ -0,0 +1,220 @@ +""" +Apply value substitution (replacement) on tox strings. +""" +from __future__ import annotations + +import os +import re +import sys +from configparser import SectionProxy +from functools import lru_cache +from pathlib import Path +from typing import TYPE_CHECKING, Iterator, Pattern + +from tox.config.loader.api import ConfigLoadArgs +from tox.config.loader.stringify import stringify +from tox.config.set_env import SetEnv +from tox.config.sets import ConfigSet +from tox.execute.request import shell_cmd + +if TYPE_CHECKING: + from tox.config.loader.ini import IniLoader + from tox.config.main import Config + +# split alongside :, unless it's escaped, or it's preceded by a single capital letter (Windows drive letter in paths) +ARGS_GROUP = re.compile(r"(? str: + # perform all non-escaped replaces + end = 0 + while True: + start, end, to_replace = find_replace_part(value, end) + if to_replace is None: + break + replaced = _replace_match(conf, loader, to_replace, args.copy()) + if replaced is None: + # if we cannot replace, keep what was there, and continue looking for additional replaces following + # note, here we cannot raise because the content may be a factorial expression, and in those case we don't + # want to enforce escaping curly braces, e.g. it should work to write: env_list = {py39,py38}-{,dep} + end = end + 1 + continue + new_value = f"{value[:start]}{replaced}{value[end + 1:]}" + end = 0 # if we performed a replacement start over + if new_value == value: # if we're not making progress stop (circular reference?) + break + value = new_value + # remove escape sequences + value = value.replace("\\{", "{") + value = value.replace("\\}", "}") + value = value.replace("\\[", "[") + value = value.replace("\\]", "]") + return value + + +REPLACE_PART = re.compile( + r""" + (? tuple[int, int, str | None]: + match = REPLACE_PART.search(value, end) + if match is None: + return -1, -1, None + if match.group() == "[]": + return match.start(), match.end() - 1, "posargs" # brackets is an alias for positional arguments + matched_part = match.group()[1:-1] + return match.start(), match.end() - 1, matched_part + + +def _replace_match(conf: Config, loader: IniLoader, value: str, conf_args: ConfigLoadArgs) -> str | None: + of_type, *args = ARGS_GROUP.split(value) + if of_type == "/": + replace_value: str | None = os.sep + elif of_type == "" and args == [""]: + replace_value = os.pathsep + elif of_type == "env": + replace_value = replace_env(conf, args, conf_args) + elif of_type == "tty": + replace_value = replace_tty(args) + elif of_type == "posargs": + replace_value = replace_pos_args(conf, args, conf_args) + else: + replace_value = replace_reference(conf, loader, value, conf_args) + return replace_value + + +@lru_cache(maxsize=None) +def _replace_ref(env: str | None) -> Pattern[str]: + return re.compile( + rf""" + (\[(?P{re.escape(env or '.*')}(:(?P[^]]+))?|(?P
[-\w]+))])? # env/section + (?P[-a-zA-Z0-9_]+) # key + (:(?P.*))? # default value +""", + re.VERBOSE, + ) + + +def replace_reference(conf: Config, loader: IniLoader, value: str, conf_args: ConfigLoadArgs) -> str | None: + # a return value of None indicates could not replace + pattern = _replace_ref(loader.section.prefix or loader.section.name) + match = pattern.match(value) + if match: + settings = match.groupdict() + + key = settings["key"] + if settings["section"] is None and settings["full_env"]: + settings["section"] = settings["full_env"] + + exception: Exception | None = None + try: + for src in _config_value_sources(settings["env"], settings["section"], conf_args.env_name, conf, loader): + try: + if isinstance(src, SectionProxy): + return loader.process_raw(conf, conf_args.env_name, src[key]) + value = src.load(key, conf_args.chain) + as_str, _ = stringify(value) + as_str = as_str.replace("#", r"\#") # escape comment characters as these will be stripped + return as_str + except KeyError as exc: # if fails, keep trying maybe another source can satisfy + exception = exc + except Exception as exc: + exception = exc + if exception is not None: + if isinstance(exception, KeyError): # if the lookup failed replace - else keep + default = settings["default"] + if default is not None: + return default + # we cannot raise here as that would mean users could not write factorials: depends = {py39,py38}-{,b} + else: + raise exception + return None + + +def _config_value_sources( + env: str | None, + section: str | None, + current_env: str | None, + conf: Config, + loader: IniLoader, +) -> Iterator[SectionProxy | ConfigSet]: + # if we have an env name specified take only from there + if env is not None: + if env in conf: + yield conf.get_env(env) + + if section is None: + # if no section specified perhaps it's an unregistered config: + # 1. try first from core conf + yield conf.core + # 2. and then fallback to our own environment + if current_env is not None: + yield conf.get_env(current_env) + return + + # if there's a section, special handle the core section + if section == loader.core_section.name: + yield conf.core # try via registered configs + value = loader.get_section(section) # fallback to section + if value is not None: + yield value + + +def replace_pos_args(conf: Config, args: list[str], conf_args: ConfigLoadArgs) -> str: + to_path: Path | None = None + if conf_args.env_name is not None: # pragma: no branch + env_conf = conf.get_env(conf_args.env_name) + try: + if env_conf["args_are_paths"]: # pragma: no branch + to_path = env_conf["change_dir"] + except KeyError: + pass + pos_args = conf.pos_args(to_path) + if pos_args is None: + replace_value = ":".join(args) # if we use the defaults join back remaining args + else: + replace_value = shell_cmd(pos_args) + return replace_value + + +def replace_env(conf: Config, args: list[str], conf_args: ConfigLoadArgs) -> str: + key = args[0] + new_key = f"env:{key}" + + if conf_args.env_name is not None: # on core no set env support # pragma: no branch + if new_key not in conf_args.chain: # check if set env + conf_args.chain.append(new_key) + env_conf = conf.get_env(conf_args.env_name) + set_env: SetEnv = env_conf["set_env"] + if key in set_env: + return set_env.load(key, conf_args) + elif conf_args.chain[-1] != new_key: # if there's a chain but only self-refers than use os.environ + circular = ", ".join(i[4:] for i in conf_args.chain[conf_args.chain.index(new_key) :]) + raise ValueError(f"circular chain between set env {circular}") + + if key in os.environ: + return os.environ[key] + + return "" if len(args) == 1 else ":".join(args[1:]) + + +def replace_tty(args: list[str]) -> str: + if sys.stdout.isatty(): + result = args[0] if len(args) > 0 else "" + else: + result = args[1] if len(args) > 1 else "" + return result + + +__all__ = ( + "replace", + "find_replace_part", +) diff --git a/src/tox/config/loader/memory.py b/src/tox/config/loader/memory.py new file mode 100644 index 000000000..03fcef6cf --- /dev/null +++ b/src/tox/config/loader/memory.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Iterator + +from tox.config.types import Command, EnvList + +from .api import Loader +from .section import Section +from .str_convert import StrConvert + +if TYPE_CHECKING: + from tox.config.main import Config + + +class MemoryLoader(Loader[Any]): + def __init__(self, **kwargs: Any) -> None: + super().__init__(Section(prefix="", name=str(id(self))), []) + self.raw: dict[str, Any] = {**kwargs} + + def load_raw(self, key: Any, conf: Config | None, env_name: str | None) -> Any: # noqa: U100 + return self.raw[key] + + def found_keys(self) -> set[str]: + return set(self.raw.keys()) + + @staticmethod + def to_bool(value: Any) -> bool: + return bool(value) + + @staticmethod + def to_str(value: Any) -> str: + return str(value) + + @staticmethod + def to_list(value: Any, of_type: type[Any]) -> Iterator[Any]: # noqa: U100 + return iter(value) + + @staticmethod + def to_set(value: Any, of_type: type[Any]) -> Iterator[Any]: # noqa: U100 + return iter(value) + + @staticmethod + def to_dict(value: Any, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[Any, Any]]: # noqa: U100 + return value.items() # type: ignore[no-any-return] + + @staticmethod + def to_path(value: Any) -> Path: + return Path(value) + + @staticmethod + def to_command(value: Any) -> Command: + if isinstance(value, Command): + return value + if isinstance(value, str): + return StrConvert.to_command(value) + raise TypeError(value) + + @staticmethod + def to_env_list(value: Any) -> EnvList: + if isinstance(value, EnvList): + return value + if isinstance(value, str): + return StrConvert.to_env_list(value) + raise TypeError(value) diff --git a/src/tox/config/loader/section.py b/src/tox/config/loader/section.py new file mode 100644 index 000000000..98cbf42af --- /dev/null +++ b/src/tox/config/loader/section.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any, TypeVar + +_Section = TypeVar("_Section", bound="Section") + + +class Section: + """tox configuration section""" + + SEP = ":" #: string used to separate the prefix and the section in the key + + def __init__(self, prefix: str | None, name: str) -> None: + self._prefix = prefix + self._name = name + + @classmethod + def from_key(cls: type[_Section], key: str) -> _Section: + """ + Create a section from a section key. + + :param key: the section key + :return: the constructed section + """ + sep_at = key.find(cls.SEP) + if sep_at == -1: + prefix, name = None, key + else: + prefix, name = key[:sep_at], key[sep_at + 1 :] + return cls(prefix, name) + + @property + def prefix(self) -> str | None: + """:return: the prefix of the section""" + return self._prefix + + @property + def name(self) -> str: + """:return: the name of the section""" + return self._name + + @property + def key(self) -> str: + """:return: the section key""" + return self.SEP.join(i for i in (self._prefix, self._name) if i is not None) + + def __str__(self) -> str: + return self.key + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(prefix={self._prefix!r}, name={self._name!r})" + + def __eq__(self, other: Any) -> bool: + return isinstance(other, self.__class__) and (self._prefix, self._name) == (other._prefix, other.name) + + +__all__ = [ + "Section", +] diff --git a/src/tox/config/loader/str_convert.py b/src/tox/config/loader/str_convert.py new file mode 100644 index 000000000..dd518e5e3 --- /dev/null +++ b/src/tox/config/loader/str_convert.py @@ -0,0 +1,93 @@ +"""Convert string configuration values to tox python configuration objects.""" +from __future__ import annotations + +import shlex +import sys +from itertools import chain +from pathlib import Path +from typing import Any, Iterator + +from tox.config.loader.convert import Convert +from tox.config.types import Command, EnvList + + +class StrConvert(Convert[str]): + """A class converting string values to tox types""" + + @staticmethod + def to_str(value: str) -> str: + return str(value).strip() + + @staticmethod + def to_path(value: str) -> Path: + return Path(value) + + @staticmethod + def to_list(value: str, of_type: type[Any]) -> Iterator[str]: + splitter = "\n" if issubclass(of_type, Command) or "\n" in value else "," + splitter = splitter.replace("\r", "") + for token in value.split(splitter): + value = token.strip() + if value: + yield value + + @staticmethod + def to_set(value: str, of_type: type[Any]) -> Iterator[str]: + yield from StrConvert.to_list(value, of_type) + + @staticmethod + def to_dict(value: str, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[str, str]]: # noqa: U100 + for row in value.split("\n"): + if row.strip(): + key, sep, value = row.partition("=") + if sep: + yield key.strip(), value.strip() + else: + raise TypeError(f"dictionary lines must be of form key=value, found {row!r}") + + @staticmethod + def to_command(value: str) -> Command: + is_win = sys.platform == "win32" + value = value.replace(r"\#", "#") + splitter = shlex.shlex(value, posix=not is_win) + splitter.whitespace_split = True + splitter.commenters = "" # comments handled earlier, and the shlex does not know escaped comment characters + args: list[str] = [] + pos = 0 + try: + for arg in splitter: + if is_win and len(arg) > 1 and arg[0] == arg[-1] and arg.startswith(("'", '"')): # pragma: win32 cover + # on Windows quoted arguments will remain quoted, strip it + arg = arg[1:-1] + args.append(arg) + pos = splitter.instream.tell() + except ValueError: + args.append(value[pos:]) + if args[0] != "-" and args[0].startswith("-"): + args[0] = args[0][1:] + args = ["-"] + args + return Command(args) + + @staticmethod + def to_env_list(value: str) -> EnvList: + from tox.config.loader.ini.factor import extend_factors + + elements = list(chain.from_iterable(extend_factors(expr) for expr in value.split("\n"))) + return EnvList(elements) + + TRUTHFUL_VALUES = {"true", "1", "yes", "on"} + FALSE_VALUES = {"false", "0", "no", "off", ""} + VALID_BOOL = sorted(TRUTHFUL_VALUES | FALSE_VALUES) + + @staticmethod + def to_bool(value: str) -> bool: + norm = value.strip().lower() + if norm in StrConvert.TRUTHFUL_VALUES: + return True + elif norm in StrConvert.FALSE_VALUES: + return False + else: + raise TypeError(f"value {value} cannot be transformed to bool, valid: {', '.join(StrConvert.VALID_BOOL)}") + + +__all__ = ("StrConvert",) diff --git a/src/tox/config/loader/stringify.py b/src/tox/config/loader/stringify.py new file mode 100644 index 000000000..f6eafc830 --- /dev/null +++ b/src/tox/config/loader/stringify.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping, Sequence, Set + +from tox.config.set_env import SetEnv +from tox.config.types import Command, EnvList +from tox.tox_env.python.pip.req_file import PythonDeps + + +def stringify(value: Any) -> tuple[str, bool]: + """ + Transform a value into a string representation. + + :param value: the value in question + :return: a tuple, first the value as str, second a flag if the value if a multi-line one + """ + if isinstance(value, str): + return value, False + if isinstance(value, (Path, float, int, bool)): + return str(value), False + if isinstance(value, Mapping): + return "\n".join(f"{stringify(k)[0]}={stringify(v)[0]}" for k, v in value.items()), True + if isinstance(value, (Sequence, Set)): + return "\n".join(stringify(i)[0] for i in value), True + if isinstance(value, EnvList): + return "\n".join(e for e in value.envs), True + if isinstance(value, Command): + return value.shell, True + if isinstance(value, SetEnv): + env_var_keys = sorted(value) + return stringify({k: value.load(k) for k in env_var_keys}) + if isinstance(value, PythonDeps): + return stringify(value.lines()) + return str(value), False + + +__all__ = ("stringify",) diff --git a/src/tox/config/main.py b/src/tox/config/main.py new file mode 100644 index 000000000..38efb600b --- /dev/null +++ b/src/tox/config/main.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import os +from collections import OrderedDict, defaultdict +from pathlib import Path +from typing import TYPE_CHECKING, Any, Iterator, Sequence, TypeVar + +from tox.config.loader.api import Loader, OverrideMap + +from .loader.section import Section +from .sets import ConfigSet, CoreConfigSet, EnvConfigSet +from .source import Source + +if TYPE_CHECKING: + from .cli.parser import Parsed + + +T = TypeVar("T", bound=ConfigSet) + + +class Config: + """Main configuration object for tox.""" + + def __init__( + self, + config_source: Source, + options: Parsed, + root: Path, + pos_args: Sequence[str] | None, + work_dir: Path, + ) -> None: + self._pos_args = None if pos_args is None else tuple(pos_args) + self._work_dir = work_dir + self._root = root + self._options = options + + self._overrides: OverrideMap = defaultdict(list) + for override in options.override: + self._overrides[override.namespace].append(override) + + self._src = config_source + self._key_to_conf_set: dict[tuple[str, str], ConfigSet] = OrderedDict() + self._core_set: CoreConfigSet | None = None + + def pos_args(self, to_path: Path | None) -> tuple[str, ...] | None: + """ + :param to_path: if not None rewrite relative posargs paths from cwd to to_path + :return: positional argument + """ + if self._pos_args is not None and to_path is not None and Path.cwd() != to_path: + args = [] + to_path_str = os.path.abspath(str(to_path)) # we use os.path to unroll .. in path without resolve + for arg in self._pos_args: + path_arg = Path(arg) + if path_arg.exists() and not path_arg.is_absolute(): + path_arg_str = os.path.abspath(str(path_arg)) # we use os.path to unroll .. in path without resolve + relative = os.path.relpath(path_arg_str, to_path_str) # we use os.path to not fail when not within + args.append(relative) + else: + args.append(arg) + return tuple(args) + return self._pos_args + + @property + def work_dir(self) -> Path: + """:return: working directory for this project""" + return self._work_dir + + @property + def src_path(self) -> Path: + """:return: the location of the tox configuration source""" + return self._src.path + + def __iter__(self) -> Iterator[str]: + """:return: an iterator that goes through existing environments""" + return self._src.envs(self.core) + + def sections(self) -> Iterator[Section]: + yield from self._src.sections() + + def __repr__(self) -> str: + return f"{type(self).__name__}(config_source={self._src!r})" + + def __contains__(self, item: str) -> bool: + """:return: check if an environment already exists""" + return any(name for name in self if name == item) + + @classmethod + def make(cls, parsed: Parsed, pos_args: Sequence[str] | None, source: Source) -> Config: + """Make a tox configuration object.""" + # root is the project root, where the configuration file is at + # work dir is where we put our own files + root: Path = source.path.parent if parsed.root_dir is None else parsed.root_dir + work_dir: Path = source.path.parent if parsed.work_dir is None else parsed.work_dir + # if these are relative we need to expand them them to ensure paths built on this can resolve independent on cwd + root = root.resolve() + work_dir = work_dir.resolve() + return cls( + config_source=source, + options=parsed, + pos_args=pos_args, + root=root, + work_dir=work_dir, + ) + + @property + def options(self) -> Parsed: + return self._options + + @property + def core(self) -> CoreConfigSet: + """:return: the core configuration""" + if self._core_set is not None: + return self._core_set + core_section = self._src.get_core_section() + core = CoreConfigSet(self, core_section, self._root, self.src_path) + core.loaders.extend(self._src.get_loaders(core_section, base=[], override_map=self._overrides, conf=core)) + self._core_set = core + return core + + def get_section_config( + self, + section: Section, + base: list[str] | None, + of_type: type[T], + for_env: str | None, + loaders: Sequence[Loader[Any]] | None = None, + ) -> T: + key = section.key, for_env or "" + try: + return self._key_to_conf_set[key] # type: ignore[return-value] # expected T but found ConfigSet + except KeyError: + conf_set = of_type(self, section, for_env) + self._key_to_conf_set[key] = conf_set + for loader in self._src.get_loaders(section, base, self._overrides, conf_set): + conf_set.loaders.append(loader) + if loaders is not None: + conf_set.loaders.extend(loaders) + return conf_set + + def get_env( + self, + item: str, + package: bool = False, + loaders: Sequence[Loader[Any]] | None = None, + ) -> EnvConfigSet: + """ + Return the configuration for a given tox environment (will create if not exist yet). + + :param item: the name of the environment + :param package: a flag indicating if the environment is of type packaging or not (only used for creation) + :param loaders: loaders to use for this configuration (only used for creation) + :return: the tox environments config + """ + section, base = self._src.get_tox_env_section(item) + conf_set = self.get_section_config( + section, + base=None if package else base, + of_type=EnvConfigSet, + for_env=item, + loaders=loaders, + ) + return conf_set + + def clear_env(self, name: str) -> None: + section, _ = self._src.get_tox_env_section(name) + del self._key_to_conf_set[(section.key, name)] + + +___all__ = [ + "Config", +] diff --git a/src/tox/config/of_type.py b/src/tox/config/of_type.py new file mode 100644 index 000000000..59c870413 --- /dev/null +++ b/src/tox/config/of_type.py @@ -0,0 +1,133 @@ +""" +Group together configuration values that belong together (such as base tox configuration, tox environment configs) +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from itertools import product +from typing import TYPE_CHECKING, Any, Callable, Generic, Iterable, TypeVar, cast + +from tox.config.loader.api import ConfigLoadArgs, Loader +from tox.config.loader.convert import Factory + +if TYPE_CHECKING: + from tox.config.main import Config # pragma: no cover + + +T = TypeVar("T") +V = TypeVar("V") + + +class ConfigDefinition(ABC, Generic[T]): + """Abstract base class for configuration definitions""" + + def __init__(self, keys: Iterable[str], desc: str) -> None: + self.keys = keys + self.desc = desc + + @abstractmethod + def __call__(self, conf: Config, loaders: list[Loader[T]], args: ConfigLoadArgs) -> T: + raise NotImplementedError + + def __eq__(self, o: Any) -> bool: + return type(self) == type(o) and (self.keys, self.desc) == (o.keys, o.desc) + + def __ne__(self, o: Any) -> bool: + return not (self == o) + + +class ConfigConstantDefinition(ConfigDefinition[T]): + """A configuration definition whose value is defined upfront (such as the tox environment name)""" + + def __init__( + self, + keys: Iterable[str], + desc: str, + value: Callable[[], T] | T, + ) -> None: + super().__init__(keys, desc) + self.value = value + + def __call__( + self, + conf: Config, # noqa: U100 + loaders: list[Loader[T]], # noqa: U100 + args: ConfigLoadArgs, # noqa: U100 + ) -> T: + if callable(self.value): + value = self.value() + else: + value = self.value + return value + + def __eq__(self, o: Any) -> bool: + return type(self) == type(o) and super().__eq__(o) and self.value == o.value + + +_PLACE_HOLDER = object() + + +class ConfigDynamicDefinition(ConfigDefinition[T]): + """A configuration definition that comes from a source (such as in memory, an ini file, a toml file, etc.)""" + + def __init__( + self, + keys: Iterable[str], + desc: str, + of_type: type[T], + default: Callable[[Config, str | None], T] | T, + post_process: Callable[[T], T] | None = None, + factory: Factory[T] = None, + ) -> None: + super().__init__(keys, desc) + self.of_type = of_type + self.default = default + self.post_process = post_process + self.factory = factory + self._cache: object | T = _PLACE_HOLDER + + def __call__( + self, + conf: Config, + loaders: list[Loader[T]], + args: ConfigLoadArgs, + ) -> T: + if self._cache is _PLACE_HOLDER: + for key, loader in product(self.keys, loaders): + chain_key = f"{loader.section.key}.{key}" + if chain_key in args.chain: + raise ValueError(f"circular chain detected {', '.join(args.chain[args.chain.index(chain_key):])}") + args.chain.append(chain_key) + try: + value = loader.load(key, self.of_type, self.factory, conf, args) + except KeyError: + continue + else: + break + finally: + del args.chain[-1] + else: + value = self.default(conf, args.env_name) if callable(self.default) else self.default + if self.post_process is not None: + value = self.post_process(value) + self._cache = value + return cast(T, self._cache) + + def __repr__(self) -> str: + values = ((k, v) for k, v in vars(self).items() if k != "post_process" and v is not None) + return f"{type(self).__name__}({', '.join(f'{k}={v}' for k, v in values)})" + + def __eq__(self, o: Any) -> bool: + return ( + type(self) == type(o) + and super().__eq__(o) + and (self.of_type, self.default, self.post_process) == (o.of_type, o.default, o.post_process) + ) + + +__all__ = [ + "ConfigLoadArgs", + "ConfigDefinition", + "ConfigDynamicDefinition", + "ConfigConstantDefinition", +] diff --git a/src/tox/config/parallel.py b/src/tox/config/parallel.py deleted file mode 100644 index 1519d58a8..000000000 --- a/src/tox/config/parallel.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -from argparse import ArgumentTypeError - -ENV_VAR_KEY_PUBLIC = "TOX_PARALLEL_ENV" -ENV_VAR_KEY_PRIVATE = "_TOX_PARALLEL_ENV" -OFF_VALUE = 0 -DEFAULT_PARALLEL = OFF_VALUE - - -def auto_detect_cpus(): - try: - from os import sched_getaffinity # python 3 only - - def cpu_count(): - return len(sched_getaffinity(0)) - - except ImportError: - # python 2 options - try: - from os import cpu_count - except ImportError: - from multiprocessing import cpu_count - - try: - n = cpu_count() - except NotImplementedError: # pragma: no cov - n = None # pragma: no cov - return n if n else 1 - - -def parse_num_processes(s): - if s == "all": - return None - if s == "auto": - return auto_detect_cpus() - else: - value = int(s) - if value < 0: - raise ArgumentTypeError("value must be positive") - return value - - -def add_parallel_flags(parser): - parser.add_argument( - "-p", - "--parallel", - nargs="?", - const="auto", - dest="parallel", - help="run tox environments in parallel, the argument controls limit: all," - " auto or missing argument - cpu count, some positive number, 0 to turn off", - action="https://github.com/store", - type=parse_num_processes, - default=DEFAULT_PARALLEL, - metavar="VAL", - ) - parser.add_argument( - "-o", - "--parallel-live", - action="https://github.com/store_true", - dest="parallel_live", - help="connect to stdout while running environments", - ) - - -def add_parallel_config(parser): - parser.add_testenv_attribute( - "depends", - type="env-list", - help="tox environments that this environment depends on (must be run after those)", - ) - - parser.add_testenv_attribute( - "parallel_show_output", - type="bool", - default=False, - help="if set to True the content of the output will always be shown " - "when running in parallel mode", - ) diff --git a/src/tox/config/reporter.py b/src/tox/config/reporter.py deleted file mode 100644 index 9ada946d1..000000000 --- a/src/tox/config/reporter.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import absolute_import, unicode_literals - - -def add_verbosity_commands(parser): - parser.add_argument( - "-v", - "--verbose", - action="https://github.com/count", - dest="verbose_level", - default=0, - help="increase verbosity of reporting output." - "-vv mode turns off output redirection for package installation, " - "above level two verbosity flags are passed through to pip (with two less level)", - ) - parser.add_argument( - "-q", - "--quiet", - action="https://github.com/count", - dest="quiet_level", - default=0, - help="progressively silence reporting output.", - ) diff --git a/src/tox/config/set_env.py b/src/tox/config/set_env.py new file mode 100644 index 000000000..fe5add489 --- /dev/null +++ b/src/tox/config/set_env.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Callable, Iterator, Mapping + +from tox.config.loader.api import ConfigLoadArgs +from tox.tox_env.errors import Fail + +Replacer = Callable[[str, ConfigLoadArgs], str] + + +class SetEnv: + def __init__(self, raw: str, name: str, env_name: str | None, root: Path) -> None: + self.changed = False + self._materialized: dict[str, str] = {} # env vars we already loaded + self._raw: dict[str, str] = {} # could still need replacement + self._needs_replacement: list[str] = [] # env vars that need replacement + self._env_files: list[str] = [] + self._replacer: Replacer = lambda s, c: s # noqa: U100 + self._name, self._env_name, self._root = name, env_name, root + from .loader.ini.replace import find_replace_part + + for line in raw.splitlines(): + if line.strip(): + if line.startswith("file|"): + self._env_files.append(line[len("file|") :]) + else: + try: + key, value = self._extract_key_value(line) + if "{" in key: + raise ValueError(f"invalid line {line!r} in set_env") + except ValueError: + _, __, match = find_replace_part(line, 0) + if match: + self._needs_replacement.append(line) + else: + raise + else: + self._raw[key] = value + + def use_replacer(self, value: Replacer, args: ConfigLoadArgs) -> None: + self._replacer = value + for filename in self._env_files: + self._read_env_file(filename, args) + + def _read_env_file(self, filename: str, args: ConfigLoadArgs) -> None: + # Our rules in the documentation, some upstream environment file rules (we follow mostly the docker one): + # - https://www.npmjs.com/package/dotenv#rules + # - https://docs.docker.com/compose/env-file/ + env_file = Path(self._replacer(filename, args.copy())) # apply any replace options + env_file = env_file if env_file.is_absolute() else self._root / env_file + if not env_file.exists(): + raise Fail(f"{env_file} does not exist for set_env") + for env_line in env_file.read_text().splitlines(): + env_line = env_line.strip() + if not env_line or env_line.startswith("#"): + continue + key, value = self._extract_key_value(env_line) + self._raw[key] = value + + @staticmethod + def _extract_key_value(line: str) -> tuple[str, str]: + key, sep, value = line.partition("=") + if sep: + return key.strip(), value.strip() + else: + raise ValueError(f"invalid line {line!r} in set_env") + + def load(self, item: str, args: ConfigLoadArgs | None = None) -> str: + if item in self._materialized: + return self._materialized[item] + raw = self._raw[item] + args = ConfigLoadArgs([], self._name, self._env_name) if args is None else args + args.chain.append(f"env:{item}") + result = self._replacer(raw, args) # apply any replace options + result = result.replace(r"\#", "#") # unroll escaped comment with replacement + self._materialized[item] = result + self._raw.pop(item, None) # if the replace requires the env we may be called again, so allow pop to fail + return result + + def __contains__(self, item: object) -> bool: + return isinstance(item, str) and item in self.__iter__() + + def __iter__(self) -> Iterator[str]: + # start with the materialized ones, maybe we don't need to materialize the raw ones + yield from self._materialized.keys() + yield from list(self._raw.keys()) # iterating over this may trigger materialization and change the dict + while self._needs_replacement: + line = self._needs_replacement.pop(0) + expanded_line = self._replacer(line, ConfigLoadArgs([], self._name, self._env_name)) + sub_raw = dict(self._extract_key_value(sub_line) for sub_line in expanded_line.splitlines() if sub_line) + self._raw.update(sub_raw) + yield from sub_raw.keys() + + def update(self, param: Mapping[str, str], *, override: bool = True) -> None: + for key, value in param.items(): + # do not override something already set explicitly + if override or (key not in self._raw and key not in self._materialized): + self._materialized[key] = value + self.changed = True + + +__all__ = ("SetEnv",) diff --git a/src/tox/config/sets.py b/src/tox/config/sets.py new file mode 100644 index 000000000..0315369a7 --- /dev/null +++ b/src/tox/config/sets.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Iterator, Mapping, Sequence, TypeVar, cast + +from .loader.convert import Factory +from .loader.section import Section +from .of_type import ConfigConstantDefinition, ConfigDefinition, ConfigDynamicDefinition, ConfigLoadArgs +from .set_env import SetEnv +from .types import EnvList + +if TYPE_CHECKING: + from tox.config.loader.api import Loader + from tox.config.main import Config + +V = TypeVar("V") + + +class ConfigSet(ABC): + """A set of configuration that belong together (such as a tox environment settings, core tox settings)""" + + def __init__(self, conf: Config, section: Section, env_name: str | None): + self._section = section + self._env_name = env_name + self._conf = conf + self.loaders: list[Loader[Any]] = [] #: active configuration loaders, can alter to change configuration values + self._defined: dict[str, ConfigDefinition[Any]] = {} + self._keys: dict[str, None] = {} + self._alias: dict[str, str] = {} + self._final = False + self.register_config() + + @abstractmethod + def register_config(self) -> None: + raise NotImplementedError + + def mark_finalized(self) -> None: + self._final = True + + def add_config( + self, + keys: str | Sequence[str], + of_type: type[V], + default: Callable[[Config, str | None], V] | V, + desc: str, + post_process: Callable[[V], V] | None = None, + factory: Factory[Any] = None, + ) -> ConfigDynamicDefinition[V]: + """ + Add configuration value. + + :param keys: the keys under what to register the config (first is primary key) + :param of_type: the type of the config value + :param default: the default value of the config value + :param desc: a help message describing the configuration + :param post_process: a callback to post-process the configuration value after it has been loaded + :param factory: factory method used to build contained objects (if ``of_type`` is a container type it + should perform the contained item creation, otherwise creates objects that match the type) + :return: the new dynamic config definition + """ + if self._final: + raise RuntimeError("config set has been marked final and cannot be extended") + keys_ = self._make_keys(keys) + definition = ConfigDynamicDefinition(keys_, desc, of_type, default, post_process, factory) + result = self._add_conf(keys_, definition) + return cast(ConfigDynamicDefinition[V], result) + + def add_constant(self, keys: str | Sequence[str], desc: str, value: V) -> ConfigConstantDefinition[V]: + """ + Add a constant value. + + :param keys: the keys under what to register the config (first is primary key) + :param desc: a help message describing the configuration + :param value: the config value to use + :return: the new constant config value + """ + if self._final: + raise RuntimeError("config set has been marked final and cannot be extended") + keys_ = self._make_keys(keys) + definition = ConfigConstantDefinition(keys_, desc, value) + result = self._add_conf(keys_, definition) + return cast(ConfigConstantDefinition[V], result) + + @staticmethod + def _make_keys(keys: str | Sequence[str]) -> Sequence[str]: + return (keys,) if isinstance(keys, str) else keys + + def _add_conf(self, keys: Sequence[str], definition: ConfigDefinition[V]) -> ConfigDefinition[V]: + key = keys[0] + if key in self._defined: + self._on_duplicate_conf(key, definition) + else: + self._keys[key] = None + for item in keys: + self._alias[item] = key + for key in keys: + self._defined[key] = definition + return definition + + def _on_duplicate_conf(self, key: str, definition: ConfigDefinition[V]) -> None: + earlier = self._defined[key] + if definition != earlier: # pragma: no branch + raise ValueError(f"config {key} already defined") + + def __getitem__(self, item: str) -> Any: + """ + Get the config value for a given key (will materialize in case of dynamic config). + + :param item: the config key + :return: the configuration value + """ + return self.load(item) + + def load(self, item: str, chain: list[str] | None = None) -> Any: + """ + Get the config value for a given key (will materialize in case of dynamic config). + + :param item: the config key + :param chain: a chain of configuration keys already loaded for this load operation (used to detect circles) + :return: the configuration value + """ + config_definition = self._defined[item] + return config_definition.__call__(self._conf, self.loaders, ConfigLoadArgs(chain, self.name, self.env_name)) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(loaders={self.loaders!r})" + + def __iter__(self) -> Iterator[str]: + """:return: iterate through the defined config keys (primary keys used)""" + return iter(self._keys.keys()) + + def __contains__(self, item: str) -> bool: + """ + Check if a configuration key is within the config set. + + :param item: the configuration value + :return: a boolean indicating the truthiness of the statement + """ + return item in self._alias + + def unused(self) -> list[str]: + """:return: Return a list of keys present in the config source but not used""" + found: set[str] = set() + # keys within loaders (only if the loader is not a parent too) + parents = {id(i.parent) for i in self.loaders if i.parent is not None} + for loader in self.loaders: + if id(loader) not in parents: + found.update(loader.found_keys()) + found -= self._defined.keys() + return sorted(found) + + def primary_key(self, key: str) -> str: + """ + Get the primary key for a config key. + + :param key: the config key + :return: the key that's considered the primary for the input key + """ + return self._alias[key] + + @property + def name(self) -> str: + return self._section.name + + @property + def env_name(self) -> str | None: + return self._env_name + + +class CoreConfigSet(ConfigSet): + """Configuration set for the core tox config""" + + def __init__(self, conf: Config, section: Section, root: Path, src_path: Path) -> None: + self._root = root + self._src_path = src_path + super().__init__(conf, section=section, env_name=None) + desc = "define environments to automatically run" + self.add_config(keys=["env_list", "envlist"], of_type=EnvList, default=EnvList([]), desc=desc) + + def register_config(self) -> None: + self.add_constant(keys=["config_file_path"], desc="path to the configuration file", value=self._src_path) + self.add_config( + keys=["tox_root", "toxinidir"], + of_type=Path, + default=self._root, + desc="the root directory (where the configuration file is found)", + ) + + def work_dir_builder(conf: Config, env_name: str | None) -> Path: # noqa: U100 + return (conf.work_dir if conf.work_dir is not None else cast(Path, self["tox_root"])) / ".tox" + + self.add_config( + keys=["work_dir", "toxworkdir"], + of_type=Path, + default=work_dir_builder, + desc="working directory", + ) + self.add_config( + keys=["temp_dir"], + of_type=Path, + default=lambda conf, _: cast(Path, self["work_dir"]) / ".tmp", # noqa: U100, U101 + desc="a folder for temporary files (is not cleaned at start)", + ) + + def _on_duplicate_conf(self, key: str, definition: ConfigDefinition[V]) -> None: # noqa: U100 + pass # core definitions may be defined multiple times as long as all their options match, first defined wins + + +class EnvConfigSet(ConfigSet): + """Configuration set for a tox environment""" + + def __init__(self, conf: Config, section: Section, env_name: str) -> None: + super().__init__(conf, section, env_name) + self.default_set_env_loader: Callable[[], Mapping[str, str]] = lambda: {} + + def register_config(self) -> None: + def set_env_post_process(values: SetEnv) -> SetEnv: + values.update(self.default_set_env_loader(), override=False) + return values + + def set_env_factory(raw: object) -> SetEnv: + if not isinstance(raw, str): + raise TypeError(raw) + return SetEnv(raw, self.name, self.env_name, root) + + root = self._conf.core["tox_root"] + self.add_config( + keys=["set_env", "setenv"], + of_type=SetEnv, + factory=set_env_factory, + default=SetEnv("", self.name, self.env_name, root), + desc="environment variables to set when running commands in the tox environment", + post_process=set_env_post_process, + ) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name={self._env_name!r}, loaders={self.loaders!r})" + + +__all__ = ( + "ConfigSet", + "CoreConfigSet", + "EnvConfigSet", +) diff --git a/src/tox/config/source/__init__.py b/src/tox/config/source/__init__.py new file mode 100644 index 000000000..78353d685 --- /dev/null +++ b/src/tox/config/source/__init__.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from .api import Source +from .discover import discover_source + +__all__ = ( + "Source", + "discover_source", +) diff --git a/src/tox/config/source/api.py b/src/tox/config/source/api.py new file mode 100644 index 000000000..3b95d5043 --- /dev/null +++ b/src/tox/config/source/api.py @@ -0,0 +1,114 @@ +"""Sources.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Iterator, List + +from tox.config.loader.api import Loader, OverrideMap + +from ..loader.section import Section +from ..sets import ConfigSet, CoreConfigSet + + +class Source(ABC): + """ + Source is able to return a configuration value (for either the core or per environment source). + """ + + FILENAME = "" + + def __init__(self, path: Path) -> None: + self.path: Path = path #: the path to the configuration source + self._section_to_loaders: dict[str, list[Loader[Any]]] = {} + + def get_loaders( + self, + section: Section, + base: list[str] | None, + override_map: OverrideMap, + conf: ConfigSet, + ) -> Iterator[Loader[Any]]: + """ + Return a loader that loads settings from a given section name. + + :param section: the section to load + :param base: base sections to fallback to + :param override_map: a list of overrides to apply + :param conf: the config set to use + :returns: the loaders to use + """ + section = self.transform_section(section) + key = section.key + if key in self._section_to_loaders: + yield from self._section_to_loaders[key] + return + loaders: list[Loader[Any]] = [] + self._section_to_loaders[key] = loaders + loader: Loader[Any] | None = self.get_loader(section, override_map) + if loader is not None: + loaders.append(loader) + yield loader + + if base is not None: + conf.add_config( + keys="base", + of_type=List[str], + desc="inherit missing keys from these sections", + default=base, + ) + for base_section in self.get_base_sections(conf["base"], section): + child = loader + loader = self.get_loader(base_section, override_map) + if loader is None: + loader = child + continue + if child is not None and loader is not None: + child.parent = loader + yield loader + loaders.append(loader) + + @abstractmethod + def transform_section(self, section: Section) -> Section: + raise NotImplementedError + + @abstractmethod + def get_loader(self, section: Section, override_map: OverrideMap) -> Loader[Any] | None: + raise NotImplementedError + + @abstractmethod + def get_base_sections(self, base: list[str], in_section: Section) -> Iterator[Section]: + raise NotImplementedError + + @abstractmethod + def sections(self) -> Iterator[Section]: + """ + Return a loader that loads the core configuration values. + + :returns: the core loader from this source + """ + raise NotImplementedError + + @abstractmethod + def envs(self, core_conf: CoreConfigSet) -> Iterator[str]: + """ + :param core_conf: the core configuration set + :returns: a list of environments defined within this source + """ + raise NotImplementedError + + @abstractmethod + def get_tox_env_section(self, item: str) -> tuple[Section, list[str]]: + """:returns: the section for a tox environment""" + raise NotImplementedError + + @abstractmethod + def get_core_section(self) -> Section: + """:returns: the core section""" + raise NotImplementedError + + +__all__ = [ + "Section", + "Source", +] diff --git a/src/tox/config/source/discover.py b/src/tox/config/source/discover.py new file mode 100644 index 000000000..d9e8e507d --- /dev/null +++ b/src/tox/config/source/discover.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import logging +from itertools import chain +from pathlib import Path + +from tox.report import HandledError + +from .api import Source +from .legacy_toml import LegacyToml +from .setup_cfg import SetupCfg +from .tox_ini import ToxIni + +SOURCE_TYPES: tuple[type[Source], ...] = (ToxIni, SetupCfg, LegacyToml) + + +def discover_source(config_file: Path | None, root_dir: Path | None) -> Source: + """ + Discover a source for configuration. + + :param config_file: the file storing the source + :param root_dir: the root directory as set by the user (None means not set) + :return: the source of the config + """ + if config_file is None: + src = _locate_source() + if src is None: + src = _create_default_source(root_dir) + elif config_file.is_dir(): + src = None + for src_type in SOURCE_TYPES: + candidate: Path = config_file / src_type.FILENAME + try: + src = src_type(candidate) + break + except ValueError: + continue + if src is None: + raise HandledError(f"could not find any config file in {config_file}") + else: + src = _load_exact_source(config_file) + return src + + +def _locate_source() -> Source | None: + folder = Path.cwd() + for base in chain([folder], folder.parents): + for src_type in SOURCE_TYPES: + candidate: Path = base / src_type.FILENAME + try: + return src_type(candidate) + except ValueError: + pass + return None + + +def _load_exact_source(config_file: Path) -> Source: + # if the filename matches to the letter some config file name do not fallback to other source types + exact_match = next((s for s in SOURCE_TYPES if config_file.name == s.FILENAME), None) # pragma: no cover + for src_type in (exact_match,) if exact_match is not None else SOURCE_TYPES: # pragma: no branch + try: + return src_type(config_file) + except ValueError: + pass + raise HandledError(f"could not recognize config file {config_file}") + + +def _create_default_source(root_dir: Path | None) -> Source: + if root_dir is None: # if set use that + empty = Path.cwd() + for base in chain([empty], empty.parents): + if (base / "pyproject.toml").exists(): + empty = base + break + else: # if not set use where we find pyproject.toml in the tree or cwd + empty = root_dir + logging.warning(f"No {' or '.join(i.FILENAME for i in SOURCE_TYPES)} found, assuming empty tox.ini at {empty}") + src = ToxIni(empty / "tox.ini", content="") + return src + + +__all__ = ("discover_source",) diff --git a/src/tox/config/source/ini.py b/src/tox/config/source/ini.py new file mode 100644 index 000000000..d28ae083f --- /dev/null +++ b/src/tox/config/source/ini.py @@ -0,0 +1,107 @@ +"""Load """ +from __future__ import annotations + +from collections import defaultdict +from configparser import ConfigParser +from itertools import chain +from pathlib import Path +from typing import DefaultDict, Iterable, Iterator + +from tox.config.loader.ini.factor import find_envs + +from ..loader.api import OverrideMap +from ..loader.ini import IniLoader +from ..loader.section import Section +from ..sets import ConfigSet +from .api import Source +from .ini_section import CORE, TEST_ENV_PREFIX, IniSection + + +class IniSource(Source): + """Configuration sourced from a ini file (such as tox.ini)""" + + CORE_SECTION = CORE + + def __init__(self, path: Path, content: str | None = None) -> None: + super().__init__(path) + self._parser = ConfigParser(interpolation=None) + if content is None: + if not path.exists(): + raise ValueError + content = path.read_text() + self._parser.read_string(content, str(path)) + self._section_mapping: DefaultDict[str, list[str]] = defaultdict(list) + + def transform_section(self, section: Section) -> Section: + return IniSection(section.prefix, section.name) + + def sections(self) -> Iterator[IniSection]: + for section in self._parser.sections(): + yield IniSection.from_key(section) + + def get_loader(self, section: Section, override_map: OverrideMap) -> IniLoader | None: + sections = self._section_mapping.get(section.name) + key = sections[0] if sections else section.key + if self._parser.has_section(key): + return IniLoader( + section=section, + parser=self._parser, + overrides=override_map.get(section.key, []), + core_section=self.CORE_SECTION, + section_key=key, + ) + return None + + def get_core_section(self) -> Section: + return self.CORE_SECTION + + def get_base_sections(self, base: list[str], in_section: Section) -> Iterator[Section]: + for a_base in base: + section = IniSection.from_key(a_base) + yield section # the base specifier is explicit + if in_section.prefix is not None: # no prefix specified, so this could imply our own prefix + yield IniSection(in_section.prefix, a_base) + + def get_tox_env_section(self, item: str) -> tuple[Section, list[str]]: + return IniSection.test_env(item), [TEST_ENV_PREFIX] + + def envs(self, core_config: ConfigSet) -> Iterator[str]: + seen = set() + for name in self._discover_tox_envs(core_config): + if name not in seen: + seen.add(name) + yield name + + def _discover_tox_envs(self, core_config: ConfigSet) -> Iterator[str]: + def register_factors(envs: Iterable[str]) -> None: + known_factors.update(chain.from_iterable(e.split("-") for e in envs)) + + explicit = list(core_config["env_list"]) + yield from explicit + known_factors: set[str] = set() + register_factors(explicit) + + # discover all additional defined environments, including generative section headers + for section in self.sections(): + register_factors(section.names) + for name in section.names: + self._section_mapping[name].append(section.key) + if section.is_test_env: + yield name + # add all conditional markers that are not part of the explicitly defined sections + for section in self.sections(): + yield from self._discover_from_section(section, known_factors) + + def _discover_from_section(self, section: IniSection, known_factors: set[str]) -> Iterator[str]: + for value in self._parser[section.key].values(): + for env in find_envs(value): + if set(env.split("-")) - known_factors: + yield env + + def __repr__(self) -> str: + return f"{type(self).__name__}(path={self.path})" + + +__all__ = [ + "IniSource", +] diff --git a/src/tox/config/source/ini_section.py b/src/tox/config/source/ini_section.py new file mode 100644 index 000000000..33a718632 --- /dev/null +++ b/src/tox/config/source/ini_section.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from tox.config.loader.ini.factor import extend_factors +from tox.config.loader.section import Section + + +class IniSection(Section): + @classmethod + def test_env(cls, name: str) -> IniSection: + return cls(TEST_ENV_PREFIX, name) + + @property + def is_test_env(self) -> bool: + return self.prefix == TEST_ENV_PREFIX + + @property + def names(self) -> list[str]: + elements = list(extend_factors(self.name)) + return elements + + +TEST_ENV_PREFIX = "testenv" +CORE = IniSection(None, "tox") + +__all__ = [ + "IniSection", + "CORE", + "TEST_ENV_PREFIX", +] diff --git a/src/tox/config/source/legacy_toml.py b/src/tox/config/source/legacy_toml.py new file mode 100644 index 000000000..32ecab4a9 --- /dev/null +++ b/src/tox/config/source/legacy_toml.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +if sys.version_info >= (3, 11): # pragma: no cover (py311+) + import tomllib +else: # pragma: no cover (py311+) + import tomli as tomllib + + +from .ini import IniSource + + +class LegacyToml(IniSource): + FILENAME = "pyproject.toml" + + def __init__(self, path: Path): + if path.name != self.FILENAME or not path.exists(): + raise ValueError + with path.open("rb") as file_handler: + toml_content = tomllib.load(file_handler) + try: + content = toml_content["tool"]["tox"]["legacy_tox_ini"] + except KeyError: + raise ValueError + super().__init__(path, content=content) + + +__all__ = ("LegacyToml",) diff --git a/src/tox/config/source/setup_cfg.py b/src/tox/config/source/setup_cfg.py new file mode 100644 index 000000000..e7a25c00c --- /dev/null +++ b/src/tox/config/source/setup_cfg.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pathlib import Path + +from .ini import IniSource +from .ini_section import IniSection + + +class SetupCfg(IniSource): + """Configuration sourced from a tox.ini file""" + + CORE_SECTION = IniSection("tox", "tox") + FILENAME = "setup.cfg" + + def __init__(self, path: Path): + super().__init__(path) + if not self._parser.has_section(self.CORE_SECTION.key): + raise ValueError + + +__all__ = ("SetupCfg",) diff --git a/src/tox/config/source/tox_ini.py b/src/tox/config/source/tox_ini.py new file mode 100644 index 000000000..1d1309825 --- /dev/null +++ b/src/tox/config/source/tox_ini.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from .ini import IniSource + + +class ToxIni(IniSource): + """Configuration sourced from a tox.ini file""" + + FILENAME = "tox.ini" + + +__all__ = ("ToxIni",) diff --git a/src/tox/config/types.py b/src/tox/config/types.py new file mode 100644 index 000000000..36dd4378d --- /dev/null +++ b/src/tox/config/types.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from collections import OrderedDict +from typing import Any, Iterator, Sequence + +from tox.execute.request import shell_cmd + + +class Command: + """A command to execute.""" + + def __init__(self, args: list[str]) -> None: + """ + Create a new command to execute + + :param args: the command line arguments (first value can be ``-`` to indicate ignore the exit code) + """ + self.ignore_exit_code: bool = args[0] == "-" #: a flag indicating if the exit code should be ignored + self.args: list[str] = args[1:] if self.ignore_exit_code else args #: the command line arguments + + def __repr__(self) -> str: + return f"{type(self).__name__}(args={(['-'] if self.ignore_exit_code else [])+ self.args!r})" + + def __eq__(self, other: Any) -> bool: + return type(self) == type(other) and (self.args, self.ignore_exit_code) == (other.args, other.ignore_exit_code) + + def __ne__(self, other: Any) -> bool: + return not (self == other) + + @property + def shell(self) -> str: + """:return: a shell representation of the command (platform dependent)""" + return shell_cmd(self.args) + + +class EnvList: + """A tox environment list""" + + def __init__(self, envs: Sequence[str]) -> None: + """ + Crate a new tox environment list. + + :param envs: the list of tox environments + """ + self.envs = list(OrderedDict((e, None) for e in envs).keys()) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.envs!r})" + + def __eq__(self, other: Any) -> bool: + return type(self) == type(other) and self.envs == other.envs + + def __ne__(self, other: Any) -> bool: + return not (self == other) + + def __iter__(self) -> Iterator[str]: + """:return: iterator that goes through the defined env-list""" + return iter(self.envs) + + +__all__ = ( + "Command", + "EnvList", +) diff --git a/src/tox/constants.py b/src/tox/constants.py deleted file mode 100644 index c31f2602f..000000000 --- a/src/tox/constants.py +++ /dev/null @@ -1,65 +0,0 @@ -"""All non private names (no leading underscore) here are part of the tox API. - -They live in the tox namespace and can be accessed as tox.[NAMESPACE.]NAME -""" -import os -import re -import sys - -_THIS_FILE = os.path.realpath(os.path.abspath(__file__)) - - -class PYTHON: - PY_FACTORS_RE = re.compile("^(?!py$)(py|pypy|jython)([2-9][0-9]?[0-9]?)?$") - CURRENT_RELEASE_ENV = "py37" - """Should hold currently released py -> for easy updating""" - QUICKSTART_PY_ENVS = ["py27", "py35", "py36", CURRENT_RELEASE_ENV, "pypy", "jython"] - """For choices in tox-quickstart""" - - -class INFO: - DEFAULT_CONFIG_NAME = "tox.ini" - CONFIG_CANDIDATES = ("pyproject.toml", "tox.ini", "setup.cfg") - IS_WIN = sys.platform == "win32" - IS_PYPY = hasattr(sys, "pypy_version_info") - - -class PIP: - SHORT_OPTIONS = ["c", "e", "r", "b", "t", "d"] - LONG_OPTIONS = [ - "build", - "cache-dir", - "client-cert", - "constraint", - "download", - "editable", - "exists-action", - "extra-index-url", - "global-option", - "find-links", - "index-url", - "install-options", - "prefix", - "proxy", - "no-binary", - "only-binary", - "requirement", - "retries", - "root", - "src", - "target", - "timeout", - "trusted-host", - "upgrade-strategy", - ] - INSTALL_SHORT_OPTIONS_ARGUMENT = ["-{}".format(option) for option in SHORT_OPTIONS] - INSTALL_LONG_OPTIONS_ARGUMENT = ["--{}".format(option) for option in LONG_OPTIONS] - - -_HELP_DIR = os.path.join(os.path.dirname(_THIS_FILE), "helper") -VERSION_QUERY_SCRIPT = os.path.join(_HELP_DIR, "get_version.py") -SITE_PACKAGE_QUERY_SCRIPT = os.path.join(_HELP_DIR, "get_site_package_dir.py") -BUILD_REQUIRE_SCRIPT = os.path.join(_HELP_DIR, "build_requires.py") -BUILD_ISOLATED = os.path.join(_HELP_DIR, "build_isolated.py") -PARALLEL_RESULT_JSON_PREFIX = ".tox-result" -PARALLEL_RESULT_JSON_SUFFIX = ".json" diff --git a/src/tox/exception.py b/src/tox/exception.py deleted file mode 100644 index c82c8d278..000000000 --- a/src/tox/exception.py +++ /dev/null @@ -1,108 +0,0 @@ -import os -import signal -import sys - -if sys.version_info >= (3, 3): - from shlex import quote as shlex_quote -else: - from pipes import quote as shlex_quote - - -def exit_code_str(exception_name, command, exit_code): - """String representation for an InvocationError, with exit code - - NOTE: this might also be used by plugin tests (tox-venv at the time of writing), - so some coordination is needed if this is ever moved or a different solution for this hack - is found. - - NOTE: this is a separate function because pytest-mock `spy` does not work on Exceptions - We can use neither a class method nor a static because of https://bugs.python.org/issue23078. - Even a normal method failed with "TypeError: descriptor '__getattribute__' requires a - 'BaseException' object but received a 'type'". - """ - str_ = "{} for command {}".format(exception_name, command) - if exit_code is not None: - if exit_code < 0 or (os.name == "posix" and exit_code > 128): - signals = { - number: name for name, number in vars(signal).items() if name.startswith("SIG") - } - if exit_code < 0: - # Signal reported via subprocess.Popen. - sig_name = signals.get(-exit_code) - str_ += " (exited with code {:d} ({}))".format(exit_code, sig_name) - else: - str_ += " (exited with code {:d})".format(exit_code) - number = exit_code - 128 - name = signals.get(number) - if name: - str_ += ( - ")\nNote: this might indicate a fatal error signal " - "({:d} - 128 = {:d}: {})".format(exit_code, number, name) - ) - str_ += " (exited with code {:d})".format(exit_code) - return str_ - - -class Error(Exception): - def __str__(self): - return "{}: {}".format(self.__class__.__name__, self.args[0]) - - -class MissingSubstitution(Error): - FLAG = "TOX_MISSING_SUBSTITUTION" - """placeholder for debugging configurations""" - - def __init__(self, name): - self.name = name - super(Error, self).__init__(name) - - -class ConfigError(Error): - """Error in tox configuration.""" - - -class SubstitutionStackError(ConfigError, ValueError): - """Error in tox configuration recursive substitution.""" - - -class UnsupportedInterpreter(Error): - """Signals an unsupported Interpreter.""" - - -class InterpreterNotFound(Error): - """Signals that an interpreter could not be found.""" - - -class InvocationError(Error): - """An error while invoking a script.""" - - def __init__(self, command, exit_code=None, out=None): - super(Error, self).__init__(command, exit_code) - self.command = command - self.exit_code = exit_code - self.out = out - - def __str__(self): - return exit_code_str(self.__class__.__name__, self.command, self.exit_code) - - -class MissingDirectory(Error): - """A directory did not exist.""" - - -class MissingDependency(Error): - """A dependency could not be found or determined.""" - - -class MissingRequirement(Error): - """A requirement defined in :config:`require` is not met.""" - - def __init__(self, config): - self.config = config - - def __str__(self): - return " ".join(shlex_quote(i) for i in self.config.requires) - - -class BadRequirement(Error): - """A requirement defined in :config:`require` cannot be parsed.""" diff --git a/src/tox/execute/__init__.py b/src/tox/execute/__init__.py new file mode 100644 index 000000000..fd880b533 --- /dev/null +++ b/src/tox/execute/__init__.py @@ -0,0 +1,12 @@ +""" +Package that handles execution of commands within tox environments. +""" +from __future__ import annotations + +from .api import Outcome +from .request import ExecuteRequest + +__all__ = ( + "ExecuteRequest", + "Outcome", +) diff --git a/src/tox/execute/api.py b/src/tox/execute/api.py new file mode 100644 index 000000000..4ec95f70e --- /dev/null +++ b/src/tox/execute/api.py @@ -0,0 +1,303 @@ +""" +Abstract base API for executing commands within tox environments. +""" +from __future__ import annotations + +import logging +import sys +import time +from abc import ABC, abstractmethod +from contextlib import contextmanager +from types import TracebackType +from typing import TYPE_CHECKING, Any, Callable, Iterator, NoReturn, Sequence, cast + +from colorama import Fore + +from tox.report import OutErr + +from .request import ExecuteRequest, StdinSource +from .stream import SyncWrite + +if TYPE_CHECKING: + from tox.tox_env.api import ToxEnv + +ContentHandler = Callable[[bytes], None] +Executor = Callable[[ExecuteRequest, ContentHandler, ContentHandler], int] +LOGGER = logging.getLogger(__name__) + + +class ExecuteOptions: + def __init__(self, env: ToxEnv) -> None: + self._env = env + + @classmethod + def register_conf(cls, env: ToxEnv) -> None: + env.conf.add_config( + keys=["suicide_timeout"], + desc="timeout to allow process to exit before sending SIGINT", + of_type=float, + default=0.0, + ) + env.conf.add_config( + keys=["interrupt_timeout"], + desc="timeout before sending SIGTERM after SIGINT", + of_type=float, + default=0.3, + ) + env.conf.add_config( + keys=["terminate_timeout"], + desc="timeout before sending SIGKILL after SIGTERM", + of_type=float, + default=0.2, + ) + + @property + def suicide_timeout(self) -> float: + return cast(float, self._env.conf["suicide_timeout"]) + + @property + def interrupt_timeout(self) -> float: + return cast(float, self._env.conf["interrupt_timeout"]) + + @property + def terminate_timeout(self) -> float: + return cast(float, self._env.conf["terminate_timeout"]) + + +class ExecuteStatus(ABC): + def __init__(self, options: ExecuteOptions, out: SyncWrite, err: SyncWrite) -> None: + self.outcome: Outcome | None = None + self.options = options + self._out = out + self._err = err + + @property + @abstractmethod + def exit_code(self) -> int | None: + raise NotImplementedError + + @abstractmethod + def wait(self, timeout: float | None = None) -> int | None: + raise NotImplementedError + + @abstractmethod + def write_stdin(self, content: str) -> None: + raise NotImplementedError + + @abstractmethod + def interrupt(self) -> None: + raise NotImplementedError + + def set_out_err(self, out: SyncWrite, err: SyncWrite) -> tuple[SyncWrite, SyncWrite]: + res = self._out, self._err + self._out, self._err = out, err + return res + + @property + def out(self) -> bytearray: + return self._out.content + + @property + def err(self) -> bytearray: + return self._err.content + + @property + def metadata(self) -> dict[str, Any]: + return {} + + +class Execute(ABC): + """Abstract API for execution of a tox environment""" + + _option_class: type[ExecuteOptions] = ExecuteOptions + + def __init__(self, colored: bool) -> None: + self._colored = colored + + @contextmanager + def call(self, request: ExecuteRequest, show: bool, out_err: OutErr, env: ToxEnv) -> Iterator[ExecuteStatus]: + start = time.monotonic() + try: + # collector is what forwards the content from the file streams to the standard streams + out, err = out_err[0].buffer, out_err[1].buffer + out_sync = SyncWrite(out.name, out if show else None) + err_sync = SyncWrite(err.name, err if show else None, Fore.RED if self._colored else None) + with out_sync, err_sync: + instance = self.build_instance(request, self._option_class(env), out_sync, err_sync) + with instance as status: + yield status + exit_code = status.exit_code + finally: + end = time.monotonic() + status.outcome = Outcome( + request, + show, + exit_code, + out_sync.text, + err_sync.text, + start, + end, + instance.cmd, + status.metadata, + ) + + @abstractmethod + def build_instance( + self, + request: ExecuteRequest, + options: ExecuteOptions, + out: SyncWrite, + err: SyncWrite, + ) -> ExecuteInstance: + raise NotImplementedError + + @classmethod + def register_conf(cls, env: ToxEnv) -> None: + cls._option_class.register_conf(env) + + +class ExecuteInstance(ABC): + """An instance of a command execution""" + + def __init__(self, request: ExecuteRequest, options: ExecuteOptions, out: SyncWrite, err: SyncWrite) -> None: + self.request = request + self.options = options + self._out = out + self._err = err + + @property + def out_handler(self) -> ContentHandler: + return self._out.handler + + @property + def err_handler(self) -> ContentHandler: + return self._err.handler + + @abstractmethod + def __enter__(self) -> ExecuteStatus: + raise NotImplementedError + + @abstractmethod + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + raise NotImplementedError + + @property + @abstractmethod + def cmd(self) -> Sequence[str]: + raise NotImplementedError + + +class Outcome: + """Result of a command execution""" + + OK = 0 + + def __init__( + self, + request: ExecuteRequest, + show_on_standard: bool, + exit_code: int | None, + out: str, + err: str, + start: float, + end: float, + cmd: Sequence[str], + metadata: dict[str, Any], + ): + """ + Create a new execution outcome. + + :param request: the execution request + :param show_on_standard: a flag indicating if the execution was shown on stdout/stderr + :param exit_code: the exit code for the execution + :param out: the standard output of the execution + :param err: the standard error of the execution + :param start: a timer sample for the start of the execution + :param end: a timer sample for the end of the execution + :param cmd: the command as executed + :param metadata: additional metadata attached to the execution + """ + self.request = request #: the execution request + self.show_on_standard = show_on_standard #: a flag indicating if the execution was shown on stdout/stderr + self.exit_code = exit_code #: the exit code for the execution + self.out = out #: the standard output of the execution + self.err = err #: the standard error of the execution + self.start = start #: a timer sample for the start of the execution + self.end = end #: a timer sample for the end of the execution + self.cmd = cmd #: the command as executed + self.metadata = metadata #: additional metadata attached to the execution + + def __bool__(self) -> bool: + return self.exit_code == self.OK + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}: exit {self.exit_code} in {self.elapsed:.2f} seconds" + f" for {self.request.shell_cmd}" + ) + + def assert_success(self) -> None: + """Assert that the execution succeeded""" + if self.exit_code is not None and self.exit_code != self.OK: + self._assert_fail() + self.log_run_done(logging.INFO) + + def _assert_fail(self) -> NoReturn: + if self.show_on_standard is False: + if self.out: + sys.stdout.write(self.out) + if not self.out.endswith("\n"): + sys.stdout.write("\n") + if self.err: + sys.stderr.write(Fore.RED) + sys.stderr.write(self.err) + sys.stderr.write(Fore.RESET) + if not self.err.endswith("\n"): + sys.stderr.write("\n") + self.log_run_done(logging.CRITICAL) + raise SystemExit(self.exit_code) + + def log_run_done(self, lvl: int) -> None: + """ + Log that the run was done. + + :param lvl: the level on what to log as interpreted by :func:`logging.log` + """ + req = self.request + metadata = "" + if self.metadata: + metadata = f" {', '.join(f'{k}={v}' for k, v in self.metadata.items())}" + LOGGER.log( + lvl, + "exit %s (%.2f seconds) %s> %s%s", + self.exit_code, + self.elapsed, + req.cwd, + req.shell_cmd, + metadata, + ) + + @property + def elapsed(self) -> float: + """:return: time the execution took in seconds""" + return self.end - self.start + + def out_err(self) -> tuple[str, str]: + """:return: a tuple of the standard output and standard error""" + return self.out, self.err + + +__all__ = ( + "ContentHandler", + "Outcome", + "Execute", + "ExecuteInstance", + "ExecuteOptions", + "ExecuteStatus", + "StdinSource", +) diff --git a/src/tox/execute/local_sub_process/__init__.py b/src/tox/execute/local_sub_process/__init__.py new file mode 100644 index 000000000..c0a100889 --- /dev/null +++ b/src/tox/execute/local_sub_process/__init__.py @@ -0,0 +1,266 @@ +"""Execute that runs on local file system via subprocess-es""" +from __future__ import annotations + +import fnmatch +import logging +import os +import shutil +import sys +from subprocess import DEVNULL, PIPE, TimeoutExpired +from types import TracebackType +from typing import TYPE_CHECKING, Any, Generator, Sequence + +from tox.tox_env.errors import Fail + +from ..api import Execute, ExecuteInstance, ExecuteOptions, ExecuteStatus +from ..request import ExecuteRequest, StdinSource +from ..stream import SyncWrite +from ..util import shebang + +# mypy: warn-unused-ignores=false + +if sys.platform == "win32": # explicit check for mypy # pragma: win32 cover + # needs stdin/stdout handlers backed by overlapped IO + if TYPE_CHECKING: # the typeshed libraries don't contain this, so replace it with normal one + from subprocess import Popen + else: + from asyncio.windows_utils import Popen + from signal import CTRL_C_EVENT as SIG_INTERRUPT + from signal import SIGTERM + + from .read_via_thread_windows import ReadViaThreadWindows as ReadViaThread + +else: # pragma: win32 no cover + from signal import SIGINT as SIG_INTERRUPT + from signal import SIGKILL, SIGTERM + from subprocess import Popen + + from .read_via_thread_unix import ReadViaThreadUnix as ReadViaThread + + +IS_WIN = sys.platform == "win32" + + +class LocalSubProcessExecutor(Execute): + def build_instance( + self, + request: ExecuteRequest, + options: ExecuteOptions, + out: SyncWrite, + err: SyncWrite, + ) -> ExecuteInstance: + return LocalSubProcessExecuteInstance(request, options, out, err) + + +class LocalSubprocessExecuteStatus(ExecuteStatus): + def __init__(self, options: ExecuteOptions, out: SyncWrite, err: SyncWrite, process: Popen[bytes]): + self._process: Popen[bytes] = process + super().__init__(options, out, err) + self._interrupted = False + + @property + def exit_code(self) -> int | None: + return self._process.returncode + + def interrupt(self) -> None: + self._interrupted = True + if self._process is not None: # pragma: no branch + # A three level stop mechanism for children - INT -> TERM -> KILL + # communicate will wait for the app to stop, and then drain the standard streams and close them + to_pid, host_pid = self._process.pid, os.getpid() + msg = "requested interrupt of %d from %d, activate in %.2f" + logging.warning(msg, to_pid, host_pid, self.options.suicide_timeout) + if self.wait(self.options.suicide_timeout) is None: # still alive -> INT + # on Windows everyone in the same process group, so they got the message + if sys.platform != "win32": # pragma: win32 cover + msg = "send signal %s to %d from %d with timeout %.2f" + logging.warning(msg, f"SIGINT({SIG_INTERRUPT})", to_pid, host_pid, self.options.interrupt_timeout) + self._process.send_signal(SIG_INTERRUPT) + if self.wait(self.options.interrupt_timeout) is None: # still alive -> TERM # pragma: no branch + terminate_output = self.options.terminate_timeout + logging.warning(msg, f"SIGTERM({SIGTERM})", to_pid, host_pid, terminate_output) + self._process.terminate() + # Windows terminate is UNIX kill + if sys.platform != "win32" and self.wait(terminate_output) is None: # pragma: no branch + logging.warning(msg[:-18], f"SIGKILL({SIGKILL})", to_pid, host_pid) + self._process.kill() # still alive -> KILL + self.wait() # unconditional wait as kill should soon bring down the process + logging.warning("interrupt finished with success") + else: # pragma: no cover # difficult to test, process must die just as it's being interrupted + logging.warning("process already dead with %s within %s", self._process.returncode, host_pid) + + def wait(self, timeout: float | None = None) -> int | None: + try: # note wait in general might deadlock if output large, but we drain in background threads so not an issue + return self._process.wait(timeout=timeout) + except TimeoutExpired: + return None + + def write_stdin(self, content: str) -> None: + stdin = self._process.stdin + if stdin is None: # pragma: no branch + return # pragma: no cover + bytes_content = content.encode() + try: + if sys.platform == "win32": # explicit check for mypy # pragma: win32 cover + # on Windows we have a PipeHandle object here rather than a file stream + import _overlapped # type: ignore[import] + + ov = _overlapped.Overlapped(0) + ov.WriteFile(stdin.handle, bytes_content) # type: ignore[attr-defined] + result = ov.getresult(10) # wait up to 10ms to perform the operation + if result != len(bytes_content): + raise RuntimeError(f"failed to write to {stdin!r}") + else: + stdin.write(bytes_content) + stdin.flush() + except OSError: # pragma: no cover + if self._interrupted: # pragma: no cover + pass # pragma: no cover # if the process was asked to exit in the meantime ignore write errors + raise # pragma: no cover + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(pid={self._process.pid}, returncode={self._process.returncode!r})" + + @property + def metadata(self) -> dict[str, Any]: + return {"pid": self._process.pid} if self._process.pid else {} + + +class LocalSubprocessExecuteFailedStatus(ExecuteStatus): + def __init__(self, options: ExecuteOptions, out: SyncWrite, err: SyncWrite, exit_code: int | None) -> None: + super().__init__(options, out, err) + self._exit_code = exit_code + + @property + def exit_code(self) -> int | None: + return self._exit_code + + def wait(self, timeout: float | None = None) -> int | None: # noqa: U100 + return self._exit_code # pragma: no cover + + def write_stdin(self, content: str) -> None: # noqa: U100 + """cannot write""" + + def interrupt(self) -> None: + return None # pragma: no cover # nothing running so nothing to interrupt + + +class LocalSubProcessExecuteInstance(ExecuteInstance): + def __init__( + self, + request: ExecuteRequest, + options: ExecuteOptions, + out: SyncWrite, + err: SyncWrite, + on_exit_drain: bool = True, + ) -> None: + super().__init__(request, options, out, err) + self.process: Popen[bytes] | None = None + self._cmd: list[str] | None = None + self._read_stderr: ReadViaThread | None = None + self._read_stdout: ReadViaThread | None = None + self._on_exit_drain = on_exit_drain + + @property + def cmd(self) -> Sequence[str]: + if self._cmd is None: + base = self.request.cmd[0] + executable = shutil.which(base, path=self.request.env["PATH"]) + if executable is None: + cmd = self.request.cmd # if failed to find leave as it is + else: + if self.request.allow is not None: + for allow in self.request.allow: + # 1. allow matches just the original name of the executable + # 2. allow matches the entire resolved path + if fnmatch.fnmatch(self.request.cmd[0], allow) or fnmatch.fnmatch(executable, allow): + break + else: + msg = f"{base} (resolves to {executable})" if base == executable else base + raise Fail(f"{msg} is not allowed, use allowlist_externals to allow it") + cmd = [executable] + if sys.platform != "win32" and self.request.env.get("TOX_LIMITED_SHEBANG", "").strip(): + shebang_line = shebang(executable) + if shebang_line: + cmd = [*shebang_line, executable] + cmd.extend(self.request.cmd[1:]) + self._cmd = cmd + return self._cmd + + def __enter__(self) -> ExecuteStatus: + # adjust sub-process terminal size + columns, lines = shutil.get_terminal_size(fallback=(-1, -1)) + if columns != -1: # pragma: no branch + self.request.env.setdefault("COLUMNS", str(columns)) + if lines != -1: # pragma: no branch + self.request.env.setdefault("LINES", str(lines)) + + stdout, stderr = self.get_stream_file_no("stdout"), self.get_stream_file_no("stderr") + try: + self.process = process = Popen( + self.cmd, + stdout=next(stdout), + stderr=next(stderr), + stdin={StdinSource.USER: None, StdinSource.OFF: DEVNULL, StdinSource.API: PIPE}[self.request.stdin], + cwd=str(self.request.cwd), + env=self.request.env, + ) + except OSError as exception: + return LocalSubprocessExecuteFailedStatus(self.options, self._out, self._err, exception.errno) + + status = LocalSubprocessExecuteStatus(self.options, self._out, self._err, process) + drain, pid = self._on_exit_drain, self.process.pid + self._read_stderr = ReadViaThread(stderr.send(process), self.err_handler, name=f"err-{pid}", drain=drain) + self._read_stderr.__enter__() + self._read_stdout = ReadViaThread(stdout.send(process), self.out_handler, name=f"out-{pid}", drain=drain) + self._read_stdout.__enter__() + + if sys.platform == "win32": # explicit check for mypy: # pragma: win32 cover + process.stderr.read = self._read_stderr._drain_stream # type: ignore[assignment,union-attr] + process.stdout.read = self._read_stdout._drain_stream # type: ignore[assignment,union-attr] + return status + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self._read_stderr is not None: + self._read_stderr.__exit__(exc_type, exc_val, exc_tb) + if self._read_stdout is not None: + self._read_stdout.__exit__(exc_type, exc_val, exc_tb) + if self.process is not None: # cleanup the file handlers + for stream in (self.process.stdout, self.process.stderr, self.process.stdin): + if stream is not None and not getattr(stream, "closed", False): + try: + stream.close() + except OSError as exc: # pragma: no cover + logging.warning("error while trying to close %r with %r", stream, exc) # pragma: no cover + + @staticmethod + def get_stream_file_no(key: str) -> Generator[int, Popen[bytes], None]: + process = yield PIPE + stream = getattr(process, key) + if sys.platform == "win32": # explicit check for mypy # pragma: win32 cover + yield stream.handle + else: + yield stream.name + + def set_out_err(self, out: SyncWrite, err: SyncWrite) -> tuple[SyncWrite, SyncWrite]: + prev = self._out, self._err + if self._read_stdout is not None: # pragma: no branch + self._read_stdout.handler = out.handler + if self._read_stderr is not None: # pragma: no branch + self._read_stderr.handler = err.handler + return prev + + +__all__ = ( + "SIG_INTERRUPT", + "CREATION_FLAGS", + "LocalSubProcessExecuteInstance", + "LocalSubProcessExecutor", + "LocalSubprocessExecuteStatus", + "LocalSubprocessExecuteFailedStatus", +) diff --git a/src/tox/execute/local_sub_process/read_via_thread.py b/src/tox/execute/local_sub_process/read_via_thread.py new file mode 100644 index 000000000..ba9029be4 --- /dev/null +++ b/src/tox/execute/local_sub_process/read_via_thread.py @@ -0,0 +1,43 @@ +""" +A reader that drain a stream via its file no on a background thread. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from threading import Event, Thread +from types import TracebackType +from typing import Callable + +WAIT_GENERAL = 0.05 # stop thread join every so often (give chance to a signal interrupt) + + +class ReadViaThread(ABC): + def __init__(self, file_no: int, handler: Callable[[bytes], None], name: str, drain: bool) -> None: + self.file_no = file_no + self.stop = Event() + self.thread = Thread(target=self._read_stream, name=f"tox-r-{name}-{file_no}") + self.handler = handler + self._on_exit_drain = drain + + def __enter__(self) -> ReadViaThread: + self.thread.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, # noqa: U100 + exc_val: BaseException | None, # noqa: U100 + exc_tb: TracebackType | None, # noqa: U100 + ) -> None: + self.stop.set() # signal thread to stop + while self.thread.is_alive(): # wait until it stops + self.thread.join(WAIT_GENERAL) + self._drain_stream() # read anything left + + @abstractmethod + def _read_stream(self) -> None: + raise NotImplementedError + + @abstractmethod + def _drain_stream(self) -> None: + raise NotImplementedError diff --git a/src/tox/execute/local_sub_process/read_via_thread_unix.py b/src/tox/execute/local_sub_process/read_via_thread_unix.py new file mode 100644 index 000000000..5ba421378 --- /dev/null +++ b/src/tox/execute/local_sub_process/read_via_thread_unix.py @@ -0,0 +1,47 @@ +""" +On UNIX we use select.select to ensure we drain in a non-blocking fashion. +""" +from __future__ import annotations + +import errno # pragma: win32 no cover +import os # pragma: win32 no cover +import select # pragma: win32 no cover +from typing import Callable + +from .read_via_thread import ReadViaThread # pragma: win32 no cover + +STOP_EVENT_CHECK_PERIODICITY_IN_MS = 0.01 # pragma: win32 no cover + + +class ReadViaThreadUnix(ReadViaThread): # pragma: win32 no cover + def __init__(self, file_no: int, handler: Callable[[bytes], None], name: str, drain: bool) -> None: + super().__init__(file_no, handler, name, drain) + + def _read_stream(self) -> None: + while not self.stop.is_set(): + # we need to drain the stream, but periodically give chance for the thread to break if the stop event has + # been set (this is so that an interrupt can be handled) + if self._read_available() is None: # pragma: no branch + break # pragma: no cover + + def _drain_stream(self) -> None: + # no block just poll + while True: + if self._read_available(timeout=0) is not True: # pragma: no branch + break # pragma: no cover + + def _read_available(self, timeout: float = STOP_EVENT_CHECK_PERIODICITY_IN_MS) -> bool | None: + try: + ready, __, ___ = select.select([self.file_no], [], [], timeout) + if ready: + data = os.read(self.file_no, 1024) # read up to 1024 characters + # If the end of the file referred to by fd has been reached, an empty bytes object is returned. + if data: + self.handler(data) + return True + return False + except OSError as exception: # pragma: no cover + # Bad file descriptor or Input/output error + if exception.errno in (errno.EBADF, errno.EIO): + return None + raise diff --git a/src/tox/execute/local_sub_process/read_via_thread_windows.py b/src/tox/execute/local_sub_process/read_via_thread_windows.py new file mode 100644 index 000000000..4d9e41735 --- /dev/null +++ b/src/tox/execute/local_sub_process/read_via_thread_windows.py @@ -0,0 +1,69 @@ +""" +On Windows we use overlapped mechanism, borrowing it from asyncio (but without the event loop). +""" +from __future__ import annotations # pragma: win32 cover + +import logging # pragma: win32 cover +from asyncio.windows_utils import BUFSIZE # type: ignore # pragma: win32 cover +from time import sleep # pragma: win32 cover +from typing import Callable # pragma: win32 cover + +import _overlapped # type: ignore[import] # pragma: win32 cover + +from .read_via_thread import ReadViaThread # pragma: win32 cover + +# mypy: warn-unused-ignores=false + + +class ReadViaThreadWindows(ReadViaThread): # pragma: win32 cover + def __init__(self, file_no: int, handler: Callable[[bytes], None], name: str, drain: bool) -> None: + super().__init__(file_no, handler, name, drain) + self.closed = False + self._ov: _overlapped.Overlapped | None = None + self._waiting_for_read = False + + def _read_stream(self) -> None: + keep_reading = True + while keep_reading: # try to read at least once + wait = self._read_batch() + if wait is None: + break + if wait is True: + sleep(0.01) # sleep for 10ms if there was no data to read and try again + keep_reading = not self.stop.is_set() + + def _drain_stream(self) -> None: + wait: bool | None = self.closed + while wait is False: + wait = self._read_batch() + + def _read_batch(self) -> bool | None: + """:returns: None means error can no longer read, True wait for result, False try again""" + if self._waiting_for_read is False: + self._ov = _overlapped.Overlapped(0) # can use it only once to read a batch + try: # read up to BUFSIZE at a time + self._ov.ReadFile(self.file_no, BUFSIZE) # type: ignore[attr-defined] + self._waiting_for_read = True + except OSError: + self.closed = True + return None + try: # wait=False to not block and give chance for the stop check + data = self._ov.getresult(False) # type: ignore[union-attr] + except OSError as exception: + # 996 (0x3E4) Overlapped I/O event is not in a signaled state. + # 995 (0x3E3) The I/O operation has been aborted because of either a thread exit or an application request. + win_error = getattr(exception, "winerror", None) + if win_error == 996: + return True + else: + if win_error != 995: + logging.error("failed to read %r", exception) + return None + else: + self._ov = None + self._waiting_for_read = False + if data: + self.handler(data) + else: + return None + return False diff --git a/src/tox/execute/pep517_backend.py b/src/tox/execute/pep517_backend.py new file mode 100644 index 000000000..2f8ffc6d8 --- /dev/null +++ b/src/tox/execute/pep517_backend.py @@ -0,0 +1,134 @@ +"""A executor that reuses a single subprocess for all backend calls (saving on python startup/import overhead)""" +from __future__ import annotations + +import time +from pathlib import Path +from subprocess import TimeoutExpired +from threading import Lock +from types import TracebackType +from typing import Sequence + +from pyproject_api import BackendFailed + +from tox.execute import ExecuteRequest +from tox.execute.api import Execute, ExecuteInstance, ExecuteOptions, ExecuteStatus +from tox.execute.local_sub_process import LocalSubProcessExecuteInstance +from tox.execute.request import StdinSource +from tox.execute.stream import SyncWrite + + +class LocalSubProcessPep517Executor(Execute): + """Executor holding the backend process""" + + def __init__(self, colored: bool, cmd: Sequence[str], env: dict[str, str], cwd: Path): + super().__init__(colored) + self.cmd = cmd + self.env = env + self.cwd = cwd + self._local_execute: tuple[LocalSubProcessExecuteInstance, ExecuteStatus] | None = None + self._exc: Exception | None = None + self.is_alive: bool = False + + def build_instance( + self, + request: ExecuteRequest, + options: ExecuteOptions, + out: SyncWrite, + err: SyncWrite, + ) -> ExecuteInstance: + result = LocalSubProcessPep517ExecuteInstance(request, options, out, err, self.local_execute(options)) + return result + + def local_execute(self, options: ExecuteOptions) -> tuple[LocalSubProcessExecuteInstance, ExecuteStatus]: + if self._exc is not None: + raise self._exc + if self._local_execute is None: + request = ExecuteRequest(cmd=self.cmd, cwd=self.cwd, env=self.env, stdin=StdinSource.API, run_id="pep517") + + instance = LocalSubProcessExecuteInstance( + request=request, + options=options, + out=SyncWrite(name="pep517-out", target=None, color=None), # not enabled no need to enter/exit + err=SyncWrite(name="pep517-err", target=None, color=None), # not enabled no need to enter/exit + on_exit_drain=False, + ) + status = instance.__enter__() + self._local_execute = instance, status + while True: + if b"started backend " in status.out: + self.is_alive = True + break + if b"failed to start backend" in status.err: + from tox.tox_env.python.virtual_env.package.pyproject import ToxBackendFailed + + failure = BackendFailed( + result={ + "code": -5, + "exc_type": "FailedToStart", + "exc_msg": "could not start backend", + }, + out=status.out.decode(), + err=status.err.decode(), + ) + self._exc = ToxBackendFailed(failure) + raise self._exc + time.sleep(0.01) # wait a short while for the output to populate + return self._local_execute + + @staticmethod + def _handler(into: bytearray, content: bytes) -> None: + """ignore content generated""" + into.extend(content) # pragma: no cover + + def close(self) -> None: + if self._local_execute is not None: # pragma: no branch + execute, status = self._local_execute + execute.__exit__(None, None, None) + if execute.process is not None: # pragma: no branch + if execute.process.returncode is None: # pragma: no cover + try: # pragma: no cover + execute.process.wait(timeout=0.1) # pragma: no cover + except TimeoutExpired: # pragma: no cover + execute.process.terminate() # pragma: no cover # if does not stop on its own kill it + self.is_alive = False + + +class LocalSubProcessPep517ExecuteInstance(ExecuteInstance): + """A backend invocation""" + + def __init__( + self, + request: ExecuteRequest, + options: ExecuteOptions, + out: SyncWrite, + err: SyncWrite, + instance_status: tuple[LocalSubProcessExecuteInstance, ExecuteStatus], + ): + super().__init__(request, options, out, err) + self._instance, self._status = instance_status + self._lock = Lock() + + @property + def cmd(self) -> Sequence[str]: + return self._instance.cmd + + def __enter__(self) -> ExecuteStatus: + self._lock.acquire() + self._swap_out_err() + return self._status + + def __exit__( + self, + exc_type: type[BaseException] | None, # noqa: U100 + exc_val: BaseException | None, # noqa: U100 + exc_tb: TracebackType | None, # noqa: U100 + ) -> None: + self._swap_out_err() + self._lock.release() + + def _swap_out_err(self) -> None: + out, err = self._out, self._err + # update status to see the newly collected content + self._out, self._err = self._instance.set_out_err(out, err) + # update the thread out/err + self._status.set_out_err(out, err) diff --git a/src/tox/execute/request.py b/src/tox/execute/request.py new file mode 100644 index 000000000..1a2d9158e --- /dev/null +++ b/src/tox/execute/request.py @@ -0,0 +1,83 @@ +"""Module declaring a command execution request.""" +from __future__ import annotations + +import sys +from enum import Enum +from pathlib import Path +from typing import Sequence + + +class StdinSource(Enum): + OFF = 0 #: input disabled + USER = 1 #: input via the standard input + API = 2 #: input via programmatic access + + @staticmethod + def user_only() -> StdinSource: + """:return: ``USER`` if the standard input is tty type else ``OFF``""" + return StdinSource.USER if sys.stdin.isatty() else StdinSource.OFF + + +class ExecuteRequest: + """Defines a commands execution request""" + + def __init__( + self, + cmd: Sequence[str | Path], + cwd: Path, + env: dict[str, str], + stdin: StdinSource, + run_id: str, + allow: list[str] | None = None, + ) -> None: + """ + Create a new execution request. + + :param cmd: the command to run + :param cwd: the current working directory + :param env: the environment variables + :param stdin: the type of standard input allowed + :param run_id: an id to identify this run + """ + if len(cmd) == 0: + raise ValueError("cannot execute an empty command") + self.cmd: list[str] = [str(i) for i in cmd] #: the command to run + self.cwd = cwd #: the working directory to use + self.env = env #: the environment variables to use + self.stdin = stdin #: the type of standard input interaction allowed + self.run_id = run_id #: an id to identify this run + if allow is not None and "*" in allow: + allow = None # if we allow everything we can just disable the check + self.allow = allow + + @property + def shell_cmd(self) -> str: + """:return: the command to run as a shell command""" + try: + exe = str(Path(self.cmd[0]).relative_to(self.cwd)) + except ValueError: + exe = self.cmd[0] + _cmd = [exe] + _cmd.extend(self.cmd[1:]) + return shell_cmd(_cmd) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(cmd={self.cmd!r}, cwd={self.cwd!r}, env=..., stdin={self.stdin!r})" + + +def shell_cmd(cmd: Sequence[str]) -> str: + if sys.platform == "win32": # pragma: win32 cover + from subprocess import list2cmdline + + return list2cmdline(tuple(str(x) for x in cmd)) + else: # pragma: win32 no cover + from shlex import quote as shlex_quote + + return " ".join(shlex_quote(str(x)) for x in cmd) + + +__all__ = ( + "StdinSource", + "ExecuteRequest", + "shell_cmd", +) diff --git a/src/tox/execute/stream.py b/src/tox/execute/stream.py new file mode 100644 index 000000000..2141a3144 --- /dev/null +++ b/src/tox/execute/stream.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from contextlib import contextmanager +from threading import Event, Lock, Timer +from types import TracebackType +from typing import IO, Iterator + +from colorama import Fore + + +class SyncWrite: + """ + Make sure data collected is synced in-memory and to the target stream on every newline and time period. + + Used to propagate executed commands output to the standard output/error streams visible to the user. + """ + + REFRESH_RATE = 0.1 + + def __init__(self, name: str, target: IO[bytes] | None, color: str | None = None) -> None: + self._content = bytearray() + self._target: IO[bytes] | None = target + self._target_enabled: bool = target is not None + self._keep_printing: Event = Event() + self._content_lock: Lock = Lock() + self._lock: Lock = Lock() + self._at: int = 0 + self._color: str | None = color + self.name = name + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name={self.name!r}, target={self._target!r}, color={self._color!r})" + + def __enter__(self) -> SyncWrite: + if self._target_enabled: + self._start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, # noqa: U100 + exc_val: BaseException | None, # noqa: U100 + exc_tb: TracebackType | None, # noqa: U100 + ) -> None: + if self._target_enabled: + self._cancel() + self._write(len(self._content)) + + def handler(self, content: bytes) -> None: + """A callback called whenever content is written""" + with self._content_lock: + self._content.extend(content) + if self._target_enabled is False: + return + at = content.rfind(b"\n") + if at != -1: # pragma: no branch + at = len(self._content) - len(content) + at + 1 + if at != -1: + self._cancel() + try: + self._write(at) + finally: + self._start() + + def _start(self) -> None: + self.timer = Timer(self.REFRESH_RATE, self._trigger_timer) + self.timer.name = f"{self.name}-sync-timer" + self.timer.start() + + def _cancel(self) -> None: + self.timer.cancel() + + def _trigger_timer(self) -> None: + with self._content_lock: + at = len(self._content) + self._write(at) + + def _write(self, at: int) -> None: + assert self._target is not None # because _do_print is guarding the call of this method + with self._lock: + if at > self._at: # pragma: no branch + try: + with self.colored(): + self._target.write(self._content[self._at : at]) + self._target.flush() + finally: + self._at = at + + @contextmanager + def colored(self) -> Iterator[None]: + if self._color is None or self._target is None: + yield + else: + self._target.write(self._color.encode("utf-8")) + try: + yield + finally: + self._target.write(Fore.RESET.encode("utf-8")) + + @property + def text(self) -> str: + with self._content_lock: + return self._content.decode("utf-8") + + @property + def content(self) -> bytearray: + with self._content_lock: + return self._content diff --git a/src/tox/execute/util.py b/src/tox/execute/util.py new file mode 100644 index 000000000..02fda4b92 --- /dev/null +++ b/src/tox/execute/util.py @@ -0,0 +1,30 @@ +from __future__ import annotations + + +def shebang(exe: str) -> list[str] | None: + """ + :param exe: the executable + :return: the shebang interpreter arguments + """ + # When invoking a command using a shebang line that exceeds the OS shebang limit (e.g. Linux has a limit of 128; + # BINPRM_BUF_SIZE) the invocation will fail. In this case you'd want to replace the shebang invocation with an + # explicit invocation. + # see https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/fs/binfmt_script.c#n34 + try: + with open(exe, "rb") as file_handler: + marker = file_handler.read(2) + if marker != b"#!": + return None + shebang_line = file_handler.readline() + except OSError: + return None + try: + decoded = shebang_line.decode("UTF-8") + except UnicodeDecodeError: + return None + return [i.strip() for i in decoded.strip().split() if i.strip()] + + +__all__ = [ + "shebang", +] diff --git a/src/tox/helper/build_isolated.py b/src/tox/helper/build_isolated.py deleted file mode 100644 index d70cf1c7d..000000000 --- a/src/tox/helper/build_isolated.py +++ /dev/null @@ -1,42 +0,0 @@ -"""PEP 517 build backend invocation script. - -It accepts externally parsed build configuration from `[build-system]` -in `pyproject.toml` and invokes an API endpoint for building an sdist -tarball. -""" - -import os -import sys - - -def _ensure_module_in_paths(module, paths): - """Verify that the imported backend belongs in-tree.""" - if not paths: - return - - module_path = os.path.normcase(os.path.abspath(module.__file__)) - normalized_paths = (os.path.normcase(os.path.abspath(path)) for path in paths) - - if any(os.path.commonprefix((module_path, path)) == path for path in normalized_paths): - return - - raise SystemExit( - "build-backend ({!r}) must exist in one of the paths " - "specified by backend-path ({!r})".format(module, paths), - ) - - -dist_folder = sys.argv[1] -backend_spec = sys.argv[2] -backend_obj = sys.argv[3] if len(sys.argv) >= 4 else None -backend_paths = sys.argv[4].split(os.path.pathsep) if (len(sys.argv) >= 5 and sys.argv[4]) else [] - -sys.path[:0] = backend_paths - -backend = __import__(backend_spec, fromlist=["_trash"]) -_ensure_module_in_paths(backend, backend_paths) -if backend_obj: - backend = getattr(backend, backend_obj) - -basename = backend.build_sdist(dist_folder, {}) -print(basename) diff --git a/src/tox/helper/build_requires.py b/src/tox/helper/build_requires.py deleted file mode 100644 index a91671c07..000000000 --- a/src/tox/helper/build_requires.py +++ /dev/null @@ -1,24 +0,0 @@ -import json -import os -import sys - -backend_spec = sys.argv[1] -backend_obj = sys.argv[2] if len(sys.argv) >= 3 else None -backend_paths = sys.argv[3].split(os.path.pathsep) if len(sys.argv) >= 4 else [] - -sys.path[:0] = backend_paths - -backend = __import__(backend_spec, fromlist=["_trash"]) -if backend_obj: - backend = getattr(backend, backend_obj) - -try: - for_build_requires = backend.get_requires_for_build_sdist(None) -except AttributeError: - # PEP 517 states that get_requires_for_build_sdist is optional for a build - # backend object. When the backend object omits it, the default - # implementation must be equivalent to return [] - for_build_requires = [] - -output = json.dumps(for_build_requires) -print(output) diff --git a/src/tox/helper/get_site_package_dir.py b/src/tox/helper/get_site_package_dir.py deleted file mode 100644 index adb6ca1d3..000000000 --- a/src/tox/helper/get_site_package_dir.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import unicode_literals - -import json -import sys -import sysconfig -import warnings - -dest_prefix = sys.argv[1] -with warnings.catch_warnings(): # disable warning for PEP-632 - warnings.simplefilter("ignore") - try: - import distutils.sysconfig - - data = distutils.sysconfig.get_python_lib(prefix=dest_prefix) - except ImportError: # if removed or not installed ignore - config_vars = { - k: dest_prefix if any(v == p for p in (sys.prefix, sys.base_prefix)) else v - for k, v in sysconfig.get_config_vars().items() - } - data = sysconfig.get_path("purelib", vars=config_vars) - -print(json.dumps({"dir": data})) diff --git a/src/tox/helper/get_version.py b/src/tox/helper/get_version.py deleted file mode 100644 index 40acdc256..000000000 --- a/src/tox/helper/get_version.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import unicode_literals - -import json -import os -import platform -import sys - -info = { - "executable": sys.executable, - "implementation": platform.python_implementation(), - "version_info": list(sys.version_info), - "version": sys.version, - "is_64": sys.maxsize > 2**32, - "sysplatform": sys.platform, - "os_sep": os.sep, - "extra_version_info": getattr(sys, "pypy_version_info", None), -} -info_as_dump = json.dumps(info) -print(info_as_dump) diff --git a/src/tox/hookspecs.py b/src/tox/hookspecs.py deleted file mode 100644 index 5ea9c07e5..000000000 --- a/src/tox/hookspecs.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Hook specifications for tox - see https://pluggy.readthedocs.io/""" -import pluggy - -hookspec = pluggy.HookspecMarker("tox") - - -@hookspec -def tox_addoption(parser): - """add command line options to the argparse-style parser object.""" - - -@hookspec -def tox_configure(config): - """Called after command line options are parsed and ini-file has been read. - - Please be aware that the config object layout may change between major tox versions. - """ - - -@hookspec(firstresult=True) -def tox_package(session, venv): - """Return the package to be installed for the given venv. - - Called once for every environment.""" - - -@hookspec(firstresult=True) -def tox_get_python_executable(envconfig): - """Return a python executable for the given python base name. - - The first plugin/hook which returns an executable path will determine it. - - ``envconfig`` is the testenv configuration which contains - per-testenv configuration, notably the ``.envname`` and ``.basepython`` - setting. - """ - - -@hookspec(firstresult=True) -def tox_testenv_create(venv, action): - """Perform creation action for this venv. - - Some example usage: - - - To *add* behavior but still use tox's implementation to set up a - virtualenv, implement this hook but do not return a value (or explicitly - return ``None``). - - To *override* tox's virtualenv creation, implement this hook and return - a non-``None`` value. - - .. note:: This api is experimental due to the unstable api of - :class:`tox.venv.VirtualEnv`. - - .. note:: This hook uses ``firstresult=True`` (see `pluggy first result only`_) -- hooks - implementing this will be run until one returns non-``None``. - - .. _`pluggy first result only`: https://pluggy.readthedocs.io/en/latest/#first-result-only - """ - - -@hookspec(firstresult=True) -def tox_testenv_install_deps(venv, action): - """Perform install dependencies action for this venv. - - Some example usage: - - - To *add* behavior but still use tox's implementation to install - dependencies, implement this hook but do not return a value (or - explicitly return ``None``). One use-case may be to install (or ensure) - non-python dependencies such as debian packages. - - To *override* tox's installation of dependencies, implement this hook - and return a non-``None`` value. One use-case may be to install via - a different installation tool such as `pip-accel`_ or `pip-faster`_. - - .. note:: This api is experimental due to the unstable api of - :class:`tox.venv.VirtualEnv`. - - .. note:: This hook uses ``firstresult=True`` (see `pluggy first result only`_) -- hooks - implementing this will be run until one returns non-``None``. - - .. _pip-accel: https://github.com/paylogic/pip-accel - .. _pip-faster: https://github.com/Yelp/venv-update - """ - - -@hookspec -def tox_runtest_pre(venv): - """Perform arbitrary action before running tests for this venv. - - This could be used to indicate that tests for a given venv have started, for instance. - """ - - -@hookspec(firstresult=True) -def tox_runtest(venv, redirect): - """Run the tests for this venv. - - .. note:: This hook uses ``firstresult=True`` (see `pluggy first result only`_) -- hooks - implementing this will be run until one returns non-``None``. - """ - - -@hookspec -def tox_runtest_post(venv): - """Perform arbitrary action after running tests for this venv. - - This could be used to have per-venv test reporting of pass/fail status. - """ - - -@hookspec(firstresult=True) -def tox_runenvreport(venv, action): - """Get the installed packages and versions in this venv. - - This could be used for alternative (ie non-pip) package managers, this - plugin should return a ``list`` of type ``str`` - """ - - -@hookspec -def tox_cleanup(session): - """Called just before the session is destroyed, allowing any final cleanup operation""" diff --git a/src/tox/interpreters/__init__.py b/src/tox/interpreters/__init__.py deleted file mode 100644 index 7bc2fbd1a..000000000 --- a/src/tox/interpreters/__init__.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import unicode_literals - -import json -import sys - -import tox -from tox import reporter -from tox.constants import SITE_PACKAGE_QUERY_SCRIPT -from tox.interpreters.via_path import get_python_info - - -class Interpreters: - def __init__(self, hook): - self.name2executable = {} - self.executable2info = {} - self.hook = hook - - def get_executable(self, envconfig): - """return path object to the executable for the given - name (e.g. python2.7, python3.6, python etc.) - if name is already an existing path, return name. - If an interpreter cannot be found, return None. - """ - try: - return self.name2executable[envconfig.envname] - except KeyError: - exe = self.hook.tox_get_python_executable(envconfig=envconfig) - reporter.verbosity2("{} uses {}".format(envconfig.envname, exe)) - self.name2executable[envconfig.envname] = exe - return exe - - def get_info(self, envconfig): - executable = self.get_executable(envconfig) - name = envconfig.basepython - if not executable: - return NoInterpreterInfo(name=name) - try: - return self.executable2info[executable] - except KeyError: - info = run_and_get_interpreter_info(name, executable) - self.executable2info[executable] = info - return info - - def get_sitepackagesdir(self, info, envdir): - if not info.executable: - return "" - envdir = str(envdir) - try: - res = exec_on_interpreter(str(info.executable), SITE_PACKAGE_QUERY_SCRIPT, str(envdir)) - except ExecFailed as e: - reporter.verbosity1("execution failed: {} -- {}".format(e.out, e.err)) - return "" - else: - return res["dir"] - - -def run_and_get_interpreter_info(name, executable): - assert executable - try: - result = get_python_info(str(executable)) - result["version_info"] = tuple(result["version_info"]) # fix json dump transformation - if result["extra_version_info"] is not None: - result["extra_version_info"] = tuple( - result["extra_version_info"], - ) # fix json dump transformation - del result["version"] - result["executable"] = str(executable) - except ExecFailed as e: - return NoInterpreterInfo(name, executable=e.executable, out=e.out, err=e.err) - else: - return InterpreterInfo(**result) - - -def exec_on_interpreter(*args): - from subprocess import PIPE, Popen - - popen = Popen(args, stdout=PIPE, stderr=PIPE, universal_newlines=True) - out, err = popen.communicate() - if popen.returncode: - raise ExecFailed(args[0], args[1:], out, err) - if err: - sys.stderr.write(err) - try: - result = json.loads(out) - except Exception: - raise ExecFailed(args[0], args[1:], out, "could not decode {!r}".format(out)) - return result - - -class ExecFailed(Exception): - def __init__(self, executable, source, out, err): - self.executable = executable - self.source = source - self.out = out - self.err = err - - -class InterpreterInfo: - def __init__( - self, - implementation, - executable, - version_info, - sysplatform, - is_64, - os_sep, - extra_version_info, - ): - self.implementation = implementation - self.executable = executable - - self.version_info = version_info - self.sysplatform = sysplatform - self.is_64 = is_64 - self.os_sep = os_sep - self.extra_version_info = extra_version_info - - def __str__(self): - return "".format(self.executable, self.version_info) - - -class NoInterpreterInfo: - def __init__(self, name, executable=None, out=None, err="not found"): - self.name = name - self.executable = executable - self.version_info = None - self.out = out - self.err = err - - def __str__(self): - if self.executable: - return "".format(self.executable) - else: - return "".format(self.name) - - -if tox.INFO.IS_WIN: - from .windows import tox_get_python_executable -else: - from .unix import tox_get_python_executable -assert tox_get_python_executable diff --git a/src/tox/interpreters/common.py b/src/tox/interpreters/common.py deleted file mode 100644 index a1087fe48..000000000 --- a/src/tox/interpreters/common.py +++ /dev/null @@ -1,25 +0,0 @@ -import os - -from tox.interpreters.py_spec import CURRENT, PythonSpec -from tox.interpreters.via_path import exe_spec - - -def base_discover(envconfig): - base_python = envconfig.basepython - spec = PythonSpec.from_name(base_python) - - # 1. check passed in discover elements - discovers = envconfig.config.option.discover - if not discovers: - discovers = os.environ.get(str("TOX_DISCOVER"), "").split(os.pathsep) - for discover in discovers: - if os.path.exists(discover): - cur_spec = exe_spec(discover, envconfig.basepython) - if cur_spec is not None and cur_spec.satisfies(spec): - return spec, cur_spec.path - - # 2. check current - if spec.name is not None and CURRENT.satisfies(spec): - return spec, CURRENT.path - - return spec, None diff --git a/src/tox/interpreters/py_spec.py b/src/tox/interpreters/py_spec.py deleted file mode 100644 index 7b079e811..000000000 --- a/src/tox/interpreters/py_spec.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import unicode_literals - -import os -import re -import sys - -import six - -import tox - - -class PythonSpec(object): - def __init__(self, name, major, minor, architecture, path, args=None): - self.name = name - self.major = major - self.minor = minor - self.architecture = architecture - self.path = path - self.args = args - - def __repr__(self): - return ( - "{0.__class__.__name__}(name={0.name!r}, major={0.major!r}, minor={0.minor!r}, " - "architecture={0.architecture!r}, path={0.path!r}, args={0.args!r})" - ).format(self) - - def __str__(self): - msg = repr(self) - return msg.encode("utf-8") if six.PY2 else msg - - def satisfies(self, req): - if req.is_abs and self.is_abs and self.path != req.path: - return False - if req.name is not None and req.name != self.name: - return False - if req.architecture is not None and req.architecture != self.architecture: - return False - if req.major is not None and req.major != self.major: - return False - if req.minor is not None and req.minor != self.minor: - return False - if req.major is None and req.minor is not None: - return False - return True - - @property - def is_abs(self): - return self.path is not None and os.path.isabs(self.path) - - @classmethod - def from_name(cls, base_python): - name, major, minor, architecture, path = None, None, None, None, None - if os.path.isabs(base_python): - path = base_python - else: - match = re.match(r"(python|pypy|jython)(\d)?(?:\.(\d+))?(?:-(32|64))?$", base_python) - if match: - groups = match.groups() - name = groups[0] - major = int(groups[1]) if len(groups) >= 2 and groups[1] is not None else None - minor = int(groups[2]) if len(groups) >= 3 and groups[2] is not None else None - architecture = ( - int(groups[3]) if len(groups) >= 4 and groups[3] is not None else None - ) - else: - path = base_python - return cls(name, major, minor, architecture, path) - - -CURRENT = PythonSpec( - "pypy" if tox.constants.INFO.IS_PYPY else "python", - sys.version_info[0], - sys.version_info[1], - 64 if sys.maxsize > 2**32 else 32, - sys.executable, -) diff --git a/src/tox/interpreters/unix.py b/src/tox/interpreters/unix.py deleted file mode 100644 index 08194adf1..000000000 --- a/src/tox/interpreters/unix.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import unicode_literals - -import tox - -from .common import base_discover -from .via_path import check_with_path - - -@tox.hookimpl -def tox_get_python_executable(envconfig): - spec, path = base_discover(envconfig) - if path is not None: - return path - # 3. check if the literal base python - candidates = [envconfig.basepython] - # 4. check if the un-versioned name is good - if spec.name is not None and spec.name != envconfig.basepython: - candidates.append(spec.name) - return check_with_path(candidates, spec) diff --git a/src/tox/interpreters/via_path.py b/src/tox/interpreters/via_path.py deleted file mode 100644 index 8634d697d..000000000 --- a/src/tox/interpreters/via_path.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import unicode_literals - -import json -import os -import subprocess -from collections import defaultdict -from threading import Lock - -import py - -from tox import reporter -from tox.constants import VERSION_QUERY_SCRIPT - -from .py_spec import PythonSpec - - -def check_with_path(candidates, spec): - for path in candidates: - base = path - if not os.path.isabs(path): - path = py.path.local.sysfind(path) - if path is not None: - if os.path.exists(str(path)): - cur_spec = exe_spec(path, base) - if cur_spec is not None and cur_spec.satisfies(spec): - return cur_spec.path - - -_SPECS = {} -_SPECK_LOCK = defaultdict(Lock) - - -def exe_spec(python_exe, base): - if not isinstance(python_exe, str): - python_exe = str(python_exe) - with _SPECK_LOCK[python_exe]: - if python_exe not in _SPECS: - info = get_python_info(python_exe) - if info is not None: - found = PythonSpec( - "pypy" if info["implementation"] == "PyPy" else "python", - info["version_info"][0], - info["version_info"][1], - 64 if info["is_64"] else 32, - info["executable"], - ) - reporter.verbosity2("{} ({}) is {}".format(base, python_exe, info)) - else: - found = None - _SPECS[python_exe] = found - return _SPECS[python_exe] - - -_python_info_cache = {} - - -def get_python_info(cmd): - try: - return _python_info_cache[cmd].copy() - except KeyError: - pass - proc = subprocess.Popen( - [cmd] + [VERSION_QUERY_SCRIPT], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - ) - out, err = proc.communicate() - if not proc.returncode: - try: - result = json.loads(out) - except ValueError as exception: - failure = exception - else: - _python_info_cache[cmd] = result - return result.copy() - else: - failure = "exit code {}".format(proc.returncode) - reporter.verbosity1("{!r} cmd {!r} out {!r} err {!r} ".format(failure, cmd, out, err)) diff --git a/src/tox/interpreters/windows/__init__.py b/src/tox/interpreters/windows/__init__.py deleted file mode 100644 index e03c342ce..000000000 --- a/src/tox/interpreters/windows/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import unicode_literals - -from threading import Lock - -import tox - -from ..common import base_discover -from ..py_spec import CURRENT -from ..via_path import check_with_path - - -@tox.hookimpl -def tox_get_python_executable(envconfig): - spec, path = base_discover(envconfig) - if path is not None: - return path - # second check if the py.exe has it (only for non path specs) - if spec.path is None: - py_exe = locate_via_pep514(spec) - if py_exe is not None: - return py_exe - - # third check if the literal base python is on PATH - candidates = [envconfig.basepython] - # fourth check if the name is on PATH - if spec.name is not None and spec.name != envconfig.basepython: - candidates.append(spec.name) - # or check known locations - if spec.major is not None and spec.minor is not None: - if spec.name == "python": - # The standard names are in predictable places. - candidates.append(r"c:\python{}{}\python.exe".format(spec.major, spec.minor)) - return check_with_path(candidates, spec) - - -_PY_AVAILABLE = [] -_PY_LOCK = Lock() - - -def locate_via_pep514(spec): - with _PY_LOCK: - if not _PY_AVAILABLE: - from . import pep514 - - _PY_AVAILABLE.extend(pep514.discover_pythons()) - _PY_AVAILABLE.append(CURRENT) - for cur_spec in _PY_AVAILABLE: - if cur_spec.satisfies(spec): - return cur_spec.path diff --git a/src/tox/interpreters/windows/pep514.py b/src/tox/interpreters/windows/pep514.py deleted file mode 100644 index 3dac56480..000000000 --- a/src/tox/interpreters/windows/pep514.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Implement https://www.python.org/dev/peps/pep-0514/ to discover interpreters - Windows only""" -from __future__ import unicode_literals - -import os -import re - -import six -from six.moves import winreg - -from tox import reporter -from tox.interpreters.py_spec import PythonSpec - - -def enum_keys(key): - at = 0 - while True: - try: - yield winreg.EnumKey(key, at) - except OSError: - break - at += 1 - - -def get_value(key, value_name): - try: - return winreg.QueryValueEx(key, value_name)[0] - except OSError: - return None - - -def discover_pythons(): - for hive, hive_name, key, flags, default_arch in [ - (winreg.HKEY_CURRENT_USER, "HKEY_CURRENT_USER", r"Software\Python", 0, 64), - ( - winreg.HKEY_LOCAL_MACHINE, - "HKEY_LOCAL_MACHINE", - r"Software\Python", - winreg.KEY_WOW64_64KEY, - 64, - ), - ( - winreg.HKEY_LOCAL_MACHINE, - "HKEY_LOCAL_MACHINE", - r"Software\Python", - winreg.KEY_WOW64_32KEY, - 32, - ), - ]: - for spec in process_set(hive, hive_name, key, flags, default_arch): - yield spec - - -def process_set(hive, hive_name, key, flags, default_arch): - try: - with winreg.OpenKeyEx(hive, key, 0, winreg.KEY_READ | flags) as root_key: - for company in enum_keys(root_key): - if company == "PyLauncher": # reserved - continue - for spec in process_company(hive_name, company, root_key, default_arch): - yield spec - except OSError: - pass - - -def process_company(hive_name, company, root_key, default_arch): - with winreg.OpenKeyEx(root_key, company) as company_key: - for tag in enum_keys(company_key): - for spec in process_tag(hive_name, company, company_key, tag, default_arch): - yield spec - - -def process_tag(hive_name, company, company_key, tag, default_arch): - with winreg.OpenKeyEx(company_key, tag) as tag_key: - major, minor = load_version_data(hive_name, company, tag, tag_key) - if major is None: - return - arch = load_arch_data(hive_name, company, tag, tag_key, default_arch) - exe, args = load_exe(hive_name, company, company_key, tag) - if exe is not None: - name = "python" if company == "PythonCore" else company - yield PythonSpec(name, major, minor, arch, exe, args) - - -def load_exe(hive_name, company, company_key, tag): - key_path = "{}/{}/{}".format(hive_name, company, tag) - try: - with winreg.OpenKeyEx(company_key, r"{}\InstallPath".format(tag)) as ip_key: - with ip_key: - exe = get_value(ip_key, "ExecutablePath") - if exe is None: - ip = get_value(ip_key, None) - if ip is None: - msg(key_path, "no ExecutablePath or default for it") - - else: - exe = os.path.join(ip, "python.exe") - if os.path.exists(exe): - args = get_value(ip_key, "ExecutableArguments") - return exe, args - else: - msg(key_path, "exe does not exists {}".format(exe)) - except OSError: - msg("{}/{}".format(key_path, "InstallPath"), "missing") - return None, None - - -def load_arch_data(hive_name, company, tag, tag_key, default_arch): - arch_str = get_value(tag_key, "SysArchitecture") - if arch_str is not None: - key_path = "{}/{}/{}/SysArchitecture".format(hive_name, company, tag) - try: - return parse_arch(arch_str) - except ValueError as sys_arch: - msg(key_path, sys_arch) - return default_arch - - -def parse_arch(arch_str): - if not isinstance(arch_str, six.string_types): - raise ValueError("arch is not string") - match = re.match(r"(\d+)bit", arch_str) - if match: - return int(next(iter(match.groups()))) - raise ValueError("invalid format {}".format(arch_str)) - - -def load_version_data(hive_name, company, tag, tag_key): - version_str = get_value(tag_key, "SysVersion") - major, minor = None, None - if version_str is not None: - key_path = "{}/{}/{}/SysVersion".format(hive_name, company, tag) - try: - major, minor = parse_version(get_value(tag_key, "SysVersion")) - except ValueError as sys_version: - msg(key_path, sys_version) - if major is None: - key_path = "{}/{}/{}".format(hive_name, company, tag) - try: - major, minor = parse_version(tag) - except ValueError as tag_version: - msg(key_path, tag_version) - return major, minor - - -def parse_version(version_str): - if not isinstance(version_str, six.string_types): - raise ValueError("key is not string") - match = re.match(r"(\d+)\.(\d+).*", version_str) - if match: - return tuple(int(i) for i in match.groups()) - raise ValueError("invalid format {}".format(version_str)) - - -def msg(path, what): - reporter.verbosity1("PEP-514 violation in Windows Registry at {} error: {}".format(path, what)) - - -def _run(): - reporter.update_default_reporter(0, reporter.Verbosity.DEBUG) - for spec in discover_pythons(): - print(repr(spec)) - - -if __name__ == "__main__": - _run() diff --git a/src/tox/journal/__init__.py b/src/tox/journal/__init__.py new file mode 100644 index 000000000..b5609f3f3 --- /dev/null +++ b/src/tox/journal/__init__.py @@ -0,0 +1,22 @@ +"""This module handles collecting and persisting in json format a tox session""" +from __future__ import annotations + +import json +from pathlib import Path + +from .env import EnvJournal +from .main import Journal + + +def write_journal(path: Path | None, journal: Journal) -> None: + if path is None: + return + with open(path, "w") as file_handler: + json.dump(journal.content, file_handler, indent=2, ensure_ascii=False) + + +__all__ = ( + "Journal", + "EnvJournal", + "write_journal", +) diff --git a/src/tox/journal/env.py b/src/tox/journal/env.py new file mode 100644 index 000000000..29daa9411 --- /dev/null +++ b/src/tox/journal/env.py @@ -0,0 +1,68 @@ +"""Record information about tox environments""" +from __future__ import annotations + +from typing import Any + +from tox.execute import Outcome + + +class EnvJournal: + """Report the status of a tox environment""" + + def __init__(self, enabled: bool, name: str) -> None: + self._enabled = enabled + self.name = name + self._content: dict[str, Any] = {} + self._executes: list[tuple[str, Outcome]] = [] + + def __setitem__(self, key: str, value: Any) -> None: + """ + Add a new entry under key into the event journal. + + :param key: the key under what to add the data + :param value: the data to add + """ + self._content[key] = value + + def __bool__(self) -> bool: + """:return: a flag indicating if the event journal is on or not""" + return self._enabled + + def add_execute(self, outcome: Outcome, run_id: str) -> None: + """ + Add a command execution to the journal. + + :param outcome: the execution outcome + :param run_id: the execution id + """ + self._executes.append((run_id, outcome)) + + @property + def content(self) -> dict[str, Any]: + """:return: the env journal content (merges explicit keys and execution commands)""" + tests: list[dict[str, Any]] = [] + setup: list[dict[str, Any]] = [] + for run_id, outcome in self._executes: + one = { + "command": outcome.cmd, + "output": outcome.out, + "err": outcome.err, + "retcode": outcome.exit_code, + "elapsed": outcome.elapsed, + "show_on_standard": outcome.show_on_standard, + "run_id": run_id, + "start": outcome.start, + "end": outcome.end, + } + if run_id.startswith("commands") or run_id.startswith("build"): + tests.append(one) + else: + setup.append(one) + if tests: + self["test"] = tests + if setup: + self["setup"] = setup + return self._content + + +__all__ = ("EnvJournal",) diff --git a/src/tox/journal/main.py b/src/tox/journal/main.py new file mode 100644 index 000000000..84f8e2680 --- /dev/null +++ b/src/tox/journal/main.py @@ -0,0 +1,51 @@ +"""Generate json report of a tox run""" +from __future__ import annotations + +import socket +import sys +from typing import Any + +from tox.version import version + +from .env import EnvJournal + + +class Journal: + """The result of a tox session""" + + def __init__(self, enabled: bool) -> None: + self._enabled = enabled + self._content: dict[str, Any] = {} + self._env: dict[str, EnvJournal] = {} + + if self._enabled: + self._content.update( + { + "reportversion": "1", + "toxversion": version, + "platform": sys.platform, + "host": socket.getfqdn(), + }, + ) + + def get_env_journal(self, name: str) -> EnvJournal: + """Return the env log of an environment (create on first call)""" + if name not in self._env: + env = EnvJournal(self._enabled, name) + self._env[name] = env + return self._env[name] + + @property + def content(self) -> dict[str, Any]: + test_env_journals: dict[str, Any] = {} + for name, value in self._env.items(): + test_env_journals[name] = value.content + if test_env_journals: + self._content["testenvs"] = test_env_journals + return self._content + + def __bool__(self) -> bool: + return self._enabled + + +__all__ = ("Journal",) diff --git a/src/tox/logs/__init__.py b/src/tox/logs/__init__.py deleted file mode 100644 index ed5490686..000000000 --- a/src/tox/logs/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""This module handles collecting and persisting in json format a tox session""" -from .result import ResultLog - -__all__ = ("ResultLog",) diff --git a/src/tox/logs/command.py b/src/tox/logs/command.py deleted file mode 100644 index a22a2a654..000000000 --- a/src/tox/logs/command.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import absolute_import, unicode_literals - - -class CommandLog(object): - """Report commands interacting with third party tools""" - - def __init__(self, env_log, list): - self.envlog = env_log - self.list = list - - def add_command(self, argv, output, retcode): - data = {"command": argv, "output": output, "retcode": retcode} - self.list.append(data) - return data diff --git a/src/tox/logs/env.py b/src/tox/logs/env.py deleted file mode 100644 index ff8fc8e02..000000000 --- a/src/tox/logs/env.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -from tox.interpreters.via_path import get_python_info - -from .command import CommandLog - - -class EnvLog(object): - """Report the status of a tox environment""" - - def __init__(self, result_log, name, dict): - self.reportlog = result_log - self.name = name - self.dict = dict - - def set_python_info(self, python_executable): - answer = get_python_info(str(python_executable)) - answer["executable"] = python_executable - self.dict["python"] = answer - - def get_commandlog(self, name): - """get the command log for a given group name""" - data = self.dict.setdefault(name, []) - return CommandLog(self, data) - - def set_installed(self, packages): - self.dict["installed_packages"] = packages - - def set_header(self, installpkg): - """ - :param py.path.local installpkg: Path to the package. - """ - self.dict["installpkg"] = { - "sha256": installpkg.computehash("sha256"), - "basename": installpkg.basename, - } diff --git a/src/tox/logs/result.py b/src/tox/logs/result.py deleted file mode 100644 index d81e22b8a..000000000 --- a/src/tox/logs/result.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Generate json report of a run""" -from __future__ import absolute_import, unicode_literals - -import json -import os -import socket -import sys - -from tox.version import __version__ - -from .command import CommandLog -from .env import EnvLog - - -class ResultLog(object): - """The result of a tox session""" - - def __init__(self): - command_log = [] - self.command_log = CommandLog(None, command_log) - self.dict = { - "reportversion": "1", - "toxversion": __version__, - "platform": sys.platform, - "host": os.getenv(str("HOSTNAME")) or socket.gethostname(), - "commands": command_log, - } - - @classmethod - def from_json(cls, data): - result = cls() - result.dict = json.loads(data) - result.command_log = CommandLog(None, result.dict["commands"]) - return result - - def get_envlog(self, name): - """Return the env log of an environment (create on first call)""" - test_envs = self.dict.setdefault("testenvs", {}) - env_data = test_envs.setdefault(name, {}) - return EnvLog(self, name, env_data) - - def dumps_json(self): - """Return the json dump of the current state, indented""" - return json.dumps(self.dict, indent=2) diff --git a/src/tox/package/__init__.py b/src/tox/package/__init__.py deleted file mode 100644 index 9a32f3f99..000000000 --- a/src/tox/package/__init__.py +++ /dev/null @@ -1,73 +0,0 @@ -import py - -import tox -from tox.reporter import error, info, verbosity0, verbosity2, warning -from tox.util.lock import hold_lock - -from .builder import build_package -from .local import resolve_package -from .view import create_session_view - - -@tox.hookimpl -def tox_package(session, venv): - """Build an sdist at first call return that for all calls""" - if not hasattr(session, "package"): - session.package, session.dist = get_package(session) - return session.package - - -def get_package(session): - """Perform the package operation""" - config = session.config - if config.skipsdist: - info("skipping sdist step") - return None - lock_file = session.config.toxworkdir.join("{}.lock".format(session.config.isolated_build_env)) - - with hold_lock(lock_file, verbosity0): - package = acquire_package(config, session) - session_package = create_session_view(package, config.temp_dir) - return session_package, package - - -def acquire_package(config, session): - """acquire a source distribution (either by loading a local file or triggering a build)""" - if not config.option.sdistonly and (config.sdistsrc or config.option.installpkg): - path = get_local_package(config) - else: - try: - path = build_package(config, session) - except tox.exception.InvocationError as exception: - error("FAIL could not package project - v = {!r}".format(exception)) - return None - sdist_file = config.distshare.join(path.basename) - if sdist_file != path: - info("copying new sdistfile to {!r}".format(str(sdist_file))) - try: - sdist_file.dirpath().ensure(dir=1) - except py.error.Error: - warning("could not copy distfile to {}".format(sdist_file.dirpath())) - else: - path.copy(sdist_file) - return path - - -def get_local_package(config): - path = config.option.installpkg - if not path: - path = config.sdistsrc - py_path = py.path.local(resolve_package(path)) - info("using package {!r}, skipping 'sdist' activity ".format(str(py_path))) - return py_path - - -@tox.hookimpl -def tox_cleanup(session): - for tox_env in session.venv_dict.values(): - if hasattr(tox_env, "package") and isinstance(tox_env.package, py.path.local): - package = tox_env.package - if package.exists(): - verbosity2("cleanup {}".format(package)) - package.remove() - py.path.local(package.dirname).remove(ignore_errors=True) diff --git a/src/tox/package/builder/__init__.py b/src/tox/package/builder/__init__.py deleted file mode 100644 index 11a06574f..000000000 --- a/src/tox/package/builder/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .isolated import build -from .legacy import make_sdist - - -def build_package(config, session): - if not config.isolated_build: - return make_sdist(config, session) - else: - return build(config, session) diff --git a/src/tox/package/builder/isolated.py b/src/tox/package/builder/isolated.py deleted file mode 100644 index 00ef8f20f..000000000 --- a/src/tox/package/builder/isolated.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import unicode_literals - -import json -import os -from collections import namedtuple - -import six -from packaging.requirements import Requirement -from packaging.utils import canonicalize_name - -from tox import reporter -from tox.config import DepConfig, get_py_project_toml -from tox.constants import BUILD_ISOLATED, BUILD_REQUIRE_SCRIPT - -BuildInfo = namedtuple( - "BuildInfo", - ["requires", "backend_module", "backend_object", "backend_paths"], -) - - -def build(config, session): - build_info = get_build_info(config.setupdir) - package_venv = session.getvenv(config.isolated_build_env) - package_venv.envconfig.deps_matches_subset = True - - # we allow user specified dependencies so the users can write extensions to - # install additional type of dependencies (e.g. binary) - user_specified_deps = package_venv.envconfig.deps - package_venv.envconfig.deps = [DepConfig(r, None) for r in build_info.requires] - package_venv.envconfig.deps.extend(user_specified_deps) - - if package_venv.setupenv(): - package_venv.finishvenv() - if isinstance(package_venv.status, Exception): - raise package_venv.status - - build_requires = get_build_requires(build_info, package_venv, config.setupdir) - # we need to filter out requirements already specified in pyproject.toml or user deps - base_build_deps = { - canonicalize_name(Requirement(r.name).name) for r in package_venv.envconfig.deps - } - build_requires_dep = [ - DepConfig(r, None) - for r in build_requires - if canonicalize_name(Requirement(r).name) not in base_build_deps - ] - if build_requires_dep: - with package_venv.new_action("build_requires", package_venv.envconfig.envdir) as action: - package_venv.run_install_command(packages=build_requires_dep, action=action) - package_venv.finishvenv() - return perform_isolated_build(build_info, package_venv, config.distdir, config.setupdir) - - -def get_build_info(folder): - toml_file = folder.join("pyproject.toml") - - # as per https://www.python.org/dev/peps/pep-0517/ - - def abort(message): - reporter.error("{} inside {}".format(message, toml_file)) - raise SystemExit(1) - - if not toml_file.exists(): - reporter.error("missing {}".format(toml_file)) - raise SystemExit(1) - - config_data = get_py_project_toml(toml_file) - - if "build-system" not in config_data: - abort("build-system section missing") - - build_system = config_data["build-system"] - - if "requires" not in build_system: - abort("missing requires key at build-system section") - if "build-backend" not in build_system: - abort("missing build-backend key at build-system section") - - requires = build_system["requires"] - if not isinstance(requires, list) or not all(isinstance(i, six.text_type) for i in requires): - abort("requires key at build-system section must be a list of string") - - backend = build_system["build-backend"] - if not isinstance(backend, six.text_type): - abort("build-backend key at build-system section must be a string") - - args = backend.split(":") - module = args[0] - obj = args[1] if len(args) > 1 else "" - - backend_paths = build_system.get("backend-path", []) - if not isinstance(backend_paths, list): - abort("backend-path key at build-system section must be a list, if specified") - backend_paths = [folder.join(p) for p in backend_paths] - - normalized_folder = os.path.normcase(str(folder.realpath())) - normalized_paths = (os.path.normcase(str(path.realpath())) for path in backend_paths) - - if not all( - os.path.commonprefix((normalized_folder, path)) == normalized_folder - for path in normalized_paths - ): - abort("backend-path must exist in the project root") - - return BuildInfo(requires, module, obj, backend_paths) - - -def perform_isolated_build(build_info, package_venv, dist_dir, setup_dir): - with package_venv.new_action( - "perform-isolated-build", - package_venv.envconfig.envdir, - ) as action: - # need to start with an empty (but existing) source distribution folder - if dist_dir.exists(): - dist_dir.remove(rec=1, ignore_errors=True) - dist_dir.ensure_dir() - - result = package_venv._pcall( - [ - package_venv.envconfig.envpython, - BUILD_ISOLATED, - str(dist_dir), - build_info.backend_module, - build_info.backend_object, - os.path.pathsep.join(str(p) for p in build_info.backend_paths), - ], - returnout=True, - capture_err=False, - action=action, - cwd=setup_dir, - ) - reporter.verbosity2(result) - return dist_dir.join(result.split("\n")[-2]) - - -def get_build_requires(build_info, package_venv, setup_dir): - with package_venv.new_action("get-build-requires", package_venv.envconfig.envdir) as action: - result = package_venv._pcall( - [ - package_venv.envconfig.envpython, - BUILD_REQUIRE_SCRIPT, - build_info.backend_module, - build_info.backend_object, - os.path.pathsep.join(str(p) for p in build_info.backend_paths), - ], - returnout=True, - action=action, - cwd=setup_dir, - capture_err=False, - ) - return json.loads(result.split("\n")[-2]) diff --git a/src/tox/package/builder/legacy.py b/src/tox/package/builder/legacy.py deleted file mode 100644 index 5b9d1af33..000000000 --- a/src/tox/package/builder/legacy.py +++ /dev/null @@ -1,59 +0,0 @@ -import sys - -import py - -from tox import reporter -from tox.util.path import ensure_empty_dir - - -def make_sdist(config, session): - setup = config.setupdir.join("setup.py") - pyproject = config.setupdir.join("pyproject.toml") - setup_check = setup.check() - if not setup_check and not pyproject.check(): - reporter.error( - "No pyproject.toml or setup.py file found. The expected locations are:\n" - " {pyproject} or {setup}\n" - "You can\n" - " 1. Create one:\n" - " https://tox.readthedocs.io/en/latest/example/package.html\n" - " 2. Configure tox to avoid running sdist:\n" - " https://tox.readthedocs.io/en/latest/example/general.html\n" - " 3. Configure tox to use an isolated_build".format(pyproject=pyproject, setup=setup), - ) - raise SystemExit(1) - if not setup_check: - reporter.error( - "pyproject.toml file found.\n" - "To use a PEP 517 build-backend you are required to " - "configure tox to use an isolated_build:\n" - "https://tox.readthedocs.io/en/latest/example/package.html/n", - ) - raise SystemExit(1) - with session.newaction("GLOB", "packaging") as action: - action.setactivity("sdist-make", setup) - ensure_empty_dir(config.distdir) - build_log = action.popen( - [sys.executable, setup, "sdist", "--formats=zip", "--dist-dir", config.distdir], - cwd=config.setupdir, - returnout=True, - ) - reporter.verbosity2(build_log) - try: - return config.distdir.listdir()[0] - except py.error.ENOENT: - # check if empty or comment only - data = [] - with open(str(setup)) as fp: - for line in fp: - if line and line[0] == "#": - continue - data.append(line) - if not "".join(data).strip(): - reporter.error("setup.py is empty") - raise SystemExit(1) - reporter.error( - "No dist directory found. Please check setup.py, e.g with:\n" - " python setup.py sdist", - ) - raise SystemExit(1) diff --git a/src/tox/package/local.py b/src/tox/package/local.py deleted file mode 100644 index 0ae86155a..000000000 --- a/src/tox/package/local.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -import re - -import packaging.version -import py - -import tox -from tox import reporter -from tox.exception import MissingDependency - -_SPEC_2_PACKAGE = {} - - -def resolve_package(package_spec): - global _SPEC_2_PACKAGE - try: - return _SPEC_2_PACKAGE[package_spec] - except KeyError: - _SPEC_2_PACKAGE[package_spec] = x = get_latest_version_of_package(package_spec) - return x - - -def get_latest_version_of_package(package_spec): - if not os.path.isabs(str(package_spec)): - return package_spec - p = py.path.local(package_spec) - if p.check(): - return p - if not p.dirpath().check(dir=1): - raise tox.exception.MissingDirectory(p.dirpath()) - reporter.info("determining {}".format(p)) - candidates = p.dirpath().listdir(p.basename) - if len(candidates) == 0: - raise MissingDependency(package_spec) - if len(candidates) > 1: - version_package = [] - for filename in candidates: - version = get_version_from_filename(filename.basename) - if version is not None: - version_package.append((version, filename)) - else: - reporter.warning("could not determine version of: {}".format(str(filename))) - if not version_package: - raise tox.exception.MissingDependency(package_spec) - version_package.sort() - _, package_with_largest_version = version_package[-1] - return package_with_largest_version - else: - return candidates[0] - - -_REGEX_FILE_NAME_WITH_VERSION = re.compile(r"[\w_+.-]+-(.*)\.(zip|tar\.gz)") - - -def get_version_from_filename(basename): - m = _REGEX_FILE_NAME_WITH_VERSION.match(basename) - if m is None: - return None - version = m.group(1) - try: - return packaging.version.Version(version) - except packaging.version.InvalidVersion: - return None diff --git a/src/tox/package/view.py b/src/tox/package/view.py deleted file mode 100644 index e4841049d..000000000 --- a/src/tox/package/view.py +++ /dev/null @@ -1,50 +0,0 @@ -import os -from itertools import chain - -import six - -from tox.reporter import verbosity1 - - -def create_session_view(package, temp_dir): - """once we build a package we cannot return that directly, as a subsequent call - might delete that package (in order to do its own build); therefore we need to - return a view of the file that it's not prone to deletion and can be removed when the - session ends - """ - if not package: - return package - package_dir = temp_dir.join("package") - package_dir.ensure(dir=True) - - # we'll number the active instances, and use the max value as session folder for a new build - # note we cannot change package names as PEP-491 (wheel binary format) - # is strict about file name structure - exists = [i.basename for i in package_dir.listdir()] - file_id = max(chain((0,), (int(i) for i in exists if six.text_type(i).isnumeric()))) - - session_dir = package_dir.join(str(file_id + 1)) - session_dir.ensure(dir=True) - session_package = session_dir.join(package.basename) - - # if we can do hard links do that, otherwise just copy - links = False - if hasattr(os, "link"): - try: - os.link(str(package), str(session_package)) - links = True - except (OSError, NotImplementedError): - pass - if not links: - package.copy(session_package) - operation = "links" if links else "copied" - common = session_package.common(package) - verbosity1( - "package {} {} to {} ({})".format( - common.bestrelpath(session_package), - operation, - common.bestrelpath(package), - common, - ), - ) - return session_package diff --git a/src/tox/plugin/__init__.py b/src/tox/plugin/__init__.py new file mode 100644 index 000000000..6565ea588 --- /dev/null +++ b/src/tox/plugin/__init__.py @@ -0,0 +1,33 @@ +""" +tox uses `pluggy `_ to customize the default behaviour. For example the +following code snippet would define a new ``--magic`` command line interface flag the user can specify: + +.. code-block:: python + + from tox.config.cli.parser import ToxParser + from tox.plugin import impl + + + @impl + def tox_add_option(parser: ToxParser) -> None: + parser.add_argument("--magic", action="https://github.com/store_true", help="magical flag") + +You can define such hooks either in a package installed alongside tox or within a ``toxfile.py`` found alongside your +tox configuration file (root of your project). +""" +from __future__ import annotations + +from typing import Any, Callable, TypeVar + +import pluggy + +NAME = "tox" #: the name of the tox hook + +_F = TypeVar("_F", bound=Callable[..., Any]) +impl: Callable[[_F], _F] = pluggy.HookimplMarker(NAME) #: decorator to mark tox plugin hooks + + +__all__ = ( + "NAME", + "impl", +) diff --git a/src/tox/plugin/inline.py b/src/tox/plugin/inline.py new file mode 100644 index 000000000..113ddf4fc --- /dev/null +++ b/src/tox/plugin/inline.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import ModuleType + + +def load_inline(path: Path) -> ModuleType | None: + # nox uses here the importlib.machinery.SourceFileLoader but I consider this similarly good, and we can keep any + # name for the tox file, its content will always be loaded in this module from a system point of view + for name in ("toxfile", "☣"): + candidate = path.parent / f"{name}.py" + if candidate.exists(): + return _load_plugin(candidate) + return None + + +def _load_plugin(path: Path) -> ModuleType: + in_folder = path.parent + module_name = path.stem + + sys.path.insert(0, str(in_folder)) + try: + if module_name in sys.modules: + del sys.modules[module_name] # pragma: no cover + module = importlib.import_module(module_name) + return module + finally: + del sys.path[0] diff --git a/src/tox/plugin/manager.py b/src/tox/plugin/manager.py new file mode 100644 index 000000000..14f28b8fa --- /dev/null +++ b/src/tox/plugin/manager.py @@ -0,0 +1,96 @@ +"""Contains the plugin manager object""" +from __future__ import annotations + +from pathlib import Path +from types import ModuleType + +import pluggy + +from tox import provision +from tox.config.cli.parser import ToxParser +from tox.config.loader import api as loader_api +from tox.config.sets import ConfigSet, EnvConfigSet +from tox.session.cmd.run import parallel, sequential +from tox.tox_env import package as package_api +from tox.tox_env.python.virtual_env import runner +from tox.tox_env.python.virtual_env.package import cmd_builder, pyproject +from tox.tox_env.register import REGISTER, ToxEnvRegister + +from ..execute import Outcome +from ..session.state import State +from ..tox_env.api import ToxEnv +from . import NAME, spec +from .inline import load_inline + + +class Plugin: + def __init__(self) -> None: + self.manager: pluggy.PluginManager = pluggy.PluginManager(NAME) + self.manager.add_hookspecs(spec) + + def _register_plugins(self, inline: ModuleType | None) -> None: + from tox.session import state + from tox.session.cmd import depends, devenv, exec_, legacy, list_env, quickstart, show_config, version_flag + + if inline is not None: + self.manager.register(inline) + self.manager.load_setuptools_entrypoints(NAME) + internal_plugins = ( + loader_api, + provision, + runner, + pyproject, + cmd_builder, + legacy, + version_flag, + exec_, + quickstart, + show_config, + devenv, + list_env, + depends, + parallel, + sequential, + package_api, + ) + for plugin in internal_plugins: + self.manager.register(plugin) + self.manager.register(state) + self.manager.check_pending() + + def tox_add_option(self, parser: ToxParser) -> None: + self.manager.hook.tox_add_option(parser=parser) + + def tox_add_core_config(self, core_conf: ConfigSet, state: State) -> None: + self.manager.hook.tox_add_core_config(core_conf=core_conf, state=state) + + def tox_add_env_config(self, env_conf: EnvConfigSet, state: State) -> None: + self.manager.hook.tox_add_env_config(env_conf=env_conf, state=state) + + def tox_register_tox_env(self, register: ToxEnvRegister) -> None: + self.manager.hook.tox_register_tox_env(register=register) + + def tox_before_run_commands(self, tox_env: ToxEnv) -> None: + self.manager.hook.tox_before_run_commands(tox_env=tox_env) + + def tox_after_run_commands(self, tox_env: ToxEnv, exit_code: int, outcomes: list[Outcome]) -> None: + self.manager.hook.tox_after_run_commands(tox_env=tox_env, exit_code=exit_code, outcomes=outcomes) + + def load_plugins(self, path: Path) -> None: + for _plugin in self.manager.get_plugins(): # make sure we start with a clean state, repeated in memory run + self.manager.unregister(_plugin) + inline = _load_inline(path) + self._register_plugins(inline) + REGISTER._register_tox_env_types(self) + + +def _load_inline(path: Path) -> ModuleType | None: # used to be able to unregister plugin tests + return load_inline(path) + + +MANAGER = Plugin() + +__all__ = ( + "MANAGER", + "Plugin", +) diff --git a/src/tox/plugin/spec.py b/src/tox/plugin/spec.py new file mode 100644 index 000000000..502ece718 --- /dev/null +++ b/src/tox/plugin/spec.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import Any, Callable, TypeVar, cast + +import pluggy + +from tox.config.sets import ConfigSet, EnvConfigSet +from tox.tox_env.register import ToxEnvRegister + +from ..config.cli.parser import ToxParser +from ..execute import Outcome +from ..session.state import State +from ..tox_env.api import ToxEnv +from . import NAME + +_F = TypeVar("_F", bound=Callable[..., Any]) +_spec_marker = pluggy.HookspecMarker(NAME) + + +def _spec(func: _F) -> _F: + return cast(_F, _spec_marker(func)) + + +@_spec +def tox_register_tox_env(register: ToxEnvRegister) -> None: # noqa: U100 + """ + Register new tox environment type. You can register: + + - **run environment**: by default this is a local subprocess backed virtualenv Python + - **packaging environment**: by default this is a PEP-517 compliant local subprocess backed virtualenv Python + + :param register: a object that can be used to register new tox environment types + """ + + +@_spec +def tox_add_option(parser: ToxParser) -> None: # noqa: U100 + """ + Add a command line argument. This is the first hook to be called, right after the logging setup and config source + discovery. + + :param parser: the command line parser + """ + + +@_spec +def tox_add_core_config(core_conf: ConfigSet, state: State) -> None: # noqa: U100 + """ + Called when the core configuration is built for a tox environment. + + :param core_conf: the core configuration object + :param state: the global tox state object + """ + + +@_spec +def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: # noqa: U100 + """ + Called when configuration is built for a tox environment. + + :param env_conf: the core configuration object + :param state: the global tox state object + """ + + +@_spec +def tox_before_run_commands(tox_env: ToxEnv) -> None: # noqa: U100 + """ + Called before the commands set is executed. + + :param tox_env: the tox environment being executed + """ + + +@_spec +def tox_after_run_commands(tox_env: ToxEnv, exit_code: int, outcomes: list[Outcome]) -> None: # noqa: U100 + """ + Called after the commands set is executed. + + :param tox_env: the tox environment being executed + :param exit_code: exit code of the command + :param outcomes: outcome of each command execution + """ + + +__all__ = [ + "NAME", + "tox_register_tox_env", + "tox_add_option", + "tox_add_core_config", + "tox_add_env_config", + "tox_before_run_commands", + "tox_after_run_commands", +] diff --git a/src/tox/provision.py b/src/tox/provision.py new file mode 100644 index 000000000..529276d59 --- /dev/null +++ b/src/tox/provision.py @@ -0,0 +1,152 @@ +""" +This package handles provisioning an appropriate tox version per requirements. +""" +from __future__ import annotations + +import json +import logging +import sys +from argparse import ArgumentParser +from pathlib import Path +from typing import TYPE_CHECKING, List, cast + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version + +from tox.config.loader.memory import MemoryLoader +from tox.execute.api import StdinSource +from tox.plugin import impl +from tox.report import HandledError +from tox.tox_env.errors import Skip +from tox.tox_env.python.pip.req_file import PythonDeps +from tox.tox_env.python.runner import PythonRun + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from importlib.metadata import PackageNotFoundError, distribution +else: # pragma: no cover (py38+) + from importlib_metadata import PackageNotFoundError, distribution + +if TYPE_CHECKING: + from tox.session.state import State + + +@impl +def tox_add_option(parser: ArgumentParser) -> None: + parser.add_argument( + "--no-provision", + default=False, + const=True, + nargs="?", + metavar="REQ_JSON", + help="do not perform provision, but fail and if a path was provided write provision metadata as JSON to it", + ) + parser.add_argument( + "--no-recreate-provision", + dest="no_recreate_provision", + help="if recreate is set do not recreate provision tox environment", + action="https://github.com/store_true", + ) + parser.add_argument( + "-r", + "--recreate", + dest="recreate", + help="recreate the tox environments", + action="https://github.com/store_true", + ) + + +def provision(state: State) -> int | bool: + # remove the dev and marker to allow local development of the package + state.conf.core.add_config( + keys=["min_version", "minversion"], + of_type=Version, + # do not include local version specifier (because it's not allowed in version spec per PEP-440) + default=Version("4.0"), + desc="Define the minimal tox version required to run", + ) + state.conf.core.add_config( + keys="provision_tox_env", + of_type=str, + default=".tox", + desc="Name of the virtual environment used to provision a tox.", + ) + + def add_tox_requires_min_version(requires: list[Requirement]) -> list[Requirement]: + min_version: Version = state.conf.core["min_version"] + requires.append(Requirement(f"tox >= {min_version}")) + return requires + + state.conf.core.add_config( + keys="requires", + of_type=List[Requirement], + default=[], + desc="Name of the virtual environment used to provision a tox.", + post_process=add_tox_requires_min_version, + ) + requires: list[Requirement] = state.conf.core["requires"] + missing = _get_missing(requires) + + deps = ", ".join(f"{p}{'' if v is None else f' ({v})'}" for p, v in missing) + loader = MemoryLoader( # these configuration values are loaded from in-memory always (no file conf) + base=[], # disable inheritance for provision environments + package="skip", # no packaging for this please + # use our own dependency specification + deps=PythonDeps("\n".join(str(r) for r in requires), root=state.conf.core["tox_root"]), + pass_env=["*"], # do not filter environment variables, will be handled by provisioned tox + recreate=state.conf.options.recreate and not state.conf.options.no_recreate_provision, + ) + provision_tox_env: str = state.conf.core["provision_tox_env"] + state.envs._mark_provision(bool(missing), provision_tox_env, loader) + + from tox.plugin.manager import MANAGER + + MANAGER.tox_add_core_config(state.conf.core, state) + + if not missing: + return False + + miss_msg = f"is missing [requires (has)]: {deps}" + + no_provision: bool | str = state.conf.options.no_provision + if no_provision: + msg = f"provisioning explicitly disabled within {sys.executable}, but {miss_msg}" + if isinstance(no_provision, str): + msg += f" and wrote to {no_provision}" + requires_dict = { + "minversion": str(next(i.specifier for i in requires if i.name == "tox")).split("=")[1], + "requires": [str(i) for i in requires], + } + Path(no_provision).write_text(json.dumps(requires_dict, indent=4)) + raise HandledError(msg) + + logging.warning("will run in automatically provisioned tox, host %s %s", sys.executable, miss_msg) + return run_provision(provision_tox_env, state) + + +def _get_missing(requires: list[Requirement]) -> list[tuple[Requirement, str | None]]: + missing: list[tuple[Requirement, str | None]] = [] + for package in requires: + package_name = canonicalize_name(package.name) + try: + dist = distribution(package_name) + except PackageNotFoundError: + missing.append((package, None)) + else: + if not package.specifier.contains(dist.version, prereleases=True): + missing.append((package, dist.version)) + return missing + + +def run_provision(name: str, state: State) -> int: + tox_env: PythonRun = cast(PythonRun, state.envs[name]) + env_python = tox_env.env_python() + logging.info("will run in a automatically provisioned python environment under %s", env_python) + try: + tox_env.setup() + except Skip as exception: + raise HandledError(f"cannot provision tox environment {tox_env.conf['env_name']} because {exception}") + args: list[str] = [str(env_python), "-m", "tox"] + args.extend(state.args) + outcome = tox_env.execute(cmd=args, stdin=StdinSource.user_only(), show=True, run_id="provision") + return cast(int, outcome.exit_code) diff --git a/src/tox/session/commands/run/__init__.py b/src/tox/py.typed similarity index 100% rename from src/tox/session/commands/run/__init__.py rename to src/tox/py.typed diff --git a/src/tox/pytest.py b/src/tox/pytest.py new file mode 100644 index 000000000..21cd816ad --- /dev/null +++ b/src/tox/pytest.py @@ -0,0 +1,533 @@ +""" +A pytest plugin useful to test tox itself (and its plugins). +""" +from __future__ import annotations + +import inspect +import os +import re +import shutil +import socket +import sys +import textwrap +import warnings +from contextlib import closing, contextmanager +from pathlib import Path +from types import ModuleType, TracebackType +from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, cast +from unittest.mock import MagicMock + +import pytest +from _pytest.capture import CaptureFixture as _CaptureFixture +from _pytest.config import Config as PyTestConfig +from _pytest.config.argparsing import Parser +from _pytest.fixtures import SubRequest +from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch +from _pytest.python import Function +from _pytest.tmpdir import TempPathFactory +from devpi_process import IndexServer +from pytest_mock import MockerFixture +from virtualenv.info import fs_supports_symlink + +import tox.run +from tox.config.sets import EnvConfigSet +from tox.execute.api import Execute, ExecuteInstance, ExecuteOptions, ExecuteStatus, Outcome +from tox.execute.request import ExecuteRequest, shell_cmd +from tox.execute.stream import SyncWrite +from tox.plugin import manager +from tox.report import LOGGER, OutErr +from tox.run import run as tox_run +from tox.run import setup_state as previous_setup_state +from tox.session.cmd.run.parallel import ENV_VAR_KEY +from tox.session.state import State +from tox.tox_env import api as tox_env_api +from tox.tox_env.api import ToxEnv + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from typing import Protocol +else: # pragma: no cover ( Iterator[None]: # noqa: PT004 + before_handlers = list(LOGGER.handlers) + yield + LOGGER.handlers = before_handlers + + +@pytest.fixture(autouse=True) +def _disable_root_tox_py(request: SubRequest, mocker: MockerFixture) -> Iterator[None]: + """unless this is a plugin test do not allow loading toxfile.py""" + if request.node.get_closest_marker("plugin_test"): # unregister inline plugin + module, load_inline = None, manager._load_inline + + def _load_inline(path: Path) -> ModuleType | None: # register only on first run, and unregister at end + nonlocal module + module = load_inline(path) + return module + + mocker.patch.object(manager, "_load_inline", _load_inline) + yield + if module is not None: # pragma: no branch + manager.MANAGER.manager.unregister(module) + else: # do not allow loading inline plugins + mocker.patch("tox.plugin.inline._load_plugin", return_value=None) + yield + + +@contextmanager +def check_os_environ() -> Iterator[None]: + old = os.environ.copy() + to_clean = {k: os.environ.pop(k, None) for k in {ENV_VAR_KEY, "TOX_WORK_DIR", "PYTHONPATH", "COV_CORE_CONTEXT"}} + + yield + + for key, value in to_clean.items(): + if value is not None: + os.environ[key] = value + + new = os.environ + extra = {k: new[k] for k in set(new) - set(old)} + extra.pop("PLAT", None) + miss = {k: old[k] for k in set(old) - set(new)} + diff = { + f"{k} = {old[k]} vs {new[k]}" for k in set(old) & set(new) if old[k] != new[k] and not k.startswith("PYTEST_") + } + if extra or miss or diff: + msg = "test changed environ" + if extra: + msg += f" extra {extra}" + if miss: + msg += f" miss {miss}" + if diff: + msg += f" diff {diff}" + pytest.fail(msg) + + +@pytest.fixture(autouse=True) +def check_os_environ_stable(monkeypatch: MonkeyPatch) -> Iterator[None]: # noqa: PT004 + with check_os_environ(): + yield + monkeypatch.undo() + + +@pytest.fixture(autouse=True) +def no_color(monkeypatch: MonkeyPatch, check_os_environ_stable: None) -> None: # noqa: PT004, U100 + monkeypatch.setenv("NO_COLOR", "yes") + + +class ToxProject: + def __init__( + self, + files: dict[str, Any], + base: Path | None, + path: Path, + capfd: CaptureFixture, + monkeypatch: MonkeyPatch, + mocker: MockerFixture, + ) -> None: + self.path: Path = path + self.monkeypatch: MonkeyPatch = monkeypatch + self.mocker = mocker + self._capfd = capfd + self._setup_files(self.path, base, files) + + @staticmethod + def _setup_files(dest: Path, base: Path | None, content: dict[str, Any]) -> None: + if base is not None: + shutil.copytree(str(base), str(dest)) + dest.mkdir(exist_ok=True) + for key, value in content.items(): + if not isinstance(key, str): + raise TypeError(f"{key!r} at {dest}") # pragma: no cover + at_path = dest / key + if callable(value): + value = textwrap.dedent("\n".join(inspect.getsourcelines(value)[0][1:])) + if isinstance(value, dict): + at_path.mkdir(exist_ok=True) + ToxProject._setup_files(at_path, None, value) + elif isinstance(value, str): + at_path.write_text(textwrap.dedent(value)) + elif value is None: + at_path.mkdir() + else: + msg = f"could not handle {at_path / key} with content {value!r}" # pragma: no cover + raise TypeError(msg) # pragma: no cover + + def patch_execute(self, handle: Callable[[ExecuteRequest], int | None]) -> MagicMock: + class MockExecute(Execute): + def __init__(self, colored: bool, exit_code: int) -> None: + self.exit_code = exit_code + super().__init__(colored) + + def build_instance( + self, + request: ExecuteRequest, + options: ExecuteOptions, + out: SyncWrite, + err: SyncWrite, + ) -> ExecuteInstance: + return MockExecuteInstance(request, options, out, err, self.exit_code) + + class MockExecuteStatus(ExecuteStatus): + def __init__(self, options: ExecuteOptions, out: SyncWrite, err: SyncWrite, exit_code: int) -> None: + super().__init__(options, out, err) + self._exit_code = exit_code + + @property + def exit_code(self) -> int | None: + return self._exit_code + + def wait(self, timeout: float | None = None) -> int | None: # noqa: U100 + return self._exit_code + + def write_stdin(self, content: str) -> None: # noqa: U100 + return None # pragma: no cover + + def interrupt(self) -> None: + return None # pragma: no cover + + class MockExecuteInstance(ExecuteInstance): + def __init__( + self, + request: ExecuteRequest, + options: ExecuteOptions, + out: SyncWrite, + err: SyncWrite, + exit_code: int, + ) -> None: + super().__init__(request, options, out, err) + self.exit_code = exit_code + + def __enter__(self) -> ExecuteStatus: + return MockExecuteStatus(self.options, self._out, self._err, self.exit_code) + + def __exit__( + self, + exc_type: type[BaseException] | None, # noqa: U100 + exc_val: BaseException | None, # noqa: U100 + exc_tb: TracebackType | None, # noqa: U100 + ) -> None: + pass + + @property + def cmd(self) -> Sequence[str]: + return self.request.cmd + + @contextmanager + def _execute_call( + self: ToxEnv, + executor: Execute, + out_err: OutErr, + request: ExecuteRequest, + show: bool, + ) -> Iterator[ExecuteStatus]: + exit_code = handle(request) + if exit_code is not None: + executor = MockExecute(colored=executor._colored, exit_code=exit_code) + with original_execute_call(self, executor, out_err, request, show) as status: + yield status + + original_execute_call = ToxEnv._execute_call + result = self.mocker.patch.object(ToxEnv, "_execute_call", side_effect=_execute_call, autospec=True) + return result + + @property + def structure(self) -> dict[str, Any]: + result: dict[str, Any] = {} + for dir_name, _, files in os.walk(str(self.path)): + dir_path = Path(dir_name) + into = result + relative = dir_path.relative_to(str(self.path)) + for elem in relative.parts: + into = into.setdefault(elem, {}) + for file_name in files: + into[file_name] = (dir_path / file_name).read_text() + return result + + @contextmanager + def chdir(self, to: Path | None = None) -> Iterator[None]: + cur_dir = os.getcwd() + os.chdir(str(to or self.path)) + try: + yield + finally: + os.chdir(cur_dir) + + def run(self, *args: str, from_cwd: Path | None = None) -> ToxRunOutcome: + with self.chdir(from_cwd): + state = None + self._capfd.readouterr() # start with a clean state - drain + code = None + state = None + + def our_setup_state(value: Sequence[str]) -> State: + nonlocal state + state = previous_setup_state(value) + return state + + with self.monkeypatch.context() as m: + m.setattr(tox_env_api, "_CWD", self.path) + m.setattr(tox.run, "setup_state", our_setup_state) + m.setattr(sys, "argv", [sys.executable, "-m", "tox"] + list(args)) + m.setenv("VIRTUALENV_SYMLINK_APP_DATA", "1") + m.setenv("VIRTUALENV_SYMLINKS", "1") + m.setenv("VIRTUALENV_PIP", "embed") + m.setenv("VIRTUALENV_WHEEL", "embed") + m.setenv("VIRTUALENV_SETUPTOOLS", "embed") + try: + tox_run(args) + except SystemExit as exception: + code = exception.code + if code is None: # pragma: no branch + raise RuntimeError("exit code not set") + out, err = self._capfd.readouterr() + return ToxRunOutcome(args, self.path, cast(int, code), out, err, state) + + def __repr__(self) -> str: + return f"{type(self).__name__}(path={self.path}) at {id(self)}" + + +@pytest.fixture(autouse=True, scope="session") +def enable_pep517_backend_coverage() -> Iterator[None]: # noqa: PT004 + try: + import coverage # noqa: F401 + except ImportError: # pragma: no cover + yield # pragma: no cover + return # pragma: no cover + # the COV_ env variables needs to be passed on for the PEP-517 backend + from tox.tox_env.python.virtual_env.package.pyproject import Pep517VirtualEnvPackager + + def default_pass_env(self: Pep517VirtualEnvPackager) -> list[str]: + result = previous(self) + result.append("COV_*") + return result + + previous = Pep517VirtualEnvPackager._default_pass_env + try: + Pep517VirtualEnvPackager._default_pass_env = default_pass_env # type: ignore + yield + finally: + Pep517VirtualEnvPackager._default_pass_env = previous # type: ignore + + +class ToxRunOutcome: + def __init__(self, cmd: Sequence[str], cwd: Path, code: int, out: str, err: str, state: State | None) -> None: + extended_cmd = [sys.executable, "-m", "tox"] + extended_cmd.extend(cmd) + self.cmd: list[str] = extended_cmd + self.cwd: Path = cwd + self.code: int = code + self.out: str = out + self.err: str = err + self._state: State | None = state + + @property + def state(self) -> State: + if self._state is None: + raise RuntimeError("no state") + return self._state + + def env_conf(self, name: str) -> EnvConfigSet: + return self.state.conf.get_env(name) + + @property + def success(self) -> bool: + return self.code == Outcome.OK + + def assert_success(self) -> None: + assert self.success, repr(self) + + def assert_failed(self, code: int | None = None) -> None: + status_match = self.code != 0 if code is None else self.code == code + assert status_match, f"should be {code}, got {self}" + + def __repr__(self) -> str: + return "\n".join( + "{}{}{}".format(k, "\n" if "\n" in v else ": ", v) + for k, v in ( + ("code", str(self.code)), + ("cmd", self.shell_cmd), + ("cwd", str(self.cwd)), + ("standard output", self.out), + ("standard error", self.err), + ) + if v + ) + + @property + def shell_cmd(self) -> str: + return shell_cmd(self.cmd) + + def assert_out_err(self, out: str, err: str, *, dedent: bool = True, regex: bool = False) -> None: + if dedent: + out = textwrap.dedent(out).lstrip() + if regex: + self.matches(out, self.out, re.MULTILINE | re.DOTALL) + else: + assert self.out == out + if dedent: + err = textwrap.dedent(err).lstrip() + if regex: + self.matches(err, self.err, re.MULTILINE | re.DOTALL) + else: + assert self.err == err + + @staticmethod + def matches(pattern: str, text: str, flags: int = 0) -> None: + try: + from re_assert import Matches + except ImportError: # pragma: no cover # hard to test + match = re.match(pattern, text, flags) + if match is None: + warnings.warn("install the re-assert PyPI package for bette error message", UserWarning) + assert match + else: + assert Matches(pattern, flags=flags) == text + + +class ToxProjectCreator(Protocol): + def __call__( + self, + files: dict[str, Any], # noqa: U100 + base: Path | None = None, # noqa: U100 + prj_path: Path | None = None, # noqa: U100 + ) -> ToxProject: + ... + + +@pytest.fixture(name="tox_project") +def init_fixture( + tmp_path: Path, + capfd: CaptureFixture, + monkeypatch: MonkeyPatch, + mocker: MockerFixture, +) -> ToxProjectCreator: + def _init(files: dict[str, Any], base: Path | None = None, prj_path: Path | None = None) -> ToxProject: + """create tox projects""" + return ToxProject(files, base, prj_path or tmp_path / "p", capfd, monkeypatch, mocker) + + return _init + + +@pytest.fixture() +def empty_project(tox_project: ToxProjectCreator, monkeypatch: MonkeyPatch) -> ToxProject: + project = tox_project({"tox.ini": ""}) + monkeypatch.chdir(project.path) + return project + + +_RUN_INTEGRATION_TEST_FLAG = "--run-integration" + + +def pytest_addoption(parser: Parser) -> None: + parser.addoption(_RUN_INTEGRATION_TEST_FLAG, action="https://github.com/store_true", help="run the integration tests") + + +def pytest_configure(config: PyTestConfig) -> None: + config.addinivalue_line("markers", "integration") + config.addinivalue_line("markers", "plugin_test") + + +@pytest.hookimpl(trylast=True) # type: ignore # not typed decorator +def pytest_collection_modifyitems(config: PyTestConfig, items: list[Function]) -> None: + # do not require flags if called directly + if len(items) == 1: # pragma: no cover # hard to test + return + + skip_int = pytest.mark.skip(reason=f"integration tests not run (no {_RUN_INTEGRATION_TEST_FLAG} flag)") + + def is_integration(test_item: Function) -> bool: + return test_item.get_closest_marker("integration") is not None + + integration_enabled = config.getoption(_RUN_INTEGRATION_TEST_FLAG) + if not integration_enabled: # pragma: no cover # hard to test + for item in items: + if is_integration(item): + item.add_marker(skip_int) + # run integration tests (is_integration is True) after unit tests (False) + items.sort(key=is_integration) + + +def enable_pypi_server(monkeypatch: MonkeyPatch, url: str | None) -> None: + if url is None: # pragma: no cover # only one of the branches can be hit depending on env + monkeypatch.delenv("PIP_INDEX_URL", raising=False) + else: # pragma: no cover + monkeypatch.setenv("PIP_INDEX_URL", url) + monkeypatch.setenv("PIP_RETRIES", str(5)) + monkeypatch.setenv("PIP_TIMEOUT", str(2)) + + +@pytest.fixture(scope="session") +def pypi_server(tmp_path_factory: TempPathFactory) -> Iterator[IndexServer]: + # takes around 2.5s + path = tmp_path_factory.mktemp("pypi") + with IndexServer(path) as server: + server.create_index("empty", "volatile=False") + yield server + + +@pytest.fixture(scope="session") +def _invalid_index_fake_port() -> int: # noqa: PT005 + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as socket_handler: + socket_handler.bind(("", 0)) + return cast(int, socket_handler.getsockname()[1]) + + +@pytest.fixture(autouse=True) +def disable_pip_pypi_access(_invalid_index_fake_port: int, monkeypatch: MonkeyPatch) -> tuple[str, str | None]: + """set a fake pip index url, tests that want to use a pypi server should create and overwrite this""" + previous_url = os.environ.get("PIP_INDEX_URL") + new_url = f"http://localhost:{_invalid_index_fake_port}/bad-pypi-server" + monkeypatch.setenv("PIP_INDEX_URL", new_url) + monkeypatch.setenv("PIP_RETRIES", str(0)) + monkeypatch.setenv("PIP_TIMEOUT", str(0.001)) + return new_url, previous_url + + +@pytest.fixture(name="enable_pip_pypi_access") +def enable_pip_pypi_access_fixture( + disable_pip_pypi_access: tuple[str, str | None], + monkeypatch: MonkeyPatch, +) -> str | None: + """set a fake pip index url, tests that want to use a pypi server should create and overwrite this""" + _, previous_url = disable_pip_pypi_access + enable_pypi_server(monkeypatch, previous_url) + return previous_url + + +def register_inline_plugin(mocker: MockerFixture, *args: Callable[..., Any]) -> None: + frame_info = inspect.stack()[1] + caller_module = inspect.getmodule(frame_info[0]) + assert caller_module is not None + plugin = ModuleType(f"{caller_module.__name__}|{frame_info[3]}") + plugin.__file__ = caller_module.__file__ + plugin.__dict__.update({f.__name__: f for f in args}) + mocker.patch("tox.plugin.manager.load_inline", return_value=plugin) + + +__all__ = ( + "CaptureFixture", + "LogCaptureFixture", + "TempPathFactory", + "MonkeyPatch", + "ToxRunOutcome", + "ToxProject", + "ToxProjectCreator", + "check_os_environ", + "register_inline_plugin", +) diff --git a/src/tox/report.py b/src/tox/report.py new file mode 100644 index 000000000..ccff84af3 --- /dev/null +++ b/src/tox/report.py @@ -0,0 +1,258 @@ +"""Handle reporting from within tox""" +from __future__ import annotations + +import logging +import os +import sys +from contextlib import contextmanager +from io import BytesIO, TextIOWrapper +from threading import Thread, current_thread, enumerate, local +from typing import IO, Iterator, Tuple + +from colorama import Fore, Style, deinit, init + +LEVELS = { + 0: logging.CRITICAL, + 1: logging.ERROR, + 2: logging.WARNING, + 3: logging.INFO, + 4: logging.DEBUG, + 5: logging.NOTSET, +} + +MAX_LEVEL = max(LEVELS.keys()) +LOGGER = logging.getLogger() +OutErr = Tuple[TextIOWrapper, TextIOWrapper] + + +class _LogThreadLocal(local): + """A thread local variable that inherits values from its parent""" + + _ident_to_data: dict[int | None, str] = {} + + def __init__(self, out_err: OutErr) -> None: + self.name = self._ident_to_data.get(getattr(current_thread(), "parent_ident", None), "ROOT") + self.out_err = out_err + + @staticmethod + @contextmanager + def patch_thread() -> Iterator[None]: + def new_start(self: Thread) -> None: # need to patch this + self.parent_ident = current_thread().ident # type: ignore[attr-defined] + old_start(self) + + old_start, Thread.start = Thread.start, new_start # type: ignore[assignment] + try: + yield + finally: + Thread.start = old_start # type: ignore[assignment] + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value: str) -> None: + self._name = value + + for ident in self._ident_to_data.keys() - {t.ident for t in enumerate()}: + self._ident_to_data.pop(ident) + self._ident_to_data[current_thread().ident] = value + + @contextmanager + def with_name(self, name: str) -> Iterator[None]: + previous, self.name = self.name, name + try: + yield + finally: + self.name = previous + + @contextmanager + def suspend_out_err(self, yes: bool, out_err: OutErr | None = None) -> Iterator[OutErr]: + previous_out, previous_err = self.out_err + try: + if yes: + if out_err is None: # pragma: no branch + out = self._make(f"out-{self.name}", previous_out) + err = self._make(f"err-{self.name}", previous_err) + else: + out, err = out_err # pragma: no cover + self.out_err = out, err + yield self.out_err + finally: + if yes: + self.out_err = previous_out, previous_err + + @staticmethod + def _make(prefix: str, based_of: TextIOWrapper) -> TextIOWrapper: + return TextIOWrapper(NamedBytesIO(f"{prefix}-{based_of.name}")) + + +class NamedBytesIO(BytesIO): + def __init__(self, name: str) -> None: + super().__init__() + self.name: str = name + + +class ToxHandler(logging.StreamHandler): # type: ignore[type-arg] # is generic but at runtime doesn't take a type arg + # """Controls tox output.""" + + def __init__(self, level: int, is_colored: bool, out_err: OutErr) -> None: + self._local = _LogThreadLocal(out_err) + super().__init__(stream=self.stdout) + if is_colored: + deinit() + init() + self._is_colored = is_colored + self._setup_level(is_colored, level) + + def _setup_level(self, is_colored: bool, level: int) -> None: + self.setLevel(level) + self._error_formatter = self._get_formatter(logging.ERROR, level, is_colored) + self._warning_formatter = self._get_formatter(logging.WARNING, level, is_colored) + self._remaining_formatter = self._get_formatter(logging.INFO, level, is_colored) + + @contextmanager + def with_context(self, name: str) -> Iterator[None]: + """ + Set a new tox environment context + + :param name: the name of the tox environment + """ + with self._local.with_name(name): + yield + + @property + def name(self) -> str: # type: ignore[override] + """:return: the current tox environment name""" + return self._local.name # pragma: no cover + + @property + def stdout(self) -> TextIOWrapper: + """:return: the current standard output""" + return self._local.out_err[0] + + @property + def stderr(self) -> TextIOWrapper: + """:return: the current standard error""" + return self._local.out_err[1] + + @property # type: ignore[override] + def stream(self) -> IO[str]: + """:return: the current stream to write to (alias for the current standard output)""" + return self.stdout + + @stream.setter + def stream(self, value: IO[str]) -> None: # noqa: U100 + """ignore anyone changing this""" + + @contextmanager + def suspend_out_err(self, yes: bool, out_err: OutErr | None = None) -> Iterator[OutErr]: + with self._local.suspend_out_err(yes, out_err) as out_err_res: + yield out_err_res + + def write_out_err(self, out_err: tuple[bytes, bytes]) -> None: + # read/write through the buffer as we collect bytes to print bytes (no transcoding needed) + self.stdout.buffer.write(out_err[0]) + self.stderr.buffer.write(out_err[1]) + + @staticmethod + def _get_formatter(level: int, enabled_level: int, is_colored: bool) -> logging.Formatter: + color: int | str = "" + if is_colored: + if level >= logging.ERROR: + color = Fore.RED + elif level >= logging.WARNING: + color = Fore.CYAN + else: + color = Fore.WHITE + + def _c(val: int) -> str: + return str(val) if color else "" + + fmt = f"{color} %(message)s{_c(Style.RESET_ALL)}" + if enabled_level <= logging.DEBUG: + fmt = ( + f"{_c(Fore.GREEN)} %(relativeCreated)d %(levelname).1s{_c(Style.RESET_ALL)}{fmt}{_c(Style.DIM)}" + f" [%(pathname)s:%(lineno)d]{_c(Style.RESET_ALL)}" + ) + fmt = f"{_c(Style.BRIGHT)}{_c(Fore.MAGENTA)}%(env_name)s:{_c(Style.RESET_ALL)}" + fmt + formatter = logging.Formatter(fmt) + return formatter + + def format(self, record: logging.LogRecord) -> str: + # shorten the pathname to start from within the site-packages folder + record.env_name = "root" if self._local.name is None else self._local.name + basename = os.path.dirname(record.pathname) + len_sys_path_match = max((len(p) for p in sys.path if basename.startswith(p)), default=-1) + record.pathname = record.pathname[len_sys_path_match + 1 :] + + if record.levelno >= logging.ERROR: + return self._error_formatter.format(record) + if record.levelno >= logging.WARNING: + if self._is_colored and record.msg == "%s%s> %s" and record.args: + record.msg = f"%s{Style.NORMAL}%s{Style.DIM}>{Style.RESET_ALL} %s" + return self._warning_formatter.format(record) + return self._remaining_formatter.format(record) + + @staticmethod + @contextmanager + def patch_thread() -> Iterator[None]: + with _LogThreadLocal.patch_thread(): + yield + + def update_verbosity(self, verbosity: int) -> None: + level = _get_level(verbosity) + LOGGER.setLevel(level) + for name in ("distlib.util", "filelock"): + logger = logging.getLogger(name) + for logging_filter in logger.filters: # pragma: no branch # the filters is never empty + if isinstance(logging_filter, LowerInfoLevel): # pragma: no branch # we always find it + logging_filter.level = level + break + self._setup_level(self._is_colored, level) + + +class LowerInfoLevel(logging.Filter): + def __init__(self, level: int) -> None: + super().__init__() + self.level = level + + def filter(self, record: logging.LogRecord) -> bool: + if record.levelname in "INFO": + record.levelno = logging.DEBUG + record.levelname = "DEBUG" + return record.levelno >= self.level + + +def setup_report(verbosity: int, is_colored: bool) -> ToxHandler: + _clean_handlers(LOGGER) + level = _get_level(verbosity) + LOGGER.setLevel(level) + lower_info_level = LowerInfoLevel(level) + for name in ("distlib.util", "filelock"): + logger = logging.getLogger(name) + logger.filters.clear() + logger.addFilter(lower_info_level) + out_err: OutErr = (sys.stdout, sys.stderr) # type: ignore[assignment] + handler = ToxHandler(level, is_colored, out_err) + LOGGER.addHandler(handler) + + logging.debug("setup logging to %s on pid %s", logging.getLevelName(level), os.getpid()) + return handler + + +def _get_level(verbosity: int) -> int: + if verbosity > MAX_LEVEL: + verbosity = MAX_LEVEL + level = LEVELS[verbosity] + return level + + +def _clean_handlers(log: logging.Logger) -> None: + for log_handler in list(log.handlers): # remove handlers of libraries + log.removeHandler(log_handler) + + +class HandledError(RuntimeError): + """Error that has been handled so no need for stack trace""" diff --git a/src/tox/reporter.py b/src/tox/reporter.py deleted file mode 100644 index 17a3c921c..000000000 --- a/src/tox/reporter.py +++ /dev/null @@ -1,157 +0,0 @@ -"""A progress reporter inspired from the logging modules""" -from __future__ import absolute_import, unicode_literals - -import os -import time -from contextlib import contextmanager -from datetime import datetime - -import py - - -class Verbosity(object): - DEBUG = 2 - INFO = 1 - DEFAULT = 0 - QUIET = -1 - EXTRA_QUIET = -2 - - -REPORTER_TIMESTAMP_ON_ENV = str("TOX_REPORTER_TIMESTAMP") -REPORTER_TIMESTAMP_ON = os.environ.get(REPORTER_TIMESTAMP_ON_ENV, False) == "1" -START = datetime.now() - - -class Reporter(object): - def __init__(self, verbose_level=None, quiet_level=None): - kwargs = {} - if verbose_level is not None: - kwargs["verbose_level"] = verbose_level - if quiet_level is not None: - kwargs["quiet_level"] = quiet_level - self._reset(**kwargs) - - def _reset(self, verbose_level=0, quiet_level=0): - self.verbose_level = verbose_level - self.quiet_level = quiet_level - self.reported_lines = [] - self.tw = py.io.TerminalWriter() - - @property - def verbosity(self): - return self.verbose_level - self.quiet_level - - def log_popen(self, cwd, outpath, cmd_args_shell, pid): - """log information about the action.popen() created process.""" - msg = "[{}] {}$ {}".format(pid, cwd, cmd_args_shell) - if outpath: - if outpath.common(cwd) is not None: - outpath = cwd.bestrelpath(outpath) - msg = "{} >{}".format(msg, outpath) - self.verbosity1(msg, of="logpopen") - - @property - def messages(self): - return [i for _, i in self.reported_lines] - - @contextmanager - def timed_operation(self, name, msg): - self.verbosity2("{} start: {}".format(name, msg), bold=True) - start = time.time() - yield - duration = time.time() - start - self.verbosity2( - "{} finish: {} after {:.2f} seconds".format(name, msg, duration), - bold=True, - ) - - def separator(self, of, msg, level): - if self.verbosity >= level: - self.reported_lines.append(("separator", "- summary -")) - self.tw.sep(of, msg) - - def logline_if(self, level, of, msg, key=None, **kwargs): - if self.verbosity >= level: - message = str(msg) if key is None else "{}{}".format(key, msg) - self.logline(of, message, **kwargs) - - def logline(self, of, msg, **opts): - self.reported_lines.append((of, msg)) - timestamp = "" - if REPORTER_TIMESTAMP_ON: - timestamp = "{} ".format(datetime.now() - START) - line_msg = "{}{}\n".format(timestamp, msg) - self.tw.write(line_msg, **opts) - - def keyvalue(self, name, value): - if name.endswith(":"): - name += " " - self.tw.write(name, bold=True) - self.tw.write(value) - self.tw.line() - - def line(self, msg, **opts): - self.logline("line", msg, **opts) - - def info(self, msg): - self.logline_if(Verbosity.DEBUG, "info", msg) - - def using(self, msg): - self.logline_if(Verbosity.INFO, "using", msg, "using ", bold=True) - - def good(self, msg): - self.logline_if(Verbosity.QUIET, "good", msg, green=True) - - def warning(self, msg): - self.logline_if(Verbosity.QUIET, "warning", msg, "WARNING: ", red=True) - - def error(self, msg): - self.logline_if(Verbosity.QUIET, "error", msg, "ERROR: ", red=True) - - def skip(self, msg): - self.logline_if(Verbosity.QUIET, "skip", msg, "SKIPPED: ", yellow=True) - - def verbosity0(self, msg, **opts): - self.logline_if(Verbosity.DEFAULT, "verbosity0", msg, **opts) - - def verbosity1(self, msg, of="verbosity1", **opts): - self.logline_if(Verbosity.INFO, of, msg, **opts) - - def verbosity2(self, msg, **opts): - self.logline_if(Verbosity.DEBUG, "verbosity2", msg, **opts) - - def quiet(self, msg): - self.logline_if(Verbosity.QUIET, "quiet", msg) - - -_INSTANCE = Reporter() - - -def update_default_reporter(quiet_level, verbose_level): - _INSTANCE.quiet_level = quiet_level - _INSTANCE.verbose_level = verbose_level - - -def has_level(of): - return _INSTANCE.verbosity > of - - -def verbosity(): - return _INSTANCE.verbosity - - -verbosity0 = _INSTANCE.verbosity0 -verbosity1 = _INSTANCE.verbosity1 -verbosity2 = _INSTANCE.verbosity2 -error = _INSTANCE.error -warning = _INSTANCE.warning -good = _INSTANCE.good -using = _INSTANCE.using -skip = _INSTANCE.skip -info = _INSTANCE.info -line = _INSTANCE.line -separator = _INSTANCE.separator -keyvalue = _INSTANCE.keyvalue -quiet = _INSTANCE.quiet -timed_operation = _INSTANCE.timed_operation -log_popen = _INSTANCE.log_popen diff --git a/src/tox/run.py b/src/tox/run.py new file mode 100644 index 000000000..f2eef13a9 --- /dev/null +++ b/src/tox/run.py @@ -0,0 +1,59 @@ +"""Main entry point for tox.""" +from __future__ import annotations + +import faulthandler +import logging +import os +import sys +import time +from typing import Sequence + +from tox.config.cli.parse import get_options +from tox.report import HandledError, ToxHandler +from tox.session.state import State + + +def run(args: Sequence[str] | None = None) -> None: + try: + with ToxHandler.patch_thread(): + result = main(sys.argv[1:] if args is None else args) + except Exception as exception: + if isinstance(exception, HandledError): + logging.error("%s| %s", type(exception).__name__, str(exception)) + result = -2 + else: + raise + except KeyboardInterrupt: + result = -2 + finally: + if "_TOX_SHOW_THREAD" in os.environ: # pragma: no cover + import threading # pragma: no cover + + for thread in threading.enumerate(): # pragma: no cover + print(thread) # pragma: no cover + raise SystemExit(result) + + +def main(args: Sequence[str]) -> int: + state = setup_state(args) + from tox.provision import provision + + result = provision(state) + if result is not False: + return result + handler = state._options.cmd_handlers[state.conf.options.command] + result = handler(state) + return result + + +def setup_state(args: Sequence[str]) -> State: + """Setup the state object of this run.""" + start = time.monotonic() + # parse CLI arguments + options = get_options(*args) + options.parsed.start = start + if options.parsed.exit_and_dump_after: + faulthandler.dump_traceback_later(timeout=options.parsed.exit_and_dump_after, exit=True) # pragma: no cover + # build tox environment config objects + state = State(options, args) + return state diff --git a/src/tox/session/__init__.py b/src/tox/session/__init__.py index 0f50bbec2..7d210b6fc 100644 --- a/src/tox/session/__init__.py +++ b/src/tox/session/__init__.py @@ -1,299 +1,4 @@ """ -Automatically package and test a Python project against configurable -Python2 and Python3 based virtual environments. Environments are -setup by using virtualenv. Configuration is generally done through an -INI-style "tox.ini" file. +Package that handles execution of various commands within tox. """ -from __future__ import absolute_import, unicode_literals - -import json -import os -import re -import subprocess -import sys -from collections import OrderedDict -from contextlib import contextmanager - -import py - -import tox -from tox import reporter -from tox.action import Action -from tox.config import INTERRUPT_TIMEOUT, SUICIDE_TIMEOUT, TERMINATE_TIMEOUT, parseconfig -from tox.config.parallel import ENV_VAR_KEY_PRIVATE as PARALLEL_ENV_VAR_KEY_PRIVATE -from tox.config.parallel import OFF_VALUE as PARALLEL_OFF -from tox.logs.result import ResultLog -from tox.reporter import update_default_reporter -from tox.util import set_os_env_var -from tox.util.graph import stable_topological_sort -from tox.util.stdlib import suppress_output -from tox.venv import VirtualEnv - -from .commands.help import show_help -from .commands.help_ini import show_help_ini -from .commands.provision import provision_tox -from .commands.run.parallel import run_parallel -from .commands.run.sequential import run_sequential -from .commands.show_config import show_config -from .commands.show_env import show_envs - - -def cmdline(args=None): - if args is None: - args = sys.argv[1:] - main(args) - - -def setup_reporter(args): - from argparse import ArgumentParser - - from tox.config.reporter import add_verbosity_commands - - parser = ArgumentParser(add_help=False) - add_verbosity_commands(parser) - with suppress_output(): - try: - options, _ = parser.parse_known_args(args) - update_default_reporter(options.quiet_level, options.verbose_level) - except SystemExit: - pass - - -def main(args): - setup_reporter(args) - try: - config = load_config(args) - config.logdir.ensure(dir=1) - with set_os_env_var(str("TOX_WORK_DIR"), config.toxworkdir): - session = build_session(config) - exit_code = session.runcommand() - if exit_code is None: - exit_code = 0 - raise SystemExit(exit_code) - except tox.exception.BadRequirement: - raise SystemExit(1) - except KeyboardInterrupt: - raise SystemExit(2) - - -def load_config(args): - try: - config = parseconfig(args) - if config.option.help: - show_help(config) - raise SystemExit(0) - elif config.option.helpini: - show_help_ini(config) - raise SystemExit(0) - except tox.exception.MissingRequirement as exception: - config = exception.config - return config - - -def build_session(config): - return Session(config) - - -class Session(object): - """The session object that ties together configuration, reporting, venv creation, testing.""" - - def __init__(self, config, popen=subprocess.Popen): - self._reset(config, popen) - - def _reset(self, config, popen=subprocess.Popen): - self.config = config - self.popen = popen - self.resultlog = ResultLog() - self.existing_venvs = OrderedDict() - self.venv_dict = {} if self.config.run_provision else self._build_venvs() - - def _build_venvs(self): - try: - need_to_run = OrderedDict((v, self.getvenv(v)) for v in self._evaluated_env_list) - try: - venv_order = stable_topological_sort( - OrderedDict((name, v.envconfig.depends) for name, v in need_to_run.items()), - ) - - venvs = OrderedDict((v, need_to_run[v]) for v in venv_order) - return venvs - except ValueError as exception: - reporter.error("circular dependency detected: {}".format(exception)) - except LookupError: - pass - except tox.exception.ConfigError as exception: - reporter.error(str(exception)) - raise SystemExit(1) - - def getvenv(self, name): - if name in self.existing_venvs: - return self.existing_venvs[name] - env_config = self.config.envconfigs.get(name, None) - if env_config is None: - reporter.error("unknown environment {!r}".format(name)) - raise LookupError(name) - elif env_config.envdir == self.config.toxinidir: - reporter.error("venv {!r} in {} would delete project".format(name, env_config.envdir)) - raise tox.exception.ConfigError("envdir must not equal toxinidir") - env_log = self.resultlog.get_envlog(name) - venv = VirtualEnv(envconfig=env_config, popen=self.popen, env_log=env_log) - self.existing_venvs[name] = venv - return venv - - @property - def _evaluated_env_list(self): - tox_env_filter = os.environ.get("TOX_SKIP_ENV") - tox_env_filter_re = re.compile(tox_env_filter) if tox_env_filter is not None else None - visited = set() - for name in self.config.envlist: - if name in visited: - continue - visited.add(name) - if tox_env_filter_re is not None and tox_env_filter_re.match(name): - msg = "skip environment {}, matches filter {!r}".format( - name, - tox_env_filter_re.pattern, - ) - reporter.verbosity1(msg) - continue - yield name - - @property - def hook(self): - return self.config.pluginmanager.hook - - def newaction(self, name, msg, *args): - return Action( - name, - msg, - args, - self.config.logdir, - self.config.option.resultjson, - self.resultlog.command_log, - self.popen, - sys.executable, - SUICIDE_TIMEOUT, - INTERRUPT_TIMEOUT, - TERMINATE_TIMEOUT, - ) - - def runcommand(self): - reporter.using( - "tox-{} from {} (pid {})".format(tox.__version__, tox.__file__, os.getpid()), - ) - show_description = reporter.has_level(reporter.Verbosity.DEFAULT) - if self.config.run_provision: - provision_tox_venv = self.getvenv(self.config.provision_tox_env) - return provision_tox(provision_tox_venv, self.config.args) - else: - if self.config.option.showconfig: - self.showconfig() - elif self.config.option.listenvs: - self.showenvs(all_envs=False, description=show_description) - elif self.config.option.listenvs_all: - self.showenvs(all_envs=True, description=show_description) - else: - with self.cleanup(): - return self.subcommand_test() - - @contextmanager - def cleanup(self): - self.config.temp_dir.ensure(dir=True) - try: - yield - finally: - self.hook.tox_cleanup(session=self) - - def subcommand_test(self): - if self.config.skipsdist: - reporter.info("skipping sdist step") - else: - for venv in self.venv_dict.values(): - if not venv.envconfig.skip_install: - venv.package = self.hook.tox_package(session=self, venv=venv) - if not venv.package: - return 2 - venv.envconfig.setenv[str("TOX_PACKAGE")] = str(venv.package) - if self.config.option.sdistonly: - return - - within_parallel = PARALLEL_ENV_VAR_KEY_PRIVATE in os.environ - try: - if not within_parallel and self.config.option.parallel != PARALLEL_OFF: - run_parallel(self.config, self.venv_dict) - else: - run_sequential(self.config, self.venv_dict) - finally: - retcode = self._summary() - return retcode - - def _add_parallel_summaries(self): - if self.config.option.parallel != PARALLEL_OFF and "testenvs" in self.resultlog.dict: - result_log = self.resultlog.dict["testenvs"] - for tox_env in self.venv_dict.values(): - data = self._load_parallel_env_report(tox_env) - if data and "testenvs" in data and tox_env.name in data["testenvs"]: - result_log[tox_env.name] = data["testenvs"][tox_env.name] - - @staticmethod - def _load_parallel_env_report(tox_env): - """Load report data into memory, remove disk file""" - result_json_path = tox_env.get_result_json_path() - if result_json_path and result_json_path.exists(): - with result_json_path.open("r") as file_handler: - data = json.load(file_handler) - result_json_path.remove() - return data - - def _summary(self): - is_parallel_child = PARALLEL_ENV_VAR_KEY_PRIVATE in os.environ - if not is_parallel_child: - reporter.separator("_", "summary", reporter.Verbosity.QUIET) - exit_code = 0 - for venv in self.venv_dict.values(): - report = reporter.good - status = getattr(venv, "status", "undefined") - if isinstance(status, tox.exception.InterpreterNotFound): - msg = " {}: {}".format(venv.envconfig.envname, str(status)) - if self.config.option.skip_missing_interpreters == "true": - report = reporter.skip - else: - exit_code = 1 - report = reporter.error - elif status == "platform mismatch": - msg = " {}: {} ({!r} does not match {!r})".format( - venv.envconfig.envname, - str(status), - sys.platform, - venv.envconfig.platform, - ) - report = reporter.skip - elif status and status == "ignored failed command": - msg = " {}: {}".format(venv.envconfig.envname, str(status)) - elif status and status != "skipped tests": - msg = " {}: {}".format(venv.envconfig.envname, str(status)) - report = reporter.error - exit_code = 1 - else: - if not status: - status = "commands succeeded" - msg = " {}: {}".format(venv.envconfig.envname, status) - if not is_parallel_child: - report(msg) - if not exit_code and not is_parallel_child: - reporter.good(" congratulations :)") - path = self.config.option.resultjson - if path: - if not is_parallel_child: - self._add_parallel_summaries() - path = py.path.local(path) - data = self.resultlog.dumps_json() - reporter.line("write json report at: {}".format(path)) - path.write(data) - return exit_code - - def showconfig(self): - show_config(self.config) - - def showenvs(self, all_envs=False, description=False): - show_envs(self.config, all_envs=all_envs, description=description) +from __future__ import annotations diff --git a/tests/unit/__init__.py b/src/tox/session/cmd/__init__.py similarity index 100% rename from tests/unit/__init__.py rename to src/tox/session/cmd/__init__.py diff --git a/src/tox/session/cmd/depends.py b/src/tox/session/cmd/depends.py new file mode 100644 index 000000000..44c173fd3 --- /dev/null +++ b/src/tox/session/cmd/depends.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import cast + +from tox.config.cli.parser import ToxParser +from tox.plugin import impl +from tox.session.cmd.run.common import env_run_create_flags, run_order +from tox.session.state import State +from tox.tox_env.runner import RunToxEnv + + +@impl +def tox_add_option(parser: ToxParser) -> None: + our = parser.add_command( + "depends", + ["de"], + "visualize tox environment dependencies", + depends, + ) + env_run_create_flags(our, mode="depends") + + +def depends(state: State) -> int: + to_run_list = list(state.envs.iter(only_active=False)) + order, todo = run_order(state, to_run_list) + print(f"Execution order: {', '.join(order)}") + + deps: dict[str, list[str]] = {k: [o for o in order if o in v] for k, v in todo.items()} + deps["ALL"] = to_run_list + + def _handle(at: int, env: str) -> None: + print(" " * at, end="") + print(env, end="") + if env != "ALL": + run_env = cast(RunToxEnv, state.envs[env]) + packager_list: list[str] = [] + try: + for pkg_env in run_env.package_envs: + packager_list.append(pkg_env.name) + except Exception as exception: + packager_list.append(f"... ({exception})") + names = " | ".join(packager_list) + if names: + print(f" ~ {names}", end="") + print("") + at += 1 + for dep in deps[env]: + _handle(at, dep) + + _handle(0, "ALL") + return 0 diff --git a/src/tox/session/cmd/devenv.py b/src/tox/session/cmd/devenv.py new file mode 100644 index 000000000..ff98180ac --- /dev/null +++ b/src/tox/session/cmd/devenv.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from tox.config.cli.parser import ToxParser +from tox.config.loader.memory import MemoryLoader +from tox.plugin import impl +from tox.report import HandledError +from tox.session.cmd.run.common import env_run_create_flags +from tox.session.cmd.run.sequential import run_sequential +from tox.session.env_select import CliEnv, register_env_select_flags +from tox.session.state import State + + +@impl +def tox_add_option(parser: ToxParser) -> None: + help_msg = "sets up a development environment at ENVDIR based on the tox configuration specified " + our = parser.add_command("devenv", ["d"], help_msg, devenv) + our.add_argument("devenv_path", metavar="path", default=Path("venv").absolute(), nargs="?") + register_env_select_flags(our, default=CliEnv("py"), multiple=False) + env_run_create_flags(our, mode="devenv") + + +def devenv(state: State) -> int: + opt = state.conf.options + opt.skip_missing_interpreters = False # the target python must exist + opt.no_test = False # do not run the test suite + opt.package_only = False + opt.install_pkg = None # no explicit packages to install + opt.skip_pkg_install = False # always install a package in this case + opt.no_test = True # do not run the test phase + + state.envs.ensure_only_run_env_is_active() + envs = list(state.envs.iter()) + if len(envs) != 1: + raise HandledError(f"exactly one target environment allowed in devenv mode but found {', '.join(envs)}") + loader = MemoryLoader( # these configuration values are loaded from in-memory always (no file conf) + usedevelop=True, # dev environments must be of type dev + env_dir=Path(opt.devenv_path), # move it in source + ) + tox_env = state.envs[envs[0]] + tox_env.conf.loaders.insert(0, loader) + result = run_sequential(state) + if result == 0: + logging.warning(f"created development environment under {tox_env.conf['env_dir']}") + return result diff --git a/src/tox/session/cmd/exec_.py b/src/tox/session/cmd/exec_.py new file mode 100644 index 000000000..891189b76 --- /dev/null +++ b/src/tox/session/cmd/exec_.py @@ -0,0 +1,43 @@ +""" +Execute a command in a tox environment. +""" +from __future__ import annotations + +from pathlib import Path + +from tox.config.cli.parser import ToxParser +from tox.config.loader.memory import MemoryLoader +from tox.config.types import Command +from tox.plugin import impl +from tox.report import HandledError +from tox.session.cmd.run.common import env_run_create_flags +from tox.session.cmd.run.sequential import run_sequential +from tox.session.env_select import CliEnv, register_env_select_flags +from tox.session.state import State + + +@impl +def tox_add_option(parser: ToxParser) -> None: + our = parser.add_command("exec", ["e"], "execute an arbitrary command within a tox environment", exec_) + our.epilog = "For example: tox exec -e py39 -- python --version" + register_env_select_flags(our, default=CliEnv("py"), multiple=False) + env_run_create_flags(our, mode="exec") + + +def exec_(state: State) -> int: + envs = list(state.envs.iter()) + if len(envs) != 1: + raise HandledError(f"exactly one target environment allowed in exec mode but found {', '.join(envs)}") + loader = MemoryLoader( # these configuration values are loaded from in-memory always (no file conf) + commands_pre=[], + commands=[], + commands_post=[], + ) + conf = state.envs[envs[0]].conf + conf.loaders.insert(0, loader) + to_path: Path | None = conf["change_dir"] if conf["args_are_paths"] else None + pos_args = state.conf.pos_args(to_path) + if not pos_args: + raise HandledError("You must specify a command as positional arguments, use -- ") + loader.raw["commands"] = [Command(list(pos_args))] + return run_sequential(state) diff --git a/src/tox/session/cmd/legacy.py b/src/tox/session/cmd/legacy.py new file mode 100644 index 000000000..bb5e993fa --- /dev/null +++ b/src/tox/session/cmd/legacy.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pathlib import Path + +from tox.config.cli.parser import DEFAULT_VERBOSITY, ToxParser +from tox.plugin import impl +from tox.session.cmd.run.common import env_run_create_flags +from tox.session.cmd.run.parallel import OFF_VALUE, parallel_flags, run_parallel +from tox.session.cmd.run.sequential import run_sequential +from tox.session.state import State + +from ..env_select import CliEnv, register_env_select_flags +from .devenv import devenv +from .list_env import list_env +from .show_config import show_config + + +@impl +def tox_add_option(parser: ToxParser) -> None: + our = parser.add_command("legacy", ["le"], "legacy entry-point command", legacy) + our.add_argument("--help-ini", "--hi", action="https://github.com/store_true", help="show live configuration", dest="show_config") + our.add_argument( + "--showconfig", + action="https://github.com/store_true", + help="show live configuration (by default all env, with -l only default targets, specific via TOXENV/-e)", + dest="show_config", + ) + our.add_argument( + "-a", + "--listenvs-all", + action="https://github.com/store_true", + help="show list of all defined environments (with description if verbose)", + dest="list_envs_all", + ) + our.add_argument( + "-l", + "--listenvs", + action="https://github.com/store_true", + help="show list of test environments (with description if verbose)", + dest="list_envs", + ) + our.add_argument( + "--devenv", + help="sets up a development environment at ENVDIR based on the env's tox configuration specified by" + "`-e` (-e defaults to py)", + dest="devenv_path", + metavar="ENVDIR", + default=None, + of_type=Path, + ) + register_env_select_flags(our, default=CliEnv()) + env_run_create_flags(our, mode="legacy") + parallel_flags(our, default_parallel=OFF_VALUE, no_args=True) + our.add_argument( + "--pre", + action="https://github.com/store_true", + help="install pre-releases and development versions of dependencies. This will pass the --pre option to" + "install_command (pip by default).", + ) + our.add_argument( + "-i", + "--index-url", + action="https://github.com/append", + default=[], + metavar="url", + help="set indexserver url (if URL is of form name=url set the url for the 'name' indexserver, specifically)", + ) + our.add_argument( + "--force-dep", + action="https://github.com/append", + metavar="req", + default=[], + help="Forces a certain version of one of the dependencies when configuring the virtual environment. REQ " + "Examples 'pytest<6.1' or 'django>=2.2'.", + ) + our.add_argument( + "--sitepackages", + action="https://github.com/store_true", + help="override sitepackages setting to True in all envs", + dest="site_packages", + ) + our.add_argument( + "--alwayscopy", + action="https://github.com/store_true", + help="override always copy setting to True in all envs", + dest="always_copy", + ) + + +def legacy(state: State) -> int: + option = state.conf.options + if option.show_config: + option.list_keys_only = [] + option.show_core = not bool(option.env) + return show_config(state) + if option.list_envs or option.list_envs_all: + state.envs.on_empty_fallback_py = False + option.list_no_description = option.verbosity <= DEFAULT_VERBOSITY + option.list_default_only = not option.list_envs_all + option.show_core = False + return list_env(state) + if option.devenv_path: + option.devenv_path = Path(option.devenv_path) + return devenv(state) + if option.parallel != 0: # only 0 means sequential + return run_parallel(state) + return run_sequential(state) diff --git a/src/tox/session/cmd/list_env.py b/src/tox/session/cmd/list_env.py new file mode 100644 index 000000000..6bc34ad93 --- /dev/null +++ b/src/tox/session/cmd/list_env.py @@ -0,0 +1,56 @@ +""" +Print available tox environments. +""" +from __future__ import annotations + +from itertools import chain + +from tox.config.cli.parser import ToxParser +from tox.plugin import impl +from tox.session.env_select import register_env_select_flags +from tox.session.state import State + + +@impl +def tox_add_option(parser: ToxParser) -> None: + our = parser.add_command("list", ["l"], "list environments", list_env) + our.add_argument("--no-desc", action="https://github.com/store_true", help="do not show description", dest="list_no_description") + d = register_env_select_flags(our, default=None, group_only=True) + d.add_argument("-d", action="https://github.com/store_true", help="list just default envs", dest="list_default_only") + + +def list_env(state: State) -> int: + option = state.conf.options + has_group_select = bool(option.factors or option.labels) + active_only = has_group_select or option.list_default_only + + active = dict.fromkeys(state.envs.iter()) + inactive = {} if active_only else {env: None for env in state.envs.iter(only_active=False) if env not in active} + + if not has_group_select and not option.list_no_description and active: + print("default environments:") + max_length = max((len(env) for env in chain(active, inactive)), default=0) + + def report_env(name: str) -> None: + if not option.list_no_description: + tox_env = state.envs[name] + text = tox_env.conf["description"] + if not text.strip(): + text = "[no description]" + text = text.replace("\n", " ") + msg = f"{env.ljust(max_length)} -> {text}".strip() + else: + msg = env + print(msg) + + for env in active: + report_env(env) + + if not has_group_select and not option.list_default_only and inactive: + if not option.list_no_description: + if active: # pragma: no branch + print("") + print("additional environments:") + for env in inactive: + report_env(env) + return 0 diff --git a/src/tox/session/cmd/quickstart.py b/src/tox/session/cmd/quickstart.py new file mode 100644 index 000000000..bba00bc5c --- /dev/null +++ b/src/tox/session/cmd/quickstart.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from textwrap import dedent + +from packaging.version import Version + +from tox.config.cli.parser import ToxParser +from tox.plugin import impl +from tox.session.state import State +from tox.version import version as __version__ + + +@impl +def tox_add_option(parser: ToxParser) -> None: + our = parser.add_command( + "quickstart", + ["q"], + "Command line script to quickly create a tox config file for a Python project", + quickstart, + ) + our.add_argument( + "quickstart_root", + metavar="root", + default=Path().absolute(), + help="folder to create the tox.ini file", + type=Path, + ) + + +def quickstart(state: State) -> int: + root = state.conf.options.quickstart_root.absolute() + tox_ini = root / "tox.ini" + if tox_ini.exists(): + print(f"{tox_ini} already exist, refusing to overwrite") + return 1 + version = str(Version(__version__.split("+")[0])) + text = f""" + [tox] + env_list = + py{''.join(str(i) for i in sys.version_info[0:2])} + minversion = {version} + + [testenv] + description = run the tests with pytest + package = wheel + wheel_build_env = .pkg + deps = + pytest>=6 + commands = + pytest {{tty:--color=yes}} {{posargs}} + """ + content = dedent(text).lstrip() + + print(f"tox {__version__} quickstart utility, will create {tox_ini}:") + print(content, end="") + + root.mkdir(parents=True, exist_ok=True) + tox_ini.write_text(content) + return 0 diff --git a/src/tox/session/cmd/run/__init__.py b/src/tox/session/cmd/run/__init__.py new file mode 100644 index 000000000..a40b2b30d --- /dev/null +++ b/src/tox/session/cmd/run/__init__.py @@ -0,0 +1,4 @@ +""" +Defines how we execute a tox environment. +""" +from __future__ import annotations diff --git a/src/tox/session/cmd/run/common.py b/src/tox/session/cmd/run/common.py new file mode 100644 index 000000000..794e249a0 --- /dev/null +++ b/src/tox/session/cmd/run/common.py @@ -0,0 +1,381 @@ +"""Common functionality shared across multiple type of runs""" +from __future__ import annotations + +import logging +import os +import time +from argparse import Action, ArgumentError, ArgumentParser, Namespace +from concurrent.futures import CancelledError, Future, ThreadPoolExecutor, as_completed +from pathlib import Path +from signal import SIGINT, Handlers, signal +from threading import Event, Thread +from typing import Any, Iterator, Optional, Sequence, cast + +from colorama import Fore + +from tox.config.types import EnvList +from tox.execute import Outcome +from tox.journal import write_journal +from tox.session.cmd.run.single import ToxEnvRunResult, run_one +from tox.session.state import State +from tox.tox_env.api import ToxEnv +from tox.tox_env.runner import RunToxEnv +from tox.util.graph import stable_topological_sort +from tox.util.spinner import MISS_DURATION, Spinner + + +class SkipMissingInterpreterAction(Action): + def __call__( + self, + parser: ArgumentParser, # noqa: U100 + namespace: Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, # noqa: U100 + ) -> None: + value = "true" if values is None else values + if value not in ("config", "true", "false"): + raise ArgumentError(self, f"value must be 'config', 'true', or 'false' (got {value!r})") + setattr(namespace, self.dest, value) + + +class InstallPackageAction(Action): + def __call__( + self, + parser: ArgumentParser, # noqa: U100 + namespace: Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, # noqa: U100 + ) -> None: + if not values: + raise ArgumentError(self, "cannot be empty") + path = Path(cast(str, values)).absolute() + if not path.exists(): + raise ArgumentError(self, f"{path} does not exist") + if not path.is_file(): + raise ArgumentError(self, f"{path} is not a file") + setattr(namespace, self.dest, path) + + +def env_run_create_flags(parser: ArgumentParser, mode: str) -> None: + # mode can be one of: run, run-parallel, legacy, devenv, config + if mode not in ("config", "depends"): + parser.add_argument( + "--result-json", + dest="result_json", + metavar="path", + of_type=Path, + default=None, + help="write a JSON file with detailed information about all commands and results involved", + ) + if mode not in ("devenv", "depends"): + parser.add_argument( + "-s", + "--skip-missing-interpreters", + default="config", + metavar="v", + nargs="?", + action=SkipMissingInterpreterAction, + help="don't fail tests for missing interpreters: {config,true,false} choice", + ) + if mode not in ("devenv", "config", "depends"): + parser.add_argument( + "-n", + "--notest", + dest="no_test", + help="do not run the test commands", + action="https://github.com/store_true", + ) + parser.add_argument( + "-b", + "--pkg-only", + "--sdistonly", + action="https://github.com/store_true", + help="only perform the packaging activity", + dest="package_only", + ) + parser.add_argument( + "--installpkg", + help="use specified package for installation into venv, instead of packaging the project", + default=None, + of_type=Optional[Path], + action=InstallPackageAction, + dest="install_pkg", + ) + if mode not in ("devenv", "depends"): + parser.add_argument( + "--develop", + action="https://github.com/store_true", + help="install package in development mode", + dest="develop", + ) + if mode not in ("config", "depends"): + parser.add_argument( + "--hashseed", + metavar="SEED", + help="set PYTHONHASHSEED to SEED before running commands. Defaults to a random integer in the range " + "[1, 4294967295] ([1, 1024] on Windows). Passing 'noset' suppresses this behavior.", + type=str, + default="noset", + dest="hash_seed", + ) + parser.add_argument( + "--discover", + dest="discover", + nargs="+", + metavar="path", + help="for Python discovery first try the Python executables under these paths", + default=[], + ) + if mode not in ("depends",): + parser.add_argument( + "--no-recreate-pkg", + dest="no_recreate_pkg", + help="if recreate is set do not recreate packaging tox environment(s)", + action="https://github.com/store_true", + ) + if mode not in ("devenv", "config", "depends"): + parser.add_argument( + "--skip-pkg-install", + dest="skip_pkg_install", + help="skip package installation for this run", + action="https://github.com/store_true", + ) + + +def report(start: float, runs: list[ToxEnvRunResult], is_colored: bool) -> int: + def _print(color_: int, message: str) -> None: + print(f"{color_ if is_colored else ''}{message}{Fore.RESET if is_colored else ''}") + + successful, skipped = [], [] + for run in runs: + successful.append(run.code == Outcome.OK or run.ignore_outcome) + skipped.append(run.skipped) + duration_individual = [o.elapsed for o in run.outcomes] + extra = f"+cmd[{','.join(f'{i:.2f}' for i in duration_individual)}]" if duration_individual else "" + setup = run.duration - sum(duration_individual) + msg, color = _get_outcome_message(run) + out = f" {run.name}: {msg} ({run.duration:.2f}{f'=setup[{setup:.2f}]{extra}' if extra else ''} seconds)" + _print(color, out) + + duration = time.monotonic() - start + all_good = all(successful) and not all(skipped) + if all_good: + _print(Fore.GREEN, f" congratulations :) ({duration:.2f} seconds)") + return Outcome.OK + _print(Fore.RED, f" evaluation failed :( ({duration:.2f} seconds)") + return runs[0].code if len(runs) == 1 else -1 + + +def _get_outcome_message(run: ToxEnvRunResult) -> tuple[str, int]: + if run.skipped: + msg, color = "SKIP", Fore.YELLOW + elif run.code == Outcome.OK: + msg, color = "OK", Fore.GREEN + else: + if run.ignore_outcome: + msg, color = f"IGNORED FAIL code {run.code}", Fore.YELLOW + else: + msg, color = f"FAIL code {run.code}", Fore.RED + return msg, color + + +logger = logging.getLogger(__name__) + + +def execute(state: State, max_workers: int | None, has_spinner: bool, live: bool) -> int: + interrupt, done = Event(), Event() + results: list[ToxEnvRunResult] = [] + future_to_env: dict[Future[ToxEnvRunResult], ToxEnv] = {} + state.envs.ensure_only_run_env_is_active() + to_run_list: list[str] = list(state.envs.iter()) + for name in to_run_list: + cast(RunToxEnv, state.envs[name]).mark_active() + previous, has_previous = None, False + try: + spinner = ToxSpinner(has_spinner, state, len(to_run_list)) + thread = Thread( + target=_queue_and_wait, + name="tox-interrupt", + args=(state, to_run_list, results, future_to_env, interrupt, done, max_workers, spinner, live), + ) + thread.start() + try: + thread.join() + except KeyboardInterrupt: + previous, has_previous = signal(SIGINT, Handlers.SIG_IGN), True + spinner.print_report = False # no need to print reports at this point, final report coming up + logger.error(f"[{os.getpid()}] KeyboardInterrupt - teardown started") + interrupt.set() + # cancel in reverse order to not allow submitting new jobs as we cancel running ones + for future, tox_env in reversed(list(future_to_env.items())): + cancelled = future.cancel() + # if cannot be cancelled and not done -> still runs + if cancelled is False and not future.done(): # pragma: no branch + tox_env.interrupt() + done.wait() + # workaround for https://bugs.python.org/issue45274 + lock = getattr(thread, "_tstate_lock", None) + if lock is not None and lock.locked(): # pragma: no branch + lock.release() # pragma: no cover + thread._stop() # type: ignore # pragma: no cover # calling private method to fix thread state + thread.join() + finally: + ordered_results: list[ToxEnvRunResult] = [] + name_to_run = {r.name: r for r in results} + for env in to_run_list: + ordered_results.append(name_to_run[env]) + # write the journal + write_journal(getattr(state.conf.options, "result_json", None), state._journal) + # report the outcome + exit_code = report(state.conf.options.start, ordered_results, state.conf.options.is_colored) + if has_previous: + signal(SIGINT, previous) + return exit_code + + +class ToxSpinner(Spinner): + def __init__(self, enabled: bool, state: State, total: int) -> None: + super().__init__( + enabled=enabled, + colored=state.conf.options.is_colored, + stream=state._options.log_handler.stdout, + total=total, + ) + + def update_spinner(self, result: ToxEnvRunResult, success: bool) -> None: + if success: + done = self.skip if result.skipped else self.succeed + else: + done = self.fail + done(result.name) + + +def _queue_and_wait( + state: State, + to_run_list: list[str], + results: list[ToxEnvRunResult], + future_to_env: dict[Future[ToxEnvRunResult], ToxEnv], + interrupt: Event, + done: Event, + max_workers: int | None, + spinner: ToxSpinner, + live: bool, +) -> None: + try: + options = state._options + with spinner: + max_workers = len(to_run_list) if max_workers is None else max_workers + completed: set[str] = set() + envs_to_run_generator = ready_to_run_envs(state, to_run_list, completed) + + def _run(tox_env: RunToxEnv) -> ToxEnvRunResult: + spinner.add(tox_env.conf.name) + return run_one(tox_env, options.parsed.no_test, suspend_display=live is False) + + try: + executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="tox-driver") + env_list: list[str] = [] + while True: + for env in env_list: # queue all available + tox_env_to_run = cast(RunToxEnv, state.envs[env]) + if interrupt.is_set(): # queue the rest as failed upfront + tox_env_to_run.teardown() + future: Future[ToxEnvRunResult] = Future() + res = ToxEnvRunResult(name=env, skipped=False, code=-2, outcomes=[], duration=MISS_DURATION) + future.set_result(res) + else: + future = executor.submit(_run, tox_env_to_run) + future_to_env[future] = tox_env_to_run + + if not future_to_env: + result: ToxEnvRunResult | None = None + else: # if we have queued wait for completed + future = next(as_completed(future_to_env)) + tox_env_done = future_to_env.pop(future) + try: + result = future.result() + except CancelledError: + tox_env_done.teardown() + name = tox_env_done.conf.name + result = ToxEnvRunResult( + name=name, + skipped=False, + code=-3, + outcomes=[], + duration=MISS_DURATION, + ) + results.append(result) + completed.add(result.name) + + env_list = next(envs_to_run_generator, []) + # if nothing running and nothing more to run we're done + final_run = not env_list and not future_to_env + if final_run: # disable report on final env + spinner.print_report = False + if result is not None: + _handle_one_run_done(result, spinner, state, live) + if final_run: + break + + except BaseException: # pragma: no cover + logging.exception("Internal Error") # pragma: no cover + raise # pragma: no cover + finally: + executor.shutdown(wait=True) + finally: + try: + # call teardown - configuration only environments for example could not be finished + for name in to_run_list: + state.envs[name].teardown() + finally: + done.set() + + +def _handle_one_run_done(result: ToxEnvRunResult, spinner: ToxSpinner, state: State, live: bool) -> None: + success = result.code == Outcome.OK + spinner.update_spinner(result, success) + tox_env = cast(RunToxEnv, state.envs[result.name]) + if tox_env.journal: # add overall journal entry + tox_env.journal["result"] = { + "success": success, + "exit_code": result.code, + "duration": result.duration, + } + if live is False and state.conf.options.parallel_live is False: # teardown background run + out_err = tox_env.close_and_read_out_err() # sync writes from buffer to stdout/stderr + pkg_out_err_list = [] + for package_env in tox_env.package_envs: + pkg_out_err = package_env.close_and_read_out_err() + if pkg_out_err is not None: # pragma: no branch + pkg_out_err_list.append(pkg_out_err) + if not success or tox_env.conf["parallel_show_output"]: + for pkg_out_err in pkg_out_err_list: + state._options.log_handler.write_out_err(pkg_out_err) # pragma: no cover + if out_err is not None: # pragma: no branch # first show package build + state._options.log_handler.write_out_err(out_err) + + +def ready_to_run_envs(state: State, to_run: list[str], completed: set[str]) -> Iterator[list[str]]: + """Generate tox environments ready to run""" + order, todo = run_order(state, to_run) + while order: + ready_to_run: list[str] = [] + new_order: list[str] = [] + for env in order: # collect next batch of ready to run + if todo[env] - completed: + new_order.append(env) + else: + ready_to_run.append(env) + order = new_order + yield ready_to_run + + +def run_order(state: State, to_run: list[str]) -> tuple[list[str], dict[str, set[str]]]: + to_run_set = set(to_run) + todo: dict[str, set[str]] = {} + for env in to_run: + run_env = cast(RunToxEnv, state.envs[env]) + depends = set(cast(EnvList, run_env.conf["depends"]).envs) + todo[env] = to_run_set & depends + order = stable_topological_sort(todo) + return order, todo diff --git a/src/tox/session/cmd/run/parallel.py b/src/tox/session/cmd/run/parallel.py new file mode 100644 index 000000000..c1cd6680f --- /dev/null +++ b/src/tox/session/cmd/run/parallel.py @@ -0,0 +1,82 @@ +""" +Run tox environments in parallel. +""" +from __future__ import annotations + +import logging +from argparse import ArgumentParser, ArgumentTypeError + +from tox.config.cli.parser import ToxParser +from tox.plugin import impl +from tox.session.state import State +from tox.util.cpu import auto_detect_cpus + +from ...env_select import CliEnv, register_env_select_flags +from .common import env_run_create_flags, execute + +logger = logging.getLogger(__name__) + +ENV_VAR_KEY = "TOX_PARALLEL_ENV" +OFF_VALUE = 0 +DEFAULT_PARALLEL = OFF_VALUE + + +@impl +def tox_add_option(parser: ToxParser) -> None: + our = parser.add_command("run-parallel", ["p"], "run environments in parallel", run_parallel) + register_env_select_flags(our, default=CliEnv()) + env_run_create_flags(our, mode="run-parallel") + parallel_flags(our, default_parallel=auto_detect_cpus()) + + +def parse_num_processes(str_value: str) -> int | None: + if str_value == "all": + return None + if str_value == "auto": + return auto_detect_cpus() + try: + value = int(str_value) + except ValueError as exc: + raise ArgumentTypeError(f"value must be a positive number, is {str_value!r}") from exc + if value < 0: + raise ArgumentTypeError(f"value must be positive, is {value!r}") + return value + + +def parallel_flags(our: ArgumentParser, default_parallel: int, no_args: bool = False) -> None: + our.add_argument( + "-p", + "--parallel", + dest="parallel", + help="run tox environments in parallel, the argument controls limit: all," + " auto - cpu count, some positive number, zero is turn off", + action="https://github.com/store", + type=parse_num_processes, # type: ignore # nargs confuses it + default=default_parallel, + metavar="VAL", + **({"nargs": "?"} if no_args else {}), # type: ignore # type checker can't unroll it + ) + our.add_argument( + "-o", + "--parallel-live", + action="https://github.com/store_true", + dest="parallel_live", + help="connect to stdout while running environments", + ) + our.add_argument( + "--parallel-no-spinner", + action="https://github.com/store_true", + dest="parallel_no_spinner", + help="do not show the spinner", + ) + + +def run_parallel(state: State) -> int: + """here we'll just start parallel sub-processes""" + option = state.conf.options + return execute( + state, + max_workers=option.parallel, + has_spinner=option.parallel_no_spinner is False and option.parallel_live is False, + live=option.parallel_live, + ) diff --git a/src/tox/session/cmd/run/sequential.py b/src/tox/session/cmd/run/sequential.py new file mode 100644 index 000000000..682b35e80 --- /dev/null +++ b/src/tox/session/cmd/run/sequential.py @@ -0,0 +1,22 @@ +""" +Run tox environments in sequential order. +""" +from __future__ import annotations + +from tox.config.cli.parser import ToxParser +from tox.plugin import impl +from tox.session.state import State + +from ...env_select import CliEnv, register_env_select_flags +from .common import env_run_create_flags, execute + + +@impl +def tox_add_option(parser: ToxParser) -> None: + our = parser.add_command("run", ["r"], "run environments", run_sequential) + register_env_select_flags(our, default=CliEnv()) + env_run_create_flags(our, mode="run") + + +def run_sequential(state: State) -> int: + return execute(state, max_workers=1, has_spinner=False, live=True) diff --git a/src/tox/session/cmd/run/single.py b/src/tox/session/cmd/run/single.py new file mode 100644 index 000000000..9cc3e45e5 --- /dev/null +++ b/src/tox/session/cmd/run/single.py @@ -0,0 +1,122 @@ +""" +Defines how to run a single tox environment. +""" +from __future__ import annotations + +import logging +import time +from pathlib import Path +from typing import NamedTuple, cast + +from tox.config.types import Command +from tox.execute.api import Outcome, StdinSource +from tox.tox_env.api import ToxEnv +from tox.tox_env.errors import Fail, Skip +from tox.tox_env.python.virtual_env.package.pyproject import ToxBackendFailed +from tox.tox_env.runner import RunToxEnv + +LOGGER = logging.getLogger(__name__) + + +class ToxEnvRunResult(NamedTuple): + name: str + skipped: bool + code: int + outcomes: list[Outcome] + duration: float + ignore_outcome: bool = False + + +def run_one(tox_env: RunToxEnv, no_test: bool, suspend_display: bool) -> ToxEnvRunResult: + start_one = time.monotonic() + name = tox_env.conf.name + with tox_env.display_context(suspend_display): + skipped, code, outcomes = _evaluate(tox_env, no_test) + duration = time.monotonic() - start_one + return ToxEnvRunResult(name, skipped, code, outcomes, duration, tox_env.conf["ignore_outcome"]) + + +def _evaluate(tox_env: RunToxEnv, no_test: bool) -> tuple[bool, int, list[Outcome]]: + skipped = False + code: int = 0 + outcomes: list[Outcome] = [] + try: + try: + tox_env.setup() + code, outcomes = run_commands(tox_env, no_test) + except Skip as exception: + LOGGER.warning("skipped because %s", exception) + skipped = True + except ToxBackendFailed as exception: + LOGGER.error("%s", exception) + raise SystemExit(exception.code) + except Fail as exception: + LOGGER.error("failed with %s", exception) + code = 1 + except Exception: # pragma: no cover + LOGGER.exception("internal error") # pragma: no cover + code = 2 # pragma: no cover + finally: + tox_env.teardown() + except SystemExit as exception: # setup command fails (interrupted or via invocation) + code = cast(int, exception.code) + return skipped, code, outcomes + + +def run_commands(tox_env: RunToxEnv, no_test: bool) -> tuple[int, list[Outcome]]: + outcomes: list[Outcome] = [] + if no_test: + exit_code = Outcome.OK + else: + from tox.plugin.manager import MANAGER # importing this here to avoid circular import + + chdir: Path = tox_env.conf["change_dir"] + ignore_errors: bool = tox_env.conf["ignore_errors"] + MANAGER.tox_before_run_commands(tox_env) + status_pre, status_main, status_post = -1, -1, -1 + try: + try: + status_pre = run_command_set(tox_env, "commands_pre", chdir, ignore_errors, outcomes) + if status_pre == Outcome.OK or ignore_errors: + status_main = run_command_set(tox_env, "commands", chdir, ignore_errors, outcomes) + else: + status_main = Outcome.OK + finally: + status_post = run_command_set(tox_env, "commands_post", chdir, ignore_errors, outcomes) + finally: + exit_code = status_pre or status_main or status_post # first non-success + MANAGER.tox_after_run_commands(tox_env, exit_code, outcomes) + return exit_code, outcomes + + +def run_command_set(tox_env: ToxEnv, key: str, cwd: Path, ignore_errors: bool, outcomes: list[Outcome]) -> int: + exit_code = Outcome.OK + command_set: list[Command] = tox_env.conf[key] + for at, cmd in enumerate(command_set): + current_outcome = tox_env.execute( + cmd.args, + cwd=cwd, + stdin=StdinSource.user_only(), + show=True, + run_id=f"{key}[{at}]", + ) + outcomes.append(current_outcome) + try: + current_outcome.assert_success() + except SystemExit as exception: + if cmd.ignore_exit_code: + logging.warning("command failed but is marked ignore outcome so handling it as success") + continue + if ignore_errors: + if exit_code == Outcome.OK: + exit_code = cast(int, exception.code) # ignore errors continues ahead but saves the exit code + continue + return cast(int, exception.code) + return exit_code + + +__all__ = ( + "run_one", + "run_command_set", + "ToxEnvRunResult", +) diff --git a/src/tox/session/cmd/show_config.py b/src/tox/session/cmd/show_config.py new file mode 100644 index 000000000..82181e3c1 --- /dev/null +++ b/src/tox/session/cmd/show_config.py @@ -0,0 +1,111 @@ +""" +Show materialized configuration of tox environments. +""" +from __future__ import annotations + +from textwrap import indent +from typing import Iterable + +from colorama import Fore + +from tox.config.cli.parser import ToxParser +from tox.config.loader.stringify import stringify +from tox.config.sets import ConfigSet +from tox.plugin import impl +from tox.session.cmd.run.common import env_run_create_flags +from tox.session.env_select import CliEnv, register_env_select_flags +from tox.session.state import State +from tox.tox_env.api import ToxEnv + + +@impl +def tox_add_option(parser: ToxParser) -> None: + our = parser.add_command("config", ["c"], "show tox configuration", show_config) + our.add_argument( + "-k", + nargs="+", + help="list just configuration keys specified", + dest="list_keys_only", + default=[], + metavar="key", + ) + our.add_argument( + "--core", + action="https://github.com/store_true", + help="show core options too when selecting an env with -e", + dest="show_core", + ) + register_env_select_flags(our, default=CliEnv("ALL")) + env_run_create_flags(our, mode="config") + + +def show_config(state: State) -> int: + is_colored = state.conf.options.is_colored + keys: list[str] = state.conf.options.list_keys_only + is_first = True + + def _print_env(tox_env: ToxEnv) -> None: + nonlocal is_first + if is_first: + is_first = False + else: + print("") + print_section_header(is_colored, f"[testenv:{tox_env.conf.name}]") + if not keys: + print_key_value(is_colored, "type", type(tox_env).__name__) + print_conf(is_colored, tox_env.conf, keys) + + show_everything = state.conf.options.env.is_all + done: set[str] = set() + for name in state.envs.iter(package=True): # now go through selected ones + done.add(name) + _print_env(state.envs[name]) + + # environments may define core configuration flags, so we must exhaust first the environments to tell the core part + if show_everything or state.conf.options.show_core: + print("") + print_section_header(is_colored, "[tox]") + print_conf(is_colored, state.conf.core, keys) + return 0 + + +def _colored(is_colored: bool, color: int, msg: str) -> str: + return f"{color}{msg}{Fore.RESET}" if is_colored else msg + + +def print_section_header(is_colored: bool, name: str) -> None: + print(_colored(is_colored, Fore.YELLOW, name)) + + +def print_comment(is_colored: bool, comment: str) -> None: + print(_colored(is_colored, Fore.CYAN, comment)) + + +def print_key_value(is_colored: bool, key: str, value: str, multi_line: bool = False) -> None: + print(_colored(is_colored, Fore.GREEN, key), end="") + print(" =", end="") + if multi_line: + print("") + value_str = indent(value, prefix=" ") + else: + print(" ", end="") + value_str = value + print(value_str) + + +def print_conf(is_colored: bool, conf: ConfigSet, keys: Iterable[str]) -> None: + for key in keys if keys else conf: + if key not in conf: + continue + key = conf.primary_key(key) + try: + value = conf[key] + as_str, multi_line = stringify(value) + except Exception as exception: # because e.g. the interpreter cannot be found + as_str, multi_line = _colored(is_colored, Fore.LIGHTRED_EX, f"# Exception: {exception!r}"), False + if multi_line and "\n" not in as_str: + multi_line = False + print_key_value(is_colored, key, as_str, multi_line=multi_line) + unused = conf.unused() + if unused and not keys: + print_comment(is_colored, f"# !!! unused: {', '.join(unused)}") diff --git a/src/tox/session/cmd/version_flag.py b/src/tox/session/cmd/version_flag.py new file mode 100644 index 000000000..cad1c3f92 --- /dev/null +++ b/src/tox/session/cmd/version_flag.py @@ -0,0 +1,48 @@ +""" +Display the version information about tox. +""" +from __future__ import annotations + +import sys +from argparse import SUPPRESS, Action, ArgumentParser, Namespace +from pathlib import Path +from typing import Any, Sequence, cast + +import tox +from tox.config.cli.parser import HelpFormatter, ToxParser +from tox.plugin import impl +from tox.plugin.manager import MANAGER +from tox.version import version + + +@impl +def tox_add_option(parser: ToxParser) -> None: + class _V(Action): + def __init__(self, option_strings: Sequence[str], dest: str = SUPPRESS) -> None: + help_msg = "show program's and plugins version number and exit" + super().__init__(option_strings=option_strings, dest=dest, nargs=0, help=help_msg, default=SUPPRESS) + + def __call__( + self, + parser: ArgumentParser, + namespace: Namespace, # noqa: U100 + values: str | Sequence[Any] | None, # noqa: U100 + option_string: str | None = None, # noqa: U100 + ) -> None: + formatter = cast(HelpFormatter, parser._get_formatter()) + formatter.add_raw_text(get_version_info()) + parser._print_message(formatter.format_help(), sys.stdout) + parser.exit() + + parser.add_argument("--version", action=_V) + + +def get_version_info() -> str: + out = [f"{version} from {Path(tox.__file__).absolute()}"] + plugin_info = MANAGER.manager.list_plugin_distinfo() + if plugin_info: + out.append("registered plugins:") + for module, egg_info in plugin_info: + source = getattr(module, "__file__", repr(module)) + out.append(f" {egg_info.project_name}-{egg_info.version} at {source}") + return "\n".join(out) diff --git a/src/tox/session/commands/help.py b/src/tox/session/commands/help.py deleted file mode 100644 index 9c5cc70b7..000000000 --- a/src/tox/session/commands/help.py +++ /dev/null @@ -1,14 +0,0 @@ -from tox import reporter - - -def show_help(config): - reporter.line(config._parser._format_help()) - reporter.line("Environment variables", bold=True) - reporter.line("TOXENV: comma separated list of environments (overridable by '-e')") - reporter.line("TOX_SKIP_ENV: regular expression to filter down from running tox environments") - reporter.line( - "TOX_TESTENV_PASSENV: space-separated list of extra environment variables to be " - "passed into test command environments", - ) - reporter.line("PY_COLORS: 0 disable colorized output, 1 enable (default)") - reporter.line("TOX_PARALLEL_NO_SPINNER: 1 disable spinner for CI, 0 enable (default)") diff --git a/src/tox/session/commands/help_ini.py b/src/tox/session/commands/help_ini.py deleted file mode 100644 index 8791ded9f..000000000 --- a/src/tox/session/commands/help_ini.py +++ /dev/null @@ -1,16 +0,0 @@ -from tox import reporter - - -def show_help_ini(config): - reporter.separator("-", "per-testenv attributes", reporter.Verbosity.INFO) - for env_attr in config._testenv_attr: - reporter.line( - "{:<15} {:<8} default: {}".format( - env_attr.name, - "<{}>".format(env_attr.type), - env_attr.default, - ), - bold=True, - ) - reporter.line(env_attr.help) - reporter.line("") diff --git a/src/tox/session/commands/provision.py b/src/tox/session/commands/provision.py deleted file mode 100644 index 738bcb84e..000000000 --- a/src/tox/session/commands/provision.py +++ /dev/null @@ -1,31 +0,0 @@ -"""In case the tox environment is not correctly setup provision it and delegate execution""" -from __future__ import absolute_import, unicode_literals - -import os - -from tox.exception import InvocationError -from tox.reporter import verbosity0 -from tox.util.lock import hold_lock - - -def provision_tox(provision_venv, args): - ensure_meta_env_up_to_date(provision_venv) - with provision_venv.new_action("provision") as action: - provision_args = [str(provision_venv.envconfig.envpython), "-m", "tox"] + args - try: - env = os.environ.copy() - env[str("TOX_PROVISION")] = str("1") - env.pop("__PYVENV_LAUNCHER__", None) - action.popen(provision_args, redirect=False, report_fail=False, env=env) - return 0 - except InvocationError as exception: - return exception.exit_code - - -def ensure_meta_env_up_to_date(provision_venv): - config = provision_venv.envconfig.config - lock_file = config.toxworkdir.join("{}.lock".format(config.provision_tox_env)) - - with hold_lock(lock_file, verbosity0): - if provision_venv.setupenv(): - provision_venv.finishvenv() diff --git a/src/tox/session/commands/run/parallel.py b/src/tox/session/commands/run/parallel.py deleted file mode 100644 index 8675ea5cb..000000000 --- a/src/tox/session/commands/run/parallel.py +++ /dev/null @@ -1,143 +0,0 @@ -import os -import sys -from collections import OrderedDict, deque -from threading import Event, Semaphore, Thread - -from tox import reporter -from tox.config.parallel import ENV_VAR_KEY_PRIVATE as PARALLEL_ENV_VAR_KEY_PRIVATE -from tox.config.parallel import ENV_VAR_KEY_PUBLIC as PARALLEL_ENV_VAR_KEY_PUBLIC -from tox.exception import InvocationError -from tox.util.main import MAIN_FILE -from tox.util.spinner import Spinner - - -def run_parallel(config, venv_dict): - """here we'll just start parallel sub-processes""" - live_out = config.option.parallel_live - disable_spinner = bool(os.environ.get("TOX_PARALLEL_NO_SPINNER") == "1") - args = [sys.executable, MAIN_FILE] + config.args - try: - position = args.index("--") - except ValueError: - position = len(args) - - max_parallel = config.option.parallel - if max_parallel is None: - max_parallel = len(venv_dict) - semaphore = Semaphore(max_parallel) - finished = Event() - - show_progress = ( - not disable_spinner and not live_out and reporter.verbosity() > reporter.Verbosity.QUIET - ) - - with Spinner(enabled=show_progress) as spinner: - - def run_in_thread(tox_env, os_env, processes): - output = None - print_out = None - env_name = tox_env.envconfig.envname - status = "skipped tests" if config.option.notest else None - try: - os_env[str(PARALLEL_ENV_VAR_KEY_PRIVATE)] = str(env_name) - os_env[str(PARALLEL_ENV_VAR_KEY_PUBLIC)] = str(env_name) - args_sub = list(args) - if hasattr(tox_env, "package"): - args_sub.insert(position, str(tox_env.package)) - args_sub.insert(position, "--installpkg") - if tox_env.get_result_json_path(): - result_json_index = args_sub.index("--result-json") - args_sub[result_json_index + 1] = "{}".format(tox_env.get_result_json_path()) - with tox_env.new_action("parallel {}".format(tox_env.name)) as action: - - def collect_process(process): - processes[tox_env] = (action, process) - - print_out = not live_out and tox_env.envconfig.parallel_show_output - output = action.popen( - args=args_sub, - env=os_env, - redirect=not live_out, - capture_err=print_out, - callback=collect_process, - returnout=print_out, - ) - - except InvocationError as err: - status = "parallel child exit code {}".format(err.exit_code) - finally: - semaphore.release() - finished.set() - tox_env.status = status - done.add(env_name) - outcome = spinner.succeed - if config.option.notest: - outcome = spinner.skip - elif status is not None: - outcome = spinner.fail - outcome(env_name) - if print_out and output is not None: - reporter.verbosity0(output) - - threads = deque() - processes = {} - todo_keys = set(venv_dict.keys()) - todo = OrderedDict((n, todo_keys & set(v.envconfig.depends)) for n, v in venv_dict.items()) - done = set() - try: - while todo: - for name, depends in list(todo.items()): - if depends - done: - # skip if has unfinished dependencies - continue - del todo[name] - venv = venv_dict[name] - semaphore.acquire(blocking=True) - spinner.add(name) - thread = Thread( - target=run_in_thread, - args=(venv, os.environ.copy(), processes), - ) - thread.daemon = True - thread.start() - threads.append(thread) - if todo: - # wait until someone finishes and retry queuing jobs - finished.wait() - finished.clear() - while threads: - threads = [ - thread for thread in threads if not thread.join(0.1) and thread.is_alive() - ] - except KeyboardInterrupt: - reporter.verbosity0( - "[{}] KeyboardInterrupt parallel - stopping children".format(os.getpid()), - ) - while True: - # do not allow to interrupt until children interrupt - try: - # putting it inside a thread so it's not interrupted - stopper = Thread(target=_stop_child_processes, args=(processes, threads)) - stopper.start() - stopper.join() - except KeyboardInterrupt: - continue - raise KeyboardInterrupt - - -def _stop_child_processes(processes, main_threads): - """A three level stop mechanism for children - INT (250ms) -> TERM (100ms) -> KILL""" - - # first stop children - def shutdown(tox_env, action, process): - action.handle_interrupt(process) - - threads = [Thread(target=shutdown, args=(n, a, p)) for n, (a, p) in processes.items()] - for thread in threads: - thread.start() - for thread in threads: - thread.join() - - # then its threads - for thread in main_threads: - thread.join() diff --git a/src/tox/session/commands/run/sequential.py b/src/tox/session/commands/run/sequential.py deleted file mode 100644 index 907690985..000000000 --- a/src/tox/session/commands/run/sequential.py +++ /dev/null @@ -1,76 +0,0 @@ -import py - -import tox -from tox.exception import InvocationError - - -def run_sequential(config, venv_dict): - for venv in venv_dict.values(): - if venv.setupenv(): - if venv.envconfig.skip_install: - venv.finishvenv() - else: - if venv.envconfig.usedevelop: - develop_pkg(venv, config.setupdir) - elif config.skipsdist: - venv.finishvenv() - else: - installpkg(venv, venv.package) - if venv.status == 0: - runenvreport(venv, config) - if venv.status == 0: - runtestenv(venv, config) - - -def develop_pkg(venv, setupdir): - with venv.new_action("developpkg", setupdir) as action: - try: - venv.developpkg(setupdir, action) - return True - except InvocationError as exception: - venv.status = exception - return False - - -def installpkg(venv, path): - """Install package in the specified virtual environment. - - :param VenvConfig venv: Destination environment - :param str path: Path to the distribution package. - :return: True if package installed otherwise False. - :rtype: bool - """ - venv.env_log.set_header(installpkg=py.path.local(path)) - with venv.new_action("installpkg", path) as action: - try: - venv.installpkg(path, action) - return True - except tox.exception.InvocationError as exception: - venv.status = exception - return False - - -def runenvreport(venv, config): - """ - Run an environment report to show which package - versions are installed in the venv - """ - try: - with venv.new_action("envreport") as action: - packages = config.pluginmanager.hook.tox_runenvreport(venv=venv, action=action) - action.setactivity("installed", ",".join(packages)) - venv.env_log.set_installed(packages) - except InvocationError as exception: - venv.status = exception - - -def runtestenv(venv, config, redirect=False): - if venv.status == 0 and config.option.notest: - venv.status = "skipped tests" - else: - if venv.status: - return - config.pluginmanager.hook.tox_runtest_pre(venv=venv) - if venv.status == 0: - config.pluginmanager.hook.tox_runtest(venv=venv, redirect=redirect) - config.pluginmanager.hook.tox_runtest_post(venv=venv) diff --git a/src/tox/session/commands/show_config.py b/src/tox/session/commands/show_config.py deleted file mode 100644 index f0ff955fc..000000000 --- a/src/tox/session/commands/show_config.py +++ /dev/null @@ -1,82 +0,0 @@ -import sys -from collections import OrderedDict - -from packaging.requirements import Requirement -from six import StringIO -from six.moves import configparser - -from tox import reporter -from tox.util.stdlib import importlib_metadata - -DO_NOT_SHOW_CONFIG_ATTRIBUTES = ( - "interpreters", - "envconfigs", - "envlist", - "pluginmanager", - "envlist_explicit", -) - - -def show_config(config): - parser = configparser.RawConfigParser() - - if not config.envlist_explicit or reporter.verbosity() >= reporter.Verbosity.INFO: - tox_info(config, parser) - version_info(parser) - tox_envs_info(config, parser) - - content = StringIO() - parser.write(content) - value = content.getvalue().rstrip() - reporter.verbosity0(value) - - -def tox_envs_info(config, parser): - if config.envlist_explicit: - env_list = config.envlist - elif config.option.listenvs: - env_list = config.envlist_default - else: - env_list = list(config.envconfigs.keys()) - for name in env_list: - env_config = config.envconfigs[name] - values = OrderedDict( - (attr.name, str(getattr(env_config, attr.name))) - for attr in config._parser._testenv_attr - ) - section = "testenv:{}".format(name) - set_section(parser, section, values) - - -def tox_info(config, parser): - info = OrderedDict( - (i, str(getattr(config, i))) - for i in sorted(dir(config)) - if not i.startswith("_") and i not in DO_NOT_SHOW_CONFIG_ATTRIBUTES - ) - info["host_python"] = sys.executable - set_section(parser, "tox", info) - - -def version_info(parser): - versions = OrderedDict() - to_visit = {"tox"} - while to_visit: - current = to_visit.pop() - current_dist = importlib_metadata.distribution(current) - current_name = current_dist.metadata["name"] - versions[current_name] = current_dist.version - if current_dist.requires is not None: - for require in current_dist.requires: - pkg = Requirement(require) - if ( - pkg.marker is None or pkg.marker.evaluate({"extra": ""}) - ) and pkg.name not in versions: - to_visit.add(pkg.name) - set_section(parser, "tox:versions", versions) - - -def set_section(parser, section, values): - parser.add_section(section) - for key, value in values.items(): - parser.set(section, key, value) diff --git a/src/tox/session/commands/show_env.py b/src/tox/session/commands/show_env.py deleted file mode 100644 index 1ed9ba937..000000000 --- a/src/tox/session/commands/show_env.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -from tox import reporter as report - - -def show_envs(config, all_envs=False, description=False): - env_conf = config.envconfigs # this contains all environments - default = config.envlist_default # this only the defaults - ignore = {config.isolated_build_env, config.provision_tox_env}.union(default) - extra = [e for e in env_conf if e not in ignore] if all_envs else [] - - if description and default: - report.line("default environments:") - max_length = max(len(env) for env in (default + extra) or [""]) - - def report_env(e): - if description: - text = env_conf[e].description or "[no description]" - msg = "{} -> {}".format(e.ljust(max_length), text).strip() - else: - msg = e - report.line(msg) - - for e in default: - report_env(e) - if all_envs and extra: - if description: - if default: - report.line("") - report.line("additional environments:") - for e in extra: - report_env(e) diff --git a/src/tox/session/env_select.py b/src/tox/session/env_select.py new file mode 100644 index 000000000..15d41c43b --- /dev/null +++ b/src/tox/session/env_select.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +from argparse import ArgumentParser +from collections import Counter +from dataclasses import dataclass +from itertools import chain +from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, cast + +from tox.config.loader.str_convert import StrConvert +from tox.tox_env.api import ToxEnvCreateArgs +from tox.tox_env.register import REGISTER +from tox.tox_env.runner import RunToxEnv + +from ..config.loader.memory import MemoryLoader +from ..config.types import EnvList +from ..report import HandledError +from ..tox_env.errors import Skip +from ..tox_env.package import PackageToxEnv + +if TYPE_CHECKING: + from tox.session.state import State + + +class CliEnv: + """CLI tox env selection""" + + def __init__(self, value: None | list[str] | str = None): + if isinstance(value, str): + value = StrConvert().to(value, of_type=List[str], factory=None) + self._names: list[str] | None = value + + def __iter__(self) -> Iterator[str]: + if not self.is_all and self._names is not None: # pragma: no branch + yield from self._names + + def __bool__(self) -> bool: + return bool(self._names) + + def __str__(self) -> str: + return "ALL" if self.is_all else ("" if self.is_default_list else ",".join(self)) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({'' if self.is_default_list else repr(str(self))})" + + def __eq__(self, other: Any) -> bool: + return type(self) == type(other) and self._names == other._names + + def __ne__(self, other: Any) -> bool: + return not (self == other) + + @property + def is_all(self) -> bool: + return self._names is not None and "ALL" in self._names + + @property + def is_default_list(self) -> bool: + return not (self._names or []) + + +def register_env_select_flags( + parser: ArgumentParser, + default: CliEnv | None, + multiple: bool = True, + group_only: bool = False, +) -> ArgumentParser: + """ + Register environment selection flags. + + :param parser: the parser to register to + :param default: the default value for env selection + :param multiple: allow selecting multiple environments + :param group_only: + :return: + """ + if multiple: + group = parser.add_argument_group("select target environment(s)") + add_to: ArgumentParser = group.add_mutually_exclusive_group(required=False) # type: ignore + else: + add_to = parser + if not group_only: + if multiple: + help_msg = "enumerate (ALL -> all environments, not set -> use from config)" + else: + help_msg = "environment to run" + add_to.add_argument("-e", dest="env", help=help_msg, default=default, type=CliEnv) + if multiple: + help_msg = "labels to evaluate" + add_to.add_argument("-m", dest="labels", metavar="label", help=help_msg, default=[], type=str, nargs="+") + help_msg = "factors to evaluate" + add_to.add_argument("-f", dest="factors", metavar="factor", help=help_msg, default=[], type=str, nargs="+") + return add_to + + +@dataclass +class _ToxEnvInfo: + """tox environment information""" + + env: PackageToxEnv | RunToxEnv #: the tox environment + is_active: bool #: a flag indicating if the environment is marked as active in the current run + package_skip: tuple[str, Skip] | None = None #: if set the creation of the packaging environment failed + + +class EnvSelector: + def __init__(self, state: State) -> None: + # needs core to load the default tox environment list + # to load the package environments of a run environments we need the run environment builder + # to load labels we need core + the run environment + self.on_empty_fallback_py = True + self._state = state + self._cli_envs: CliEnv | None = getattr(self._state.conf.options, "env", None) + self._defined_envs_: None | dict[str, _ToxEnvInfo] = None + self._pkg_env_counter: Counter[str] = Counter() + from tox.plugin.manager import MANAGER + + self._manager = MANAGER + self._log_handler = self._state._options.log_handler + self._journal = self._state._journal + self._provision: None | tuple[bool, str, MemoryLoader] = None + + self._state.conf.core.add_config("labels", Dict[str, EnvList], {}, "core labels") + + def _collect_names(self) -> Iterator[tuple[Iterable[str], bool]]: + """:return: sources of tox environments defined with name and if is marked as target to run""" + if self._provision is not None: # pragma: no branch + yield (self._provision[1],), False + env_list, everything_active = self._state.conf.core["env_list"], False + if self._cli_envs is None or self._cli_envs.is_default_list: + yield env_list, True + elif self._cli_envs.is_all: + everything_active = True + else: + yield self._cli_envs, True + yield self._state.conf, everything_active + label_envs = dict.fromkeys(chain.from_iterable(self._state.conf.core["labels"].values())) + if label_envs: + yield label_envs.keys(), False + + def _env_name_to_active(self) -> dict[str, bool]: + env_name_to_active_map = {} + for a_collection, is_active in self._collect_names(): + for name in a_collection: + if name not in env_name_to_active_map: + env_name_to_active_map[name] = is_active + # for factor/label selection update the active flag + if not (getattr(self._state.conf.options, "labels", []) or getattr(self._state.conf.options, "factors", [])): + # if no active environment is defined fallback to py + if self.on_empty_fallback_py and not any(env_name_to_active_map.values()): + env_name_to_active_map["py"] = True + return env_name_to_active_map + + @property + def _defined_envs(self) -> dict[str, _ToxEnvInfo]: + # The problem of classifying run/package environments: + # There can be two type of tox environments: run or package. Given a tox environment name there's no easy way to + # find out which it is. Intuitively a run environment is any environment that's not used for packaging by + # another run environment. To find out what are the packaging environments for a run environment you have to + # first construct it. This implies a two phase solution: construct all environments and query their packaging + # environments. The run environments are the ones not marked as of packaging type. This requires being able + # to change tox environments type, if it was earlier discovered as a run environment and is marked as packaging + # we need to redefine it, e.g. when it shows up in config as [testenv:.package] and afterwards by a run env is + # marked as package_env. + + if self._defined_envs_ is None: + self._defined_envs_ = {} + failed: dict[str, Exception] = {} + env_name_to_active = self._env_name_to_active() + for name, is_active in env_name_to_active.items(): + if name in self._pkg_env_counter: # already marked as packaging, nothing to do here + continue + with self._log_handler.with_context(name): + run_env = self._build_run_env(name) + if run_env is None: + continue + self._defined_envs_[name] = _ToxEnvInfo(run_env, is_active) + pkg_name_type = run_env.get_package_env_types() + if pkg_name_type is not None: + # build package env and assign it, then register the run environment which can trigger generation + # of additional run environments + start_package_env_use_counter = self._pkg_env_counter.copy() + try: + run_env.package_env = self._build_pkg_env(pkg_name_type, name, env_name_to_active) + except Exception as exception: + # if it's not a run environment, wait to see if ends up being a packaging one -> rollback + failed[name] = exception + for key in self._pkg_env_counter - start_package_env_use_counter: + del self._defined_envs_[key] + self._state.conf.clear_env(key) + self._pkg_env_counter = start_package_env_use_counter + del self._defined_envs_[name] + self._state.conf.clear_env(name) + else: + try: + for env in run_env.package_envs: + # check if any packaging envs are already run and remove them + other_env_info = self._defined_envs_.get(env.name) + if other_env_info is not None and isinstance(other_env_info.env, RunToxEnv): + del self._defined_envs_[env.name] # pragma: no cover + for _pkg_env in other_env_info.env.package_envs: # pragma: no cover + self._pkg_env_counter[_pkg_env.name] -= 1 # pragma: no cover + except Exception: + assert self._defined_envs_[name].package_skip is not None + failed_to_create = failed.keys() - self._defined_envs_.keys() + if failed_to_create: + raise failed[next(iter(failed_to_create))] + for name, count in self._pkg_env_counter.items(): + if not count: + self._defined_envs_.pop(name) # pragma: no cover + + # reorder to as defined rather as found + order = chain(env_name_to_active, (i for i in self._defined_envs_ if i not in env_name_to_active)) + self._defined_envs_ = {name: self._defined_envs_[name] for name in order if name in self._defined_envs_} + self._finalize_config() + self._mark_active() + return self._defined_envs_ + + def _finalize_config(self) -> None: + assert self._defined_envs_ is not None + for tox_env in self._defined_envs_.values(): + tox_env.env.conf.mark_finalized() + self._state.conf.core.mark_finalized() + + def _build_run_env(self, name: str) -> RunToxEnv | None: + if self._provision is not None and self._provision[0] is False and name == self._provision[1]: + return None + env_conf = self._state.conf.get_env( + name, + package=False, + loaders=[self._provision[2]] if self._provision is not None and self._provision[1] == name else None, + ) + desc = "the tox execute used to evaluate this environment" + env_conf.add_config(keys="runner", desc=desc, of_type=str, default=self._state.conf.options.default_runner) + runner = REGISTER.runner(cast(str, env_conf["runner"])) + journal = self._journal.get_env_journal(name) + args = ToxEnvCreateArgs(env_conf, self._state.conf.core, self._state.conf.options, journal, self._log_handler) + run_env = runner(args) + self._manager.tox_add_env_config(env_conf, self._state) + return run_env + + def _build_pkg_env(self, name_type: tuple[str, str], run_env_name: str, active: dict[str, bool]) -> PackageToxEnv: + name, core_type = name_type + with self._log_handler.with_context(name): + if run_env_name == name: + raise HandledError(f"{run_env_name} cannot self-package") + missing_active = self._cli_envs is not None and self._cli_envs.is_all + try: + package_tox_env = self._get_package_env(core_type, name, active.get(name, missing_active)) + self._pkg_env_counter[name] += 1 + run_env: RunToxEnv = self._defined_envs_[run_env_name].env # type: ignore + child_package_envs = package_tox_env.register_run_env(run_env) + try: + name_type = next(child_package_envs) + while True: + child_pkg_env = self._build_pkg_env(name_type, run_env_name, active) + self._pkg_env_counter[name_type[0]] += 1 + name_type = child_package_envs.send(child_pkg_env) + except StopIteration: + pass + except Skip as exception: + assert self._defined_envs_ is not None + self._defined_envs_[run_env_name].package_skip = (name_type[0], exception) + return package_tox_env + + def _get_package_env(self, packager: str, name: str, is_active: bool) -> PackageToxEnv: + assert self._defined_envs_ is not None + if name in self._defined_envs_: + env = self._defined_envs_[name].env + if isinstance(env, PackageToxEnv): + if env.id() != packager: # pragma: no branch # same env name is used by different packaging + msg = f"{name} is already defined as a {env.id()}, cannot be {packager} too" # pragma: no cover + raise HandledError(msg) # pragma: no cover + return env + else: + self._state.conf.clear_env(name) + package_type = REGISTER.package(packager) + pkg_conf = self._state.conf.get_env(name, package=True) + journal = self._journal.get_env_journal(name) + args = ToxEnvCreateArgs(pkg_conf, self._state.conf.core, self._state.conf.options, journal, self._log_handler) + pkg_env: PackageToxEnv = package_type(args) + self._defined_envs_[name] = _ToxEnvInfo(pkg_env, is_active) + self._manager.tox_add_env_config(pkg_conf, self._state) + return pkg_env + + def _mark_active(self) -> None: + labels = set(getattr(self._state.conf.options, "labels", [])) + factors = set(getattr(self._state.conf.options, "factors", [])) + assert self._defined_envs_ is not None + if labels or factors: + for env_info in self._defined_envs_.values(): + env_info.is_active = False # if any was selected reset + if labels: + for label in labels: + for env_name in self._state.conf.core["labels"].get(label, []): + self._defined_envs_[env_name].is_active = True + for env_info in self._defined_envs_.values(): + if labels.intersection(env_info.env.conf["labels"]): + env_info.is_active = True + if self._state.conf.options.factors: # if matches mark it active + for name, env_info in self._defined_envs_.items(): + if factors.issubset(set(name.split("-"))): + env_info.is_active = True + + def __getitem__(self, item: str) -> RunToxEnv | PackageToxEnv: + """ + :param item: the name of the environment + :return: the tox environment + """ + return self._defined_envs[item].env + + def iter( + self, + *, + only_active: bool = True, + package: bool = False, + ) -> Iterator[str]: + """ + Get tox environments. + + :param only_active: active environments are marked to be executed in the current target + :param package: return package environments + + :return: an iteration of tox environments + """ + ignore_envs: set[str] = set() + for name, env_info in self._defined_envs.items(): + if only_active and not env_info.is_active: + continue + if not package and not isinstance(env_info.env, RunToxEnv): + continue + yield name + ignore_envs.add(name) + + def ensure_only_run_env_is_active(self) -> None: + envs, active = self._defined_envs, self._env_name_to_active() + invalid = [n for n, a in active.items() if a and isinstance(envs[n].env, PackageToxEnv)] + if invalid: + raise HandledError(f"cannot run packaging environment(s) {','.join(invalid)}") + + def _mark_provision(self, on: bool, provision_tox_env: str, loader: MemoryLoader) -> None: + self._provision = on, provision_tox_env, loader + + +__all__ = [ + "register_env_select_flags", + "EnvSelector", + "CliEnv", +] diff --git a/src/tox/session/state.py b/src/tox/session/state.py new file mode 100644 index 000000000..5bc7f2a76 --- /dev/null +++ b/src/tox/session/state.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Sequence + +from tox.config.main import Config +from tox.journal import Journal +from tox.plugin import impl + +from .env_select import EnvSelector + +if TYPE_CHECKING: + + from tox.config.cli.parse import Options + from tox.config.cli.parser import ToxParser + + +class State: + """Runtime state holder.""" + + def __init__(self, options: Options, args: Sequence[str]) -> None: + self.conf = Config.make(options.parsed, options.pos_args, options.source) + self._options = options + self.args = args + self._journal: Journal = Journal(getattr(options.parsed, "result_json", None) is not None) + self._selector: EnvSelector | None = None + + @property + def envs(self) -> EnvSelector: + """:return: provides access to the tox environments""" + if self._selector is None: + self._selector = EnvSelector(self) + return self._selector + + +@impl +def tox_add_option(parser: ToxParser) -> None: + from tox.tox_env.register import REGISTER + + parser.add_argument( + "--runner", + dest="default_runner", + help="the tox run engine to use when not explicitly stated in tox env configuration", + default=REGISTER.default_env_runner, + choices=list(REGISTER.env_runners), + ) diff --git a/src/tox/tox_env/__init__.py b/src/tox/tox_env/__init__.py new file mode 100644 index 000000000..a3886c137 --- /dev/null +++ b/src/tox/tox_env/__init__.py @@ -0,0 +1,4 @@ +""" +Package handling the creation and management of tox environments. +""" +from __future__ import annotations diff --git a/src/tox/tox_env/api.py b/src/tox/tox_env/api.py new file mode 100644 index 000000000..abcdc78b4 --- /dev/null +++ b/src/tox/tox_env/api.py @@ -0,0 +1,488 @@ +""" +Defines the abstract base traits of a tox environment. +""" +from __future__ import annotations + +import fnmatch +import logging +import os +import re +import sys +from abc import ABC, abstractmethod +from contextlib import contextmanager +from io import BytesIO +from pathlib import Path +from typing import TYPE_CHECKING, Any, Iterator, List, NamedTuple, Sequence, Set, cast + +from tox.config.main import Config +from tox.config.set_env import SetEnv +from tox.config.sets import CoreConfigSet, EnvConfigSet +from tox.execute.api import Execute, ExecuteStatus, Outcome, StdinSource +from tox.execute.request import ExecuteRequest +from tox.journal import EnvJournal +from tox.report import OutErr, ToxHandler +from tox.tox_env.errors import Recreate, Skip +from tox.tox_env.info import Info +from tox.tox_env.installer import Installer +from tox.util.path import ensure_empty_dir + +if TYPE_CHECKING: + from tox.config.cli.parser import Parsed + +LOGGER = logging.getLogger(__name__) + + +class ToxEnvCreateArgs(NamedTuple): + """Arguments to pass on when creating a tox environment""" + + conf: EnvConfigSet + core: CoreConfigSet + options: Parsed + journal: EnvJournal + log_handler: ToxHandler + + +class ToxEnv(ABC): + """A tox environment.""" + + def __init__(self, create_args: ToxEnvCreateArgs) -> None: + """Create a new tox environment. + + :param create_args: tox env create args + """ + self.journal: EnvJournal = create_args.journal #: handler to the tox reporting system + self.conf: EnvConfigSet = create_args.conf #: the config set to use for this environment + self.core: CoreConfigSet = create_args.core #: the core tox config set + self.options: Parsed = create_args.options #: CLI options + self.log_handler: ToxHandler = create_args.log_handler #: handler to the tox reporting system + + #: encode the run state of various methods (setup/clean/etc) + self._run_state = {"setup": False, "clean": False, "teardown": False} + self._paths_private: list[Path] = [] #: a property holding the PATH environment variables + self._hidden_outcomes: list[Outcome] | None = [] + self._env_vars: dict[str, str] | None = None + self._env_vars_pass_env: list[str] = [] + self._suspended_out_err: OutErr | None = None + self._execute_statuses: dict[int, ExecuteStatus] = {} + self._interrupted = False + self._log_id = 0 + + self.register_config() + + @property + def cache(self) -> Info: + return Info(self.env_dir) + + @staticmethod + @abstractmethod + def id() -> str: + raise NotImplementedError + + @property + @abstractmethod + def executor(self) -> Execute: + raise NotImplementedError + + @property + @abstractmethod + def installer(self) -> Installer[Any]: + raise NotImplementedError + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name={self.conf['env_name']})" + + def register_config(self) -> None: + self.conf.add_constant( + keys=["env_name", "envname"], + desc="the name of the tox environment", + value=self.conf.name, + ) + self.conf.add_config( + keys=["labels"], + of_type=Set[str], + default=set(), + desc="labels attached to the tox environment", + ) + self.conf.add_config( + keys=["env_dir", "envdir"], + of_type=Path, + default=lambda conf, name: cast(Path, conf.core["work_dir"]) / self.name, # noqa: U100 + desc="directory assigned to the tox environment", + ) + self.conf.add_config( + keys=["env_tmp_dir", "envtmpdir"], + of_type=Path, + default=lambda conf, name: cast(Path, conf.core["work_dir"]) / self.name / "tmp", # noqa: U100 + desc="a folder that is always reset at the start of the run", + ) + self.conf.add_config( + keys=["env_log_dir", "envlogdir"], + of_type=Path, + default=lambda conf, name: cast(Path, conf.core["work_dir"]) / self.name / "log", # noqa: U100 + desc="a folder for logging where tox will put logs of tool invocation", + ) + self.executor.register_conf(self) + self.conf.default_set_env_loader = self._default_set_env + self.conf.add_config( + keys=["platform"], + of_type=str, + default="", + desc="run on platforms that match this regular expression (empty means any platform)", + ) + + def pass_env_post_process(values: list[str]) -> list[str]: + values.extend(self._default_pass_env()) + return sorted({k: None for k in values}.keys()) + + self.conf.add_config( + keys=["pass_env", "passenv"], + of_type=List[str], + default=[], + desc="environment variables to pass on to the tox environment", + post_process=pass_env_post_process, + ) + self.conf.add_config( + "parallel_show_output", + of_type=bool, + default=False, + desc="if set to True the content of the output will always be shown when running in parallel mode", + ) + self.conf.add_config( + "recreate", + of_type=bool, + default=self._recreate_default, + desc="always recreate virtual environment if this option is true, otherwise leave it up to tox", + ) + self.conf.add_config( + "allowlist_externals", + of_type=List[str], + default=[], + desc="external command glob to allow calling", + ) + assert self.installer is not None # trigger installer creation to allow configuration registration + + def _recreate_default(self, conf: Config, value: str | None) -> bool: # noqa: U100 + return cast(bool, self.options.recreate) + + @property + def env_dir(self) -> Path: + """:return: the tox environments environment folder""" + return cast(Path, self.conf["env_dir"]) + + @property + def env_tmp_dir(self) -> Path: + """:return: the tox environments temp folder""" + return cast(Path, self.conf["env_tmp_dir"]) + + @property + def env_log_dir(self) -> Path: + """:return: the tox environments log folder""" + return cast(Path, self.conf["env_log_dir"]) + + @property + def name(self) -> str: + return cast(str, self.conf["env_name"]) + + def _default_set_env(self) -> dict[str, str]: + return {} + + def _default_pass_env(self) -> list[str]: + env = [ + "https_proxy", # HTTP proxy configuration + "http_proxy", # HTTP proxy configuration + "no_proxy", # HTTP proxy configuration + "LANG", # localization + "LANGUAGE", # localization + "CURL_CA_BUNDLE", # curl certificates + "SSL_CERT_FILE", # https certificates + "LD_LIBRARY_PATH", # location of libs + ] + if sys.stdout.isatty(): # if we're on a interactive shell pass on the TERM + env.append("TERM") + if sys.platform == "win32": # pragma: win32 cover + env.extend( + [ + "TEMP", # temporary file location + "TMP", # temporary file location + "USERPROFILE", # needed for `os.path.expanduser()` + "PATHEXT", # needed for discovering executables + "MSYSTEM", # controls paths printed format + ], + ) + else: # pragma: win32 no cover + env.append("TMPDIR") # temporary file location + return env + + def setup(self) -> None: + """ + Setup the tox environment. + """ + if self._run_state["setup"] is False: # pragma: no branch + self._platform_check() + recreate = cast(bool, self.conf["recreate"]) + if recreate: + self._clean(transitive=True) + try: + self._setup_env() + self._setup_with_env() + except Recreate as exception: # once we might try over + if not recreate: # pragma: no cover + logging.warning(f"recreate env because {exception.args[0]}") + self._clean(transitive=False) + self._setup_env() + self._setup_with_env() + else: + self._done_with_setup() + finally: + self._run_state["setup"] = True + + def teardown(self) -> None: + if not self._run_state["teardown"]: + try: + self._teardown() + finally: + self._run_state["teardown"] = True + + def _teardown(self) -> None: # noqa: B027 # empty abstract base class + pass + + def _platform_check(self) -> None: + """skip env when platform does not match""" + platform_str: str = self.conf["platform"] + if platform_str: + match = re.fullmatch(platform_str, self.runs_on_platform) + if match is None: + raise Skip(f"platform {self.runs_on_platform} does not match {platform_str}") + + @property + @abstractmethod + def runs_on_platform(self) -> str: + raise NotImplementedError + + def _setup_env(self) -> None: + """ + 1. env dir exists + 2. contains a runner with the same type. + """ + conf = {"name": self.conf.name, "type": type(self).__name__} + with self.cache.compare(conf, ToxEnv.__name__) as (eq, old): + if eq is False and old is not None: # pragma: no branch # recreate if already created and not equals + raise Recreate(f"env type changed from {old} to {conf}") + self._handle_env_tmp_dir() + + def _setup_with_env(self) -> None: # noqa: B027 # empty abstract base class + pass + + def _done_with_setup(self) -> None: # noqa: B027 # empty abstract base class + """called when setup is done""" + + def _handle_env_tmp_dir(self) -> None: + """Ensure exists and empty""" + env_tmp_dir = self.env_tmp_dir + if env_tmp_dir.exists() and next(env_tmp_dir.iterdir(), None) is not None: + LOGGER.debug("clear env temp folder %s", env_tmp_dir) + ensure_empty_dir(env_tmp_dir) + env_tmp_dir.mkdir(parents=True, exist_ok=True) + + def _clean(self, transitive: bool = False) -> None: # noqa: U100 + if self._run_state["clean"]: # pragma: no branch + return # pragma: no cover + env_dir = self.env_dir + if env_dir.exists(): + LOGGER.warning("remove tox env folder %s", env_dir) + ensure_empty_dir(env_dir, except_filename="file.lock") + self._log_id = 0 # we deleted logs, so start over counter + self.cache.reset() + self._run_state.update({"setup": False, "clean": True}) + + @property + def environment_variables(self) -> dict[str, str]: + pass_env: list[str] = self.conf["pass_env"] + set_env: SetEnv = self.conf["set_env"] + if self._env_vars_pass_env == pass_env and not set_env.changed and self._env_vars is not None: + return self._env_vars + + result = self._load_pass_env(pass_env) + # load/paths_env might trigger a load of the environment variables, set result here, returns current state + self._env_vars, self._env_vars_pass_env, set_env.changed = result, pass_env, False + # set PATH here in case setting and environment variable requires access to the environment variable PATH + result["PATH"] = self._make_path() + for key in set_env: + result[key] = set_env.load(key) + result["TOX_ENV_NAME"] = self.name + result["TOX_WORK_DIR"] = str(self.core["work_dir"]) + result["TOX_ENV_DIR"] = str(self.conf["env_dir"]) + return result + + @staticmethod + def _load_pass_env(pass_env: list[str]) -> dict[str, str]: + result: dict[str, str] = {} + patterns = [re.compile(fnmatch.translate(e), re.IGNORECASE) for e in pass_env] + for env, value in os.environ.items(): + if any(p.match(env) for p in patterns): + result[env] = value + return result + + @property + def _paths(self) -> list[Path]: + return self._paths_private + + @_paths.setter + def _paths(self, value: list[Path]) -> None: + self._paths_private = value + # also update the environment variable with the new value + if self._env_vars is not None: # pragma: no branch + # remove duplicates and prepend the tox env paths + result = self._make_path() + self._env_vars["PATH"] = result + + @property + def _allow_externals(self) -> list[str]: + result: list[str] = [f"{i}{os.sep}*" for i in self._paths] + result.extend(i.strip() for i in self.conf["allowlist_externals"]) + return result + + def _make_path(self) -> str: + values = dict.fromkeys(str(i) for i in self._paths) + values.update(dict.fromkeys(os.environ.get("PATH", "").split(os.pathsep))) + result = os.pathsep.join(values) + return result + + def execute( + self, + cmd: Sequence[Path | str], + stdin: StdinSource, + show: bool | None = None, + cwd: Path | None = None, + run_id: str = "", + executor: Execute | None = None, + ) -> Outcome: + with self.execute_async(cmd, stdin, show, cwd, run_id, executor) as status: + while status.wait() is None: + pass # pragma: no cover + if status.outcome is None: # pragma: no cover # this should not happen + raise RuntimeError # pragma: no cover + return status.outcome + + def interrupt(self) -> None: + """Interrupt the execution of a tox environment.""" + logging.warning("interrupt tox environment: %s", self.conf.name) + self._interrupted = True + for status in list(self._execute_statuses.values()): + status.interrupt() + + @contextmanager + def execute_async( + self, + cmd: Sequence[Path | str], + stdin: StdinSource, + show: bool | None = None, + cwd: Path | None = None, + run_id: str = "", + executor: Execute | None = None, + ) -> Iterator[ExecuteStatus]: + if self._interrupted: + raise SystemExit(-2) # pragma: no cover + if cwd is None: + cwd = self.core["tox_root"] + if show is None: + show = self.options.verbosity > 3 + request = ExecuteRequest(cmd, cwd, self.environment_variables, stdin, run_id, allow=self._allow_externals) + if _CWD == request.cwd: + repr_cwd = "" + else: + try: + repr_cwd = f" {_CWD.relative_to(cwd)}" + except ValueError: + repr_cwd = f" {cwd}" + LOGGER.warning("%s%s> %s", run_id, repr_cwd, request.shell_cmd) + out_err = self.log_handler.stdout, self.log_handler.stderr + if executor is None: + executor = self.executor + with self._execute_call(executor, out_err, request, show) as execute_status: + execute_id = id(execute_status) + try: + self._execute_statuses[execute_id] = execute_status + yield execute_status + finally: + self._execute_statuses.pop(execute_id) + if show and self._hidden_outcomes is not None: + if execute_status.outcome is not None: # pragma: no cover # if it gets cancelled before even starting + self._hidden_outcomes.append(execute_status.outcome) + if self.journal and execute_status.outcome is not None: + self.journal.add_execute(execute_status.outcome, run_id) + self._log_execute(request, execute_status) + + def _log_execute(self, request: ExecuteRequest, status: ExecuteStatus) -> None: + if self._log_id == 0: # start with fresh slate on new run + ensure_empty_dir(self.env_log_dir) + self._log_id += 1 + self._write_execute_log(self.name, self.env_log_dir / f"{self._log_id}-{request.run_id}.log", request, status) + + @staticmethod + def _write_execute_log(env_name: str, log_file: Path, request: ExecuteRequest, status: ExecuteStatus) -> None: + with log_file.open("wt") as file: + file.write(f"name: {env_name}\n") + file.write(f"run_id: {request.run_id}\n") + for env_key, env_value in request.env.items(): + file.write(f"env {env_key}: {env_value}\n") + for meta_key, meta_value in status.metadata.items(): + file.write(f"metadata {meta_key}: {meta_value}\n") + file.write(f"cwd: {request.cwd}\n") + allow = ["*"] if request.allow is None else request.allow + file.write(f"allow: {':'.join(allow)}\n") + file.write(f"cmd: {request.shell_cmd}\n") + file.write(f"exit_code: {status.exit_code}\n") + with log_file.open("ab") as file_b: + if status.out: + file_b.write(status.out) + if status.err: # pragma: no branch + file_b.write(os.linesep.encode()) + file_b.write(b"standard error:") + file_b.write(os.linesep.encode()) + file_b.write(status.err) + + @contextmanager + def _execute_call( + self, + executor: Execute, + out_err: OutErr, + request: ExecuteRequest, + show: bool, + ) -> Iterator[ExecuteStatus]: + with executor.call( + request=request, + env=self, + show=show, + out_err=out_err, + ) as execute_status: + yield execute_status + + @contextmanager + def display_context(self, suspend: bool) -> Iterator[None]: + with self._log_context(): + with self.log_handler.suspend_out_err(suspend, self._suspended_out_err) as out_err: + if suspend: # only set if suspended + self._suspended_out_err = out_err + yield + + def close_and_read_out_err(self) -> tuple[bytes, bytes] | None: + if self._suspended_out_err is None: # pragma: no branch + return None # pragma: no cover + (out, err), self._suspended_out_err = self._suspended_out_err, None + out_b, err_b = cast(BytesIO, out.buffer).getvalue(), cast(BytesIO, err.buffer).getvalue() + out.close() + err.close() + return out_b, err_b + + @contextmanager + def _log_context(self) -> Iterator[None]: + with self.log_handler.with_context(self.conf.name): + yield + + @property + def _has_display_suspended(self) -> bool: + return self._suspended_out_err is not None + + +_CWD = Path.cwd() diff --git a/src/tox/tox_env/errors.py b/src/tox/tox_env/errors.py new file mode 100644 index 000000000..7c9178f45 --- /dev/null +++ b/src/tox/tox_env/errors.py @@ -0,0 +1,14 @@ +"""Defines tox error types""" +from __future__ import annotations + + +class Recreate(Exception): # noqa: N818 + """Recreate the tox environment""" + + +class Skip(Exception): # noqa: N818 + """Skip this tox environment""" + + +class Fail(Exception): # noqa: N818 + """Failed creating env""" diff --git a/src/tox/tox_env/info.py b/src/tox/tox_env/info.py new file mode 100644 index 000000000..1371a7a28 --- /dev/null +++ b/src/tox/tox_env/info.py @@ -0,0 +1,68 @@ +""" +Declare and handle the tox env info file (a file at the root of every tox environment that contains information about +the status of the tox environment - python version of the environment, installed packages, etc.). +""" +from __future__ import annotations + +import json +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + + +class Info: + """Stores metadata about the tox environment.""" + + def __init__(self, path: Path) -> None: + self._path = path / ".tox-info.json" + try: + value = json.loads(self._path.read_text()) + except (ValueError, OSError): + value = {} + self._content = value + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(path={self._path})" + + @contextmanager + def compare( + self, + value: Any, + section: str, + sub_section: str | None = None, + ) -> Iterator[tuple[bool, Any | None]]: + """ + Compare new information with the existing one and update if differs. + + :param value: the value stored + :param section: the primary key of the information + :param sub_section: the secondary key of the information + :return: a tuple where the first value is if it differs and the second is the old value + """ + old = self._content.get(section) + if sub_section is not None and old is not None: + old = old.get(sub_section) + + if old == value: + yield True, old + else: + yield False, old + # if no exception thrown update + if sub_section is None: + self._content[section] = value + else: + if self._content.get(section) is None: + self._content[section] = {sub_section: value} + else: + self._content[section][sub_section] = value + self._write() + + def reset(self) -> None: + self._content = {} + + def _write(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(self._content, indent=2)) + + +__all__ = ("Info",) diff --git a/src/tox/tox_env/installer.py b/src/tox/tox_env/installer.py new file mode 100644 index 000000000..f329ecf50 --- /dev/null +++ b/src/tox/tox_env/installer.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +if TYPE_CHECKING: + from tox.tox_env.api import ToxEnv + +T = TypeVar("T", bound="ToxEnv") + + +class Installer(ABC, Generic[T]): + def __init__(self, tox_env: T) -> None: + self._env = tox_env + self._register_config() + + @abstractmethod + def _register_config(self) -> None: + """Register configurations for the installer""" + raise NotImplementedError + + @abstractmethod + def installed(self) -> Any: + """:returns: a list of packages installed (JSON dump-able)""" + raise NotImplementedError + + @abstractmethod + def install(self, arguments: Any, section: str, of_type: str) -> None: + raise NotImplementedError diff --git a/src/tox/tox_env/package.py b/src/tox/tox_env/package.py new file mode 100644 index 000000000..e8dffcaca --- /dev/null +++ b/src/tox/tox_env/package.py @@ -0,0 +1,107 @@ +""" +A tox environment that can build packages. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from threading import RLock +from types import MethodType +from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator, cast + +from filelock import FileLock + +from tox.config.main import Config +from tox.config.sets import EnvConfigSet + +from .api import ToxEnv, ToxEnvCreateArgs + +if TYPE_CHECKING: + from .runner import RunToxEnv + + +class Package: + """package""" + + +class PathPackage(Package): + def __init__(self, path: Path) -> None: + super().__init__() + self.path = path + + def __str__(self) -> str: + return str(self.path) + + +locked = False + + +def _lock_method(thread_lock: RLock, file_lock: FileLock | None, meth: Callable[..., Any]) -> Callable[..., Any]: + def _func(*args: Any, **kwargs: Any) -> Any: + with thread_lock: + file_locks = False + if file_lock is not None and file_lock.is_locked is False: # file_lock is to lock from other tox processes + file_lock.acquire() + file_locks = True + try: + return meth(*args, **kwargs) + finally: + if file_locks: + cast(FileLock, file_lock).release() + + return _func + + +class PackageToxEnv(ToxEnv, ABC): + def __init__(self, create_args: ToxEnvCreateArgs) -> None: + self._thread_lock = RLock() + self._file_lock: FileLock | None = None + super().__init__(create_args) + self._envs: set[str] = set() + + def __getattribute__(self, name: str) -> Any: + # the packaging class might be used by multiple environments in parallel, hold a lock for operations on it + obj = object.__getattribute__(self, name) + if isinstance(obj, MethodType): + obj = _lock_method(self._thread_lock, self._file_lock, obj) + return obj + + def register_config(self) -> None: + super().register_config() + file_lock_path: Path = self.conf["env_dir"] / "file.lock" + self._file_lock = FileLock(file_lock_path) + file_lock_path.parent.mkdir(parents=True, exist_ok=True) + self.core.add_config( + keys=["package_root", "setupdir"], + of_type=Path, + default=cast(Path, self.core["tox_root"]), + desc="indicates where the packaging root file exists (historically setup.py file or pyproject.toml now)", + ) + self.conf.add_config( + keys=["package_root", "setupdir"], + of_type=Path, + default=cast(Path, self.core["package_root"]), + desc="indicates where the packaging root file exists (historically setup.py file or pyproject.toml now)", + ) + + def _recreate_default(self, conf: Config, value: str | None) -> bool: + return self.options.no_recreate_pkg is False and super()._recreate_default(conf, value) + + @abstractmethod + def perform_packaging(self, for_env: EnvConfigSet) -> list[Package]: + raise NotImplementedError + + def register_run_env(self, run_env: RunToxEnv) -> Generator[tuple[str, str], PackageToxEnv, None]: # noqa: U100 + yield from () # empty generator by default + + def mark_active_run_env(self, run_env: RunToxEnv) -> None: + self._envs.add(run_env.conf.name) + + def teardown_env(self, conf: EnvConfigSet) -> None: + self._envs.remove(conf.name) + if len(self._envs) == 0: + self._teardown() + + @abstractmethod + def child_pkg_envs(self, run_conf: EnvConfigSet) -> Iterator[PackageToxEnv]: + raise NotImplementedError diff --git a/tests/unit/session/__init__.py b/src/tox/tox_env/python/__init__.py similarity index 100% rename from tests/unit/session/__init__.py rename to src/tox/tox_env/python/__init__.py diff --git a/src/tox/tox_env/python/api.py b/src/tox/tox_env/python/api.py new file mode 100644 index 000000000..8be79088e --- /dev/null +++ b/src/tox/tox_env/python/api.py @@ -0,0 +1,246 @@ +""" +Declare the abstract base class for tox environments that handle the Python language. +""" +from __future__ import annotations + +import sys +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, List, NamedTuple, cast + +from packaging.tags import INTERPRETER_SHORT_NAMES +from virtualenv.discovery.py_spec import PythonSpec + +from tox.config.main import Config +from tox.tox_env.api import ToxEnv, ToxEnvCreateArgs +from tox.tox_env.errors import Fail, Recreate, Skip + + +class VersionInfo(NamedTuple): + major: int + minor: int + micro: int + releaselevel: str + serial: int + + +class PythonInfo(NamedTuple): + implementation: str + version_info: VersionInfo + version: str + is_64: bool + platform: str + extra: dict[str, Any] + + @property + def version_no_dot(self) -> str: + return f"{self.version_info.major}{self.version_info.minor}" + + @property + def impl_lower(self) -> str: + return self.implementation.lower() + + +class Python(ToxEnv, ABC): + def __init__(self, create_args: ToxEnvCreateArgs) -> None: + self._base_python: PythonInfo | None = None + self._base_python_searched: bool = False + super().__init__(create_args) + + def register_config(self) -> None: + super().register_config() + + def validate_base_python(value: list[str]) -> list[str]: + return self._validate_base_python(self.name, value, self.core["ignore_base_python_conflict"]) + + self.conf.add_config( + keys=["base_python", "basepython"], + of_type=List[str], + default=self.default_base_python, + desc="environment identifier for python, first one found wins", + post_process=validate_base_python, + ) + self.core.add_config( + keys=["ignore_base_python_conflict", "ignore_basepython_conflict"], + of_type=bool, + default=False, + desc="do not raise error if the environment name conflicts with base python", + ) + self.conf.add_constant( + keys=["env_site_packages_dir", "envsitepackagesdir"], + desc="the python environments site package", + value=lambda: self.env_site_package_dir(), + ) + self.conf.add_constant( + keys=["env_bin_dir", "envbindir"], + desc="the python environments binary folder", + value=lambda: self.env_bin_dir(), + ) + self.conf.add_constant( + ["env_python", "envpython"], + desc="python executable from within the tox environment", + value=lambda: self.env_python(), + ) + + def _default_pass_env(self) -> list[str]: + env = super()._default_pass_env() + if sys.platform == "win32": # pragma: win32 cover + env.extend( + [ + "PROGRAMDATA", # needed for discovering the VS compiler + "PROGRAMFILES(x86)", # needed for discovering the VS compiler + "PROGRAMFILES", # needed for discovering the VS compiler + "SYSTEMDRIVE", + "SYSTEMROOT", # needed for python's crypto module + "COMSPEC", # needed for distutils cygwin compiler + "PROCESSOR_ARCHITECTURE", # platform.machine() + ], + ) + env.extend(["REQUESTS_CA_BUNDLE"]) + return env + + def default_base_python(self, conf: Config, env_name: str | None) -> list[str]: # noqa: U100 + base_python = None if env_name is None else self.extract_base_python(env_name) + return [sys.executable if base_python is None else base_python] + + @staticmethod + def extract_base_python(env_name: str) -> str | None: + candidates: list[str] = [] + for factor in env_name.split("-"): + spec = PythonSpec.from_string_spec(factor) + impl = spec.implementation or "python" + if impl.lower() in INTERPRETER_SHORT_NAMES and env_name is not None and spec.path is None: + candidates.append(factor) + if candidates: + if len(candidates) > 1: + raise ValueError(f"conflicting factors {', '.join(candidates)} in {env_name}") + return next(iter(candidates)) + return None + + @staticmethod + def _validate_base_python(env_name: str, base_pythons: list[str], ignore_base_python_conflict: bool) -> list[str]: + elements = {env_name} # match with full env-name + elements.update(env_name.split("-")) # and also any factor + for candidate in elements: + spec_name = PythonSpec.from_string_spec(candidate) + if spec_name.implementation and spec_name.implementation.lower() not in INTERPRETER_SHORT_NAMES: + continue + for base_python in base_pythons: + spec_base = PythonSpec.from_string_spec(base_python) + if any( + getattr(spec_base, key) != getattr(spec_name, key) + for key in ("implementation", "major", "minor", "micro", "architecture") + if getattr(spec_base, key) is not None and getattr(spec_name, key) is not None + ): + msg = f"env name {env_name} conflicting with base python {base_python}" + if ignore_base_python_conflict: + return [env_name] # ignore the base python settings + raise Fail(msg) + return base_pythons + + @abstractmethod + def env_site_package_dir(self) -> Path: + """ + If we have the python we just need to look at the last path under prefix. + E.g., Debian derivatives change the site-packages to dist-packages, so we need to fix it for site-packages. + """ + raise NotImplementedError + + @abstractmethod + def env_python(self) -> Path: + """The python executable within the tox environment""" + raise NotImplementedError + + @abstractmethod + def env_bin_dir(self) -> Path: + """The binary folder within the tox environment""" + raise NotImplementedError + + def _setup_env(self) -> None: + """setup a virtual python environment""" + super()._setup_env() + self.ensure_python_env() + self._paths = self.prepend_env_var_path() # now that the environment exist we can add them to the path + + def ensure_python_env(self) -> None: + conf = self.python_cache() + with self.cache.compare(conf, Python.__name__) as (eq, old): + if old is None: # does not exist -> create + self.create_python_env() + elif eq is False: # pragma: no branch # exists but changed -> recreate + raise Recreate(self._diff_msg(conf, old)) + + @staticmethod + def _diff_msg(conf: dict[str, Any], old: dict[str, Any]) -> str: + result: list[str] = [] + added = [f"{k}={v!r}" for k, v in conf.items() if k not in old] + if added: # pragma: no branch + result.append(f"added {' | '.join(added)}") + removed = [f"{k}={v!r}" for k, v in old.items() if k not in conf] + if removed: + result.append(f"removed {' | '.join(removed)}") + changed = [f"{k}={old[k]!r}->{v!r}" for k, v in conf.items() if k in old and v != old[k]] + if changed: + result.append(f"changed {' | '.join(changed)}") + return f'python {", ".join(result)}' + + @abstractmethod + def prepend_env_var_path(self) -> list[Path]: + raise NotImplementedError + + def _done_with_setup(self) -> None: + """called when setup is done""" + super()._done_with_setup() + if self.journal: + outcome = self.installer.installed() + self.journal["installed_packages"] = outcome + + def python_cache(self) -> dict[str, Any]: + return { + "version_info": list(self.base_python.version_info), + } + + @property + def base_python(self) -> PythonInfo: + """Resolve base python""" + if self._base_python_searched is False: + base_pythons: list[str] = self.conf["base_python"] + self._base_python_searched = True + self._base_python = self._get_python(base_pythons) + if self._base_python is None: + if self.core["skip_missing_interpreters"]: + raise Skip(f"could not find python interpreter with spec(s): {', '.join(base_pythons)}") + raise NoInterpreter(base_pythons) + if self.journal: + value = self._get_env_journal_python() + self.journal["python"] = value + return cast(PythonInfo, self._base_python) + + def _get_env_journal_python(self) -> dict[str, Any]: + assert self._base_python is not None + return { + "implementation": self._base_python.implementation, + "version_info": tuple(self.base_python.version_info), + "version": self._base_python.version, + "is_64": self._base_python.is_64, + "sysplatform": self._base_python.platform, + "extra_version_info": None, + } + + @abstractmethod + def _get_python(self, base_python: list[str]) -> PythonInfo | None: + raise NotImplementedError + + @abstractmethod + def create_python_env(self) -> None: + raise NotImplementedError + + +class NoInterpreter(Fail): + """could not find interpreter""" + + def __init__(self, base_pythons: list[str]) -> None: + self.base_pythons = base_pythons + + def __str__(self) -> str: + return f"could not find python interpreter matching any of the specs {', '.join(self.base_pythons)}" diff --git a/src/tox/tox_env/python/package.py b/src/tox/tox_env/python/package.py new file mode 100644 index 000000000..1227941a4 --- /dev/null +++ b/src/tox/tox_env/python/package.py @@ -0,0 +1,116 @@ +""" +A tox build environment that handles Python packages. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING, Any, Generator, Iterator, Sequence, cast + +from packaging.requirements import Requirement + +from ...config.sets import EnvConfigSet +from ..api import ToxEnvCreateArgs +from ..errors import Skip +from ..package import Package, PackageToxEnv, PathPackage +from ..runner import RunToxEnv +from .api import Python +from .pip.req_file import PythonDeps + +if TYPE_CHECKING: + from tox.config.main import Config + + +class PythonPackage(Package): + """python package""" + + +class PythonPathPackageWithDeps(PathPackage): + def __init__(self, path: Path, deps: Sequence[Any]) -> None: + super().__init__(path=path) + self.deps: Sequence[Package] = deps + + +class WheelPackage(PythonPathPackageWithDeps): + """wheel package""" + + +class SdistPackage(PythonPathPackageWithDeps): + """sdist package""" + + +class EditableLegacyPackage(PythonPathPackageWithDeps): + """legacy editable package""" + + +class EditablePackage(PythonPathPackageWithDeps): + """PEP-660 editable package""" + + +class PythonPackageToxEnv(Python, PackageToxEnv, ABC): + def __init__(self, create_args: ToxEnvCreateArgs) -> None: + self._wheel_build_envs: dict[str, PythonPackageToxEnv] = {} + super().__init__(create_args) + + def _setup_env(self) -> None: + """setup the tox environment""" + super()._setup_env() + self.installer.install(self.requires(), PythonPackageToxEnv.__name__, "requires") + + @abstractmethod + def requires(self) -> tuple[Requirement, ...] | PythonDeps: + raise NotImplementedError + + def register_run_env(self, run_env: RunToxEnv) -> Generator[tuple[str, str], PackageToxEnv, None]: + yield from super().register_run_env(run_env) + if ( + not isinstance(run_env, Python) + or run_env.conf["package"] not in {"wheel", "editable"} + or "wheel_build_env" in run_env.conf + ): + return + + def default_wheel_tag(conf: Config, env_name: str | None) -> str: # noqa: U100 + # https://www.python.org/dev/peps/pep-0427/#file-name-convention + # when building wheels we need to ensure that the built package is compatible with the target env + # compatibility is documented within https://www.python.org/dev/peps/pep-0427/#file-name-convention + # a wheel tag example: {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl + # python only code are often compatible at major level (unless universal wheel in which case both 2/3) + # c-extension codes are trickier, but as of today both poetry/setuptools uses pypa/wheels logic + # https://github.com/pypa/wheel/blob/master/src/wheel/bdist_wheel.py#L234-L280 + run_py = cast(Python, run_env).base_python + if run_py is None: + base = ",".join(run_env.conf["base_python"]) + raise Skip(f"could not resolve base python with {base}") + + default_pkg_py = self.base_python + if ( + default_pkg_py.version_no_dot == run_py.version_no_dot + and default_pkg_py.impl_lower == run_py.impl_lower + ): + return self.conf.name + + return f"{self.conf.name}-{run_py.impl_lower}{run_py.version_no_dot}" + + run_env.conf.add_config( + keys=["wheel_build_env"], + of_type=str, + default=default_wheel_tag, + desc="wheel tag to use for building applications", + ) + pkg_env = run_env.conf["wheel_build_env"] + result = yield pkg_env, run_env.conf["package_tox_env_type"] + self._wheel_build_envs[pkg_env] = cast(PythonPackageToxEnv, result) + + def child_pkg_envs(self, run_conf: EnvConfigSet) -> Iterator[PackageToxEnv]: + if run_conf["package"] == "wheel": + env = self._wheel_build_envs.get(run_conf["wheel_build_env"]) + if env is not None and env.name != self.name: + yield env + + def _teardown(self) -> None: + for env in self._wheel_build_envs.values(): + if env is not self: + with env.display_context(self._has_display_suspended): + env.teardown() + super()._teardown() diff --git a/src/tox/tox_env/python/pip/__init__.py b/src/tox/tox_env/python/pip/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/tox_env/python/pip/pip_install.py b/src/tox/tox_env/python/pip/pip_install.py new file mode 100644 index 000000000..7bd7fe3ab --- /dev/null +++ b/src/tox/tox_env/python/pip/pip_install.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from typing import Any, Callable, Sequence + +from packaging.requirements import Requirement + +from tox.config.cli.parser import DEFAULT_VERBOSITY +from tox.config.main import Config +from tox.config.types import Command +from tox.execute.request import StdinSource +from tox.report import HandledError +from tox.tox_env.errors import Recreate +from tox.tox_env.installer import Installer +from tox.tox_env.package import PathPackage +from tox.tox_env.python.api import Python +from tox.tox_env.python.package import EditableLegacyPackage, EditablePackage, SdistPackage, WheelPackage +from tox.tox_env.python.pip.req_file import PythonDeps + + +class Pip(Installer[Python]): + """Pip is a python installer that can install packages as defined by PEP-508 and PEP-517""" + + def __init__(self, tox_env: Python, with_list_deps: bool = True) -> None: + self._with_list_deps = with_list_deps + super().__init__(tox_env) + + def _register_config(self) -> None: + self._env.conf.add_config( + keys=["pip_pre"], + of_type=bool, + default=False, + desc="install the latest available pre-release (alpha/beta/rc) of dependencies without a specified version", + ) + self._env.conf.add_config( + keys=["install_command"], + of_type=Command, + default=self.default_install_command, + post_process=self.post_process_install_command, + desc="command used to install packages", + ) + if self._with_list_deps: # pragma: no branch + self._env.conf.add_config( + keys=["list_dependencies_command"], + of_type=Command, + default=Command(["python", "-m", "pip", "freeze", "--all"]), + desc="command used to list isntalled packages", + ) + + def default_install_command(self, conf: Config, env_name: str | None) -> Command: # noqa: U100 + isolated_flag = "-E" if self._env.base_python.version_info.major == 2 else "-I" + cmd = Command(["python", isolated_flag, "-m", "pip", "install", "{opts}", "{packages}"]) + return self.post_process_install_command(cmd) + + def post_process_install_command(self, cmd: Command) -> Command: + install_command = cmd.args + pip_pre: bool = self._env.conf["pip_pre"] + try: + opts_at = install_command.index("{opts}") + except ValueError: + if pip_pre: + install_command.append("--pre") + else: + if pip_pre: + install_command[opts_at] = "--pre" + else: + install_command.pop(opts_at) + return cmd + + def installed(self) -> list[str]: + cmd: Command = self._env.conf["list_dependencies_command"] + result = self._env.execute( + cmd=cmd.args, + stdin=StdinSource.OFF, + run_id="freeze", + show=self._env.options.verbosity > DEFAULT_VERBOSITY, + ) + result.assert_success() + return result.out.splitlines() + + def install(self, arguments: Any, section: str, of_type: str) -> None: + if isinstance(arguments, PythonDeps): + self._install_requirement_file(arguments, section, of_type) + elif isinstance(arguments, Sequence): + self._install_list_of_deps(arguments, section, of_type) + else: + logging.warning(f"pip cannot install {arguments!r}") + raise SystemExit(1) + + def _install_requirement_file(self, arguments: PythonDeps, section: str, of_type: str) -> None: + try: + new_options, new_reqs = arguments.unroll() + except ValueError as exception: + raise HandledError(f"{exception} for tox env py within deps") + new_requirements: list[str] = [] + new_constraints: list[str] = [] + for req in new_reqs: + (new_constraints if req.startswith("-c ") else new_requirements).append(req) + new = {"options": new_options, "requirements": new_requirements, "constraints": new_constraints} + # if option or constraint change in any way recreate, if the requirements change only if some are removed + with self._env.cache.compare(new, section, of_type) as (eq, old): + if not eq: # pragma: no branch + if old is not None: + self._recreate_if_diff("install flag(s)", new_options, old["options"], lambda i: i) + self._recreate_if_diff("constraint(s)", new_constraints, old["constraints"], lambda i: i[3:]) + missing_requirement = set(old["requirements"]) - set(new_requirements) + if missing_requirement: + raise Recreate(f"requirements removed: {' '.join(missing_requirement)}") + args = arguments.as_root_args + if args: # pragma: no branch + self._execute_installer(args, of_type) + + @staticmethod + def _recreate_if_diff(of_type: str, new_opts: list[str], old_opts: list[str], fmt: Callable[[str], str]) -> None: + if old_opts == new_opts: + return + removed_opts = set(old_opts) - set(new_opts) + removed = f" removed {', '.join(sorted(fmt(i) for i in removed_opts))}" if removed_opts else "" + added_opts = set(new_opts) - set(old_opts) + added = f" added {', '.join(sorted(fmt(i) for i in added_opts))}" if added_opts else "" + raise Recreate(f"changed {of_type}{removed}{added}") + + def _install_list_of_deps( + self, + arguments: Sequence[ + Requirement | WheelPackage | SdistPackage | EditableLegacyPackage | EditablePackage | PathPackage + ], + section: str, + of_type: str, + ) -> None: + groups: dict[str, list[str]] = defaultdict(list) + for arg in arguments: + if isinstance(arg, Requirement): + groups["req"].append(str(arg)) + elif isinstance(arg, (WheelPackage, SdistPackage, EditablePackage)): + groups["req"].extend(str(i) for i in arg.deps) + groups["pkg"].append(str(arg.path)) + elif isinstance(arg, EditableLegacyPackage): + groups["req"].extend(str(i) for i in arg.deps) + groups["dev_pkg"].append(str(arg.path)) + else: + logging.warning(f"pip cannot install {arg!r}") + raise SystemExit(1) + req_of_type = f"{of_type}_deps" if groups["pkg"] or groups["dev_pkg"] else of_type + for value in groups.values(): + value.sort() + with self._env.cache.compare(groups["req"], section, req_of_type) as (eq, old): + if not eq: # pragma: no branch + miss = sorted(set(old or []) - set(groups["req"])) + if miss: # no way yet to know what to uninstall here (transitive dependencies?) + raise Recreate(f"dependencies removed: {', '.join(str(i) for i in miss)}") # pragma: no branch + new_deps = sorted(set(groups["req"]) - set(old or [])) + if new_deps: # pragma: no branch + self._execute_installer(new_deps, req_of_type) + install_args = ["--force-reinstall", "--no-deps"] + if groups["pkg"]: + self._execute_installer(install_args + groups["pkg"], of_type) + if groups["dev_pkg"]: + for entry in groups["dev_pkg"]: + install_args.extend(("-e", str(entry))) + self._execute_installer(install_args, of_type) + + def _execute_installer(self, deps: Sequence[Any], of_type: str) -> None: + cmd = self.build_install_cmd(deps) + outcome = self._env.execute(cmd, stdin=StdinSource.OFF, run_id=f"install_{of_type}") + outcome.assert_success() + + def build_install_cmd(self, args: Sequence[str]) -> list[str]: + cmd: Command = self._env.conf["install_command"] + install_command = cmd.args + try: + opts_at = install_command.index("{packages}") + except ValueError: + opts_at = len(install_command) + result = install_command[:opts_at] + list(args) + install_command[opts_at + 1 :] + return result + + +__all__ = ("Pip",) diff --git a/src/tox/tox_env/python/pip/req/__init__.py b/src/tox/tox_env/python/pip/req/__init__.py new file mode 100644 index 000000000..e3bc337d3 --- /dev/null +++ b/src/tox/tox_env/python/pip/req/__init__.py @@ -0,0 +1,6 @@ +""" +Specification is defined within pip itself and documented under: +- https://pip.pypa.io/en/stable/reference/pip_install/#requirements-file-format +- https://github.com/pypa/pip/blob/master/src/pip/_internal/req/constructors.py#L291 +""" +from __future__ import annotations diff --git a/src/tox/tox_env/python/pip/req/args.py b/src/tox/tox_env/python/pip/req/args.py new file mode 100644 index 000000000..23f98ec31 --- /dev/null +++ b/src/tox/tox_env/python/pip/req/args.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import bisect +import re +from argparse import Action, ArgumentParser, ArgumentTypeError, Namespace +from typing import IO, Any, NoReturn, Sequence + + +class _OurArgumentParser(ArgumentParser): + def print_usage(self, file: IO[str] | None = None) -> None: # noqa: U100 + """ """ + + def exit(self, status: int = 0, message: str | None = None) -> NoReturn: # noqa: U100 + message = "" if message is None else message + msg = message.lstrip(": ").rstrip() + if msg.startswith("error: "): + msg = msg[len("error: ") :] + raise ValueError(msg) + + +def build_parser() -> ArgumentParser: + parser = _OurArgumentParser(add_help=False, prog="", allow_abbrev=False) + _global_options(parser) + _req_options(parser) + return parser + + +def _global_options(parser: ArgumentParser) -> None: + parser.add_argument("-i", "--index-url", "--pypi-url", dest="index_url", default=None) + parser.add_argument("--extra-index-url", action=AddUniqueAction) + parser.add_argument("--no-index", action="https://github.com/store_true", default=False) + parser.add_argument("-c", "--constraint", action=AddUniqueAction, dest="constraints") + parser.add_argument("-r", "--requirement", action=AddUniqueAction, dest="requirements") + parser.add_argument("-e", "--editable", action=AddUniqueAction, dest="editables") + parser.add_argument("-f", "--find-links", action=AddUniqueAction) + parser.add_argument("--no-binary", choices=[":all:", ":none:"]) # TODO: colon separated package names + parser.add_argument("--only-binary", choices=[":all:", ":none:"]) # TODO: colon separated package names + parser.add_argument("--prefer-binary", action="https://github.com/store_true", default=False) + parser.add_argument("--require-hashes", action="https://github.com/store_true", default=False) + parser.add_argument("--pre", action="https://github.com/store_true", default=False) + parser.add_argument("--trusted-host", action=AddSortedUniqueAction) + parser.add_argument( + "--use-feature", + choices=["2020-resolver", "fast-deps"], + action=AddSortedUniqueAction, + dest="features_enabled", + ) + + +def _req_options(parser: ArgumentParser) -> None: + parser.add_argument("--install-option", action=AddSortedUniqueAction) + parser.add_argument("--global-option", action=AddSortedUniqueAction) + parser.add_argument("--hash", action=AddSortedUniqueAction, type=_validate_hash) + + +_HASH = re.compile(r"sha(256:[a-f0-9]{64}|384:[a-f0-9]{96}|512:[a-f0-9]{128})") + + +def _validate_hash(value: str) -> str: + if not _HASH.fullmatch(value): + raise ArgumentTypeError(value) + return value + + +class AddSortedUniqueAction(Action): + def __call__( + self, + parser: ArgumentParser, # noqa: U100 + namespace: Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, # noqa: U100 + ) -> None: + if getattr(namespace, self.dest, None) is None: + setattr(namespace, self.dest, []) + current = getattr(namespace, self.dest) + if values not in current: + bisect.insort(current, values) + + +class AddUniqueAction(Action): + def __call__( + self, + parser: ArgumentParser, # noqa: U100 + namespace: Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, # noqa: U100 + ) -> None: + if getattr(namespace, self.dest, None) is None: + setattr(namespace, self.dest, []) + current = getattr(namespace, self.dest) + if values not in current: + current.append(values) diff --git a/src/tox/tox_env/python/pip/req/file.py b/src/tox/tox_env/python/pip/req/file.py new file mode 100644 index 000000000..272937e80 --- /dev/null +++ b/src/tox/tox_env/python/pip/req/file.py @@ -0,0 +1,470 @@ +"""Adapted from the pip code base""" +from __future__ import annotations + +import os +import re +import shlex +import sys +import urllib.parse +from argparse import ArgumentParser, Namespace +from pathlib import Path +from typing import IO, Any, Iterator, List, Tuple, cast +from urllib.request import urlopen + +import chardet +from packaging.requirements import InvalidRequirement, Requirement + +from .args import build_parser +from .util import VCS, get_url_scheme, is_url, url_to_path + +# Matches environment variable-style values in '${MY_VARIABLE_1}' with the variable name consisting of only uppercase +# letters, digits or the '_' (underscore). This follows the POSIX standard defined in IEEE Std 1003.1, 2013 Edition. +_ENV_VAR_RE = re.compile(r"(?P\${(?P[A-Z0-9_]+)})") +_SCHEME_RE = re.compile(r"^(http|https|file):", re.I) +_COMMENT_RE = re.compile(r"(^|\s+)#.*$") +# https://www.python.org/dev/peps/pep-0508/#extras +_EXTRA_PATH = re.compile(r"(.*)\[([-._,\sa-zA-Z0-9]*)]") +_EXTRA_ELEMENT = re.compile(r"[a-zA-Z0-9]*[-._a-zA-Z0-9]") +ReqFileLines = Iterator[Tuple[int, str]] + +DEFAULT_INDEX_URL = "https://pypi.org/simple" + + +class ParsedRequirement: + def __init__(self, req: str, options: dict[str, Any], from_file: str, lineno: int) -> None: + req = req.encode("utf-8").decode("utf-8") + try: + self._requirement: Requirement | Path | str = Requirement(req) + except InvalidRequirement: + if is_url(https://github.com/req) or any(req.startswith(f"{v}+") and is_url(https://github.com/req[len(v) + 1 :]) for v in VCS): + self._requirement = req + else: + root = Path(from_file).parent + extras: list[str] = [] + match = _EXTRA_PATH.fullmatch(Path(req).name) + if match: + for extra in match.group(2).split(","): + extra = extra.strip() + if not extra: + continue + if not _EXTRA_ELEMENT.fullmatch(extra): + extras = [] + path = root / req + break + extras.append(extra) + else: + path = root / Path(req).parent / match.group(1) + else: + path = root / req + extra_part = f"[{','.join(sorted(extras))}]" if extras else "" + try: + rel_path = str(path.resolve().relative_to(root)) + # prefix paths in cwd to not convert them to requirement + if rel_path != "." and os.sep not in rel_path: + rel_path = f".{os.sep}{rel_path}" + except ValueError: + rel_path = str(path.resolve()) + + self._requirement = f"{rel_path}{extra_part}" + self._options = options + self._from_file = from_file + self._lineno = lineno + + @property + def requirement(self) -> Requirement | Path | str: + return self._requirement + + @property + def from_file(self) -> str: + return self._from_file + + @property + def lineno(self) -> int: + return self._lineno + + @property + def options(self) -> dict[str, Any]: + return self._options + + def __repr__(self) -> str: + base = f"{self.__class__.__name__}(requirement={self._requirement}, " + if self._options: + base += f"options={self._options!r}, " + return f"{base.rstrip(', ')})" + + def __str__(self) -> str: + result = [] + if self.options.get("is_constraint"): + result.append("-c") + if self.options.get("is_editable"): + result.append("-e") + result.append(str(self.requirement)) + for hash_value in self.options.get("hash", []): + result.extend(("--hash", hash_value)) + return " ".join(result) + + def as_args(self) -> Iterator[str]: + if self.options.get("is_editable"): + yield "-e" + yield str(self._requirement) + + +class ParsedLine: + def __init__(self, filename: str, lineno: int, args: str, opts: Namespace, constraint: bool) -> None: + self.filename = filename + self.lineno = lineno + self.opts = opts + self.constraint = constraint + if args: + self.is_requirement = True + self.is_editable = False + self.requirement = args + elif opts.editables: + self.is_requirement = True + self.is_editable = True + # We don't support multiple -e on one line + self.requirement = opts.editables[0] + else: + self.is_requirement = False + + +class RequirementsFile: + def __init__(self, path: Path, constraint: bool) -> None: + self._path = path + self._is_constraint: bool = constraint + self._opt = Namespace() + self._requirements: list[ParsedRequirement] | None = None + self._as_root_args: list[str] | None = None + self._parser_private: ArgumentParser | None = None + + def __str__(self) -> str: + return f"{'-c' if self.is_constraint else '-r'} {self.path}" + + @property + def path(self) -> Path: + return self._path + + @property + def is_constraint(self) -> bool: + return self._is_constraint + + @property + def options(self) -> Namespace: + self._ensure_requirements_parsed() + return self._opt + + @property + def requirements(self) -> list[ParsedRequirement]: + self._ensure_requirements_parsed() + return cast(List[ParsedRequirement], self._requirements) + + @property + def _parser(self) -> ArgumentParser: + if self._parser_private is None: + self._parser_private = build_parser() + return self._parser_private + + def _ensure_requirements_parsed(self) -> None: + if self._requirements is None: + self._requirements = self._parse_requirements(opt=self._opt, recurse=True) + + def _parse_requirements(self, opt: Namespace, recurse: bool) -> list[ParsedRequirement]: + result, found = [], set() + for parsed_line in self._parse_and_recurse(str(self._path), self.is_constraint, recurse): + if parsed_line.is_requirement: + parsed_req = self._handle_requirement_line(parsed_line) + key = str(parsed_req) + if key not in found: + found.add(key) + result.append(parsed_req) + else: + self._merge_option_line(opt, parsed_line.opts, parsed_line.filename) + result.sort(key=self._key_func) + return result + + def _key_func(self, line: ParsedRequirement) -> tuple[int, tuple[int, str, str]]: + of_type = {Requirement: 0, Path: 1, str: 2}[type(line.requirement)] + between = of_type, str(line.requirement).lower(), str(line.options) + if "is_constraint" in line.options: + return 2, between + if "is_editable" in line.options: + return 1, between + return 0, between + + def _parse_and_recurse(self, filename: str, constraint: bool, recurse: bool) -> Iterator[ParsedLine]: + for line in self._parse_file(filename, constraint): + if not line.is_requirement and (line.opts.requirements or line.opts.constraints): + if line.opts.requirements: # parse a nested requirements file + nested_constraint, req_path = False, line.opts.requirements[0] + else: + nested_constraint, req_path = True, line.opts.constraints[0] + if _SCHEME_RE.search(filename): # original file is over http + req_path = urllib.parse.urljoin(filename, req_path) # do a url join so relative paths work + elif not _SCHEME_RE.search(req_path): # original file and nested file are paths + # do a join so relative paths work + req_path = os.path.join(os.path.dirname(filename), req_path) + if recurse: + yield from self._parse_and_recurse(req_path, nested_constraint, recurse) + else: + line.filename = req_path + yield line + else: + yield line + + def _parse_file(self, url: str, constraint: bool) -> Iterator[ParsedLine]: + content = self._get_file_content(url) + for line_number, line in self._pre_process(content): + args_str, opts = self._parse_line(line) + yield ParsedLine(url, line_number, args_str, opts, constraint) + + def _get_file_content(self, url: str) -> str: + """ + Gets the content of a file; it may be a filename, file: URL, or http: URL. Returns (location, content). + Content is unicode. Respects # -*- coding: declarations on the retrieved files. + + :param url: File path or url. + """ + scheme = get_url_scheme(url) + if scheme in ["http", "https"]: + with urlopen(url) as response: + text = self._read_decode(response) + return text + elif scheme == "file": + url = url_to_path(url) + try: + with open(url, "rb") as file_handler: + text = self._read_decode(file_handler) + except OSError as exc: + raise ValueError(f"Could not open requirements file {url}: {exc}") from exc + return text + + @staticmethod + def _read_decode(file_handler: IO[bytes]) -> str: + raw = file_handler.read() + if not raw: + return "" + codec = chardet.detect(raw)["encoding"] + text = raw.decode(codec) + return text + + def _pre_process(self, content: str) -> ReqFileLines: + """Split, filter, and join lines, and return a line iterator + + :param content: the content of the requirements file + """ + lines_enum: ReqFileLines = enumerate(content.splitlines(), start=1) + lines_enum = self._join_lines(lines_enum) + lines_enum = self._ignore_comments(lines_enum) + lines_enum = self._expand_env_variables(lines_enum) + return lines_enum + + def _parse_line(self, line: str) -> tuple[str, Namespace]: + args_str, options_str = self._break_args_options(line) + args = shlex.split(options_str, posix=sys.platform != "win32") + opts = self._parser.parse_args(args) + return args_str, opts + + @staticmethod + def _handle_requirement_line(line: ParsedLine) -> ParsedRequirement: + # For editable requirements, we don't support per-requirement options, so just return the parsed requirement. + # get the options that apply to requirements + req_options: dict[str, Any] = {} + if line.is_editable: + req_options["is_editable"] = line.is_editable + if line.constraint: + req_options["is_constraint"] = line.constraint + hash_values = getattr(line.opts, "hash", []) + if hash_values: + req_options["hash"] = hash_values + return ParsedRequirement(line.requirement, req_options, line.filename, line.lineno) + + @staticmethod + def _merge_option_line(base_opt: Namespace, opt: Namespace, filename: str) -> None: # noqa: C901 + # percolate options upward + if opt.requirements: + if not hasattr(base_opt, "requirements"): + base_opt.requirements = [] + if opt.requirements[0] not in base_opt.requirements: + base_opt.requirements.append(opt.requirements[0]) + if opt.constraints: + if not hasattr(base_opt, "constraints"): + base_opt.constraints = [] + if opt.constraints[0] not in base_opt.constraints: + base_opt.constraints.append(opt.constraints[0]) + if opt.require_hashes: + base_opt.require_hashes = True + if opt.features_enabled: + if not hasattr(base_opt, "features_enabled"): + base_opt.features_enabled = [] + for feature in opt.features_enabled: + if feature not in base_opt.features_enabled: + base_opt.features_enabled.append(feature) + base_opt.features_enabled.sort() + if opt.index_url: + if getattr(base_opt, "index_url", []): + base_opt.index_url[0] = opt.index_url + else: + base_opt.index_url = [opt.index_url] + if opt.no_index is True: + base_opt.index_url = [] + if opt.extra_index_url: + if not getattr(base_opt, "index_url", []): + base_opt.index_url = [DEFAULT_INDEX_URL] + for url in opt.extra_index_url: + if url not in base_opt.index_url: + base_opt.index_url.extend(opt.extra_index_url) + if opt.find_links: + # FIXME: it would be nice to keep track of the source of the find_links: support a find-links local path + # relative to a requirements file. + if not hasattr(base_opt, "index_url"): # pragma: no branch + base_opt.find_links = [] + value = opt.find_links[0] + req_dir = os.path.dirname(os.path.abspath(filename)) + relative_to_reqs_file = os.path.join(req_dir, value) + if os.path.exists(relative_to_reqs_file): + value = relative_to_reqs_file # pragma: no cover + if value not in base_opt.find_links: # pragma: no branch + base_opt.find_links.append(value) + if opt.pre: + base_opt.pre = True + if opt.prefer_binary: + base_opt.prefer_binary = True + for host in opt.trusted_host or []: + if not hasattr(base_opt, "trusted_hosts"): + base_opt.trusted_hosts = [] + if host not in base_opt.trusted_hosts: + base_opt.trusted_hosts.append(host) + if opt.no_binary: + base_opt.no_binary = opt.no_binary + if opt.only_binary: + base_opt.only_binary = opt.only_binary + + @staticmethod + def _break_args_options(line: str) -> tuple[str, str]: + """ + Break up the line into an args and options string. We only want to shlex (and then optparse) the options, not + the args. args can contain markers which are corrupted by shlex. + """ + tokens = line.split(" ") + args = [] + options = tokens[:] + for token in tokens: + if token.startswith("-"): # both `-` and `--` accepted + break + else: + args.append(token) + options.pop(0) + return " ".join(args), " ".join(options) + + @staticmethod + def _join_lines(lines_enum: ReqFileLines) -> ReqFileLines: + """ + Joins a line ending in '\' with the previous line (except when following comments). The joined line takes on the + index of the first line. + """ + primary_line_number = None + new_line: list[str] = [] + for line_number, line in lines_enum: + if not line.endswith("\\") or _COMMENT_RE.match(line): + if _COMMENT_RE.match(line): + line = f" {line}" # this ensures comments are always matched later + if new_line: + new_line.append(line) + assert primary_line_number is not None + yield primary_line_number, "".join(new_line) + new_line = [] + else: + yield line_number, line + else: + if not new_line: # pragma: no branch + primary_line_number = line_number + new_line.append(line.strip("\\")) + # last line contains \ + if new_line: + assert primary_line_number is not None + yield primary_line_number, "".join(new_line) + + @staticmethod + def _ignore_comments(lines_enum: ReqFileLines) -> ReqFileLines: + """Strips comments and filter empty lines.""" + for line_number, line in lines_enum: + line = _COMMENT_RE.sub("", line) + line = line.strip() + if line: + yield line_number, line + + @staticmethod + def _expand_env_variables(lines_enum: ReqFileLines) -> ReqFileLines: + """Replace all environment variables that can be retrieved via `os.getenv`. + + The only allowed format for environment variables defined in the requirement file is `${MY_VARIABLE_1}` to + ensure two things: + + 1. Strings that contain a `$` aren't accidentally (partially) expanded. + 2. Ensure consistency across platforms for requirement files. + + These points are the result of a discussion on the `github pull request #3514 + `_. Valid characters in variable names follow the `POSIX standard + `_ and are limited to uppercase letter, digits and the `_`. + """ + for line_number, line in lines_enum: + for env_var, var_name in _ENV_VAR_RE.findall(line): + value = os.getenv(var_name) + if not value: + continue + line = line.replace(env_var, value) + yield line_number, line + + @property + def as_root_args(self) -> list[str]: + if self._as_root_args is None: + opt = Namespace() + result: list[str] = [] + for req in self._parse_requirements(opt=opt, recurse=False): + result.extend(req.as_args()) + option_args = self._option_to_args(opt) + result.extend(option_args) + + self._as_root_args = result + return self._as_root_args + + @staticmethod + def _option_to_args(opt: Namespace) -> list[str]: + result: list[str] = [] + for req in getattr(opt, "requirements", []): + result.extend(("-r", req)) + for req in getattr(opt, "constraints", []): + result.extend(("-c", req)) + index_url = getattr(opt, "index_url", None) + if index_url is not None: + if index_url: + if index_url[0] != DEFAULT_INDEX_URL: + result.extend(("-i", index_url[0])) + for url in index_url[1:]: + result.extend(("--extra-index-url", url)) + else: + result.append("--no-index") + for link in getattr(opt, "find_links", []): + result.extend(("-f", link)) + if hasattr(opt, "pre"): + result.append("--pre") + for host in getattr(opt, "trusted_hosts", []): + result.extend(("--trusted-host", host)) + if hasattr(opt, "prefer_binary"): + result.append("--prefer-binary") + if hasattr(opt, "require_hashes"): + result.append("--require-hashes") + for feature in getattr(opt, "features_enabled", []): + result.extend(("--use-feature", feature)) + if hasattr(opt, "no_binary"): + result.extend(("--no-binary", opt.no_binary)) + if hasattr(opt, "only_binary"): + result.extend(("--only-binary", opt.only_binary)) + return result + + +__all__ = ( + "RequirementsFile", + "ReqFileLines", + "ParsedRequirement", +) diff --git a/src/tox/tox_env/python/pip/req/util.py b/src/tox/tox_env/python/pip/req/util.py new file mode 100644 index 000000000..9914d1111 --- /dev/null +++ b/src/tox/tox_env/python/pip/req/util.py @@ -0,0 +1,28 @@ +"""Borrowed from the pip code base""" +from __future__ import annotations + +from urllib.parse import urlsplit +from urllib.request import url2pathname + +VCS = ["ftp", "ssh", "git", "hg", "bzr", "sftp", "svn"] +VALID_SCHEMAS = ["http", "https", "file"] + VCS + + +def is_url(/service/name: str) -> bool: + return get_url_scheme(name) in VALID_SCHEMAS + + +def get_url_scheme(url: str) -> str | None: + if ":" not in url: + return None + return url.split(":", 1)[0].lower() + + +def url_to_path(url: str) -> str: + _, netloc, path, _, _ = urlsplit(url) + if not netloc or netloc == "localhost": # According to RFC 8089, same as empty authority. + netloc = "" + else: + raise ValueError(f"non-local file URIs are not supported on this platform: {url!r}") + path = url2pathname(netloc + path) + return path diff --git a/src/tox/tox_env/python/pip/req_file.py b/src/tox/tox_env/python/pip/req_file.py new file mode 100644 index 000000000..91202345d --- /dev/null +++ b/src/tox/tox_env/python/pip/req_file.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import re +from argparse import Namespace +from pathlib import Path + +from .req.file import ParsedRequirement, ReqFileLines, RequirementsFile + + +class PythonDeps(RequirementsFile): + # these options are valid in requirements.txt, but not via pip cli and + # thus cannot be used in the testenv `deps` list + _illegal_options = ["hash"] + + def __init__(self, raw: str, root: Path): + super().__init__(root / "tox.ini", constraint=False) + self._raw = self._normalize_raw(raw) + self._unroll: tuple[list[str], list[str]] | None = None + + def _get_file_content(self, url: str) -> str: + if self._is_url_self(url): + return self._raw + return super()._get_file_content(url) + + def _is_url_self(self, url: str) -> bool: + return url == str(self._path) + + def _pre_process(self, content: str) -> ReqFileLines: + for at, line in super()._pre_process(content): + if line.startswith("-r") or line.startswith("-c") and line[2].isalpha(): + line = f"{line[0:2]} {line[2:]}" + yield at, line + + def lines(self) -> list[str]: + return self._raw.splitlines() + + @staticmethod + def _normalize_raw(raw: str) -> str: + # a line ending in an unescaped \ is treated as a line continuation and the newline following it is effectively + # ignored + raw = "".join(raw.replace("\r", "").split("\\\n")) + lines: list[str] = [] + for line in raw.splitlines(): + # for tox<4 supporting requirement/constraint files via -rreq.txt/-creq.txt + arg_match = next( + ( + arg + for arg in ONE_ARG + if line.startswith(arg) + and len(line) > len(arg) + and not (line[len(arg)].isspace() or line[len(arg)] == "=") + ), + None, + ) + if arg_match is not None: + line = f"{arg_match} {line[len(arg_match):]}" + # escape spaces + escape_match = next((e for e in ONE_ARG_ESCAPE if line.startswith(e) and line[len(e)].isspace()), None) + if escape_match is not None: + # escape not already escaped spaces + escaped = re.sub(r"(? list[ParsedRequirement]: + # check for any invalid options in the deps list + # (requirements recursively included from other files are not checked) + requirements = super()._parse_requirements(opt, recurse) + for r in requirements: + if r.from_file != str(self.path): + continue + for illegal_option in self._illegal_options: + if r.options.get(illegal_option): + raise ValueError( + f"Cannot use --{illegal_option} in deps list, it must be in requirements file. ({r})", + ) + return requirements + + def unroll(self) -> tuple[list[str], list[str]]: + if self._unroll is None: + opts_dict = vars(self.options) + if not self.requirements and opts_dict: + raise ValueError("no dependencies") + result_opts: list[str] = [f"{key}={value}" for key, value in opts_dict.items()] + result_req = [str(req) for req in self.requirements] + self._unroll = result_opts, result_req + return self._unroll + + @classmethod + def factory(cls, root: Path, raw: object) -> PythonDeps: + if not isinstance(raw, str): + raise TypeError(raw) + return cls(raw, root) + + +ONE_ARG = { + "-i", + "--index-url", + "--extra-index-url", + "-e", + "--editable", + "-c", + "--constraint", + "-r", + "--requirement", + "-f", + "--find-links", + "--trusted-host", + "--use-feature", + "--no-binary", + "--only-binary", +} +ONE_ARG_ESCAPE = { + "-c", + "--constraint", + "-r", + "--requirement", + "-f", + "--find-links", + "-e", + "--editable", +} + +__all__ = ( + "PythonDeps", + "ONE_ARG", +) diff --git a/src/tox/tox_env/python/runner.py b/src/tox/tox_env/python/runner.py new file mode 100644 index 000000000..591633d74 --- /dev/null +++ b/src/tox/tox_env/python/runner.py @@ -0,0 +1,104 @@ +""" +A tox run environment that handles the Python language. +""" +from __future__ import annotations + +from functools import partial +from typing import Set + +from tox.report import HandledError +from tox.tox_env.errors import Skip +from tox.tox_env.package import Package +from tox.tox_env.python.pip.req_file import PythonDeps + +from ..api import ToxEnvCreateArgs +from ..runner import RunToxEnv +from .api import Python + + +class PythonRun(Python, RunToxEnv): + def __init__(self, create_args: ToxEnvCreateArgs) -> None: + super().__init__(create_args) + + def register_config(self) -> None: + super().register_config() + root = self.core["toxinidir"] + self.conf.add_config( + keys="deps", + of_type=PythonDeps, + factory=partial(PythonDeps.factory, root), + default=PythonDeps("", root), + desc="Name of the python dependencies as specified by PEP-440", + ) + self.core.add_config( + keys=["skip_missing_interpreters"], + default=True, + of_type=bool, + desc="skip running missing interpreters", + ) + + @property + def _package_types(self) -> tuple[str, ...]: + return "wheel", "sdist", "editable", "editable-legacy", "skip", "external" + + def _register_package_conf(self) -> bool: + # provision package type + desc = f"package installation mode - {' | '.join(i for i in self._package_types)} " + if not super()._register_package_conf(): + self.conf.add_constant(["package"], desc, "skip") + return False + if getattr(self.options, "install_pkg", None) is not None: + self.conf.add_constant(["package"], desc, "external") + else: + self.conf.add_config( + keys=["use_develop", "usedevelop"], + desc="use develop mode", + default=False, + of_type=bool, + ) + develop_mode = self.conf["use_develop"] or getattr(self.options, "develop", False) + if develop_mode: + self.conf.add_constant(["package"], desc, "editable") + else: + self.conf.add_config(keys="package", of_type=str, default=self.default_pkg_type, desc=desc) + + pkg_type = self.pkg_type + if pkg_type == "skip": + return False + self.conf.add_config( + keys=["extras"], + of_type=Set[str], + default=set(), + desc="extras to install of the target package", + ) + return True + + @property + def default_pkg_type(self) -> str: + return "sdist" + + @property + def pkg_type(self) -> str: + pkg_type: str = self.conf["package"] + if pkg_type not in self._package_types: + values = ", ".join(self._package_types) + raise HandledError(f"invalid package config type {pkg_type} requested, must be one of {values}") + return pkg_type + + def _setup_env(self) -> None: + super()._setup_env() + self._install_deps() + + def _install_deps(self) -> None: + requirements_file: PythonDeps = self.conf["deps"] + self.installer.install(requirements_file, PythonRun.__name__, "deps") + + def _build_packages(self) -> list[Package]: + package_env = self.package_env + assert package_env is not None + with package_env.display_context(self._has_display_suspended): + try: + packages = package_env.perform_packaging(self.conf) + except Skip as exception: + raise Skip(f"{exception.args[0]} for package environment {package_env.conf['env_name']}") + return packages diff --git a/src/tox/tox_env/python/virtual_env/__init__.py b/src/tox/tox_env/python/virtual_env/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/tox_env/python/virtual_env/api.py b/src/tox/tox_env/python/virtual_env/api.py new file mode 100644 index 000000000..01bdc627f --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/api.py @@ -0,0 +1,169 @@ +""" +Declare the abstract base class for tox environments that handle the Python language via the virtualenv project. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any, cast + +from virtualenv import __version__ as virtualenv_version +from virtualenv import session_via_cli +from virtualenv.create.creator import Creator +from virtualenv.run.session import Session + +from tox.config.loader.str_convert import StrConvert +from tox.execute.api import Execute +from tox.execute.local_sub_process import LocalSubProcessExecutor +from tox.tox_env.python.pip.pip_install import Pip + +from ...api import ToxEnvCreateArgs +from ..api import Python, PythonInfo + + +class VirtualEnv(Python): + """A python executor that uses the virtualenv project with pip""" + + def __init__(self, create_args: ToxEnvCreateArgs) -> None: + self._virtualenv_session: Session | None = None + self._executor: Execute | None = None + self._installer: Pip | None = None + super().__init__(create_args) + + def register_config(self) -> None: + super().register_config() + self.conf.add_config( + keys=["system_site_packages", "sitepackages"], + of_type=bool, + default=lambda conf, name: StrConvert().to_bool( # noqa: U100 + self.environment_variables.get("VIRTUALENV_SYSTEM_SITE_PACKAGES", "False"), + ), + desc="create virtual environments that also have access to globally installed packages.", + ) + self.conf.add_config( + keys=["always_copy", "alwayscopy"], + of_type=bool, + default=lambda conf, name: StrConvert().to_bool( # noqa: U100 + self.environment_variables.get( + "VIRTUALENV_COPIES", + self.environment_variables.get("VIRTUALENV_ALWAYS_COPY", "False"), + ), + ), + desc="force virtualenv to always copy rather than symlink", + ) + self.conf.add_config( + keys=["download"], + of_type=bool, + default=lambda conf, name: StrConvert().to_bool( # noqa: U100 + self.environment_variables.get("VIRTUALENV_DOWNLOAD", "False"), + ), + desc="true if you want virtualenv to upgrade pip/wheel/setuptools to the latest version", + ) + + @property + def executor(self) -> Execute: + if self._executor is None: + self._executor = LocalSubProcessExecutor(self.options.is_colored) + return self._executor + + @property + def installer(self) -> Pip: + if self._installer is None: + self._installer = Pip(self) + return self._installer + + def python_cache(self) -> dict[str, Any]: + base = super().python_cache() + base.update( + { + "executable": str(self.base_python.extra["executable"]), + "virtualenv version": virtualenv_version, + }, + ) + return base + + def _get_env_journal_python(self) -> dict[str, Any]: + base = super()._get_env_journal_python() + base["executable"] = str(self.base_python.extra["executable"]) + return base + + def _default_pass_env(self) -> list[str]: + env = super()._default_pass_env() + env.append("PIP_*") # we use pip as installer + env.append("VIRTUALENV_*") # we use virtualenv as isolation creator + return env + + def _default_set_env(self) -> dict[str, str]: + env = super()._default_set_env() + env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" + return env + + @property + def session(self) -> Session: + if self._virtualenv_session is None: + env_dir = [str(self.env_dir)] + env = self.virtualenv_env_vars() + self._virtualenv_session = session_via_cli(env_dir, options=None, setup_logging=False, env=env) + return self._virtualenv_session + + def virtualenv_env_vars(self) -> dict[str, str]: + env = self.environment_variables.copy() + base_python: list[str] = self.conf["base_python"] + if "VIRTUALENV_NO_PERIODIC_UPDATE" not in env: + env["VIRTUALENV_NO_PERIODIC_UPDATE"] = "True" + site = getattr(self.options, "site_packages", False) or self.conf["system_site_packages"] + env["VIRTUALENV_CLEAR"] = "False" + env["VIRTUALENV_SYSTEM_SITE_PACKAGES"] = str(site) + env["VIRTUALENV_COPIES"] = str(getattr(self.options, "always_copy", False) or self.conf["always_copy"]) + env["VIRTUALENV_DOWNLOAD"] = str(self.conf["download"]) + env["VIRTUALENV_PYTHON"] = "\n".join(base_python) + if hasattr(self.options, "discover"): + env["VIRTUALENV_TRY_FIRST_WITH"] = os.pathsep.join(self.options.discover) + return env + + @property + def creator(self) -> Creator: + return self.session.creator + + def create_python_env(self) -> None: + self.session.run() + + def _get_python(self, base_python: list[str]) -> PythonInfo | None: # noqa: U100 + # the base pythons are injected into the virtualenv_env_vars, so we don't need to use it here + try: + interpreter = self.creator.interpreter + except RuntimeError: # if can't find + return None + return PythonInfo( + implementation=interpreter.implementation, + version_info=interpreter.version_info, + version=interpreter.version, + is_64=(interpreter.architecture == 64), + platform=interpreter.platform, + extra={"executable": Path(interpreter.system_executable).resolve()}, + ) + + def prepend_env_var_path(self) -> list[Path]: + """Paths to add to the executable""" + # we use the original executable as shims may be somewhere else + return list(dict.fromkeys((self.creator.bin_dir, self.creator.script_dir))) + + def env_site_package_dir(self) -> Path: + return cast(Path, self.creator.purelib) + + def env_python(self) -> Path: + return cast(Path, self.creator.exe) + + def env_bin_dir(self) -> Path: + return cast(Path, self.creator.script_dir) + + @property + def runs_on_platform(self) -> str: + return sys.platform + + @property + def environment_variables(self) -> dict[str, str]: + environment_variables = super().environment_variables + environment_variables["VIRTUAL_ENV"] = str(self.conf["env_dir"]) + return environment_variables diff --git a/src/tox/tox_env/python/virtual_env/package/__init__.py b/src/tox/tox_env/python/virtual_env/package/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tox/tox_env/python/virtual_env/package/cmd_builder.py b/src/tox/tox_env/python/virtual_env/package/cmd_builder.py new file mode 100644 index 000000000..fc3005ead --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/package/cmd_builder.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import glob +import shutil +import sys +import tarfile +from functools import partial +from io import TextIOWrapper +from os import PathLike +from pathlib import Path +from typing import Generator, Iterator, List, cast +from zipfile import ZipFile + +from packaging.requirements import Requirement + +from tox.config.sets import EnvConfigSet +from tox.config.types import Command +from tox.execute import Outcome +from tox.plugin import impl +from tox.session.cmd.run.single import run_command_set +from tox.tox_env.api import ToxEnvCreateArgs +from tox.tox_env.errors import Fail +from tox.tox_env.package import Package, PackageToxEnv +from tox.tox_env.python.package import PythonPackageToxEnv, SdistPackage, WheelPackage +from tox.tox_env.python.pip.req_file import PythonDeps +from tox.tox_env.python.virtual_env.api import VirtualEnv +from tox.tox_env.register import ToxEnvRegister +from tox.tox_env.runner import RunToxEnv + +from .pyproject import Pep517VirtualEnvPackager +from .util import dependencies_with_extras + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from importlib.metadata import Distribution +else: # pragma: no cover (py38+) + from importlib_metadata import Distribution + + +class VirtualEnvCmdBuilder(PythonPackageToxEnv, VirtualEnv): + def __init__(self, create_args: ToxEnvCreateArgs) -> None: + super().__init__(create_args) + self._sdist_meta_tox_env: Pep517VirtualEnvPackager | None = None + + @staticmethod + def id() -> str: + return "virtualenv-cmd-builder" + + def register_config(self) -> None: + super().register_config() + root = self.core["toxinidir"] + self.conf.add_config( + keys="deps", + of_type=PythonDeps, + factory=partial(PythonDeps.factory, root), + default=PythonDeps("", root), + desc="Name of the python dependencies as specified by PEP-440", + ) + self.conf.add_config( + keys=["commands"], + of_type=List[Command], + default=[], + desc="the commands to be called for testing", + ) + self.conf.add_config( + keys=["change_dir", "changedir"], + of_type=Path, + default=lambda conf, name: cast(Path, conf.core["tox_root"]), # noqa: U100 + desc="change to this working directory when executing the test command", + ) + self.conf.add_config( + keys=["ignore_errors"], + of_type=bool, + default=False, + desc="when executing the commands keep going even if a sub-command exits with non-zero exit code", + ) + self.conf.add_config( + keys=["package_glob"], + of_type=str, + default=str(self.conf["env_tmp_dir"] / "dist" / "*"), + desc="when executing the commands keep going even if a sub-command exits with non-zero exit code", + ) + + def requires(self) -> PythonDeps: + return cast(PythonDeps, self.conf["deps"]) + + def perform_packaging(self, for_env: EnvConfigSet) -> list[Package]: + self.setup() + path: Path | None = getattr(self.options, "install_pkg", None) + if path is None: # use install_pkg if specified, otherwise build via commands + chdir: Path = self.conf["change_dir"] + ignore_errors: bool = self.conf["ignore_errors"] + status = run_command_set(self, "commands", chdir, ignore_errors, []) + if status != Outcome.OK: + raise Fail("stopping as failed to build package") + package_glob = self.conf["package_glob"] + found = glob.glob(package_glob) + if not found: + raise Fail(f"no package found in {package_glob}") + elif len(found) != 1: + raise Fail(f"found more than one package {', '.join(sorted(found))}") + path = Path(found[0]) + return self.extract_install_info(for_env, path) + + def extract_install_info(self, for_env: EnvConfigSet, path: Path) -> list[Package]: + extras: set[str] = for_env["extras"] + if path.suffix == ".whl": + wheel_dist = WheelDistribution(path) + requires: list[str] = wheel_dist.requires or [] + deps = dependencies_with_extras([Requirement(i) for i in requires], extras, wheel_dist.metadata["Name"]) + package: Package = WheelPackage(path, deps) + else: # must be source distribution + work_dir = self.env_tmp_dir / "sdist-extract" + if work_dir.exists(): # pragma: no branch + shutil.rmtree(work_dir) # pragma: no cover + work_dir.mkdir() + with tarfile.open(str(path), "r:gz") as tar: + tar.extractall(path=str(work_dir)) + assert self._sdist_meta_tox_env is not None # the register run env is guaranteed to be called before this + with self._sdist_meta_tox_env.display_context(self._has_display_suspended): + self._sdist_meta_tox_env.root = next(work_dir.iterdir()) # contains a single egg info folder + deps = self._sdist_meta_tox_env.get_package_dependencies(for_env) + name = self._sdist_meta_tox_env.get_package_name(for_env) + package = SdistPackage(path, dependencies_with_extras(deps, extras, name)) + return [package] + + def register_run_env(self, run_env: RunToxEnv) -> Generator[tuple[str, str], PackageToxEnv, None]: + yield from super().register_run_env(run_env) + # in case the outcome is a sdist we'll use this to find out its metadata + result = yield f"{self.conf.name}_sdist_meta", Pep517VirtualEnvPackager.id() + self._sdist_meta_tox_env = cast(Pep517VirtualEnvPackager, result) + + def child_pkg_envs(self, run_conf: EnvConfigSet) -> Iterator[PackageToxEnv]: # noqa: U100 + if self._sdist_meta_tox_env is not None: # pragma: no branch + yield self._sdist_meta_tox_env + + +class WheelDistribution(Distribution): # type: ignore # cannot subclass has type Any + def __init__(self, wheel: Path) -> None: + self._wheel = wheel + self._dist_name: str | None = None + + @property + def dist_name(self) -> str: + if self._dist_name is None: + with ZipFile(self._wheel) as zip_file: + for name in zip_file.namelist(): + root = name.split("/")[0] + if root.endswith(".dist-info"): + self._dist_name = root + break + else: + raise Fail(f"no .dist-info inside {self._wheel}") + return self._dist_name + + def read_text(self, filename: str) -> str | None: + with ZipFile(self._wheel) as zip_file: + try: + with TextIOWrapper(zip_file.open(f"{self.dist_name}/{filename}"), encoding="utf-8") as file_handler: + return file_handler.read() + except KeyError: + return None + + def locate_file(self, path: str) -> PathLike[str]: + return self._wheel / path # pragma: no cover # not used by us, but part of the ABC + + +@impl +def tox_register_tox_env(register: ToxEnvRegister) -> None: + register.add_package_env(VirtualEnvCmdBuilder) diff --git a/src/tox/tox_env/python/virtual_env/package/pyproject.py b/src/tox/tox_env/python/virtual_env/package/pyproject.py new file mode 100644 index 000000000..de3a81c59 --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/package/pyproject.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import logging +import os +import sys +from collections import defaultdict +from contextlib import contextmanager +from pathlib import Path +from threading import RLock +from typing import Any, Dict, Generator, Iterator, NoReturn, Optional, Sequence, cast + +from cachetools import cached +from packaging.requirements import Requirement +from pyproject_api import BackendFailed, CmdStatus, Frontend + +from tox.config.sets import EnvConfigSet +from tox.execute.api import ExecuteStatus +from tox.execute.pep517_backend import LocalSubProcessPep517Executor +from tox.execute.request import StdinSource +from tox.plugin import impl +from tox.tox_env.api import ToxEnvCreateArgs +from tox.tox_env.errors import Fail +from tox.tox_env.package import Package, PackageToxEnv +from tox.tox_env.python.package import ( + EditableLegacyPackage, + EditablePackage, + PythonPackageToxEnv, + SdistPackage, + WheelPackage, +) +from tox.tox_env.register import ToxEnvRegister +from tox.tox_env.runner import RunToxEnv +from tox.util.file_view import create_session_view + +from ..api import VirtualEnv +from .util import dependencies_with_extras + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from importlib.metadata import Distribution, PathDistribution +else: # pragma: no cover (= (3, 11): # pragma: no cover (py311+) + import tomllib +else: # pragma: no cover (py311+) + import tomli as tomllib + +ConfigSettings = Optional[Dict[str, Any]] + + +class ToxBackendFailed(Fail, BackendFailed): + def __init__(self, backend_failed: BackendFailed) -> None: + Fail.__init__(self) + result: dict[str, Any] = { + "code": backend_failed.code, + "exc_type": backend_failed.exc_type, + "exc_msg": backend_failed.exc_msg, + } + BackendFailed.__init__( + self, + result, + backend_failed.out, + backend_failed.err, + ) + + +class BuildEditableNotSupported(RuntimeError): + """raised when build editable is not supported""" + + +class ToxCmdStatus(CmdStatus): + def __init__(self, execute_status: ExecuteStatus) -> None: + self._execute_status = execute_status + + @property + def done(self) -> bool: + # 1. process died + status = self._execute_status + if status.exit_code is not None: # pragma: no branch + return True # pragma: no cover + # 2. the backend output reported back that our command is done + return b"\n" in status.out.rpartition(b"Backend: Wrote response ")[0] + + def out_err(self) -> tuple[str, str]: + status = self._execute_status + if status is None or status.outcome is None: # interrupt before status create # pragma: no branch + return "", "" # pragma: no cover + return status.outcome.out_err() + + +class Pep517VirtualEnvPackager(PythonPackageToxEnv, VirtualEnv): + """local file system python virtual environment via the virtualenv package""" + + def __init__(self, create_args: ToxEnvCreateArgs) -> None: + super().__init__(create_args) + self._frontend_: Pep517VirtualEnvFrontend | None = None + self.builds: defaultdict[str, list[EnvConfigSet]] = defaultdict(list) + self._distribution_meta: PathDistribution | None = None + self._package_dependencies: list[Requirement] | None = None + self._package_name: str | None = None + self._pkg_lock = RLock() # can build only one package at a time + self.root = self.conf["package_root"] + self._package_paths: set[Path] = set() + + @staticmethod + def id() -> str: + return "virtualenv-pep-517" + + @property + def _frontend(self) -> Pep517VirtualEnvFrontend: + if self._frontend_ is None: + self._frontend_ = Pep517VirtualEnvFrontend(self.root, self) + return self._frontend_ + + def register_config(self) -> None: + super().register_config() + self.conf.add_config( + keys=["meta_dir"], + of_type=Path, + default=lambda conf, name: self.env_dir / ".meta", # noqa: U100 + desc="directory where to put the project metadata files", + ) + self.conf.add_config( + keys=["pkg_dir"], + of_type=Path, + default=lambda conf, name: self.env_dir / "dist", # noqa: U100 + desc="directory where to put project packages", + ) + + @property + def pkg_dir(self) -> Path: + return cast(Path, self.conf["pkg_dir"]) + + @property + def meta_folder(self) -> Path: + meta_folder: Path = self.conf["meta_dir"] + meta_folder.mkdir(exist_ok=True) + return meta_folder + + def register_run_env(self, run_env: RunToxEnv) -> Generator[tuple[str, str], PackageToxEnv, None]: + yield from super().register_run_env(run_env) + build_type = run_env.conf["package"] + self.builds[build_type].append(run_env.conf) + + def _setup_env(self) -> None: + super()._setup_env() + if "editable" in self.builds: + if not self._frontend.optional_hooks["build_editable"]: + raise BuildEditableNotSupported + build_requires = self._frontend.get_requires_for_build_editable().requires + self.installer.install(build_requires, PythonPackageToxEnv.__name__, "requires_for_build_editable") + if "wheel" in self.builds: + build_requires = self._frontend.get_requires_for_build_wheel().requires + self.installer.install(build_requires, PythonPackageToxEnv.__name__, "requires_for_build_wheel") + if "sdist" in self.builds or "external" in self.builds: + build_requires = self._frontend.get_requires_for_build_sdist().requires + self.installer.install(build_requires, PythonPackageToxEnv.__name__, "requires_for_build_sdist") + + def _teardown(self) -> None: + executor = self._frontend.backend_executor + if executor is not None: # pragma: no branch + try: + if executor.is_alive: + self._frontend._send("_exit") # try first on amicable shutdown + except SystemExit: # pragma: no cover # if already has been interrupted ignore + pass + finally: + executor.close() + for path in self._package_paths: + if path.exists(): + logging.debug("delete package %s", path) + path.unlink() + super()._teardown() + + def perform_packaging(self, for_env: EnvConfigSet) -> list[Package]: + """build the package to install""" + try: + deps = self._load_deps(for_env) + except BuildEditableNotSupported: + targets = [e for e in self.builds.pop("editable") if e["package"] == "editable"] + names = ", ".join(sorted({t.env_name for t in targets if t.env_name})) + logging.error( + f"package config for {names} is editable, however the build backend {self._frontend.backend}" + f" does not support PEP-660, falling back to editable-legacy - change your configuration to it", + ) + for env in targets: + env._defined["package"].value = "editable-legacy" # type: ignore + self.builds["editable-legacy"].append(env) + deps = self._load_deps(for_env) + of_type: str = for_env["package"] + if of_type == "editable-legacy": + self.setup() + deps = [*self.requires(), *self._frontend.get_requires_for_build_sdist().requires] + deps + package: Package = EditableLegacyPackage(self.core["tox_root"], deps) # the folder itself is the package + elif of_type == "sdist": + self.setup() + with self._pkg_lock: + sdist = self._frontend.build_sdist(sdist_directory=self.pkg_dir).sdist + sdist = create_session_view(sdist, self._package_temp_path) + self._package_paths.add(sdist) + package = SdistPackage(sdist, deps) + elif of_type in {"wheel", "editable"}: + w_env = self._wheel_build_envs.get(for_env["wheel_build_env"]) + if w_env is not None and w_env is not self: + with w_env.display_context(self._has_display_suspended): + return w_env.perform_packaging(for_env) + else: + self.setup() + method = "build_editable" if of_type == "editable" else "build_wheel" + with self._pkg_lock: + wheel = getattr(self._frontend, method)( + wheel_directory=self.pkg_dir, + metadata_directory=self.meta_folder, + config_settings=self._wheel_config_settings, + ).wheel + wheel = create_session_view(wheel, self._package_temp_path) + self._package_paths.add(wheel) + package = (EditablePackage if of_type == "editable" else WheelPackage)(wheel, deps) + else: # pragma: no cover # for when we introduce new packaging types and don't implement + raise TypeError(f"cannot handle package type {of_type}") # pragma: no cover + return [package] + + @property + def _package_temp_path(self) -> Path: + return cast(Path, self.core["temp_dir"]) / "package" + + def _load_deps(self, for_env: EnvConfigSet) -> list[Requirement]: + # first check if this is statically available via PEP-621 + deps = self._load_deps_from_static(for_env) + if deps is None: + deps = self._load_deps_from_built_metadata(for_env) + return deps + + def _load_deps_from_static(self, for_env: EnvConfigSet) -> list[Requirement] | None: + pyproject_file = self.core["package_root"] / "pyproject.toml" + if not pyproject_file.exists(): # check if it's static PEP-621 metadata + return None + with pyproject_file.open("rb") as file_handler: + pyproject = tomllib.load(file_handler) + if "project" not in pyproject: + return None # is not a PEP-621 pyproject + project = pyproject["project"] + extras: set[str] = for_env["extras"] + for dynamic in project.get("dynamic", []): + if dynamic == "dependencies" or (extras and dynamic == "optional-dependencies"): + return None # if any dependencies are dynamic we can just calculate all dynamically + + deps: list[Requirement] = [Requirement(i) for i in project.get("dependencies", [])] + optional_deps = project.get("optional-dependencies", {}) + for extra in extras: + deps.extend(Requirement(i) for i in optional_deps.get(extra, [])) + return deps + + def _load_deps_from_built_metadata(self, for_env: EnvConfigSet) -> list[Requirement]: + # dependencies might depend on the python environment we're running in => if we build a wheel use that env + # to calculate the package metadata, otherwise ourselves + of_type: str = for_env["package"] + reqs: list[Requirement] | None = None + name = "" + if of_type in ("wheel", "editable"): # wheel packages + w_env = self._wheel_build_envs.get(for_env["wheel_build_env"]) + if w_env is not None and w_env is not self: + with w_env.display_context(self._has_display_suspended): + if isinstance(w_env, Pep517VirtualEnvPackager): + reqs, name = w_env.get_package_dependencies(for_env), w_env.get_package_name(for_env) + else: + reqs = [] + if reqs is None: + reqs = self.get_package_dependencies(for_env) + name = self.get_package_name(for_env) + extras: set[str] = for_env["extras"] + deps = dependencies_with_extras(reqs, extras, name) + return deps + + def get_package_dependencies(self, for_env: EnvConfigSet) -> list[Requirement]: + with self._pkg_lock: + if self._package_dependencies is None: # pragma: no branch + self._ensure_meta_present(for_env) + requires: list[str] = cast(PathDistribution, self._distribution_meta).requires or [] + self._package_dependencies = [Requirement(i) for i in requires] # pragma: no branch + return self._package_dependencies + + def get_package_name(self, for_env: EnvConfigSet) -> str: + with self._pkg_lock: + if self._package_name is None: # pragma: no branch + self._ensure_meta_present(for_env) + self._package_name = cast(PathDistribution, self._distribution_meta).metadata["Name"] + return self._package_name + + def _ensure_meta_present(self, for_env: EnvConfigSet) -> None: + if self._distribution_meta is not None: # pragma: no branch + return # pragma: no cover + self.setup() + end = self._frontend + if for_env["package"] == "editable": + dist_info = end.prepare_metadata_for_build_editable(self.meta_folder, self._wheel_config_settings).metadata + else: + dist_info = end.prepare_metadata_for_build_wheel(self.meta_folder, self._wheel_config_settings).metadata + self._distribution_meta = Distribution.at(str(dist_info)) + + @property + def _wheel_config_settings(self) -> ConfigSettings | None: + return {"--build-option": []} + + def requires(self) -> tuple[Requirement, ...]: + return self._frontend.requires + + +class Pep517VirtualEnvFrontend(Frontend): + def __init__(self, root: Path, env: Pep517VirtualEnvPackager) -> None: + super().__init__(*Frontend.create_args_from_folder(root)) + self._tox_env = env + self._backend_executor_: LocalSubProcessPep517Executor | None = None + into: dict[str, Any] = {} + pkg_cache = cached( + into, + key=lambda *args, **kwargs: "wheel" if "wheel_directory" in kwargs else "sdist", # noqa: U100 + ) + self.build_wheel = pkg_cache(self.build_wheel) # type: ignore + self.build_sdist = pkg_cache(self.build_sdist) # type: ignore + self.build_editable = pkg_cache(self.build_editable) # type: ignore + + @property + def backend_cmd(self) -> Sequence[str]: + return ["python"] + self.backend_args + + def _send(self, cmd: str, **kwargs: Any) -> tuple[Any, str, str]: + try: + if cmd in ("prepare_metadata_for_build_wheel", "prepare_metadata_for_build_editable"): + # given we'll build a wheel we might skip the prepare step + if "wheel" in self._tox_env.builds or "editable" in self._tox_env.builds: + return None, "", "" # will need to build wheel either way, avoid prepare + return super()._send(cmd, **kwargs) + except BackendFailed as exception: + raise exception if isinstance(exception, ToxBackendFailed) else ToxBackendFailed(exception) from exception + + @contextmanager + def _send_msg( + self, + cmd: str, + result_file: Path, # noqa: U100 + msg: str, + ) -> Iterator[ToxCmdStatus]: + with self._tox_env.execute_async( + cmd=self.backend_cmd, + cwd=self._root, + stdin=StdinSource.API, + show=None, + run_id=cmd, + executor=self.backend_executor, + ) as execute_status: + execute_status.write_stdin(f"{msg}{os.linesep}") + yield ToxCmdStatus(execute_status) + outcome = execute_status.outcome + if outcome is not None: # pragma: no branch + outcome.assert_success() + + def _unexpected_response(self, cmd: str, got: Any, expected_type: Any, out: str, err: str) -> NoReturn: + try: + super()._unexpected_response(cmd, got, expected_type, out, err) + except BackendFailed as exception: + raise exception if isinstance(exception, ToxBackendFailed) else ToxBackendFailed(exception) from exception + + @property + def backend_executor(self) -> LocalSubProcessPep517Executor: + if self._backend_executor_ is None: + environment_variables = self._tox_env.environment_variables.copy() + backend = os.pathsep.join(str(i) for i in self._backend_paths).strip() + if backend: + environment_variables["PYTHONPATH"] = backend + self._backend_executor_ = LocalSubProcessPep517Executor( + colored=self._tox_env.options.is_colored, + cmd=self.backend_cmd, + env=environment_variables, + cwd=self._root, + ) + + return self._backend_executor_ + + @contextmanager + def _wheel_directory(self) -> Iterator[Path]: + yield self._tox_env.pkg_dir # use our local wheel directory for building wheel + + +@impl +def tox_register_tox_env(register: ToxEnvRegister) -> None: + register.add_package_env(Pep517VirtualEnvPackager) diff --git a/src/tox/tox_env/python/virtual_env/package/util.py b/src/tox/tox_env/python/virtual_env/package/util.py new file mode 100644 index 000000000..5139e7bac --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/package/util.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from copy import deepcopy + +from packaging.markers import Variable # type: ignore[attr-defined] +from packaging.requirements import Requirement + + +def dependencies_with_extras(deps: list[Requirement], extras: set[str], package_name: str) -> list[Requirement]: + deps_with_markers = extract_extra_markers(deps) + result: list[Requirement] = [] + found: set[str] = set() + todo: set[str | None] = extras | {None} + visited: set[str | None] = set() + while todo: + new_extras: set[str | None] = set() + for req, extra_markers in deps_with_markers: + if todo & extra_markers: + if req.name == package_name: # support for recursive extras + new_extras.update(req.extras or set()) + else: + req_str = str(req) + if req_str not in found: + found.add(req_str) + result.append(req) + visited.update(todo) + todo = new_extras - visited + return result + + +def extract_extra_markers(deps: list[Requirement]) -> list[tuple[Requirement, set[str | None]]]: + # extras might show up as markers, move them into extras property + result: list[tuple[Requirement, set[str | None]]] = [] + for req in deps: + req = deepcopy(req) + markers: list[str | tuple[Variable, Variable, Variable]] = getattr(req.marker, "_markers", []) or [] + _at: int | None = None + extra_markers = set() + for _at, (marker_key, op, marker_value) in ( + (_at_marker, marker) + for _at_marker, marker in enumerate(markers) + if isinstance(marker, tuple) and len(marker) == 3 + ): + if marker_key.value == "extra" and op.value == "==": # pragma: no branch + extra_markers.add(marker_value.value) + del markers[_at] + _at -= 1 + if _at > 0 and (isinstance(markers[_at], str) and markers[_at] in ("and", "or")): + del markers[_at] + if len(markers) == 0: + req.marker = None + break + result.append((req, extra_markers or {None})) + return result diff --git a/src/tox/tox_env/python/virtual_env/runner.py b/src/tox/tox_env/python/virtual_env/runner.py new file mode 100644 index 000000000..4f1a51d0a --- /dev/null +++ b/src/tox/tox_env/python/virtual_env/runner.py @@ -0,0 +1,40 @@ +""" +A tox python environment runner that uses the virtualenv project. +""" +from __future__ import annotations + +from pathlib import Path + +from tox.plugin import impl +from tox.tox_env.register import ToxEnvRegister + +from ..runner import PythonRun +from .api import VirtualEnv + + +class VirtualEnvRunner(VirtualEnv, PythonRun): + """local file system python virtual environment via the virtualenv package""" + + @staticmethod + def id() -> str: + return "virtualenv" + + @property + def _package_tox_env_type(self) -> str: + return "virtualenv-pep-517" + + @property + def _external_pkg_tox_env_type(self) -> str: + return "virtualenv-cmd-builder" + + @property + def default_pkg_type(self) -> str: + tox_root: Path = self.core["tox_root"] + if not (any((tox_root / i).exists() for i in ("pyproject.toml", "setup.py", "setup.cfg"))): + return "skip" + return super().default_pkg_type + + +@impl +def tox_register_tox_env(register: ToxEnvRegister) -> None: + register.add_run_env(VirtualEnvRunner) diff --git a/src/tox/tox_env/register.py b/src/tox/tox_env/register.py new file mode 100644 index 000000000..4c3da2f96 --- /dev/null +++ b/src/tox/tox_env/register.py @@ -0,0 +1,89 @@ +""" +Manages the tox environment registry. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable + +from .package import PackageToxEnv +from .runner import RunToxEnv + +if TYPE_CHECKING: + from tox.plugin.manager import Plugin + + +class ToxEnvRegister: + """tox environment registry""" + + def __init__(self) -> None: + self._run_envs: dict[str, type[RunToxEnv]] = {} + self._package_envs: dict[str, type[PackageToxEnv]] = {} + self._default_run_env: str = "" + + def _register_tox_env_types(self, manager: Plugin) -> None: + manager.tox_register_tox_env(register=self) + + def add_run_env(self, of_type: type[RunToxEnv]) -> None: + """ + Define a new run tox environment type. + + :param of_type: the new run environment type + """ + self._run_envs[of_type.id()] = of_type + + def add_package_env(self, of_type: type[PackageToxEnv]) -> None: + """ + Define a new packaging tox environment type. + + :param of_type: the new packaging environment type + """ + self._package_envs[of_type.id()] = of_type + + @property + def env_runners(self) -> Iterable[str]: + """:returns: run environment types currently defined""" + return self._run_envs.keys() + + @property + def default_env_runner(self) -> str: + """:returns: the default run environment type""" + if not self._default_run_env and self._run_envs: + self._default_run_env = next(iter(self._run_envs.keys())) + return self._default_run_env + + @default_env_runner.setter + def default_env_runner(self, value: str) -> None: + """ + Change the default run environment type. + + :param value: the new run environment type by name + """ + if value not in self._run_envs: + raise ValueError("run env must be registered before setting it as default") + self._default_run_env = value + + def runner(self, name: str) -> type[RunToxEnv]: + """ + Lookup a run tox environment type by name. + + :param name: the name of the runner type + :return: the type of the runner type + """ + return self._run_envs[name] + + def package(self, name: str) -> type[PackageToxEnv]: + """ + Lookup a packaging tox environment type by name. + + :param name: the name of the packaging type + :return: the type of the packaging type + """ + return self._package_envs[name] + + +REGISTER = ToxEnvRegister() #: the tox register + +__all__ = ( + "REGISTER", + "ToxEnvRegister", +) diff --git a/src/tox/tox_env/runner.py b/src/tox/tox_env/runner.py new file mode 100644 index 000000000..ff3051a66 --- /dev/null +++ b/src/tox/tox_env/runner.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import logging +import os +import re +from abc import ABC, abstractmethod +from hashlib import sha256 +from pathlib import Path +from typing import Any, Iterable, List, cast + +from tox.config.types import Command, EnvList +from tox.journal import EnvJournal + +from .api import ToxEnv, ToxEnvCreateArgs +from .package import Package, PackageToxEnv, PathPackage + + +class RunToxEnv(ToxEnv, ABC): + def __init__(self, create_args: ToxEnvCreateArgs) -> None: + self.package_env: PackageToxEnv | None = None + self._packages: list[Package] = [] + super().__init__(create_args) + self._package_envs: list[PackageToxEnv | Exception] | None = None + + def register_config(self) -> None: + def ensure_one_line(value: str) -> str: + return re.sub(r"\s+", " ", value.replace("\r", "").replace("\n", " ")) + + self.conf.add_config( + keys=["description"], + of_type=str, + default="", + desc="description attached to the tox environment", + post_process=ensure_one_line, + ) + self.conf.add_config( + "depends", + of_type=EnvList, + desc="tox environments that this environment depends on (must be run after those)", + default=EnvList([]), + ) + super().register_config() + self.conf.add_config( + keys=["commands_pre"], + of_type=List[Command], + default=[], + desc="the commands to be called before testing", + ) + self.conf.add_config( + keys=["commands"], + of_type=List[Command], + default=[], + desc="the commands to be called for testing", + ) + self.conf.add_config( + keys=["commands_post"], + of_type=List[Command], + default=[], + desc="the commands to be called after testing", + ) + self.conf.add_config( + keys=["change_dir", "changedir"], + of_type=Path, + default=lambda conf, name: cast(Path, conf.core["tox_root"]), # noqa: U100 + desc="change to this working directory when executing the test command", + ) + self.conf.add_config( + keys=["args_are_paths"], + of_type=bool, + default=True, + desc="if True rewrite relative posargs paths from cwd to change_dir", + ) + self.conf.add_config( + keys=["ignore_errors"], + of_type=bool, + default=False, + desc="when executing the commands keep going even if a sub-command exits with non-zero exit code", + ) + self.conf.add_config( + keys=["ignore_outcome"], + of_type=bool, + default=False, + desc="if set to true a failing result of this testenv will not make tox fail (instead just warn)", + ) + + def _teardown(self) -> None: + super()._teardown() + self._call_pkg_envs("teardown_env", self.conf) + + def interrupt(self) -> None: + super().interrupt() + self._call_pkg_envs("interrupt") + + def get_package_env_types(self) -> tuple[str, str] | None: + has_external_pkg = getattr(self.options, "install_pkg", None) is not None + if self._register_package_conf() or has_external_pkg: + has_external_pkg = has_external_pkg or self.conf["package"] == "external" + self.core.add_config( + keys=["package_env", "isolated_build_env"], + of_type=str, + default=self._default_package_env, + desc="tox environment used to package", + ) + self.conf.add_config( + keys=["package_env"], + of_type=str, + default=f'{self.core["package_env"]}{"_external" if has_external_pkg else ""}', + desc="tox environment used to package", + ) + is_external = self.conf["package"] == "external" + self.conf.add_constant( + keys=["package_tox_env_type"], + desc="tox package type used to generate the package", + value=self._external_pkg_tox_env_type if is_external else self._package_tox_env_type, + ) + return self.conf["package_env"], self.conf["package_tox_env_type"] + return None + + def _call_pkg_envs(self, method_name: str, *args: Any) -> None: + for package_env in self.package_envs: + with package_env.display_context(suspend=self._has_display_suspended): + getattr(package_env, method_name)(*args) + + def _clean(self, transitive: bool = False) -> None: + super()._clean(transitive) + if transitive: + self._call_pkg_envs("_clean") + + @property + def _default_package_env(self) -> str: + return ".pkg" + + @property + @abstractmethod + def _package_tox_env_type(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def _external_pkg_tox_env_type(self) -> str: + raise NotImplementedError + + def _setup_with_env(self) -> None: + if self.package_env is not None: + skip_pkg_install: bool = getattr(self.options, "skip_pkg_install", False) + if skip_pkg_install is True: + logging.warning("skip building and installing the package") + else: + self._setup_pkg() + + def _register_package_conf(self) -> bool: + """If this returns True package_env and package_tox_env_type configurations must be defined""" + self.core.add_config( + keys=["no_package", "skipsdist"], + of_type=bool, + default=False, + desc="is there any packaging involved in this project", + ) + core_no_package: bool = self.core["no_package"] + if core_no_package is True: + return False + self.conf.add_config( + keys="skip_install", + of_type=bool, + default=False, + desc="skip installation", + ) + skip_install: bool = self.conf["skip_install"] + return not skip_install + + def _setup_pkg(self) -> None: + self._packages = self._build_packages() + self.installer.install(self._packages, RunToxEnv.__name__, "package") + self._handle_journal_package(self.journal, self._packages) + + @staticmethod + def _handle_journal_package(journal: EnvJournal, packages: list[Package]) -> None: + if not journal: + return + installed_meta = [] + for package in packages: + if isinstance(package, PathPackage): + pkg = package.path + of_type = "file" if pkg.is_file() else ("dir" if pkg.is_dir() else "N/A") + meta = {"basename": pkg.name, "type": of_type} + if of_type == "file": + meta["sha256"] = sha256(pkg.read_bytes()).hexdigest() + else: + raise NotImplementedError + installed_meta.append(meta) + if installed_meta: + journal["installpkg"] = installed_meta[0] if len(installed_meta) == 1 else installed_meta + + @property + def environment_variables(self) -> dict[str, str]: + environment_variables = super().environment_variables + if self.package_env is not None and self._packages: + # if package(s) have been built insert them as environment variable + environment_variables["TOX_PACKAGE"] = os.pathsep.join(str(i) for i in self._packages) + return environment_variables + + @abstractmethod + def _build_packages(self) -> list[Package]: + """:returns: a list of packages installed in the environment""" + raise NotImplementedError + + @property + def package_envs(self) -> Iterable[PackageToxEnv]: + if self.package_env is not None: + yield self.package_env + yield from self.package_env.child_pkg_envs(self.conf) + + def mark_active(self) -> None: + for pkg_env in self.package_envs: + pkg_env.mark_active_run_env(self) diff --git a/src/tox/util/__init__.py b/src/tox/util/__init__.py index c72dea0f6..e69de29bb 100644 --- a/src/tox/util/__init__.py +++ b/src/tox/util/__init__.py @@ -1,18 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import os -from contextlib import contextmanager - - -@contextmanager -def set_os_env_var(env_var_name, value): - """Set an environment variable with unrolling once the context exists""" - prev_value = os.environ.get(env_var_name) - try: - os.environ[env_var_name] = str(value) - yield - finally: - if prev_value is None: - del os.environ[env_var_name] - else: - os.environ[env_var_name] = prev_value diff --git a/src/tox/util/cpu.py b/src/tox/util/cpu.py new file mode 100644 index 000000000..283bd39a5 --- /dev/null +++ b/src/tox/util/cpu.py @@ -0,0 +1,15 @@ +"""Helper methods related to the CPU""" +from __future__ import annotations + +import multiprocessing + + +def auto_detect_cpus() -> int: + try: + n: int | None = multiprocessing.cpu_count() + except NotImplementedError: + n = None + return n if n else 1 + + +__all__ = ("auto_detect_cpus",) diff --git a/src/tox/util/file_view.py b/src/tox/util/file_view.py new file mode 100644 index 000000000..488d9aa82 --- /dev/null +++ b/src/tox/util/file_view.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import logging +import os +import shutil +from itertools import chain +from os.path import commonpath +from pathlib import Path + + +def create_session_view(package: Path, temp_path: Path) -> Path: + """Allows using the file after you no longer holding a lock to it by moving it into a temp folder""" + # we'll number the active instances, and use the max value as session folder for a new build + # note we cannot change package names as PEP-491 (wheel binary format) + # is strict about file name structure + + temp_path.mkdir(parents=True, exist_ok=True) + exists = [i.name for i in temp_path.iterdir()] + file_id = max(chain((0,), (int(i) for i in exists if str(i).isnumeric()))) + session_dir = temp_path / str(file_id + 1) + session_dir.mkdir() + session_package = session_dir / package.name + + links = False # if we can do hard links do that, otherwise just copy + if hasattr(os, "link"): + try: + os.link(package, session_package) + links = True + except (OSError, NotImplementedError): + pass + if not links: + shutil.copyfile(package, session_package) + operation = "links" if links else "copied" + common = commonpath((session_package, package)) + rel_session, rel_package = session_package.relative_to(common), package.relative_to(common) + logging.debug("package %s %s to %s (%s)", rel_session, operation, rel_package, common) + return session_package diff --git a/src/tox/util/graph.py b/src/tox/util/graph.py index 318b5b32d..cb354809b 100644 --- a/src/tox/util/graph.py +++ b/src/tox/util/graph.py @@ -1,16 +1,17 @@ -from __future__ import absolute_import, unicode_literals +"""Helper methods related to graph theory.""" +from __future__ import annotations from collections import OrderedDict, defaultdict -def stable_topological_sort(graph): +def stable_topological_sort(graph: dict[str, set[str]]) -> list[str]: to_order = set(graph.keys()) # keep a log of what we need to order # normalize graph - fill missing nodes (assume no dependency) for values in list(graph.values()): for value in values: if value not in graph: - graph[value] = () + graph[value] = set() inverse_graph = defaultdict(set) for key, depends in graph.items(): @@ -47,11 +48,11 @@ def stable_topological_sort(graph): return result -def identify_cycle(graph): - path = OrderedDict() +def identify_cycle(graph: dict[str, set[str]]) -> None: + path: dict[str, None] = OrderedDict() visited = set() - def visit(vertex): + def visit(vertex: str) -> dict[str, None] | None: if vertex in visited: return None visited.add(vertex) @@ -62,7 +63,7 @@ def visit(vertex): del path[vertex] return None - for node in graph: + for node in graph: # pragma: no branch # we never get here if the graph is empty result = visit(node) if result is not None: - raise ValueError("{}".format(" | ".join(result.keys()))) + raise ValueError(f"{' | '.join(result.keys())}") diff --git a/src/tox/util/lock.py b/src/tox/util/lock.py deleted file mode 100644 index fd6473407..000000000 --- a/src/tox/util/lock.py +++ /dev/null @@ -1,41 +0,0 @@ -"""holds locking functionality that works across processes""" -from __future__ import absolute_import, unicode_literals - -from contextlib import contextmanager - -import py -from filelock import FileLock, Timeout - -from tox.reporter import verbosity1 - - -@contextmanager -def hold_lock(lock_file, reporter=verbosity1): - py.path.local(lock_file.dirname).ensure(dir=1) - lock = FileLock(str(lock_file)) - try: - try: - lock.acquire(0.0001) - except Timeout: - reporter("lock file {} present, will block until released".format(lock_file)) - lock.acquire() - yield - finally: - lock.release(force=True) - - -def get_unique_file(path, prefix, suffix): - """get a unique file in a folder having a given prefix and suffix, - with unique number in between""" - lock_file = path.join(".lock") - prefix = "{}-".format(prefix) - with hold_lock(lock_file): - max_value = -1 - for candidate in path.listdir("{}*{}".format(prefix, suffix)): - try: - max_value = max(max_value, int(candidate.basename[len(prefix) : -len(suffix)])) - except ValueError: - continue - winner = path.join("{}{}{}".format(prefix, max_value + 1, suffix)) - winner.ensure(dir=0) - return winner diff --git a/src/tox/util/main.py b/src/tox/util/main.py deleted file mode 100644 index ebd0faa31..000000000 --- a/src/tox/util/main.py +++ /dev/null @@ -1,6 +0,0 @@ -import inspect -import os - -import tox - -MAIN_FILE = os.path.join(os.path.dirname(inspect.getfile(tox)), "__main__.py") diff --git a/src/tox/util/path.py b/src/tox/util/path.py index 07dd4b58e..120bfa156 100644 --- a/src/tox/util/path.py +++ b/src/tox/util/path.py @@ -1,26 +1,26 @@ -import errno -import os -import shutil -import stat +from __future__ import annotations -from tox import reporter +from pathlib import Path +from shutil import rmtree -def ensure_empty_dir(path): - if path.check(): - reporter.info(" removing {}".format(path)) - shutil.rmtree(str(path), onerror=_remove_readonly) - path.ensure(dir=1) +def ensure_empty_dir(path: Path, except_filename: str | None = None) -> None: + if path.exists(): + if path.is_dir(): + for sub_path in path.iterdir(): + if sub_path.name == except_filename: + continue + if sub_path.is_dir(): + rmtree(sub_path, ignore_errors=True) + else: + sub_path.unlink() + else: + path.unlink() + path.mkdir() + else: + path.mkdir(parents=True) -def _remove_readonly(func, path, exc_info): - """Clear the readonly bit and reattempt the removal.""" - if isinstance(exc_info[1], OSError): - if exc_info[1].errno == errno.EACCES: - try: - os.chmod(path, stat.S_IWRITE) - func(path) - except Exception: - # when second attempt fails, ignore the problem - # to maintain some level of backward compatibility - pass +__all__ = [ + "ensure_empty_dir", +] diff --git a/src/tox/util/spinner.py b/src/tox/util/spinner.py index ee2258958..e816786ba 100644 --- a/src/tox/util/spinner.py +++ b/src/tox/util/spinner.py @@ -1,60 +1,84 @@ -# -*- coding: utf-8 -*- """A minimal non-colored version of https://pypi.org/project/halo, to track list progress""" -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os import sys +import textwrap import threading -from collections import OrderedDict, namedtuple -from datetime import datetime +import time +from collections import OrderedDict +from types import TracebackType +from typing import IO, NamedTuple, Sequence, TypeVar -import py +from colorama import Fore -threads = [] - -if os.name == "nt": +if sys.platform == "win32": # pragma: win32 cover import ctypes class _CursorInfo(ctypes.Structure): _fields_ = [("size", ctypes.c_int), ("visible", ctypes.c_byte)] -_BaseMessage = namedtuple("_BaseMessage", ["unicode_msg", "ascii_msg"]) +def _file_support_encoding(chars: Sequence[str], file: IO[str]) -> bool: + encoding = getattr(file, "encoding", None) + if encoding is not None: # pragma: no branch # this should be always set, unless someone passes in something bad + for char in chars: + try: + char.encode(encoding) + except UnicodeEncodeError: + break + else: + return True + return False -class SpinnerMessage(_BaseMessage): - def for_file(self, file): - try: - self.unicode_msg.encode(file.encoding) - except (AttributeError, TypeError, UnicodeEncodeError): - return self.ascii_msg - else: - return self.unicode_msg +T = TypeVar("T", bound="Spinner") +MISS_DURATION = 0.01 -class Spinner(object): +class Outcome(NamedTuple): + ok: str + fail: str + skip: str + + +class Spinner: CLEAR_LINE = "\033[K" max_width = 120 - FRAMES = SpinnerMessage("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏", "|-+x*") - OK_FLAG = SpinnerMessage("✔ OK", "[ OK ]") - FAIL_FLAG = SpinnerMessage("✖ FAIL", "[FAIL]") - SKIP_FLAG = SpinnerMessage("⚠ SKIP", "[SKIP]") - - def __init__(self, enabled=True, refresh_rate=0.1): + UNICODE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + ASCII_FRAMES = ["|", "-", "+", "x", "*"] + UNICODE_OUTCOME = Outcome(ok="✔", fail="✖", skip="⚠") + ASCII_OUTCOME = Outcome(ok="+", fail="!", skip="?") + + def __init__( + self, + enabled: bool = True, + refresh_rate: float = 0.1, + colored: bool = True, + stream: IO[str] | None = None, + total: int | None = None, + ) -> None: + self.is_colored = colored self.refresh_rate = refresh_rate self.enabled = enabled - self._file = sys.stdout - self.frames = self.FRAMES.for_file(self._file) - self.stream = py.io.TerminalWriter(file=self._file) - self._envs = OrderedDict() + stream = sys.stdout if stream is None else stream + self.frames = self.UNICODE_FRAMES if _file_support_encoding(self.UNICODE_FRAMES, stream) else self.ASCII_FRAMES + self.outcome = ( + self.UNICODE_OUTCOME if _file_support_encoding(self.UNICODE_OUTCOME, stream) else self.ASCII_OUTCOME + ) + self.stream = stream + self.total = total + self.print_report = True + + self._envs: dict[str, float] = OrderedDict() self._frame_index = 0 - def clear(self): + def clear(self) -> None: if self.enabled: self.stream.write("\r") self.stream.write(self.CLEAR_LINE) - def render(self): + def render(self) -> Spinner: while True: self._stop_spinner.wait(self.refresh_rate) if self._stop_spinner.is_set(): @@ -62,21 +86,21 @@ def render(self): self.render_frame() return self - def render_frame(self): + def render_frame(self) -> None: if self.enabled: self.clear() - self.stream.write("\r{}".format(self.frame())) + self.stream.write(f"\r{self.frame()}") - def frame(self): + def frame(self) -> str: frame = self.frames[self._frame_index] self._frame_index += 1 - self._frame_index = self._frame_index % len(self.frames) - text_frame = "[{}] {}".format(len(self._envs), " | ".join(self._envs)) - if len(text_frame) > self.max_width - 1: - text_frame = "{}...".format(text_frame[: self.max_width - 1 - 3]) - return "{} {}".format(*[(frame, text_frame)][0]) + self._frame_index %= len(self.frames) + total = f"/{self.total}" if self.total is not None else "" + text_frame = f"[{len(self._envs)}{total}] {' | '.join(self._envs)}" + text_frame = textwrap.shorten(text_frame, width=self.max_width - 1, placeholder="...") + return f"{frame} {text_frame}" - def __enter__(self): + def __enter__(self: T) -> T: if self.enabled: self.disable_cursor() self.render_frame() @@ -86,9 +110,14 @@ def __enter__(self): self._spinner_thread.start() return self - def __exit__(self, exc_type, exc_val, exc_tb): - if not self._stop_spinner.is_set(): - if self._spinner_thread: + def __exit__( + self, + exc_type: type[BaseException] | None, # noqa: U100 + exc_val: BaseException | None, # noqa: U100 + exc_tb: TracebackType | None, # noqa: U100 + ) -> None: + if not self._stop_spinner.is_set(): # pragma: no branch + if self._spinner_thread: # pragma: no branch # hard to test self._stop_spinner.set() self._spinner_thread.join() @@ -97,78 +126,70 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.clear() self.enable_cursor() - return self - - def add(self, name): - self._envs[name] = datetime.now() + def add(self, name: str) -> None: + self._envs[name] = time.monotonic() - def succeed(self, key): - self.finalize(key, self.OK_FLAG.for_file(self._file), green=True) + def succeed(self, key: str) -> None: + self.finalize(key, f"OK {self.outcome.ok}", Fore.GREEN) - def fail(self, key): - self.finalize(key, self.FAIL_FLAG.for_file(self._file), red=True) + def fail(self, key: str) -> None: + self.finalize(key, f"FAIL {self.outcome.fail}", Fore.RED) - def skip(self, key): - self.finalize(key, self.SKIP_FLAG.for_file(self._file), white=True) + def skip(self, key: str) -> None: + self.finalize(key, f"SKIP {self.outcome.skip}", Fore.YELLOW) - def finalize(self, key, status, **kwargs): - start_at = self._envs[key] - del self._envs[key] + def finalize(self, key: str, status: str, color: int) -> None: + start_at = self._envs.pop(key, None) if self.enabled: self.clear() - self.stream.write( - "{} {} in {}{}".format( - status, - key, - td_human_readable(datetime.now() - start_at), - os.linesep, - ), - **kwargs - ) - if not self._envs: - self.__exit__(None, None, None) - - def disable_cursor(self): - if self._file.isatty(): - if os.name == "nt": + if self.print_report: + duration = MISS_DURATION if start_at is None else time.monotonic() - start_at + base = f"{key}: {status} in {td_human_readable(duration)}" + if self.is_colored: + base = f"{color}{base}{Fore.RESET}" + base += os.linesep + self.stream.write(base) + + def disable_cursor(self) -> None: + if self.stream.isatty(): + if sys.platform == "win32": # pragma: win32 cover ci = _CursorInfo() handle = ctypes.windll.kernel32.GetStdHandle(-11) ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(ci)) ci.visible = False ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(ci)) - elif os.name == "posix": + else: self.stream.write("\033[?25l") - def enable_cursor(self): - if self._file.isatty(): - if os.name == "nt": + def enable_cursor(self) -> None: + if self.stream.isatty(): + if sys.platform == "win32": # pragma: win32 cover ci = _CursorInfo() handle = ctypes.windll.kernel32.GetStdHandle(-11) ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(ci)) ci.visible = True ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(ci)) - elif os.name == "posix": + else: self.stream.write("\033[?25h") -def td_human_readable(delta): - seconds = int(delta.total_seconds()) - periods = [ - ("year", 60 * 60 * 24 * 365), - ("month", 60 * 60 * 24 * 30), - ("day", 60 * 60 * 24), - ("hour", 60 * 60), - ("minute", 60), - ("second", 1), - ] - - texts = [] - for period_name, period_seconds in periods: - if seconds > period_seconds or period_seconds == 1: +_PERIODS = [ + ("day", 60 * 60 * 24), + ("hour", 60 * 60), + ("minute", 60), + ("second", 1), +] + + +def td_human_readable(seconds: float) -> str: + texts: list[str] = [] + for period_name, period_seconds in _PERIODS: + period_str = None + if period_name == "second" and (seconds >= 0.01 or not texts): + period_str = f"{seconds:.2f}".rstrip("0").rstrip(".") + elif seconds >= period_seconds: period_value, seconds = divmod(seconds, period_seconds) - if period_name == "second": - ms = delta.total_seconds() - int(delta.total_seconds()) - period_value = round(period_value + ms, 3) - has_s = "s" if period_value != 1 else "" - texts.append("{} {}{}".format(period_value, period_name, has_s)) - return ", ".join(texts) + period_str = f"{period_value:.0f}" + if period_str is not None: + texts.append(f"{period_str} {period_name}{'' if period_str == '1' else 's'}") + return " ".join(texts) diff --git a/src/tox/util/stdlib.py b/src/tox/util/stdlib.py deleted file mode 100644 index 29a3f78bd..000000000 --- a/src/tox/util/stdlib.py +++ /dev/null @@ -1,55 +0,0 @@ -import sys -import threading -from contextlib import contextmanager -from tempfile import TemporaryFile - -if sys.version_info >= (3, 8): - from importlib import metadata as importlib_metadata # noqa -else: - import importlib_metadata # noqa - - -def is_main_thread(): - """returns true if we are within the main thread""" - cur_thread = threading.current_thread() - if sys.version_info >= (3, 4): - return cur_thread is threading.main_thread() - else: - # noinspection PyUnresolvedReferences - return isinstance(cur_thread, threading._MainThread) - - -# noinspection PyPep8Naming -@contextmanager -def suppress_output(): - """suppress both stdout and stderr outputs""" - if sys.version_info >= (3, 5): - from contextlib import redirect_stderr, redirect_stdout - else: - - class _RedirectStream(object): - - _stream = None - - def __init__(self, new_target): - self._new_target = new_target - self._old_targets = [] - - def __enter__(self): - self._old_targets.append(getattr(sys, self._stream)) - setattr(sys, self._stream, self._new_target) - return self._new_target - - def __exit__(self, exctype, excinst, exctb): - setattr(sys, self._stream, self._old_targets.pop()) - - class redirect_stdout(_RedirectStream): - _stream = "stdout" - - class redirect_stderr(_RedirectStream): - _stream = "stderr" - - with TemporaryFile("wt") as file: - with redirect_stdout(file): - with redirect_stderr(file): - yield diff --git a/src/tox/venv.py b/src/tox/venv.py deleted file mode 100644 index 8acb0c775..000000000 --- a/src/tox/venv.py +++ /dev/null @@ -1,847 +0,0 @@ -import codecs -import json -import os -import re -import sys -from itertools import chain - -import py - -import tox -from tox import reporter -from tox.action import Action -from tox.config.parallel import ENV_VAR_KEY_PRIVATE as PARALLEL_ENV_VAR_KEY_PRIVATE -from tox.constants import INFO, PARALLEL_RESULT_JSON_PREFIX, PARALLEL_RESULT_JSON_SUFFIX -from tox.package.local import resolve_package -from tox.util.lock import get_unique_file -from tox.util.path import ensure_empty_dir - -from .config import DepConfig - -if sys.version_info >= (3, 3): - from shlex import quote as shlex_quote -else: - from pipes import quote as shlex_quote - -#: maximum parsed shebang interpreter length (see: prepend_shebang_interpreter) -MAXINTERP = 2048 - - -class CreationConfig: - def __init__( - self, - base_resolved_python_sha256, - base_resolved_python_path, - tox_version, - sitepackages, - usedevelop, - deps, - alwayscopy, - ): - self.base_resolved_python_sha256 = base_resolved_python_sha256 - self.base_resolved_python_path = base_resolved_python_path - self.tox_version = tox_version - self.sitepackages = sitepackages - self.usedevelop = usedevelop - self.alwayscopy = alwayscopy - self.deps = deps - - def writeconfig(self, path): - lines = [ - "{} {}".format(self.base_resolved_python_sha256, self.base_resolved_python_path), - "{} {:d} {:d} {:d}".format( - self.tox_version, - self.sitepackages, - self.usedevelop, - self.alwayscopy, - ), - ] - for dep in self.deps: - lines.append("{} {}".format(*dep)) - content = "\n".join(lines) - path.ensure() - path.write(content) - return content - - @classmethod - def readconfig(cls, path): - try: - lines = path.readlines(cr=0) - base_resolved_python_info = lines.pop(0).split(None, 1) - tox_version, sitepackages, usedevelop, alwayscopy = lines.pop(0).split(None, 4) - sitepackages = bool(int(sitepackages)) - usedevelop = bool(int(usedevelop)) - alwayscopy = bool(int(alwayscopy)) - deps = [] - for line in lines: - base_resolved_python_sha256, depstring = line.split(None, 1) - deps.append((base_resolved_python_sha256, depstring)) - base_resolved_python_sha256, base_resolved_python_path = base_resolved_python_info - return CreationConfig( - base_resolved_python_sha256, - base_resolved_python_path, - tox_version, - sitepackages, - usedevelop, - deps, - alwayscopy, - ) - except Exception: - return None - - def matches_with_reason(self, other, deps_matches_subset=False): - for attr in ( - "base_resolved_python_sha256", - "base_resolved_python_path", - "tox_version", - "sitepackages", - "usedevelop", - "alwayscopy", - ): - left = getattr(self, attr) - right = getattr(other, attr) - if left != right: - return False, "attr {} {!r}!={!r}".format(attr, left, right) - self_deps = set(self.deps) - other_deps = set(other.deps) - if self_deps != other_deps: - if deps_matches_subset: - diff = other_deps - self_deps - if diff: - return False, "missing in previous {!r}".format(diff) - else: - return False, "{!r}!={!r}".format(self_deps, other_deps) - return True, None - - def matches(self, other, deps_matches_subset=False): - outcome, _ = self.matches_with_reason(other, deps_matches_subset) - return outcome - - -class VirtualEnv(object): - def __init__(self, envconfig=None, popen=None, env_log=None): - self.envconfig = envconfig - self.popen = popen - self._actions = [] - self.env_log = env_log - self._result_json_path = None - - def new_action(self, msg, *args): - config = self.envconfig.config - command_log = self.env_log.get_commandlog( - "test" if msg in ("run-test", "run-test-pre", "run-test-post") else "setup", - ) - return Action( - self.name, - msg, - args, - self.envconfig.envlogdir, - config.option.resultjson, - command_log, - self.popen, - self.envconfig.envpython, - self.envconfig.suicide_timeout, - self.envconfig.interrupt_timeout, - self.envconfig.terminate_timeout, - ) - - def get_result_json_path(self): - if self._result_json_path is None: - if self.envconfig.config.option.resultjson: - self._result_json_path = get_unique_file( - self.path, - PARALLEL_RESULT_JSON_PREFIX, - PARALLEL_RESULT_JSON_SUFFIX, - ) - return self._result_json_path - - @property - def hook(self): - return self.envconfig.config.pluginmanager.hook - - @property - def path(self): - """Path to environment base dir.""" - return self.envconfig.envdir - - @property - def path_config(self): - return self.path.join(".tox-config1") - - @property - def name(self): - """test environment name.""" - return self.envconfig.envname - - def __repr__(self): - return "".format(self.path) - - def getcommandpath(self, name, venv=True, cwd=None): - """Return absolute path (str or localpath) for specified command name. - - - If it's a local path we will rewrite it as as a relative path. - - If venv is True we will check if the command is coming from the venv - or is allowed to come from external. - """ - name = str(name) - if os.path.isabs(name): - return name - if os.path.split(name)[0] == ".": - path = cwd.join(name) - if path.check(): - return str(path) - - if venv: - path = self._venv_lookup_and_check_external_allowlist(name) - else: - path = self._normal_lookup(name) - - if path is None: - raise tox.exception.InvocationError( - "could not find executable {}".format(shlex_quote(name)), - ) - - return str(path) # will not be rewritten for reporting - - def _venv_lookup_and_check_external_allowlist(self, name): - path = self._venv_lookup(name) - if path is None: - path = self._normal_lookup(name) - if path is not None: - self._check_external_allowed_and_warn(path) - return path - - def _venv_lookup(self, name): - return py.path.local.sysfind(name, paths=[self.envconfig.envbindir]) - - def _normal_lookup(self, name): - return py.path.local.sysfind(name) - - def _check_external_allowed_and_warn(self, path): - if not self.is_allowed_external(path): - reporter.warning( - "test command found but not installed in testenv\n" - " cmd: {}\n" - " env: {}\n" - "Maybe you forgot to specify a dependency? " - "See also the allowlist_externals envconfig setting.\n\n" - "DEPRECATION WARNING: this will be an error in tox 4 and above!".format( - path, - self.envconfig.envdir, - ), - ) - - def is_allowed_external(self, p): - tryadd = [""] - if tox.INFO.IS_WIN: - tryadd += [os.path.normcase(x) for x in os.environ["PATHEXT"].split(os.pathsep)] - p = py.path.local(os.path.normcase(str(p))) - - if self.envconfig.allowlist_externals and self.envconfig.whitelist_externals: - raise tox.exception.ConfigError( - "Either whitelist_externals or allowlist_externals might be specified, not both", - ) - - allowed_externals = ( - self.envconfig.whitelist_externals or self.envconfig.allowlist_externals - ) - for x in allowed_externals: - for add in tryadd: - if p.fnmatch(x + add): - return True - return False - - def update(self, action): - """return status string for updating actual venv to match configuration. - if status string is empty, all is ok. - """ - rconfig = CreationConfig.readconfig(self.path_config) - if self.envconfig.recreate: - reason = "-r flag" - else: - if rconfig is None: - reason = "no previous config {}".format(self.path_config) - else: - live_config = self._getliveconfig() - deps_subset_match = getattr(self.envconfig, "deps_matches_subset", False) - outcome, reason = rconfig.matches_with_reason(live_config, deps_subset_match) - if reason is None: - action.info("reusing", self.envconfig.envdir) - return - action.info("cannot reuse", reason) - if rconfig is None: - action.setactivity("create", self.envconfig.envdir) - else: - action.setactivity("recreate", self.envconfig.envdir) - try: - self.hook.tox_testenv_create(action=action, venv=self) - self.just_created = True - except tox.exception.UnsupportedInterpreter as exception: - return exception - try: - self.hook.tox_testenv_install_deps(action=action, venv=self) - except tox.exception.InvocationError as exception: - return "could not install deps {}; v = {!r}".format(self.envconfig.deps, exception) - - def _getliveconfig(self): - base_resolved_python_path = self.envconfig.python_info.executable - version = tox.__version__ - sitepackages = self.envconfig.sitepackages - develop = self.envconfig.usedevelop - alwayscopy = self.envconfig.alwayscopy - deps = [] - for dep in self.get_resolved_dependencies(): - dep_name_sha256 = getdigest(dep.name) - deps.append((dep_name_sha256, dep.name)) - base_resolved_python_sha256 = getdigest(base_resolved_python_path) - return CreationConfig( - base_resolved_python_sha256, - base_resolved_python_path, - version, - sitepackages, - develop, - deps, - alwayscopy, - ) - - def get_resolved_dependencies(self): - dependencies = [] - for dependency in self.envconfig.deps: - if dependency.indexserver is None: - package = resolve_package(package_spec=dependency.name) - if package != dependency.name: - dependency = dependency.__class__(package) - dependencies.append(dependency) - return dependencies - - def getsupportedinterpreter(self): - return self.envconfig.getsupportedinterpreter() - - def matching_platform(self): - return re.match(self.envconfig.platform, sys.platform) - - def finish(self): - previous_config = CreationConfig.readconfig(self.path_config) - live_config = self._getliveconfig() - if previous_config is None or not previous_config.matches(live_config): - content = live_config.writeconfig(self.path_config) - reporter.verbosity1("write config to {} as {!r}".format(self.path_config, content)) - - def _needs_reinstall(self, setupdir, action): - setup_py = setupdir.join("setup.py") - - if not setup_py.exists(): - return False - - setup_cfg = setupdir.join("setup.cfg") - args = [self.envconfig.envpython, str(setup_py), "--name"] - env = self._get_os_environ() - output = action.popen( - args, - cwd=setupdir, - redirect=False, - returnout=True, - env=env, - capture_err=False, - ) - name = next( - (i for i in output.split("\n") if i and not i.startswith("pydev debugger:")), - "", - ) - args = [ - self.envconfig.envpython, - "-c", - "import sys; import json; print(json.dumps(sys.path))", - ] - out = action.popen(args, redirect=False, returnout=True, env=env) - try: - sys_path = json.loads(out) - except ValueError: - sys_path = [] - egg_info_fname = ".".join((name.replace("-", "_"), "egg-info")) - for d in reversed(sys_path): - egg_info = py.path.local(d).join(egg_info_fname) - if egg_info.check(): - break - else: - return True - needs_reinstall = any( - conf_file.check() and conf_file.mtime() > egg_info.mtime() - for conf_file in (setup_py, setup_cfg) - ) - - # Ensure the modification time of the egg-info folder is updated so we - # won't need to do this again. - # TODO(stephenfin): Remove once the minimum version of setuptools is - # high enough to include https://github.com/pypa/setuptools/pull/1427/ - if needs_reinstall: - egg_info.setmtime() - - return needs_reinstall - - def install_pkg(self, dir, action, name, is_develop=False): - assert action is not None - - if getattr(self, "just_created", False): - action.setactivity(name, dir) - self.finish() - pip_flags = ["--exists-action", "w"] - else: - if is_develop and not self._needs_reinstall(dir, action): - action.setactivity("{}-noop".format(name), dir) - return - action.setactivity("{}-nodeps".format(name), dir) - pip_flags = ["--no-deps"] + ([] if is_develop else ["-U"]) - pip_flags.extend(["-v"] * min(3, reporter.verbosity() - 2)) - if self.envconfig.extras: - dir += "[{}]".format(",".join(self.envconfig.extras)) - target = [dir] - if is_develop: - target.insert(0, "-e") - self._install(target, extraopts=pip_flags, action=action) - - def developpkg(self, setupdir, action): - self.install_pkg(setupdir, action, "develop-inst", is_develop=True) - - def installpkg(self, sdistpath, action): - self.install_pkg(sdistpath, action, "inst") - - def _installopts(self, indexserver): - options = [] - if indexserver: - options += ["-i", indexserver] - if self.envconfig.pip_pre: - options.append("--pre") - return options - - def run_install_command(self, packages, action, options=()): - def expand(val): - # expand an install command - if val == "{packages}": - for package in packages: - yield package - elif val == "{opts}": - for opt in options: - yield opt - else: - yield val - - cmd = list(chain.from_iterable(expand(val) for val in self.envconfig.install_command)) - - env = self._get_os_environ() - self.ensure_pip_os_environ_ok(env) - - old_stdout = sys.stdout - sys.stdout = codecs.getwriter("utf8")(sys.stdout) - try: - self._pcall( - cmd, - cwd=self.envconfig.config.toxinidir, - action=action, - redirect=reporter.verbosity() < reporter.Verbosity.DEBUG, - env=env, - ) - except KeyboardInterrupt: - self.status = "keyboardinterrupt" - raise - finally: - sys.stdout = old_stdout - - def ensure_pip_os_environ_ok(self, env): - for key in ("PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"): - env.pop(key, None) - if all("PYTHONPATH" not in i for i in (self.envconfig.passenv, self.envconfig.setenv)): - # If PYTHONPATH not explicitly asked for, remove it. - if "PYTHONPATH" in env: - if sys.version_info < (3, 4) or bool(env["PYTHONPATH"]): - # https://docs.python.org/3/whatsnew/3.4.html#changes-in-python-command-behavior - # In a posix shell, setting the PATH environment variable to an empty value is - # equivalent to not setting it at all. - reporter.warning( - "Discarding $PYTHONPATH from environment, to override " - "specify PYTHONPATH in 'passenv' in your configuration.", - ) - env.pop("PYTHONPATH") - - # installing packages at user level may mean we're not installing inside the venv - env["PIP_USER"] = "0" - - # installing without dependencies may lead to broken packages - env["PIP_NO_DEPS"] = "0" - - def _install(self, deps, extraopts=None, action=None): - if not deps: - return - d = {} - ixservers = [] - for dep in deps: - if isinstance(dep, (str, py.path.local)): - dep = DepConfig(str(dep), None) - assert isinstance(dep, DepConfig), dep - if dep.indexserver is None: - ixserver = self.envconfig.config.indexserver["default"] - else: - ixserver = dep.indexserver - d.setdefault(ixserver, []).append(dep.name) - if ixserver not in ixservers: - ixservers.append(ixserver) - assert ixserver.url is None or isinstance(ixserver.url, str) - - for ixserver in ixservers: - packages = d[ixserver] - options = self._installopts(ixserver.url) - if extraopts: - options.extend(extraopts) - self.run_install_command(packages=packages, options=options, action=action) - - def _get_os_environ(self, is_test_command=False): - if is_test_command: - # for executing tests we construct a clean environment - env = {} - for env_key in self.envconfig.passenv: - if env_key in os.environ: - env[env_key] = os.environ[env_key] - else: - # for executing non-test commands we use the full - # invocation environment - env = os.environ.copy() - - # in any case we honor per-testenv setenv configuration - env.update(self.envconfig.setenv.export()) - - env["VIRTUAL_ENV"] = str(self.path) - return env - - def test( - self, - redirect=False, - name="run-test", - commands=None, - ignore_outcome=None, - ignore_errors=None, - display_hash_seed=False, - ): - if commands is None: - commands = self.envconfig.commands - if ignore_outcome is None: - ignore_outcome = self.envconfig.ignore_outcome - if ignore_errors is None: - ignore_errors = self.envconfig.ignore_errors - with self.new_action(name) as action: - cwd = self.envconfig.changedir - if display_hash_seed: - env = self._get_os_environ(is_test_command=True) - # Display PYTHONHASHSEED to assist with reproducibility. - action.setactivity(name, "PYTHONHASHSEED={!r}".format(env.get("PYTHONHASHSEED"))) - for i, argv in enumerate(filter(bool, commands)): - # have to make strings as _pcall changes argv[0] to a local() - # happens if the same environment is invoked twice - message = "commands[{}] | {}".format( - i, - " ".join(shlex_quote(str(x)) for x in argv), - ) - action.setactivity(name, message) - # check to see if we need to ignore the return code - # if so, we need to alter the command line arguments - if argv[0].startswith("-"): - ignore_ret = True - if argv[0] == "-": - del argv[0] - else: - argv[0] = argv[0].lstrip("-") - else: - ignore_ret = False - - try: - self._pcall( - argv, - cwd=cwd, - action=action, - redirect=redirect, - ignore_ret=ignore_ret, - is_test_command=True, - ) - except tox.exception.InvocationError as err: - if ignore_outcome: - msg = "command failed but result from testenv is ignored\ncmd:" - reporter.warning("{} {}".format(msg, err)) - self.status = "ignored failed command" - continue # keep processing commands - - reporter.error(str(err)) - self.status = "commands failed" - if not ignore_errors: - break # Don't process remaining commands - except KeyboardInterrupt: - self.status = "keyboardinterrupt" - raise - - def _pcall( - self, - args, - cwd, - venv=True, - is_test_command=False, - action=None, - redirect=True, - ignore_ret=False, - returnout=False, - env=None, - capture_err=True, - ): - if env is None: - env = self._get_os_environ(is_test_command=is_test_command) - - # construct environment variables - env.pop("VIRTUALENV_PYTHON", None) - bin_dir = str(self.envconfig.envbindir) - path = self.envconfig.setenv.get("PATH") or os.environ["PATH"] - env["PATH"] = os.pathsep.join([bin_dir, path]) - reporter.verbosity2("setting PATH={}".format(env["PATH"])) - - # get command - try: - args[0] = self.getcommandpath(args[0], venv, cwd) - except tox.exception.InvocationError: - if ignore_ret: - self.status = getattr(self, "status", 0) - msg = "command not found but explicitly ignored" - reporter.warning("{}\ncmd: {}".format(msg, args[0])) - return "" # in case it's returnout - else: - raise - - if sys.platform != "win32" and "TOX_LIMITED_SHEBANG" in os.environ: - args = prepend_shebang_interpreter(args) - - cwd.ensure(dir=1) # ensure the cwd exists - return action.popen( - args, - cwd=cwd, - env=env, - redirect=redirect, - ignore_ret=ignore_ret, - returnout=returnout, - report_fail=not is_test_command, - capture_err=capture_err, - ) - - def setupenv(self): - if self.envconfig._missing_subs: - self.status = ( - "unresolvable substitution(s):\n {}\n" - "Environment variables are missing or defined recursively.".format( - "\n ".join( - "{}: '{}'".format(section_key, exc.name) - for section_key, exc in sorted(self.envconfig._missing_subs.items()) - ), - ) - ) - return - if not self.matching_platform(): - self.status = "platform mismatch" - return # we simply omit non-matching platforms - with self.new_action("getenv", self.envconfig.envdir) as action: - self.status = 0 - default_ret_code = 1 - envlog = self.env_log - try: - status = self.update(action=action) - except IOError as e: - if e.args[0] != 2: - raise - status = ( - "Error creating virtualenv. Note that spaces in paths are " - "not supported by virtualenv. Error details: {!r}".format(e) - ) - except tox.exception.InvocationError as e: - status = e - except tox.exception.InterpreterNotFound as e: - status = e - if self.envconfig.config.option.skip_missing_interpreters == "true": - default_ret_code = 0 - except KeyboardInterrupt: - self.status = "keyboardinterrupt" - raise - if status: - str_status = str(status) - command_log = envlog.get_commandlog("setup") - command_log.add_command(["setup virtualenv"], str_status, default_ret_code) - self.status = status - if default_ret_code == 0: - reporter.skip(str_status) - else: - reporter.error(str_status) - return False - command_path = self.getcommandpath("python") - envlog.set_python_info(command_path) - return True - - def finishvenv(self): - with self.new_action("finishvenv"): - self.finish() - return True - - -def getdigest(path): - path = py.path.local(path) - if not path.check(file=1): - return "0" * 32 - return path.computehash("sha256") - - -def prepend_shebang_interpreter(args): - # prepend interpreter directive (if any) to argument list - # - # When preparing virtual environments in a file container which has large - # length, the system might not be able to invoke shebang scripts which - # define interpreters beyond system limits (e.g. Linux has a limit of 128; - # BINPRM_BUF_SIZE). This method can be used to check if the executable is - # a script containing a shebang line. If so, extract the interpreter (and - # possible optional argument) and prepend the values to the provided - # argument list. tox will only attempt to read an interpreter directive of - # a maximum size of 2048 bytes to limit excessive reading and support UNIX - # systems which may support a longer interpret length. - try: - with open(args[0], "rb") as f: - if f.read(1) == b"#" and f.read(1) == b"!": - interp = f.readline(MAXINTERP + 1).rstrip().decode("UTF-8") - if len(interp) > MAXINTERP: # avoid a truncated interpreter - return args - interp_args = interp.split(None, 1)[:2] - return interp_args + args - except (UnicodeDecodeError, IOError): - pass - return args - - -_SKIP_VENV_CREATION = os.environ.get("_TOX_SKIP_ENV_CREATION_TEST", False) == "1" - - -@tox.hookimpl -def tox_testenv_create(venv, action): - config_interpreter = venv.getsupportedinterpreter() - args = [sys.executable, "-m", "virtualenv"] - if venv.envconfig.sitepackages: - args.append("--system-site-packages") - if venv.envconfig.alwayscopy: - args.append("--always-copy") - if not venv.envconfig.download: - args.append("--no-download") - else: - args.append("--download") - # add interpreter explicitly, to prevent using default (virtualenv.ini) - args.extend(["--python", str(config_interpreter)]) - - cleanup_for_venv(venv) - - base_path = venv.path.dirpath() - base_path.ensure(dir=1) - args.append(venv.path.basename) - if not _SKIP_VENV_CREATION: - try: - venv._pcall( - args, - venv=False, - action=action, - cwd=base_path, - redirect=reporter.verbosity() < reporter.Verbosity.DEBUG, - ) - except KeyboardInterrupt: - venv.status = "keyboardinterrupt" - raise - return True # Return non-None to indicate plugin has completed - - -def cleanup_for_venv(venv): - within_parallel = PARALLEL_ENV_VAR_KEY_PRIVATE in os.environ - # if the directory exists and it doesn't look like a virtualenv, produce - # an error - if venv.path.exists(): - dir_items = set(os.listdir(str(venv.path))) - {".lock", "log"} - dir_items = {p for p in dir_items if not p.startswith(".tox-") or p == ".tox-config1"} - else: - dir_items = set() - - if not ( - # doesn't exist => OK - not venv.path.exists() - # does exist, but it's empty => OK - or not dir_items - # tox has marked this as an environment it has created in the past - or ".tox-config1" in dir_items - # it exists and we're on windows with Lib and Scripts => OK - or (INFO.IS_WIN and dir_items > {"Scripts", "Lib"}) - # non-windows, with lib and bin => OK - or dir_items > {"bin", "lib"} - # pypy has a different lib folder => OK - or dir_items > {"bin", "lib_pypy"} - ): - venv.status = "error" - reporter.error( - "cowardly refusing to delete `envdir` (it does not look like a virtualenv): " - "{}".format(venv.path), - ) - raise SystemExit(2) - - if within_parallel: - if venv.path.exists(): - # do not delete the log folder as that's used by parent - for content in venv.path.listdir(): - if not content.basename == "log": - content.remove(rec=1, ignore_errors=True) - else: - ensure_empty_dir(venv.path) - - -@tox.hookimpl -def tox_testenv_install_deps(venv, action): - deps = venv.get_resolved_dependencies() - if deps: - depinfo = ", ".join(map(str, deps)) - action.setactivity("installdeps", depinfo) - venv._install(deps, action=action) - return True # Return non-None to indicate plugin has completed - - -@tox.hookimpl -def tox_runtest(venv, redirect): - venv.test(redirect=redirect) - return True # Return non-None to indicate plugin has completed - - -@tox.hookimpl -def tox_runtest_pre(venv): - venv.status = 0 - ensure_empty_dir(venv.envconfig.envtmpdir) - venv.envconfig.envtmpdir.ensure(dir=1) - venv.test( - name="run-test-pre", - commands=venv.envconfig.commands_pre, - redirect=False, - ignore_outcome=False, - ignore_errors=False, - display_hash_seed=True, - ) - - -@tox.hookimpl -def tox_runtest_post(venv): - venv.test( - name="run-test-post", - commands=venv.envconfig.commands_post, - redirect=False, - ignore_outcome=False, - ignore_errors=False, - ) - - -@tox.hookimpl -def tox_runenvreport(venv, action): - # write out version dependency information - args = venv.envconfig.list_dependencies_command - output = venv._pcall(args, cwd=venv.envconfig.config.toxinidir, action=action, returnout=True) - # the output contains a mime-header, skip it - output = output.split("\n\n")[-1] - packages = output.strip().split("\n") - return packages # Return non-None to indicate plugin has completed diff --git a/tasks/release.py b/tasks/release.py index 1e06af2cd..bc5ba33d9 100644 --- a/tasks/release.py +++ b/tasks/release.py @@ -1,7 +1,8 @@ """Handles creating a release PR""" +from __future__ import annotations + from pathlib import Path from subprocess import check_call -from typing import Tuple from git import Commit, Head, Remote, Repo, TagReference from packaging.version import Version @@ -25,12 +26,12 @@ def main(version_str: str) -> None: print("All done! ✨ 🍰 ✨") -def create_release_branch(repo: Repo, version: Version) -> Tuple[Remote, Head]: - print("create release branch from upstream master") +def create_release_branch(repo: Repo, version: Version) -> tuple[Remote, Head]: + print("create release branch from upstream main") upstream = get_upstream(repo) upstream.fetch() branch_name = f"release-{version}" - release_branch = repo.create_head(branch_name, upstream.refs.master, force=True) + release_branch = repo.create_head(branch_name, upstream.refs.main, force=True) upstream.push(refspec=f"{branch_name}:{branch_name}", force=True) release_branch.set_tracking_branch(repo.refs[f"{upstream.name}/{branch_name}"]) release_branch.checkout() @@ -39,9 +40,8 @@ def create_release_branch(repo: Repo, version: Version) -> Tuple[Remote, Head]: def get_upstream(repo: Repo) -> Remote: for remote in repo.remotes: - for url in remote.urls: - if url.endswith("tox-dev/tox.git"): - return remote + if any(url.endswith("tox-dev/tox.git") for url in remote.urls): + return remote raise RuntimeError("could not find tox-dev/tox.git remote") diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/config/cli/__init__.py b/tests/config/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/config/cli/conftest.py b/tests/config/cli/conftest.py new file mode 100644 index 000000000..20393a5e4 --- /dev/null +++ b/tests/config/cli/conftest.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Callable + +import pytest + +from tox.session.cmd.depends import depends +from tox.session.cmd.devenv import devenv +from tox.session.cmd.exec_ import exec_ +from tox.session.cmd.legacy import legacy +from tox.session.cmd.list_env import list_env +from tox.session.cmd.quickstart import quickstart +from tox.session.cmd.run.parallel import run_parallel +from tox.session.cmd.run.sequential import run_sequential +from tox.session.cmd.show_config import show_config +from tox.session.state import State + + +@pytest.fixture() +def core_handlers() -> dict[str, Callable[[State], int]]: + return { + "config": show_config, + "c": show_config, + "list": list_env, + "l": list_env, + "run": run_sequential, + "r": run_sequential, + "run-parallel": run_parallel, + "p": run_parallel, + "d": devenv, + "devenv": devenv, + "q": quickstart, + "quickstart": quickstart, + "de": depends, + "depends": depends, + "le": legacy, + "legacy": legacy, + "e": exec_, + "exec": exec_, + } diff --git a/tests/config/cli/test_cli_env_var.py b/tests/config/cli/test_cli_env_var.py new file mode 100644 index 000000000..89f156b29 --- /dev/null +++ b/tests/config/cli/test_cli_env_var.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from typing import Callable + +import pytest + +from tox.config.cli.parse import get_options +from tox.config.loader.api import Override +from tox.pytest import CaptureFixture, LogCaptureFixture, MonkeyPatch +from tox.session.env_select import CliEnv +from tox.session.state import State + + +def test_verbose() -> None: + parsed, _, __, ___, ____ = get_options("-v", "-v") + assert parsed.verbosity == 4 + + +def test_verbose_compound() -> None: + parsed, _, __, ___, ____ = get_options("-vv") + assert parsed.verbosity == 4 + + +def test_verbose_no_test() -> None: + parsed, _, __, ___, ____ = get_options("--notest", "-vv", "--runner", "virtualenv") + assert vars(parsed) == { + "verbose": 4, + "quiet": 0, + "colored": "no", + "work_dir": None, + "root_dir": None, + "config_file": None, + "result_json": None, + "command": "legacy", + "default_runner": "virtualenv", + "force_dep": [], + "site_packages": False, + "always_copy": False, + "override": [], + "show_config": False, + "list_envs_all": False, + "no_recreate_pkg": False, + "no_provision": False, + "list_envs": False, + "devenv_path": None, + "env": CliEnv(), + "exit_and_dump_after": 0, + "skip_missing_interpreters": "config", + "skip_pkg_install": False, + "recreate": False, + "no_recreate_provision": False, + "no_test": True, + "package_only": False, + "install_pkg": None, + "develop": False, + "hash_seed": "noset", + "discover": [], + "parallel": 0, + "parallel_live": False, + "parallel_no_spinner": False, + "pre": False, + "index_url": [], + "factors": [], + "labels": [], + } + + +def test_env_var_exhaustive_parallel_values( + monkeypatch: MonkeyPatch, + core_handlers: dict[str, Callable[[State], int]], +) -> None: + monkeypatch.setenv("TOX_COMMAND", "run-parallel") + monkeypatch.setenv("TOX_VERBOSE", "5") + monkeypatch.setenv("TOX_QUIET", "1") + monkeypatch.setenv("TOX_ENV", "py37,py36") + monkeypatch.setenv("TOX_DEFAULT_RUNNER", "magic") + monkeypatch.setenv("TOX_RECREATE", "yes") + monkeypatch.setenv("TOX_NO_TEST", "yes") + monkeypatch.setenv("TOX_PARALLEL", "3") + monkeypatch.setenv("TOX_PARALLEL_LIVE", "no") + monkeypatch.setenv("TOX_OVERRIDE", "a=b\nc=d") + + options = get_options() + assert vars(options.parsed) == { + "always_copy": False, + "colored": "no", + "command": "legacy", + "default_runner": "virtualenv", + "develop": False, + "devenv_path": None, + "discover": [], + "env": CliEnv(["py37", "py36"]), + "force_dep": [], + "hash_seed": "noset", + "index_url": [], + "install_pkg": None, + "no_provision": False, + "list_envs": False, + "list_envs_all": False, + "no_recreate_pkg": False, + "no_test": True, + "override": [Override("a=b"), Override("c=d")], + "package_only": False, + "parallel": 3, + "parallel_live": False, + "parallel_no_spinner": False, + "pre": False, + "quiet": 1, + "recreate": True, + "no_recreate_provision": False, + "result_json": None, + "show_config": False, + "site_packages": False, + "skip_missing_interpreters": "config", + "skip_pkg_install": False, + "verbose": 5, + "work_dir": None, + "root_dir": None, + "config_file": None, + "factors": [], + "labels": [], + "exit_and_dump_after": 0, + } + assert options.parsed.verbosity == 4 + assert options.cmd_handlers == core_handlers + + +def test_ini_help(monkeypatch: MonkeyPatch, capsys: CaptureFixture) -> None: + monkeypatch.setenv("TOX_VERBOSE", "5") + monkeypatch.setenv("TOX_QUIET", "1") + with pytest.raises(SystemExit) as context: + get_options("-h") + assert context.value.code == 0 + out, err = capsys.readouterr() + assert not err + assert "from env var TOX_VERBOSE" in out + assert "from env var TOX_QUIET" in out + + +def test_bad_env_var( + monkeypatch: MonkeyPatch, + capsys: CaptureFixture, + caplog: LogCaptureFixture, + value_error: Callable[[str], str], +) -> None: + monkeypatch.setenv("TOX_VERBOSE", "should-be-number") + monkeypatch.setenv("TOX_QUIET", "1.00") + parsed, _, __, ___, ____ = get_options() + assert parsed.verbose == 2 + assert parsed.quiet == 0 + assert parsed.verbosity == 2 + first = "env var TOX_VERBOSE='should-be-number' cannot be transformed to because {}".format( + value_error("invalid literal for int() with base 10: 'should-be-number'"), + ) + second = "env var TOX_QUIET='1.00' cannot be transformed to because {}".format( + value_error("invalid literal for int() with base 10: '1.00'"), + ) + capsys.readouterr() + assert caplog.messages[0] == first + assert caplog.messages[1] == second + assert len(caplog.messages) == 2, caplog.text diff --git a/tests/config/cli/test_cli_ini.py b/tests/config/cli/test_cli_ini.py new file mode 100644 index 000000000..cfb8a2835 --- /dev/null +++ b/tests/config/cli/test_cli_ini.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import logging +import sys +import textwrap +from pathlib import Path +from typing import Any, Callable + +import pytest +from pytest_mock import MockerFixture + +from tox.config.cli.ini import IniConfig +from tox.config.cli.parse import get_options +from tox.config.cli.parser import Parsed +from tox.config.loader.api import Override +from tox.config.main import Config +from tox.config.source import discover_source +from tox.pytest import CaptureFixture, LogCaptureFixture, MonkeyPatch +from tox.session.env_select import CliEnv +from tox.session.state import State + + +@pytest.fixture() +def exhaustive_ini(tmp_path: Path, monkeypatch: MonkeyPatch) -> Path: + to = tmp_path / "tox.ini" + to.write_text( + textwrap.dedent( + """ + [tox] + colored = yes + verbose = 5 + quiet = 1 + command = run-parallel + env = py37, py36 + default_runner = virtualenv + recreate = true + no_test = true + parallel = 3 + parallel_live = True + override = + a=b + c=d + """, + ), + ) + monkeypatch.setenv("TOX_CONFIG_FILE", str(to)) + return to + + +@pytest.mark.parametrize("content", ["[tox]", ""]) +def test_ini_empty( + tmp_path: Path, + core_handlers: dict[str, Callable[[State], int]], + default_options: dict[str, Any], + mocker: MockerFixture, + monkeypatch: MonkeyPatch, + content: str, +) -> None: + to = tmp_path / "tox.ini" + monkeypatch.setenv("TOX_CONFIG_FILE", str(to)) + to.write_text(content) + mocker.patch("tox.config.cli.parse.discover_source", return_value=mocker.MagicMock(path=Path())) + options = get_options("r") + assert vars(options.parsed) == default_options + assert options.parsed.verbosity == 2 + assert options.cmd_handlers == core_handlers + + to.unlink() + missing_options = get_options("r") + assert vars(missing_options.parsed) == vars(options.parsed) + + +@pytest.fixture() +def default_options(tmp_path: Path) -> dict[str, Any]: + return { + "colored": "no", + "command": "r", + "default_runner": "virtualenv", + "develop": False, + "discover": [], + "env": CliEnv(), + "hash_seed": "noset", + "install_pkg": None, + "no_test": False, + "override": [], + "package_only": False, + "quiet": 0, + "recreate": False, + "no_recreate_provision": False, + "no_provision": False, + "no_recreate_pkg": False, + "result_json": None, + "skip_missing_interpreters": "config", + "skip_pkg_install": False, + "verbose": 2, + "work_dir": None, + "root_dir": None, + "config_file": (tmp_path / "tox.ini").absolute(), + "factors": [], + "labels": [], + "exit_and_dump_after": 0, + } + + +def test_ini_exhaustive_parallel_values(exhaustive_ini: Path, core_handlers: dict[str, Callable[[State], int]]) -> None: + options = get_options("p") + assert vars(options.parsed) == { + "colored": "yes", + "command": "p", + "default_runner": "virtualenv", + "develop": False, + "discover": [], + "env": CliEnv(["py37", "py36"]), + "hash_seed": "noset", + "install_pkg": None, + "no_test": True, + "override": [Override("a=b"), Override("c=d")], + "package_only": False, + "no_recreate_pkg": False, + "parallel": 3, + "parallel_live": True, + "parallel_no_spinner": False, + "quiet": 1, + "no_provision": False, + "recreate": True, + "no_recreate_provision": False, + "result_json": None, + "skip_missing_interpreters": "config", + "skip_pkg_install": False, + "verbose": 5, + "work_dir": None, + "root_dir": None, + "config_file": exhaustive_ini, + "factors": [], + "labels": [], + "exit_and_dump_after": 0, + } + assert options.parsed.verbosity == 4 + assert options.cmd_handlers == core_handlers + + +def test_ini_help(exhaustive_ini: Path, capsys: CaptureFixture) -> None: + with pytest.raises(SystemExit) as context: + get_options("-h") + assert context.value.code == 0 + out, err = capsys.readouterr() + assert not err + assert f"config file '{exhaustive_ini}' active (changed via env var TOX_CONFIG_FILE)" + + +def test_bad_cli_ini( + tmp_path: Path, + monkeypatch: MonkeyPatch, + caplog: LogCaptureFixture, + default_options: dict[str, Any], + mocker: MockerFixture, +) -> None: + mocker.patch("tox.config.cli.parse.discover_source", return_value=mocker.MagicMock(path=Path())) + caplog.set_level(logging.WARNING) + monkeypatch.setenv("TOX_CONFIG_FILE", str(tmp_path)) + options = get_options("r") + msg = ( + "PermissionError(13, 'Permission denied')" + if sys.platform == "win32" + else "IsADirectoryError(21, 'Is a directory')" + ) + assert caplog.messages == [f"failed to read config file {tmp_path} because {msg}"] + default_options["config_file"] = tmp_path + assert vars(options.parsed) == default_options + + +def test_bad_option_cli_ini( + tmp_path: Path, + monkeypatch: MonkeyPatch, + caplog: LogCaptureFixture, + value_error: Callable[[str], str], + default_options: dict[str, Any], +) -> None: + caplog.set_level(logging.WARNING) + to = tmp_path / "tox.ini" + to.write_text( + textwrap.dedent( + """ + [tox] + verbose = what + + """, + ), + ) + monkeypatch.setenv("TOX_CONFIG_FILE", str(to)) + parsed, _, __, ___, ____ = get_options("r") + assert caplog.messages == [ + "{} key verbose as type failed with {}".format( + to, + value_error("invalid literal for int() with base 10: 'what'"), + ), + ] + assert vars(parsed) == default_options + + +def test_cli_ini_with_interpolated(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + to = tmp_path / "tox.ini" + to.write_text("[tox]\na = %(b)s") + monkeypatch.setenv("TOX_CONFIG_FILE", str(to)) + conf = IniConfig() + assert conf.get("a", str) + + +@pytest.mark.parametrize( + ("conf_arg", "filename", "content"), + [ + pytest.param("", "tox.ini", "[tox]", id="ini-dir"), + pytest.param("tox.ini", "tox.ini", "[tox]", id="ini"), + pytest.param("", "setup.cfg", "[tox:tox]", id="cfg-dir"), + pytest.param("setup.cfg", "setup.cfg", "[tox:tox]", id="cfg"), + pytest.param("", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml-dir"), + pytest.param("pyproject.toml", "pyproject.toml", '[tool.tox]\nlegacy_tox_ini = """\n[tox]\n"""\n', id="toml"), + ], +) +def test_conf_arg(tmp_path: Path, conf_arg: str, filename: str, content: str) -> None: + dest = tmp_path / "c" + dest.mkdir() + if filename: + cfg = dest / filename + cfg.write_bytes(content.encode(encoding="utf-8")) + + config_file = dest / conf_arg + source = discover_source(config_file, None) + + Config.make( + Parsed(work_dir=dest, override=[], config_file=config_file, root_dir=None), + pos_args=[], + source=source, + ) diff --git a/tests/config/cli/test_parse.py b/tests/config/cli/test_parse.py new file mode 100644 index 000000000..8852bfdb6 --- /dev/null +++ b/tests/config/cli/test_parse.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import logging + +import pytest + +from tox.config.cli.parse import get_options +from tox.pytest import CaptureFixture +from tox.report import LowerInfoLevel + + +def test_help_does_not_default_cmd(capsys: CaptureFixture) -> None: + with pytest.raises(SystemExit): + get_options("-h") + out, err = capsys.readouterr() + assert not err + assert "--verbose" in out + assert "subcommands:" in out + + +def test_verbosity_guess_miss_match(capsys: CaptureFixture) -> None: + result = get_options("-rv") + assert result.parsed.verbosity == 3 + + assert logging.getLogger().level == logging.INFO + + for name in ("distlib.util", "filelock"): + logger = logging.getLogger(name) + for logging_filter in logger.filters: # pragma: no branch # never empty + if isinstance(logging_filter, LowerInfoLevel): # pragma: no branch # we always find it + assert logging_filter.level == logging.INFO + break + + logging.error("E") + logging.warning("W") + logging.info("I") + logging.debug("D") + + out, err = capsys.readouterr() + assert out == "ROOT: E\nROOT: W\nROOT: I\n" + + +@pytest.mark.parametrize("arg", ["-av", "-va"]) +def test_verbosity(arg: str) -> None: + result = get_options(arg) + assert result.parsed.verbosity == 3 diff --git a/tests/config/cli/test_parser.py b/tests/config/cli/test_parser.py new file mode 100644 index 000000000..2cbf21d73 --- /dev/null +++ b/tests/config/cli/test_parser.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import sys +from argparse import Action + +import pytest +from pytest_mock import MockerFixture + +from tox.config.cli.parser import Parsed, ToxParser +from tox.pytest import CaptureFixture, MonkeyPatch + + +def test_parser_const_with_default_none(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("TOX_ALPHA", "2") + parser = ToxParser.base() + parser.add_argument( + "-a", + dest="alpha", + action="https://github.com/store_const", + const=1, + default=None, + help="sum the integers (default: find the max)", + ) + parser.fix_defaults() + + result = parser.parse_args([]) + assert result.alpha == 2 + + +@pytest.mark.parametrize("is_atty", [True, False]) +@pytest.mark.parametrize("no_color", [None, "0", "1"]) +@pytest.mark.parametrize("force_color", [None, "0", "1"]) +@pytest.mark.parametrize("tox_color", [None, "bad", "no", "yes"]) +@pytest.mark.parametrize("term", [None, "xterm", "dumb"]) +def test_parser_color( + monkeypatch: MonkeyPatch, + mocker: MockerFixture, + no_color: str | None, + force_color: str | None, + tox_color: str | None, + is_atty: bool, + term: str | None, +) -> None: + for key, value in { + "NO_COLOR": no_color, + "TOX_COLORED": tox_color, + "FORCE_COLOR": force_color, + "TERM": term, + }.items(): + if value is None: + monkeypatch.delenv(key, raising=False) + else: + monkeypatch.setenv(key, value) + stdout_mock = mocker.patch("tox.config.cli.parser.sys.stdout") + stdout_mock.isatty.return_value = is_atty + + if tox_color in ("yes", "no"): + expected = tox_color == "yes" + elif no_color == "1": + expected = False + elif force_color == "1": + expected = True + elif term == "dumb": + expected = False + else: + expected = is_atty + + is_colored = ToxParser.base().parse_args([], Parsed()).is_colored + assert is_colored is expected + + +def test_parser_unsupported_type() -> None: + parser = ToxParser.base() + parser.add_argument("--magic", action="https://github.com/store", default=None) + with pytest.raises(TypeError) as context: + parser.fix_defaults() + action = context.value.args[0] + assert isinstance(action, Action) + assert action.dest == "magic" + + +def test_sub_sub_command() -> None: + parser = ToxParser.base() + with pytest.raises(RuntimeError, match="no sub-command group allowed"): + parser.add_command("c", [], "help", lambda s: 0) # pragma: no cover - the lambda will never be run # noqa: U100 + + +def test_parse_known_args_not_set(mocker: MockerFixture) -> None: + mocker.patch.object(sys, "argv", ["a", "--help"]) + parser = ToxParser.base() + _, unknown = parser.parse_known_args(None) + assert unknown == ["--help"] + + +def test_parser_hint(capsys: CaptureFixture) -> None: + parser = ToxParser.base() + with pytest.raises(SystemExit): + parser.parse_args("foo") + out, err = capsys.readouterr() + assert err.endswith("hint: if you tried to pass arguments to a command use -- to separate them from tox ones\n") diff --git a/tests/config/conftest.py b/tests/config/conftest.py new file mode 100644 index 000000000..119950181 --- /dev/null +++ b/tests/config/conftest.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import pytest + +from tests.conftest import ToxIniCreator +from tox.config.main import Config + + +@pytest.fixture() +def empty_config(tox_ini_conf: ToxIniCreator) -> Config: + return tox_ini_conf("") diff --git a/tests/config/loader/__init__.py b/tests/config/loader/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/config/loader/ini/__init__.py b/tests/config/loader/ini/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/config/loader/ini/conftest.py b/tests/config/loader/ini/conftest.py new file mode 100644 index 000000000..52e066e70 --- /dev/null +++ b/tests/config/loader/ini/conftest.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from configparser import ConfigParser +from pathlib import Path +from typing import Callable + +import pytest + + +@pytest.fixture() +def mk_ini_conf(tmp_path: Path) -> Callable[[str], ConfigParser]: + def _func(raw: str) -> ConfigParser: + filename = tmp_path / "demo.ini" + filename.write_bytes(raw.encode("utf-8")) # win32: avoid CR normalization - what you pass is what you get + parser = ConfigParser(interpolation=None) + with filename.open() as file_handler: + parser.read_file(file_handler) + return parser + + return _func diff --git a/tests/config/loader/ini/replace/__init__.py b/tests/config/loader/ini/replace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/config/loader/ini/replace/conftest.py b/tests/config/loader/ini/replace/conftest.py new file mode 100644 index 000000000..6025f8394 --- /dev/null +++ b/tests/config/loader/ini/replace/conftest.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from tox.config.cli.parser import Parsed +from tox.config.loader.api import ConfigLoadArgs +from tox.config.main import Config +from tox.config.source.tox_ini import ToxIni + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from typing import Protocol +else: # pragma: no cover ( str: # noqa: U100 + ... + + +@pytest.fixture() +def replace_one(tmp_path: Path) -> ReplaceOne: + def example(conf: str, pos_args: list[str] | None = None) -> str: + tox_ini_file = tmp_path / "tox.ini" + tox_ini_file.write_text(f"[testenv:py]\nenv={conf}\n") + tox_ini = ToxIni(tox_ini_file) + + config = Config( + tox_ini, + options=Parsed(override=[]), + root=tmp_path, + pos_args=pos_args, + work_dir=tmp_path, + ) + loader = config.get_env("py").loaders[0] + args = ConfigLoadArgs(chain=[], name="a", env_name="a") + return loader.load(key="env", of_type=str, conf=config, factory=None, args=args) + + return example diff --git a/tests/config/loader/ini/replace/test_replace.py b/tests/config/loader/ini/replace/test_replace.py new file mode 100644 index 000000000..39aab9d36 --- /dev/null +++ b/tests/config/loader/ini/replace/test_replace.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import pytest + +from tox.config.loader.ini.replace import find_replace_part + + +@pytest.mark.parametrize( + ("value", "result"), + [ + ("[]", (0, 1, "posargs")), + ("123[]", (3, 4, "posargs")), + ("[]123", (0, 1, "posargs")), + (r"\[\] []", (5, 6, "posargs")), + (r"[\] []", (4, 5, "posargs")), + (r"\[] []", (4, 5, "posargs")), + ("{foo}", (0, 4, "foo")), + (r"\{foo} {bar}", (7, 11, "bar")), + ("{foo} {bar}", (0, 4, "foo")), + (r"{foo\} {bar}", (7, 11, "bar")), + (r"{foo:{bar}}", (5, 9, "bar")), + (r"{\{}", (0, 3, r"\{")), + (r"{\}}", (0, 3, r"\}")), + ], +) +def test_match(value: str, result: tuple[int, int, str]) -> None: + assert find_replace_part(value, 0) == result diff --git a/tests/config/loader/ini/replace/test_replace_env_var.py b/tests/config/loader/ini/replace/test_replace_env_var.py new file mode 100644 index 000000000..9164a1e7c --- /dev/null +++ b/tests/config/loader/ini/replace/test_replace_env_var.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from tests.config.loader.ini.replace.conftest import ReplaceOne +from tox.pytest import MonkeyPatch + + +def test_replace_env_set(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """If we have a factor that is not specified within the core env-list then that's also an environment""" + monkeypatch.setenv("MAGIC", "something good") + result = replace_one("{env:MAGIC}") + assert result == "something good" + + +def test_replace_env_missing(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """If we have a factor that is not specified within the core env-list then that's also an environment""" + monkeypatch.delenv("MAGIC", raising=False) + result = replace_one("{env:MAGIC}") + assert result == "" + + +def test_replace_env_missing_default(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """If we have a factor that is not specified within the core env-list then that's also an environment""" + monkeypatch.delenv("MAGIC", raising=False) + result = replace_one("{env:MAGIC:def}") + assert result == "def" + + +def test_replace_env_missing_default_from_env(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """If we have a factor that is not specified within the core env-list then that's also an environment""" + monkeypatch.delenv("MAGIC", raising=False) + monkeypatch.setenv("MAGIC_DEFAULT", "yes") + result = replace_one("{env:MAGIC:{env:MAGIC_DEFAULT}}") + assert result == "yes" + + +def test_replace_env_var_circular(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """If we have a factor that is not specified within the core env-list then that's also an environment""" + monkeypatch.setenv("MAGIC", "{env:MAGIC}") + result = replace_one("{env:MAGIC}") + assert result == "{env:MAGIC}" + + +def test_replace_env_default_with_colon(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """If we have a factor that is not specified within the core env-list then that's also an environment""" + monkeypatch.delenv("MAGIC", raising=False) + result = replace_one("{env:MAGIC:https://some.url.org}") + assert result == "https://some.url.org/" diff --git a/tests/config/loader/ini/replace/test_replace_os_pathsep.py b/tests/config/loader/ini/replace/test_replace_os_pathsep.py new file mode 100644 index 000000000..eb46c9596 --- /dev/null +++ b/tests/config/loader/ini/replace/test_replace_os_pathsep.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import os + +from tests.config.loader.ini.replace.conftest import ReplaceOne + + +def test_replace_os_pathsep(replace_one: ReplaceOne) -> None: + result = replace_one("{:}") + assert result == os.pathsep diff --git a/tests/config/loader/ini/replace/test_replace_os_sep.py b/tests/config/loader/ini/replace/test_replace_os_sep.py new file mode 100644 index 000000000..04920c7ba --- /dev/null +++ b/tests/config/loader/ini/replace/test_replace_os_sep.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import os + +from tests.config.loader.ini.replace.conftest import ReplaceOne + + +def test_replace_os_sep(replace_one: ReplaceOne) -> None: + result = replace_one("{/}") + assert result == os.sep diff --git a/tests/config/loader/ini/replace/test_replace_posargs.py b/tests/config/loader/ini/replace/test_replace_posargs.py new file mode 100644 index 000000000..840869ac6 --- /dev/null +++ b/tests/config/loader/ini/replace/test_replace_posargs.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import sys + +import pytest + +from tests.config.loader.ini.replace.conftest import ReplaceOne + + +@pytest.mark.parametrize("syntax", ["{posargs}", "[]"]) +def test_replace_pos_args_none_sys_argv(syntax: str, replace_one: ReplaceOne) -> None: + result = replace_one(syntax, None) + assert result == "" + + +@pytest.mark.parametrize("syntax", ["{posargs}", "[]"]) +def test_replace_pos_args_empty_sys_argv(syntax: str, replace_one: ReplaceOne) -> None: + result = replace_one(syntax, []) + assert result == "" + + +@pytest.mark.parametrize("syntax", ["{posargs}", "[]"]) +def test_replace_pos_args_extra_sys_argv(syntax: str, replace_one: ReplaceOne) -> None: + result = replace_one(syntax, [sys.executable, "magic"]) + assert result == f"{sys.executable} magic" + + +@pytest.mark.parametrize("syntax", ["{posargs}", "[]"]) +def test_replace_pos_args(syntax: str, replace_one: ReplaceOne) -> None: + result = replace_one(syntax, ["ok", "what", " yes "]) + quote = '"' if sys.platform == "win32" else "'" + assert result == f"ok what {quote} yes {quote}" + + +@pytest.mark.parametrize( + ("value", "result"), + [ + ("magic", "magic"), + ("magic:colon", "magic:colon"), + ("magic\n b:c", "magic\nb:c"), # an unescaped newline keeps the newline + ("magi\\\n c:d", "magic:d"), # an escaped newline merges the lines + ("\\{a\\}", "{a}"), # escaped curly braces + ], +) +def test_replace_pos_args_default(replace_one: ReplaceOne, value: str, result: str) -> None: + outcome = replace_one(f"{{posargs:{value}}}", None) + assert result == outcome + + +@pytest.mark.parametrize( + "value", + [ + "\\{posargs}", + "{posargs\\}", + "\\{posargs\\}", + "{\\{posargs}", + "{\\{posargs}{}", + "\\[]", + "[\\]", + "\\[\\]", + ], +) +def test_replace_pos_args_escaped(replace_one: ReplaceOne, value: str) -> None: + result = replace_one(value, None) + outcome = value.replace("\\", "") + assert result == outcome + + +@pytest.mark.parametrize( + ("value", "result"), + [ + ("[]-{posargs}", "foo-foo"), + ("{posargs}-[]", "foo-foo"), + ], +) +def test_replace_mixed_brackets_and_braces(replace_one: ReplaceOne, value: str, result: str) -> None: + outcome = replace_one(value, ["foo"]) + assert result == outcome + + +def test_half_escaped_braces(replace_one: ReplaceOne) -> None: + """See https://github.com/tox-dev/tox/issues/1956""" + outcome = replace_one(r"\{posargs} {posargs}", ["foo"]) + assert "{posargs} foo" == outcome diff --git a/tests/config/loader/ini/replace/test_replace_tox_env.py b/tests/config/loader/ini/replace/test_replace_tox_env.py new file mode 100644 index 000000000..284d6c7cc --- /dev/null +++ b/tests/config/loader/ini/replace/test_replace_tox_env.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Callable + +import pytest + +from tests.config.loader.ini.replace.conftest import ReplaceOne +from tests.conftest import ToxIniCreator +from tox.config.sets import ConfigSet +from tox.report import HandledError + +EnvConfigCreator = Callable[[str], ConfigSet] + + +@pytest.fixture() +def example(tox_ini_conf: ToxIniCreator) -> EnvConfigCreator: + def func(conf: str) -> ConfigSet: + config = tox_ini_conf(f"""[tox]\nenv_list = a\n[testenv]\n{conf}\n""") + env_config = config.get_env("a") + return env_config + + return func + + +def test_replace_within_tox_env(example: EnvConfigCreator) -> None: + env_config = example("r = 1\no = {r}") + env_config.add_config(keys="r", of_type=str, default="r", desc="r") + env_config.add_config(keys="o", of_type=str, default="o", desc="o") + result = env_config["o"] + assert result == "1" + + +def test_replace_within_tox_env_missing_raises(example: EnvConfigCreator) -> None: + env_config = example("o = {p}") + env_config.add_config(keys="o", of_type=str, default="o", desc="o") + + assert env_config["o"] == "{p}" + + +def test_replace_within_tox_env_missing_default(example: EnvConfigCreator) -> None: + env_config = example("o = {p:one}") + env_config.add_config(keys="o", of_type=str, default="o", desc="o") + result = env_config["o"] + assert result == "one" + + +def test_replace_within_tox_env_missing_default_env_only(example: EnvConfigCreator) -> None: + env_config = example("o = {[testenv:a]p:one}") + env_config.add_config(keys="o", of_type=str, default="o", desc="o") + result = env_config["o"] + assert result == "one" + + +def test_replace_within_tox_env_missing_no_default(example: EnvConfigCreator) -> None: + env_config = example("o = {[testenv:b]p}") + env_config.add_config(keys="o", of_type=str, default="o", desc="o") + assert env_config["o"] == "{[testenv:b]p}" + + +def test_replace_within_tox_env_from_base(example: EnvConfigCreator) -> None: + env_config = example("p = one\n[testenv:a]\no = {[testenv]p}") + env_config.add_config(keys="p", of_type=str, default="p", desc="p") + env_config.add_config(keys="o", of_type=str, default="o", desc="o") + result = env_config["o"] + assert result == "one" + + +def test_replace_ref_bad_type(tox_ini_conf: ToxIniCreator) -> None: + config = tox_ini_conf("[testenv:a]\nx = {[testenv:b]v}\n[testenv:b]\nv=1") + + class BadType: + def __init__(self, value: str) -> None: + if value != "magic": + raise ValueError(value) + + conf_b = config.get_env("b") + conf_b.add_config(keys="v", of_type=BadType, default=BadType("magic"), desc="p") + + conf_a = config.get_env("a") + conf_a.add_config(keys="x", of_type=str, default="o", desc="o") + + with pytest.raises(HandledError, match=r"replace failed in a.x with ValueError.*'1'.*"): + assert conf_a["x"] + + +@pytest.mark.parametrize( + ("start", "end"), + [ + ("0", "0"), + ("0}", "0}"), + ("{0", "{0"), + ("{0}", "{0}"), + ("{}{0}", "{}{0}"), + ("{0}{}", "{0}{}"), + ("\\{0}", "{0}"), + ("{0\\}", "{0}"), + ("\\{0\\}", "{0}"), + ("f\\{0\\}", "f{0}"), + ("\\{0\\}f", "{0}f"), + ("\\{\\{0", "{{0"), + ("0\\}\\}", "0}}"), + ("\\{\\{0\\}\\}", "{{0}}"), + ], +) +def test_do_not_replace(replace_one: ReplaceOne, start: str, end: str) -> None: + """If we have a factor that is not specified within the core env-list then that's also an environment""" + value = replace_one(start) + assert value == end + + +def test_replace_from_tox_section_non_registered(tox_ini_conf: ToxIniCreator) -> None: + conf_a = tox_ini_conf("[tox]\na=1\n[testenv:a]\nx = {[tox]a}").get_env("a") + conf_a.add_config(keys="x", of_type=str, default="o", desc="o") + assert conf_a["x"] == "1" + + +def test_replace_from_tox_section_missing_section(tox_ini_conf: ToxIniCreator) -> None: + conf_a = tox_ini_conf("[testenv:a]\nx = {[magic]a}").get_env("a") + conf_a.add_config(keys="x", of_type=str, default="o", desc="o") + assert conf_a["x"] == "{[magic]a}" + + +def test_replace_from_tox_section_key_with_dash(tox_ini_conf: ToxIniCreator) -> None: + conf_a = tox_ini_conf("[testenv:a]\nx = {[magic]a-b}\n[magic]\na-b=1").get_env("a") + conf_a.add_config(keys="x", of_type=str, default="o", desc="o") + assert conf_a["x"] == "1" + + +def test_replace_circular(tox_ini_conf: ToxIniCreator) -> None: + conf_a = tox_ini_conf("[testenv:a]\nx = {y}\ny = {x}").get_env("a") + conf_a.add_config(keys="x", of_type=str, default="o", desc="o") + conf_a.add_config(keys="y", of_type=str, default="n", desc="n") + with pytest.raises(HandledError) as exc: + assert conf_a["x"] + assert "circular chain detected testenv:a.x, testenv:a.y" in str(exc.value) + + +def test_replace_from_tox_section_missing_value(tox_ini_conf: ToxIniCreator) -> None: + conf_a = tox_ini_conf("[testenv:e]\nx = {[m]a}\n[m]").get_env("e") + conf_a.add_config(keys="x", of_type=str, default="o", desc="d") + assert conf_a["x"] == "{[m]a}" + + +def test_replace_from_section_bad_type(tox_ini_conf: ToxIniCreator) -> None: + conf_a = tox_ini_conf("[testenv:e]\nx = {[m]a}\n[m]\na=w\n").get_env("e") + conf_a.add_config(keys="x", of_type=int, default=1, desc="d") + with pytest.raises(ValueError, match="invalid literal.*w.*"): + assert conf_a["x"] + + +def test_replace_from_tox_section_registered(tox_ini_conf: ToxIniCreator, tmp_path: Path) -> None: + conf_a = tox_ini_conf("[testenv:a]\nx = {[tox]tox_root}").get_env("a") + conf_a.add_config(keys="x", of_type=Path, default=Path.cwd() / "magic", desc="d") + assert conf_a["x"] == (tmp_path / "c") + + +def test_replace_from_tox_other_tox_section_same_name(tox_ini_conf: ToxIniCreator) -> None: + conf_a = tox_ini_conf("[testenv:a]\nx={[testenv:b]c}\nc=d\n[testenv:b]}").get_env("a") + conf_a.add_config(keys="x", of_type=str, default="", desc="d") + assert conf_a["x"] == "{[testenv:b]c}" + + +@pytest.mark.parametrize( + ("env_name", "exp"), + [ + ("testenv:foobar", "1"), + ("testenv:foo-bar", "1"), + ("foo-bar", "1"), + ("foobar", "1"), + ], +) +def test_replace_valid_section_names(tox_ini_conf: ToxIniCreator, env_name: str, exp: str) -> None: + conf_a = tox_ini_conf(f"[{env_name}]\na={exp}\n[testenv:a]\nx = {{[{env_name}]a}}").get_env("a") + conf_a.add_config(keys="x", of_type=str, default="o", desc="o") + assert conf_a["x"] == exp diff --git a/tests/config/loader/ini/replace/test_replace_tty.py b/tests/config/loader/ini/replace/test_replace_tty.py new file mode 100644 index 000000000..004f1e10d --- /dev/null +++ b/tests/config/loader/ini/replace/test_replace_tty.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import sys + +import pytest +from pytest_mock import MockFixture + +from tests.config.loader.ini.replace.conftest import ReplaceOne + + +@pytest.mark.parametrize("is_atty", [True, False]) +def test_replace_env_set(replace_one: ReplaceOne, mocker: MockFixture, is_atty: bool) -> None: + mocker.patch.object(sys.stdout, "isatty", return_value=is_atty) + + result = replace_one("1 {tty} 2") + assert result == "1 2" + + result = replace_one("1 {tty:a} 2") + assert result == f"1 {'a' if is_atty else ''} 2" + + result = replace_one("1 {tty:a:b} 2") + assert result == f"1 {'a' if is_atty else 'b'} 2" diff --git a/tests/config/loader/ini/test_factor.py b/tests/config/loader/ini/test_factor.py new file mode 100644 index 000000000..9f60b5814 --- /dev/null +++ b/tests/config/loader/ini/test_factor.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from configparser import ConfigParser +from textwrap import dedent +from typing import Callable, List + +import pytest + +from tests.conftest import ToxIniCreator +from tox.config.loader.ini import IniLoader +from tox.config.loader.ini.factor import filter_for_env, find_envs +from tox.config.main import Config +from tox.config.source.ini_section import IniSection + + +def test_factor_env_discover_empty() -> None: + result = list(find_envs("\n\n")) + assert result == [] + + +@pytest.fixture(scope="session") +def complex_example() -> str: + return dedent( + """ + default + lines + py: py only + !py: not py + {py,!pi}-{a,b}{,-dev},c: complex + extra: extra + more-default + """, + ) + + +def test_factor_env_discover(complex_example: str) -> None: + result = list(find_envs(complex_example)) + assert result == [ + "py", + "py-a", + "py-a-dev", + "py-b", + "py-b-dev", + "pi-a", + "pi-a-dev", + "pi-b", + "pi-b-dev", + "c", + "extra", + ] + + +@pytest.mark.parametrize( + "env", + [ + "py", + "py-a", + "py-a-dev", + "py-b", + "py-b-dev", + "pi-a", + "pi-a-dev", + "pi-b", + "pi-b-dev", + "c", + "extra", + ], +) +def test_factor_env_filter(env: str, complex_example: str) -> None: + result = filter_for_env(complex_example, name=env) + assert "default" in result + assert "lines" in result + assert "more-default" in result + if "py" in env: + assert "py only" in result + assert "not py" not in result + else: + assert "py only" not in result + assert "not py" in result + if "extra" == env: + assert "extra" in result + else: + assert "extra" not in result + if env in {"py-a", "py-a-dev", "py-b", "py-b-dev", "c"}: + assert "complex" in result + else: + assert "complex" not in result + + +def test_factor_env_list(tox_ini_conf: ToxIniCreator) -> None: + config = tox_ini_conf("[tox]\nenv_list = {py27,py36}-django{ 15, 16 }{,-dev}, docs, flake") + result = list(config) + assert result == [ + "py27-django15", + "py27-django15-dev", + "py27-django16", + "py27-django16-dev", + "py36-django15", + "py36-django15-dev", + "py36-django16", + "py36-django16-dev", + "docs", + "flake", + ] + + +def test_simple_env_list(tox_ini_conf: ToxIniCreator) -> None: + config = tox_ini_conf("[tox]\nenv_list = docs, flake8") + assert list(config) == ["docs", "flake8"] + + +def test_factor_config(tox_ini_conf: ToxIniCreator) -> None: + config = tox_ini_conf( + """ + [tox] + env_list = {py36,py37}-{django15,django16} + [testenv] + deps-x = + pytest + django15: Django>=1.5,<1.6 + django16: Django>=1.6,<1.7 + py36: unittest2 + """, + ) + assert list(config) == ["py36-django15", "py36-django16", "py37-django15", "py37-django16"] + for env in config.core["env_list"]: + env_config = config.get_env(env) + env_config.add_config(keys="deps-x", of_type=List[str], default=[], desc="deps") + deps = env_config["deps-x"] + assert "pytest" in deps + if "py36" in env: + assert "unittest2" in deps + if "django15" in env: + assert "Django>=1.5,<1.6" in deps + if "django16" in env: + assert "Django>=1.6,<1.7" in deps + + +def test_factor_config_do_not_replace_unescaped_comma(tox_ini_conf: ToxIniCreator) -> None: + config = tox_ini_conf("[tox]\nenv_list = py37-{base,i18n},b") + assert list(config) == ["py37-base", "py37-i18n", "b"] + + +def test_factor_config_no_env_list_creates_env(tox_ini_conf: ToxIniCreator) -> None: + """If we have a factor that is not specified within the core env-list then that's also an environment""" + config = tox_ini_conf( + """ + [tox] + env_list = py37-{django15,django16} + [testenv] + deps = + pytest + django15: Django>=1.5,<1.6 + django16: Django>=1.6,<1.7 + py36: unittest2 + """, + ) + + assert list(config) == ["py37-django15", "py37-django16", "py36"] + + +@pytest.mark.parametrize( + ("env", "result"), + [ + ("py35", "python -m coverage html -d cov"), + ("py36", "python -m coverage html -d cov\n--show-contexts"), + ], +) +def test_ini_loader_raw_with_factors( + mk_ini_conf: Callable[[str], ConfigParser], + env: str, + result: str, + empty_config: Config, +) -> None: + commands = "python -m coverage html -d cov \n !py35: --show-contexts" + loader = IniLoader( + section=IniSection(None, "testenv"), + parser=mk_ini_conf(f"[tox]\nenvlist=py35,py36\n[testenv]\ncommands={commands}"), + overrides=[], + core_section=IniSection(None, "tox"), + ) + outcome = loader.load_raw(key="commands", conf=empty_config, env_name=env) + assert outcome == result + + +def test_generative_section_name(tox_ini_conf: ToxIniCreator) -> None: + config = tox_ini_conf( + """ + [testenv:{py311,py310}-{black,lint}] + deps-x = + black: black + lint: flake8 + """, + ) + assert list(config) == ["py311-black", "py311-lint", "py310-black", "py310-lint"] + + env_config = config.get_env("py311-black") + env_config.add_config(keys="deps-x", of_type=List[str], default=[], desc="deps") + deps = env_config["deps-x"] + assert deps == ["black"] + + env_config = config.get_env("py311-lint") + env_config.add_config(keys="deps-x", of_type=List[str], default=[], desc="deps") + deps = env_config["deps-x"] + assert deps == ["flake8"] diff --git a/tests/config/loader/ini/test_ini_loader.py b/tests/config/loader/ini/test_ini_loader.py new file mode 100644 index 000000000..468490717 --- /dev/null +++ b/tests/config/loader/ini/test_ini_loader.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from configparser import ConfigParser +from typing import Callable + +import pytest + +from tox.config.loader.api import ConfigLoadArgs, Override +from tox.config.loader.ini import IniLoader +from tox.config.source.ini_section import IniSection + + +def test_ini_loader_keys(mk_ini_conf: Callable[[str], ConfigParser]) -> None: + core = IniSection(None, "tox") + loader = IniLoader(core, mk_ini_conf("\n[tox]\n\na=b\nc=d\n\n"), [], core_section=core) + assert loader.found_keys() == {"a", "c"} + + +def test_ini_loader_repr(mk_ini_conf: Callable[[str], ConfigParser]) -> None: + core = IniSection(None, "tox") + loader = IniLoader(core, mk_ini_conf("\n[tox]\n\na=b\nc=d\n\n"), [Override("tox.a=1")], core_section=core) + assert repr(loader) == "IniLoader(section=tox, overrides={'a': Override('tox.a=1')})" + + +def test_ini_loader_has_section(mk_ini_conf: Callable[[str], ConfigParser]) -> None: + core = IniSection(None, "tox") + loader = IniLoader(core, mk_ini_conf("[magic]\n[tox]\n\na=b\nc=d\n\n"), [], core_section=core) + assert loader.get_section("magic") is not None + + +def test_ini_loader_has_no_section(mk_ini_conf: Callable[[str], ConfigParser]) -> None: + core = IniSection(None, "tox") + loader = IniLoader(core, mk_ini_conf("[tox]\n\na=b\nc=d\n\n"), [], core_section=core) + assert loader.get_section("magic") is None + + +def test_ini_loader_raw(mk_ini_conf: Callable[[str], ConfigParser]) -> None: + core = IniSection(None, "tox") + args = ConfigLoadArgs([], "name", None) + loader = IniLoader(core, mk_ini_conf("[tox]\na=b"), [], core_section=core) + result = loader.load(key="a", of_type=str, conf=None, factory=None, args=args) + assert result == "b" + + +@pytest.mark.parametrize("sep", ["\n", "\r\n"]) +def test_ini_loader_raw_strip_escaped_newline(mk_ini_conf: Callable[[str], ConfigParser], sep: str) -> None: + core = IniSection(None, "tox") + args = ConfigLoadArgs([], "name", None) + loader = IniLoader(core, mk_ini_conf(f"[tox]{sep}a=b\\{sep} c"), [], core_section=core) + result = loader.load(key="a", of_type=str, conf=None, factory=None, args=args) + assert result == "bc" + + +@pytest.mark.parametrize( + ("case", "result"), + [ + ("# a", ""), + ("#", ""), + ("a # w", "a"), + ("a\t# w", "a"), + ("a# w", "a"), + ("a\\# w", "a# w"), + ("#a\n b # w\n w", "b\nw"), + ], +) +def test_ini_loader_strip_comments(mk_ini_conf: Callable[[str], ConfigParser], case: str, result: str) -> None: + core = IniSection(None, "tox") + args = ConfigLoadArgs([], "name", None) + loader = IniLoader(core, mk_ini_conf(f"[tox]\na={case}"), [], core_section=core) + outcome = loader.load(key="a", of_type=str, conf=None, factory=None, args=args) + assert outcome == result diff --git a/tests/config/loader/test_loader.py b/tests/config/loader/test_loader.py new file mode 100644 index 000000000..5d65f9004 --- /dev/null +++ b/tests/config/loader/test_loader.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest + +from tox.config.cli.parse import get_options +from tox.config.loader.api import Override +from tox.pytest import CaptureFixture + + +@pytest.mark.parametrize("flag", ["-x", "--override"]) +def test_override_incorrect(flag: str, capsys: CaptureFixture) -> None: + with pytest.raises(SystemExit): + get_options(flag, "magic") + out, err = capsys.readouterr() + assert not out + assert "override magic has no = sign in it" in err + + +@pytest.mark.parametrize("flag", ["-x", "--override"]) +def test_override_add(flag: str) -> None: + parsed, _, __, ___, ____ = get_options(flag, "magic=true") + assert len(parsed.override) == 1 + value = parsed.override[0] + assert value.key == "magic" + assert value.value == "true" + assert value.namespace == "" + + +def test_override_equals() -> None: + assert Override("a=b") == Override("a=b") + + +def test_override_not_equals() -> None: + assert Override("a=b") != Override("c=d") + + +def test_override_not_equals_different_type() -> None: + assert Override("a=b") != 1 + + +def test_override_repr() -> None: + assert repr(Override("b.a=c")) == "Override('b.a=c')" diff --git a/tests/config/loader/test_memory_loader.py b/tests/config/loader/test_memory_loader.py new file mode 100644 index 000000000..6bd5a2db6 --- /dev/null +++ b/tests/config/loader/test_memory_loader.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +import pytest + +from tox.config.loader.api import ConfigLoadArgs, Override +from tox.config.loader.memory import MemoryLoader +from tox.config.types import Command, EnvList + + +def test_memory_loader_repr() -> None: + loader = MemoryLoader(a=1) + assert repr(loader) == "MemoryLoader" + + +def test_memory_loader_override() -> None: + loader = MemoryLoader(a=1) + loader.overrides["a"] = Override("a=2") + args = ConfigLoadArgs([], "name", None) + loaded = loader.load("a", of_type=int, conf=None, factory=None, args=args) + assert loaded == 2 + + +@pytest.mark.parametrize( + ("value", "of_type", "outcome"), + [ + (True, bool, True), + (1, int, 1), + ("magic", str, "magic"), + ({"1"}, Set[str], {"1"}), + ([1], List[int], [1]), + ({1: 2}, Dict[int, int], {1: 2}), + (Path.cwd(), Path, Path.cwd()), + (Command(["a"]), Command, Command(["a"])), + (EnvList("a,b"), EnvList, EnvList("a,b")), + (1, Optional[int], 1), + ("1", Optional[str], "1"), + (0, bool, False), + (1, bool, True), + ("1", int, 1), + (1, str, "1"), + ({1}, Set[str], {"1"}), + ({"1"}, List[int], [1]), + ({"1": "2"}, Dict[int, int], {1: 2}), + (os.getcwd(), Path, Path.cwd()), + ("pip list", Command, Command(["pip", "list"])), + ("a\nb", EnvList, EnvList(["a", "b"])), + ("1", Optional[int], 1), + ], +) +def test_memory_loader(value: Any, of_type: type[Any], outcome: Any) -> None: + loader = MemoryLoader(**{"a": value}, kwargs={}) + args = ConfigLoadArgs([], "name", None) + loaded = loader.load("a", of_type=of_type, conf=None, factory=None, args=args) + assert loaded == outcome + + +@pytest.mark.parametrize( + ("value", "of_type", "exception", "msg"), + [ + ("m", int, ValueError, "invalid literal for int"), + ({"m"}, Set[int], ValueError, "invalid literal for int"), + (["m"], List[int], ValueError, "invalid literal for int"), + ({"m": 1}, Dict[int, int], ValueError, "invalid literal for int"), + ({1: "m"}, Dict[int, int], ValueError, "invalid literal for int"), + (object, Path, TypeError, "expected str, bytes or os.PathLike object"), + (1, Command, TypeError, "1"), + (1, EnvList, TypeError, "1"), + ], +) +def test_memory_loader_fails_invalid(value: Any, of_type: type[Any], exception: Exception, msg: str) -> None: + loader = MemoryLoader(**{"a": value}, kwargs={}) + args = ConfigLoadArgs([], "name", None) + with pytest.raises(exception, match=msg): # type: ignore[call-overload] + loader.load("a", of_type=of_type, conf=None, factory=None, args=args) + + +def test_memory_found_keys() -> None: + loader = MemoryLoader(a=1, c=2) + assert loader.found_keys() == {"a", "c"} + + +def test_memory_loader_contains() -> None: + loader = MemoryLoader(a=1) + assert "a" in loader + assert "b" not in loader diff --git a/tests/config/loader/test_section.py b/tests/config/loader/test_section.py new file mode 100644 index 000000000..b12badee9 --- /dev/null +++ b/tests/config/loader/test_section.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from tox.config.loader.section import Section + + +@pytest.mark.parametrize( + ("section", "outcome"), + [ + (Section("a", "b"), "a:b"), + (Section(None, "a"), "a"), + ], +) +def test_section_str(section: Section, outcome: str) -> None: + assert str(section) == outcome + + +@pytest.mark.parametrize( + ("section", "outcome"), + [ + (Section("a", "b"), "Section(prefix='a', name='b')"), + (Section(None, "a"), "Section(prefix=None, name='a')"), + ], +) +def test_section_repr(section: Section, outcome: str) -> None: + assert repr(section) == outcome + + +def test_section_eq() -> None: + assert Section(None, "a") == Section(None, "a") + + +@pytest.mark.parametrize( + ("section", "other"), + [ + (Section("a", "b"), "a-b"), + (Section(None, "a"), Section("b", "a")), + (Section("a", "b"), Section("a", "c")), + ], +) +def test_section_not_eq(section: Section, other: Any) -> None: + assert section != other diff --git a/tests/config/loader/test_str_convert.py b/tests/config/loader/test_str_convert.py new file mode 100644 index 000000000..49948c4fd --- /dev/null +++ b/tests/config/loader/test_str_convert.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, TypeVar, Union + +import pytest + +from tox.config.loader.str_convert import StrConvert +from tox.config.types import Command, EnvList + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from typing import Literal +else: # pragma: no cover (py38+) + from typing_extensions import Literal + + +@pytest.mark.parametrize( + ("raw", "value", "of_type"), + [ + ("true", True, bool), + ("false", False, bool), + ("True", True, bool), + ("False", False, bool), + ("TruE", True, bool), + ("FalsE", False, bool), + ("1", True, bool), + ("0", False, bool), + ("1", 1, int), + ("0", 0, int), + ("+1", 1, int), + ("-1", -1, int), + ("1.1", 1.1, float), + ("0.1", 0.1, float), + ("+1.1", 1.1, float), + ("-1.1", -1.1, float), + ("magic", "magic", str), + ("1", {"1"}, Set[str]), + ("1", [1], List[int]), + ("1=2", {1: 2}, Dict[int, int]), + ("a=1\n\nc=2", {"a": 1, "c": 2}, Dict[str, int]), + ("a", Path("a"), Path), + ("a", Command(["a"]), Command), + ("a,b", EnvList(["a", "b"]), EnvList), + ("", None, Optional[int]), + ("1", 1, Optional[int]), + ("", None, Optional[str]), + ("1", "1", Optional[str]), + ("", None, Optional[List[str]]), + ("1,2", ["1", "2"], Optional[List[str]]), + ("1", "1", Literal["1", "2"]), + ], +) +def test_str_convert_ok(raw: str, value: Any, of_type: type[Any]) -> None: + result = StrConvert().to(raw, of_type, None) + assert result == value + + +@pytest.mark.parametrize( + ("raw", "of_type", "exc_type", "msg"), + [ + ("a", TypeVar, TypeError, r"a cannot cast to .*typing.TypeVar.*"), + ("3", Literal["1", "2"], ValueError, r"3 must be one of \('1', '2'\)"), + ("3", Union[str, int], TypeError, r"3 cannot cast to typing.Union\[str, int\]"), + ], +) +def test_str_convert_nok(raw: str, of_type: type[Any], msg: str, exc_type: type[Exception]) -> None: + with pytest.raises(exc_type, match=msg): + StrConvert().to(raw, of_type, None) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("python ' ok", ["python", "' ok"]), + ('python " ok', ["python", '" ok']), + ], +) +def test_invalid_shell_expression(value: str, expected: list[str]) -> None: + result = StrConvert().to_command(value).args + assert result == expected diff --git a/tests/config/source/__init__.py b/tests/config/source/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/config/source/test_discover.py b/tests/config/source/test_discover.py new file mode 100644 index 000000000..1ef7073db --- /dev/null +++ b/tests/config/source/test_discover.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path + +from tox.pytest import ToxProjectCreator + + +def out_no_src(path: Path) -> str: + return ( + f"ROOT: No tox.ini or setup.cfg or pyproject.toml found, assuming empty tox.ini at {path}\n" + f"default environments:\npy -> [no description]\n" + ) + + +def test_no_src_cwd(tox_project: ToxProjectCreator) -> None: + project = tox_project({}) + outcome = project.run("l") + outcome.assert_success() + assert outcome.out == out_no_src(project.path) + assert outcome.state.conf.src_path == (project.path / "tox.ini") + + +def test_no_src_has_py_project_toml_above(tox_project: ToxProjectCreator, tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("") + project = tox_project({}) + outcome = project.run("l") + outcome.assert_success() + assert outcome.out == out_no_src(tmp_path) + assert outcome.state.conf.src_path == (tmp_path / "tox.ini") + + +def test_no_src_root_dir(tox_project: ToxProjectCreator, tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + project = tox_project({}) + outcome = project.run("l", "--root", str(root)) + outcome.assert_success() + assert outcome.out == out_no_src(root) + assert outcome.state.conf.src_path == (root / "tox.ini") + + +def test_bad_src_content(tox_project: ToxProjectCreator, tmp_path: Path) -> None: + project = tox_project({}) + + outcome = project.run("l", "-c", str(tmp_path / "setup.cfg")) + outcome.assert_failed() + assert outcome.out == f"ROOT: HandledError| could not recognize config file {tmp_path / 'setup.cfg'}\n" diff --git a/tests/config/source/test_legacy_toml.py b/tests/config/source/test_legacy_toml.py new file mode 100644 index 000000000..affe57e86 --- /dev/null +++ b/tests/config/source/test_legacy_toml.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from tox.pytest import ToxProjectCreator + + +def test_conf_in_legacy_toml(tox_project: ToxProjectCreator) -> None: + project = tox_project({"pyproject.toml": '[tool.tox]\nlegacy_tox_ini="""[tox]\nenv_list=\n a\n b\n"""'}) + + outcome = project.run("l") + outcome.assert_success() + assert outcome.out == "default environments:\na -> [no description]\nb -> [no description]\n" diff --git a/tests/config/source/test_setup_cfg.py b/tests/config/source/test_setup_cfg.py new file mode 100644 index 000000000..9ea675100 --- /dev/null +++ b/tests/config/source/test_setup_cfg.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from tox.pytest import ToxProjectCreator + + +def test_conf_in_setup_cfg(tox_project: ToxProjectCreator) -> None: + project = tox_project({"setup.cfg": "[tox:tox]\nenv_list=\n a\n b"}) + + outcome = project.run("l") + outcome.assert_success() + assert outcome.out == "default environments:\na -> [no description]\nb -> [no description]\n" + + +def test_bad_conf_setup_cfg(tox_project: ToxProjectCreator) -> None: + project = tox_project({"setup.cfg": "[tox]\nenv_list=\n a\n b"}) + filename = str(project.path / "setup.cfg") + outcome = project.run("l", "-c", filename) + outcome.assert_failed() + assert outcome.out == f"ROOT: HandledError| could not recognize config file {filename}\n" diff --git a/tests/config/source/test_source_ini.py b/tests/config/source/test_source_ini.py new file mode 100644 index 000000000..c31624868 --- /dev/null +++ b/tests/config/source/test_source_ini.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pathlib import Path + +from tox.config.loader.section import Section +from tox.config.source.ini import IniSource + + +def test_source_ini_with_interpolated(tmp_path: Path) -> None: + loader = IniSource(tmp_path, content="[tox]\na = %(c)s").get_loader(Section(None, "tox"), {}) + assert loader is not None + loader.load_raw("a", None, None) diff --git a/tests/config/test_main.py b/tests/config/test_main.py new file mode 100644 index 000000000..cff7d2f4a --- /dev/null +++ b/tests/config/test_main.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from tests.conftest import ToxIniCreator +from tox.config.loader.api import Override +from tox.config.loader.memory import MemoryLoader +from tox.config.main import Config +from tox.config.sets import ConfigSet +from tox.pytest import ToxProjectCreator + + +def test_empty_config_repr(empty_config: Config) -> None: + text = repr(empty_config) + assert str(empty_config.core["tox_root"]) in text + assert "config_source=ToxIni" in text + + +def test_empty_conf_tox_envs(empty_config: Config) -> None: + tox_env_keys = list(empty_config) + assert tox_env_keys == [] + + +def test_empty_conf_get(empty_config: Config) -> None: + result = empty_config.get_env("magic") + assert isinstance(result, ConfigSet) + loaders = result["base"] + assert loaders == ["testenv"] + + +def test_config_some_envs(tox_ini_conf: ToxIniCreator) -> None: + example = """ + [tox] + env_list = py38, py37 + [testenv] + deps = 1 + other: 2 + [testenv:magic] + """ + config = tox_ini_conf(example) + tox_env_keys = list(config) + assert tox_env_keys == ["py38", "py37", "magic", "other"] + + config_set = config.get_env("py38") + assert repr(config_set) + assert isinstance(config_set, ConfigSet) + assert list(config_set) + + +def test_config_overrides(tox_ini_conf: ToxIniCreator) -> None: + conf = tox_ini_conf("[testenv]", override=[Override("testenv.c=ok")]).get_env("py") + conf.add_config("c", of_type=str, default="d", desc="desc") + assert conf["c"] == "ok" + + +def test_config_override_wins_memory_loader(tox_ini_conf: ToxIniCreator) -> None: + main_conf = tox_ini_conf("[testenv]", override=[Override("testenv.c=ok")]) + conf = main_conf.get_env("py", loaders=[MemoryLoader(c="something_else")]) + conf.add_config("c", of_type=str, default="d", desc="desc") + assert conf["c"] == "ok" + + +def test_args_are_paths_when_disabled(tox_project: ToxProjectCreator) -> None: + ini = "[testenv]\npackage=skip\ncommands={posargs}\nargs_are_paths=False" + project = tox_project({"tox.ini": ini, "w": {"a.txt": "a"}}) + args = "magic.py", str(project.path), f"..{os.sep}tox.ini", "..", f"..{os.sep}.." + result = project.run("c", "-e", "py", "-k", "commands", "--", *args, from_cwd=project.path / "w") + result.assert_success() + assert result.out == f"[testenv:py]\ncommands = magic.py {project.path} ..{os.sep}tox.ini .. ..{os.sep}..\n" + + +def test_args_are_paths_when_from_child_dir(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands={posargs}", "w": {"a.txt": "a"}}) + args = "magic.py", str(project.path), f"..{os.sep}tox.ini", "..", f"..{os.sep}.." + result = project.run("c", "-e", "py", "-k", "commands", "--", *args, from_cwd=project.path / "w") + result.assert_success() + assert result.out == f"[testenv:py]\ncommands = magic.py {project.path} tox.ini . ..\n" + + +def test_args_are_paths_when_with_change_dir(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands={posargs}\nchange_dir=w", "w": {"a.txt": "a"}}) + args = "magic.py", str(project.path), "tox.ini", f"w{os.sep}a.txt", "w", "." + result = project.run("c", "-e", "py", "-k", "commands", "--", *args) + result.assert_success() + assert result.out == f"[testenv:py]\ncommands = magic.py {project.path} ..{os.sep}tox.ini a.txt . ..\n" + + +def test_relative_config_paths_resolve(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[tox]"}) + result = project.run( + "c", + "-c", + str(Path(project.path.name) / "tox.ini"), + "-k", + "change_dir", + "env_dir", + from_cwd=project.path.parent, + ) + result.assert_success() + expected = f"[testenv:py]\nchange_dir = {project.path}\nenv_dir = {project.path / '.tox' / 'py'}\n\n[tox]\n" + assert result.out == expected diff --git a/tests/config/test_of_types.py b/tests/config/test_of_types.py new file mode 100644 index 000000000..551e3395c --- /dev/null +++ b/tests/config/test_of_types.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from tox.config.of_type import ConfigConstantDefinition, ConfigDynamicDefinition + + +def test_config_constant_eq() -> None: + val_1 = ConfigConstantDefinition(("key",), "description", "value") + val_2 = ConfigConstantDefinition(("key",), "description", "value") + assert val_1 == val_2 + + +def test_config_dynamic_eq() -> None: + def func(name: str) -> str: + return name # pragma: no cover + + val_1 = ConfigDynamicDefinition(("key",), "description", str, "default", post_process=func) + val_2 = ConfigDynamicDefinition(("key",), "description", str, "default", post_process=func) + assert val_1 == val_2 diff --git a/tests/config/test_set_env.py b/tests/config/test_set_env.py new file mode 100644 index 000000000..17f925276 --- /dev/null +++ b/tests/config/test_set_env.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import pytest +from pytest_mock import MockerFixture + +from tox.config.set_env import SetEnv +from tox.pytest import MonkeyPatch, ToxProjectCreator + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from typing import Protocol +else: # pragma: no cover ( None: + set_env = SetEnv("\nA=1\nB = 2\nC= 3\nD= 4", "py", "py", Path()) + set_env.update({"E": "5 ", "F": "6"}, override=False) + + keys = list(set_env) + assert keys == ["E", "F", "A", "B", "C", "D"] + values = [set_env.load(k) for k in keys] + assert values == ["5 ", "6", "1", "2", "3", "4"] + + for key in keys: + assert key in set_env + assert "MISS" not in set_env + + +def test_set_env_bad_line() -> None: + with pytest.raises(ValueError, match="A"): + SetEnv("A", "py", "py", Path()) + + +class EvalSetEnv(Protocol): + def __call__( + self, + tox_ini: str, # noqa: U100 + extra_files: dict[str, Any] | None = ..., # noqa: U100 + from_cwd: Path | None = ..., # noqa: U100 + ) -> SetEnv: + ... + + +@pytest.fixture() +def eval_set_env(tox_project: ToxProjectCreator) -> EvalSetEnv: + def func(tox_ini: str, extra_files: dict[str, Any] | None = None, from_cwd: Path | None = None) -> SetEnv: + prj = tox_project({"tox.ini": tox_ini, **(extra_files or {})}) + result = prj.run("c", "-k", "set_env", "-e", "py", from_cwd=None if from_cwd is None else prj.path / from_cwd) + result.assert_success() + set_env: SetEnv = result.env_conf("py")["set_env"] + return set_env + + return func + + +def test_set_env_default(eval_set_env: EvalSetEnv) -> None: + set_env = eval_set_env("") + keys = list(set_env) + assert keys == ["PIP_DISABLE_PIP_VERSION_CHECK"] + values = [set_env.load(k) for k in keys] + assert values == ["1"] + + +def test_set_env_self_key(eval_set_env: EvalSetEnv, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("a", "1") + set_env = eval_set_env("[testenv]\npackage=skip\nset_env=a={env:a:2}") + assert set_env.load("a") == "1" + + +def test_set_env_other_env_set(eval_set_env: EvalSetEnv, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("b", "1") + set_env = eval_set_env("[testenv]\npackage=skip\nset_env=a={env:b:2}") + assert set_env.load("a") == "1" + + +def test_set_env_other_env_default(eval_set_env: EvalSetEnv, monkeypatch: MonkeyPatch) -> None: + monkeypatch.delenv("b", raising=False) + set_env = eval_set_env("[testenv]\npackage=skip\nset_env=a={env:b:2}") + assert set_env.load("a") == "2" + + +def test_set_env_delayed_eval(eval_set_env: EvalSetEnv, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("b", "c=1") + set_env = eval_set_env("[testenv]\npackage=skip\nset_env={env:b}") + assert set_env.load("c") == "1" + + +def test_set_env_tty_on(eval_set_env: EvalSetEnv, mocker: MockerFixture) -> None: + mocker.patch("sys.stdout.isatty", return_value=True) + set_env = eval_set_env("[testenv]\npackage=skip\nset_env={tty:A=1:B=1}") + assert set_env.load("A") == "1" + assert "B" not in set_env + + +def test_set_env_tty_off(eval_set_env: EvalSetEnv, mocker: MockerFixture) -> None: + mocker.patch("sys.stdout.isatty", return_value=False) + set_env = eval_set_env("[testenv]\npackage=skip\nset_env={tty:A=1:B=1}") + assert set_env.load("B") == "1" + assert "A" not in set_env + + +def test_set_env_circular_use_os_environ(tox_project: ToxProjectCreator) -> None: + prj = tox_project({"tox.ini": "[testenv]\npackage=skip\nset_env=a={env:b}\n b={env:a}"}) + result = prj.run("c", "-e", "py") + result.assert_success() + assert "replace failed in py.set_env with ValueError" in result.out, result.out + assert "circular chain between set env a, b" in result.out, result.out + + +def test_set_env_invalid_lines(eval_set_env: EvalSetEnv) -> None: + with pytest.raises(ValueError, match="a"): + eval_set_env("[testenv]\npackage=skip\nset_env=a\n b") + + +def test_set_env_replacer(eval_set_env: EvalSetEnv, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("MAGIC", "\nb=2\n") + set_env = eval_set_env("[testenv]\npackage=skip\nset_env=a=1\n {env:MAGIC}") + env = {k: set_env.load(k) for k in set_env} + assert env == {"PIP_DISABLE_PIP_VERSION_CHECK": "1", "a": "1", "b": "2"} + + +def test_set_env_honor_override(eval_set_env: EvalSetEnv) -> None: + set_env = eval_set_env("[testenv]\npackage=skip\nset_env=PIP_DISABLE_PIP_VERSION_CHECK=0") + assert set_env.load("PIP_DISABLE_PIP_VERSION_CHECK") == "0" + + +def test_set_env_environment_file(eval_set_env: EvalSetEnv) -> None: + env_file = """ + A=1 + B= 2 + C = 1 + # D = comment # noqa: E800 + E = "1" + F = + """ + extra = {"A": {"a.txt": env_file}, "B": None, "C": None} + ini = "[testenv]\npackage=skip\nset_env=file|A{/}a.txt\nchange_dir=C" + set_env = eval_set_env(ini, extra_files=extra, from_cwd=Path("B")) + content = {k: set_env.load(k) for k in set_env} + assert content == { + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "A": "1", + "B": "2", + "C": "1", + "E": '"1"', + "F": "", + } + + +def test_set_env_environment_file_missing(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\nset_env=file|magic.txt"}) + result = project.run("r") + result.assert_failed() + assert f"py: failed with {project.path / 'magic.txt'} does not exist for set_env" in result.out diff --git a/tests/config/test_sets.py b/tests/config/test_sets.py new file mode 100644 index 000000000..ca5778754 --- /dev/null +++ b/tests/config/test_sets.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from collections import OrderedDict +from pathlib import Path +from typing import Callable, Dict, Optional, Set, TypeVar + +import pytest +from pytest_mock import MockerFixture + +from tests.conftest import ToxIniCreator +from tox.config.cli.parser import Parsed +from tox.config.loader.memory import MemoryLoader +from tox.config.main import Config +from tox.config.sets import ConfigSet, EnvConfigSet +from tox.config.source.api import Section +from tox.pytest import ToxProjectCreator + +ConfBuilder = Callable[[str], ConfigSet] + + +@pytest.fixture(name="conf_builder") +def _conf_builder(tox_ini_conf: ToxIniCreator) -> ConfBuilder: # noqa: PT005 + def _make(conf_str: str) -> ConfigSet: + return tox_ini_conf(f"[tox]\nenvlist=py39\n[testenv]\n{conf_str}").get_env("py39") + + return _make + + +def test_config_str(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("deps-x = 1\n other: 2") + config_set.add_config(keys="deps-x", of_type=str, default="", desc="ok") + result = config_set["deps-x"] + assert result == "1" + + +def test_config_path(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("path = path") + config_set.add_config(keys="path", of_type=Path, default=Path(), desc="path") + path_materialize = config_set["path"] + assert path_materialize == Path("path") + + +def test_config_set(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("set = 1\n 2\n 3") + config_set.add_config(keys="set", of_type=Set[int], default=set(), desc="set") + set_materialize = config_set["set"] + assert set_materialize == {1, 2, 3} + + +def test_config_optional_none(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("") + config_set.add_config( + keys="optional_none", + of_type=Optional[int], # type: ignore[arg-type] + default=None, + desc="optional_none", + ) + optional_none = config_set["optional_none"] + assert optional_none is None + + +def test_config_dict(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("dict = a=1\n b=2\n c=3") + config_set.add_config(keys="dict", of_type=Dict[str, int], default={}, desc="dict") + dict_val = config_set["dict"] + assert dict_val == OrderedDict([("a", 1), ("b", 2), ("c", 3)]) + + +def test_config_bad_type(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("crazy = something-bad") + + config_set.add_config(keys="crazy", of_type=TypeVar, default=TypeVar("V"), desc="crazy") + with pytest.raises(TypeError) as context: + assert config_set["crazy"] + assert str(context.value) == f"something-bad cannot cast to {TypeVar!r}" + + +def test_config_bad_dict(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("bad_dict = something") + + config_set.add_config(keys="bad_dict", of_type=Dict[str, str], default={}, desc="bad_dict") + with pytest.raises(TypeError) as context: + assert config_set["bad_dict"] + assert str(context.value) == "dictionary lines must be of form key=value, found 'something'" + + +def test_config_bad_bool(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("bad_bool = whatever") + config_set.add_config(keys="bad_bool", of_type=bool, default=False, desc="bad_bool") + with pytest.raises(TypeError) as context: + assert config_set["bad_bool"] + error = "value whatever cannot be transformed to bool, valid: , 0, 1, false, no, off, on, true, yes" + assert str(context.value) == error + + +def test_config_constant(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("") + config_set.add_constant(keys="a", value=1, desc="ok") + const = config_set["a"] + assert const == 1 + + +def test_config_lazy_constant(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("") + config_set.add_constant(keys="b", value=lambda: 2, desc="ok") + lazy_const = config_set["b"] + assert lazy_const == 2 + + +def test_config_constant_repr(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("") + defined = config_set.add_constant(keys="a", value=1, desc="ok") + assert repr(defined) + + +def test_config_dynamic_repr(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("path = path") + defined = config_set.add_config(keys="path", of_type=Path, default=Path(), desc="path") + assert repr(defined) + + +def test_config_redefine_constant_fail(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("path = path") + config_set.add_constant(keys="path", desc="desc", value="value") + with pytest.raises(ValueError, match="config path already defined"): + config_set.add_constant(keys="path", desc="desc2", value="value") + + +def test_config_redefine_dynamic_fail(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("path = path") + config_set.add_config(keys="path", of_type=str, default="default_1", desc="path") + with pytest.raises(ValueError, match="config path already defined"): + config_set.add_config(keys="path", of_type=str, default="default_2", desc="path") + + +def test_config_dynamic_not_equal(conf_builder: ConfBuilder) -> None: + config_set = conf_builder("") + path = config_set.add_config(keys="path", of_type=Path, default=Path(), desc="path") + paths = config_set.add_config(keys="paths", of_type=Path, default=Path(), desc="path") + assert path != paths + + +def test_define_custom_set(tox_project: ToxProjectCreator) -> None: + class MagicConfigSet(ConfigSet): + + SECTION = Section(None, "magic") + + def register_config(self) -> None: + self.add_config("a", of_type=int, default=0, desc="number") + self.add_config("b", of_type=str, default="", desc="string") + + project = tox_project({"tox.ini": "[testenv]\npackage=skip\n[A]\na=1\n[magic]\nb = ok"}) + result = project.run() + section = MagicConfigSet.SECTION + conf = result.state.conf.get_section_config(section, base=["A"], of_type=MagicConfigSet, for_env=None) + assert conf["a"] == 1 + assert conf["b"] == "ok" + exp = "MagicConfigSet(loaders=[IniLoader(section=magic, overrides={}), " "IniLoader(section=A, overrides={})])" + assert repr(conf) == exp + + assert isinstance(result.state.conf._options, Parsed) + + +def test_do_not_allow_create_config_set(mocker: MockerFixture) -> None: + with pytest.raises(TypeError, match="Can't instantiate"): + ConfigSet(mocker.create_autospec(Config)) # type: ignore # the type checker also warns that ABC + + +def test_set_env_raises_on_non_str(mocker: MockerFixture) -> None: + env_set = EnvConfigSet(mocker.create_autospec(Config), Section("a", "b"), "b") + env_set.loaders.insert(0, MemoryLoader(set_env=1)) + with pytest.raises(TypeError, match="1"): + assert env_set["set_env"] diff --git a/tests/config/test_types.py b/tests/config/test_types.py new file mode 100644 index 000000000..e859ea18b --- /dev/null +++ b/tests/config/test_types.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from tox.config.types import Command, EnvList + + +def tests_command_repr() -> None: + cmd = Command(["python", "-m", "pip", "list"]) + assert repr(cmd) == "Command(args=['python', '-m', 'pip', 'list'])" + assert cmd.ignore_exit_code is False + + +def tests_command_repr_ignore() -> None: + cmd = Command(["-", "python", "-m", "pip", "list"]) + assert repr(cmd) == "Command(args=['-', 'python', '-m', 'pip', 'list'])" + assert cmd.ignore_exit_code is True + + +def tests_command_eq() -> None: + cmd_1 = Command(["python", "-m", "pip", "list"]) + cmd_2 = Command(["python", "-m", "pip", "list"]) + assert cmd_1 == cmd_2 + + +def tests_command_ne() -> None: + cmd_1 = Command(["python", "-m", "pip", "list"]) + cmd_2 = Command(["-", "python", "-m", "pip", "list"]) + assert cmd_1 != cmd_2 + + +def tests_env_list_repr() -> None: + env = EnvList(["py39", "py38"]) + assert repr(env) == "EnvList(['py39', 'py38'])" + + +def tests_env_list_eq() -> None: + env_1 = EnvList(["py39", "py38"]) + env_2 = EnvList(["py39", "py38"]) + assert env_1 == env_2 + + +def tests_env_list_ne() -> None: + env_1 = EnvList(["py39", "py38"]) + env_2 = EnvList(["py38", "py39"]) + assert env_1 != env_2 diff --git a/tests/conftest.py b/tests/conftest.py index ec59f4a1c..81c4a4938 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,146 @@ -# FIXME this seems unnecessary -# TODO move fixtures here and only keep helper functions/classes in the plugin -# TODO _pytest_helpers might be a better name than _pytestplugin then? -# noinspection PyUnresolvedReferences -from tox._pytestplugin import * # noqa +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Callable, Iterator, Sequence +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from _pytest.monkeypatch import MonkeyPatch # cannot import from tox.pytest yet +from _pytest.tmpdir import TempPathFactory +from distlib.scripts import ScriptMaker +from pytest_mock import MockerFixture +from virtualenv import cli_run + +from tox.config.cli.parser import Parsed +from tox.config.loader.api import Override +from tox.config.main import Config +from tox.config.source import discover_source +from tox.tox_env.python.api import PythonInfo, VersionInfo +from tox.tox_env.python.virtual_env.api import VirtualEnv + +pytest_plugins = "tox.pytest" +HERE = Path(__file__).absolute().parent + + +@pytest.fixture(scope="session") +def value_error() -> Callable[[str], str]: + def _fmt(msg: str) -> str: + return f'ValueError("{msg}"{"," if sys.version_info < (3, 7) else ""})' + + return _fmt + + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from typing import Protocol +else: # pragma: no cover ( Config: # noqa: U100 + ... + + +@pytest.fixture() +def tox_ini_conf(tmp_path: Path, monkeypatch: MonkeyPatch) -> ToxIniCreator: + def func(conf: str, override: Sequence[Override] | None = None) -> Config: + dest = tmp_path / "c" + dest.mkdir() + config_file = dest / "tox.ini" + config_file.write_bytes(conf.encode("utf-8")) + with monkeypatch.context() as context: + context.chdir(tmp_path) + source = discover_source(config_file, None) + + config = Config.make( + Parsed(work_dir=dest, override=override or [], config_file=config_file, root_dir=None), + pos_args=[], + source=source, + ) + return config + + return func + + +@pytest.fixture(scope="session") +def demo_pkg_setuptools() -> Path: + return HERE / "demo_pkg_setuptools" + + +@pytest.fixture(scope="session") +def demo_pkg_inline() -> Path: + return HERE / "demo_pkg_inline" + + +@pytest.fixture() +def patch_prev_py(mocker: MockerFixture) -> Callable[[bool], tuple[str, str]]: + def _func(has_prev: bool) -> tuple[str, str]: + ver = sys.version_info[0:2] + prev_ver = "".join(str(i) for i in (ver[0], ver[1] - 1)) + prev_py = f"py{prev_ver}" + impl = sys.implementation.name.lower() + + def get_python(self: VirtualEnv, base_python: list[str]) -> PythonInfo | None: + if base_python[0] == "py31" or (base_python[0] == prev_py and not has_prev): + return None + raw = list(sys.version_info) + if base_python[0] == prev_py: + raw[1] -= 1 # type: ignore[operator] + ver_info = VersionInfo(*raw) # type: ignore[arg-type] + return PythonInfo( + implementation=impl, + version_info=ver_info, + version="", + is_64=True, + platform=sys.platform, + extra={"executable": Path(sys.executable)}, + ) + + mocker.patch.object(VirtualEnv, "_get_python", get_python) + return prev_ver, impl + + return _func + + +@pytest.fixture(scope="session", autouse=True) +def _do_not_share_virtualenv_for_parallel_runs(tmp_path_factory: TempPathFactory, worker_id: str) -> None: + # virtualenv uses locks to manage access to its cache, when running with xdist this may throw off test timings + if worker_id != "master": # pragma: no branch + temp_app_data = str(tmp_path_factory.mktemp(f"virtualenv-app-{worker_id}")) # pragma: no cover + os.environ["VIRTUALENV_APP_DATA"] = temp_app_data # pragma: no cover + seed_env_folder = str(tmp_path_factory.mktemp(f"seed-cache-{worker_id}")) # pragma: no cover + args = [seed_env_folder, "--without-pip", "--activators", ""] # pragma: no cover + cli_run(args, setup_logging=False) # pragma: no cover + + +@pytest.fixture(scope="session") +def fake_exe_on_path(tmp_path_factory: TempPathFactory) -> Iterator[Path]: + tmp_path = Path(tmp_path_factory.mktemp("a")) + cmd_name = uuid4().hex + maker = ScriptMaker(None, str(tmp_path)) + maker.set_mode = True + maker.variants = {""} + maker.make(f"{cmd_name} = b:c") + with patch.dict(os.environ, {"PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}"}): + yield tmp_path / cmd_name + + +@pytest.fixture(scope="session") +def demo_pkg_inline_wheel(tmp_path_factory: TempPathFactory, demo_pkg_inline: Path) -> Path: + return build_pkg(tmp_path_factory.mktemp("dist"), demo_pkg_inline, ["wheel"]) + + +def build_pkg(dist_dir: Path, of: Path, distributions: list[str], isolation: bool = True) -> Path: + from build.__main__ import build_package + + build_package(str(of), str(dist_dir), distributions=distributions, isolation=isolation) + package = next(dist_dir.iterdir()) + return package + + +@pytest.fixture(scope="session") +def pkg_builder() -> Callable[[Path, Path, list[str], bool], Path]: + return build_pkg diff --git a/tests/demo_pkg_inline/build.py b/tests/demo_pkg_inline/build.py new file mode 100644 index 000000000..35e22f03a --- /dev/null +++ b/tests/demo_pkg_inline/build.py @@ -0,0 +1,88 @@ +""" +Please keep this file Python 2.7 compatible. +See https://tox.readthedocs.io/en/rewrite/development.html#code-style-guide +""" +import os +import sys +import tarfile +from textwrap import dedent +from zipfile import ZipFile + +name = "demo_pkg_inline" +pkg_name = name.replace("_", "-") + +version = "1.0.0" +dist_info = "{}-{}.dist-info".format(name, version) +logic = "{}/__init__.py".format(name) +metadata = "{}/METADATA".format(dist_info) +wheel = "{}/WHEEL".format(dist_info) +record = "{}/RECORD".format(dist_info) +content = { + logic: "def do():\n print('greetings from {}')".format(name), + metadata: """ + Metadata-Version: 2.1 + Name: {} + Version: {} + Summary: UNKNOWN + Home-page: UNKNOWN + Author: UNKNOWN + Author-email: UNKNOWN + License: UNKNOWN + {} + Platform: UNKNOWN + + UNKNOWN + """.format( + pkg_name, + version, + "\n ".join(os.environ.get("METADATA_EXTRA", "").split("\n")), + ), + wheel: """ + Wheel-Version: 1.0 + Generator: {}-{} + Root-Is-Purelib: true + Tag: py{}-none-any + """.format( + name, + version, + sys.version_info[0], + ), + "{}/top_level.txt".format(dist_info): name, + record: """ + {0}/__init__.py,, + {1}/METADATA,, + {1}/WHEEL,, + {1}/top_level.txt,, + {1}/RECORD,, + """.format( + name, + dist_info, + ), +} + + +def build_wheel(wheel_directory, metadata_directory=None, config_settings=None): # noqa: U100 + base_name = "{}-{}-py{}-none-any.whl".format(name, version, sys.version_info[0]) + path = os.path.join(wheel_directory, base_name) + with ZipFile(path, "w") as zip_file_handler: + for arc_name, data in content.items(): # pragma: no branch + zip_file_handler.writestr(arc_name, dedent(data).strip()) + print("created wheel {}".format(path)) + return base_name + + +def get_requires_for_build_wheel(config_settings=None): # noqa: U100 + return [] # pragma: no cover # only executed in non-host pythons + + +def build_sdist(sdist_directory, config_settings=None): # noqa: U100 + result = "{}-{}.tar.gz".format(name, version) # pragma: win32 cover + with tarfile.open(os.path.join(sdist_directory, result), "w:gz") as tar: # pragma: win32 cover + root = os.path.dirname(os.path.abspath(__file__)) # pragma: win32 cover + tar.add(os.path.join(root, "build.py"), "build.py") # pragma: win32 cover + tar.add(os.path.join(root, "pyproject.toml"), "pyproject.toml") # pragma: win32 cover + return result # pragma: win32 cover + + +def get_requires_for_build_sdist(config_settings=None): # noqa: U100 + return [] # pragma: no cover # only executed in non-host pythons diff --git a/tests/demo_pkg_inline/build.pyi b/tests/demo_pkg_inline/build.pyi new file mode 100644 index 000000000..8cc902eb6 --- /dev/null +++ b/tests/demo_pkg_inline/build.pyi @@ -0,0 +1,14 @@ +name: str = ... +pkg_name: str = ... +version: str = ... +dist_info: str = ... +content: dict[str, str] = ... + +def build_wheel( + wheel_directory: str, + metadata_directory: str | None = ..., + config_settings: dict[str, str] | None = ..., +) -> str: ... +def get_requires_for_build_wheel(config_settings: dict[str, str] | None = ...) -> list[str]: ... +def build_sdist(sdist_directory: str, config_settings: dict[str, str] | None = ...) -> str: ... +def get_requires_for_build_sdist(config_settings: dict[str, str] | None = ...) -> list[str]: ... diff --git a/tests/demo_pkg_inline/pyproject.toml b/tests/demo_pkg_inline/pyproject.toml new file mode 100644 index 000000000..f752bdfbd --- /dev/null +++ b/tests/demo_pkg_inline/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = [] +build-backend = "build" +backend-path = ["."] + +[tool.black] +line-length = 120 diff --git a/tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py b/tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py new file mode 100644 index 000000000..ce844967e --- /dev/null +++ b/tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +def do(): # type: () -> None + print("greetings from demo_pkg_setuptools") diff --git a/tests/demo_pkg_setuptools/pyproject.toml b/tests/demo_pkg_setuptools/pyproject.toml new file mode 100644 index 000000000..0f94e90bc --- /dev/null +++ b/tests/demo_pkg_setuptools/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=63"] +build-backend = 'setuptools.build_meta' diff --git a/tests/demo_pkg_setuptools/setup.cfg b/tests/demo_pkg_setuptools/setup.cfg new file mode 100644 index 000000000..80e425487 --- /dev/null +++ b/tests/demo_pkg_setuptools/setup.cfg @@ -0,0 +1,6 @@ +[metadata] +name = demo_pkg_setuptools +version = 1.2.3 + +[options] +packages = find: diff --git a/tests/execute/__init__.py b/tests/execute/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/execute/conftest.py b/tests/execute/conftest.py new file mode 100644 index 000000000..3c97340e0 --- /dev/null +++ b/tests/execute/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import os + +import pytest + + +@pytest.fixture(scope="session") +def os_env() -> dict[str, str]: + return os.environ.copy() diff --git a/tests/execute/local_subprocess/__init__.py b/tests/execute/local_subprocess/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/execute/local_subprocess/bad_process.py b/tests/execute/local_subprocess/bad_process.py new file mode 100644 index 000000000..4870d0583 --- /dev/null +++ b/tests/execute/local_subprocess/bad_process.py @@ -0,0 +1,36 @@ +"""This is a non compliant process that does not listens to signals""" +# pragma: no cover +from __future__ import annotations + +import os +import signal +import sys +import time +from pathlib import Path +from types import FrameType + +out = sys.stdout + + +def handler(signum: int, _: FrameType | None) -> None: # noqa: U101 + _p(f"how about no signal {signum!r}") + + +def _p(m: str) -> None: + out.write(f"{m}{os.linesep}") + out.flush() # force output flush in case we get killed + + +_p(f"start {__name__} with {sys.argv!r}") +signal.signal(signal.SIGINT, handler) +signal.signal(signal.SIGTERM, handler) + +try: + start_file = Path(sys.argv[1]) + _p(f"create {start_file}") + start_file.write_text("") + _p(f"created {start_file}") + while True: + time.sleep(0.01) +finally: + _p(f"done {__name__}") diff --git a/tests/execute/local_subprocess/local_subprocess_sigint.py b/tests/execute/local_subprocess/local_subprocess_sigint.py new file mode 100644 index 000000000..8bee5fe27 --- /dev/null +++ b/tests/execute/local_subprocess/local_subprocess_sigint.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import logging +import os +import signal +import sys +from io import TextIOWrapper +from pathlib import Path +from types import FrameType +from unittest.mock import MagicMock + +from tox.execute import Outcome +from tox.execute.local_sub_process import LocalSubProcessExecutor +from tox.execute.request import ExecuteRequest, StdinSource +from tox.report import NamedBytesIO + +logging.basicConfig(level=logging.DEBUG, format="%(relativeCreated)d\t%(levelname).1s\t%(message)s") +bad_process = Path(__file__).parent / "bad_process.py" + +executor = LocalSubProcessExecutor(colored=False) +request = ExecuteRequest( + cmd=[sys.executable, bad_process, sys.argv[1]], + cwd=Path().absolute(), + env=os.environ.copy(), + stdin=StdinSource.API, + run_id="", +) +out_err = TextIOWrapper(NamedBytesIO("out")), TextIOWrapper(NamedBytesIO("err")) + + +def show_outcome(outcome: Outcome | None) -> None: + if outcome is not None: # pragma: no branch + print(outcome.exit_code) + print(repr(outcome.out)) + print(repr(outcome.err)) + print(outcome.elapsed, end="") + print("done show outcome", file=sys.stderr) + + +def handler(s: int, f: FrameType | None) -> None: + logging.info(f"signal {s} at {f}") + global interrupt_done + if interrupt_done is False: # pragma: no branch + interrupt_done = True + logging.info(f"interrupt via {status}") + status.interrupt() + logging.info(f"interrupt finished via {status}") + + +interrupt_done = False +signal.signal(signal.SIGINT, handler) +logging.info("PID %d start %r", os.getpid(), request) +tox_env = MagicMock(conf={"suicide_timeout": 0.01, "interrupt_timeout": 0.05, "terminate_timeout": 0.07}) +try: + with executor.call(request, show=False, out_err=out_err, env=tox_env) as status: + logging.info("wait on %r", status) + while status.exit_code is None: + status.wait(timeout=0.01) # use wait here with timeout to not block the main thread + logging.info("wait over on %r", status) + show_outcome(status.outcome) +except Exception as exception: # pragma: no cover + logging.exception(exception) # pragma: no cover +finally: + logging.info("done") + logging.shutdown() diff --git a/tests/execute/local_subprocess/test_execute_util.py b/tests/execute/local_subprocess/test_execute_util.py new file mode 100644 index 000000000..d353ce32b --- /dev/null +++ b/tests/execute/local_subprocess/test_execute_util.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from pathlib import Path + +from tox.execute.util import shebang + + +def test_shebang_found(tmp_path: Path) -> None: + script_path = tmp_path / "a" + script_path.write_text("#! /bin/python \t-c\t") + assert shebang(str(script_path)) == ["/bin/python", "-c"] + + +def test_shebang_file_missing(tmp_path: Path) -> None: + script_path = tmp_path / "a" + assert shebang(str(script_path)) is None + + +def test_shebang_no_shebang(tmp_path: Path) -> None: + script_path = tmp_path / "a" + script_path.write_bytes(b"magic") + assert shebang(str(script_path)) is None + + +def test_shebang_non_utf8_file(tmp_path: Path) -> None: + script_path, content = tmp_path / "a", b"#!" + bytearray.fromhex("c0") + script_path.write_bytes(content) + assert shebang(str(script_path)) is None diff --git a/tests/execute/local_subprocess/test_local_subprocess.py b/tests/execute/local_subprocess/test_local_subprocess.py new file mode 100644 index 000000000..6bbf36fa5 --- /dev/null +++ b/tests/execute/local_subprocess/test_local_subprocess.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import json +import logging +import os +import shutil +import stat +import subprocess +import sys +from io import TextIOWrapper +from pathlib import Path +from unittest.mock import MagicMock, create_autospec + +import psutil +import pytest +from colorama import Fore +from flaky import flaky +from psutil import AccessDenied +from pytest_mock import MockerFixture + +from tox.execute.api import ExecuteOptions, Outcome +from tox.execute.local_sub_process import SIG_INTERRUPT, LocalSubProcessExecuteInstance, LocalSubProcessExecutor +from tox.execute.request import ExecuteRequest, StdinSource +from tox.execute.stream import SyncWrite +from tox.pytest import CaptureFixture, LogCaptureFixture, MonkeyPatch +from tox.report import NamedBytesIO + + +class FakeOutErr: + def __init__(self) -> None: + self.out_err = TextIOWrapper(NamedBytesIO("out")), TextIOWrapper(NamedBytesIO("err")) + + def read_out_err(self) -> tuple[str, str]: + out_got = self.out_err[0].buffer.getvalue().decode(self.out_err[0].encoding) # type: ignore[attr-defined] + err_got = self.out_err[1].buffer.getvalue().decode(self.out_err[1].encoding) # type: ignore[attr-defined] + return out_got, err_got + + +@pytest.mark.parametrize("color", [True, False], ids=["color", "no_color"]) +@pytest.mark.parametrize(("out", "err"), [("out", "err"), ("", "")], ids=["simple", "nothing"]) +@pytest.mark.parametrize("show", [True, False], ids=["show", "no_show"]) +def test_local_execute_basic_pass( + caplog: LogCaptureFixture, + os_env: dict[str, str], + out: str, + err: str, + show: bool, + color: bool, +) -> None: + caplog.set_level(logging.NOTSET) + executor = LocalSubProcessExecutor(colored=color) + code = f"import sys; print({out!r}, end=''); print({err!r}, end='', file=sys.stderr)" + request = ExecuteRequest(cmd=[sys.executable, "-c", code], cwd=Path(), env=os_env, stdin=StdinSource.OFF, run_id="") + out_err = FakeOutErr() + with executor.call(request, show=show, out_err=out_err.out_err, env=MagicMock()) as status: + while status.exit_code is None: # pragma: no branch + status.wait() + assert status.out == out.encode() + assert status.err == err.encode() + outcome = status.outcome + assert outcome is not None + assert bool(outcome) is True, outcome + assert outcome.exit_code == Outcome.OK + assert outcome.err == err + assert outcome.out == out + assert outcome.request == request + + out_got, err_got = out_err.read_out_err() + if show: + assert out_got == out + expected = (f"{Fore.RED}{err}{Fore.RESET}" if color else err) if err else "" + assert err_got == expected + else: + assert not out_got + assert not err_got + assert not caplog.records + + +def test_local_execute_basic_pass_show_on_standard_newline_flush(caplog: LogCaptureFixture) -> None: + caplog.set_level(logging.NOTSET) + executor = LocalSubProcessExecutor(colored=False) + request = ExecuteRequest( + cmd=[sys.executable, "-c", "import sys; print('out'); print('yay')"], + cwd=Path(), + env=os.environ.copy(), + stdin=StdinSource.OFF, + run_id="", + ) + out_err = FakeOutErr() + with executor.call(request, show=True, out_err=out_err.out_err, env=MagicMock()) as status: + while status.exit_code is None: # pragma: no branch + status.wait() + outcome = status.outcome + assert outcome is not None + assert repr(outcome) + assert bool(outcome) is True, outcome + assert outcome.exit_code == Outcome.OK + assert not outcome.err + assert outcome.out == f"out{os.linesep}yay{os.linesep}" + out, err = out_err.read_out_err() + assert out == f"out{os.linesep}yay{os.linesep}" + assert not err + assert not caplog.records + + +def test_local_execute_write_a_lot(os_env: dict[str, str]) -> None: + count = 10_000 + executor = LocalSubProcessExecutor(colored=False) + request = ExecuteRequest( + cmd=[ + sys.executable, + "-c", + ( + "import sys; import time; from datetime import datetime; import os;" + "print('e' * {0}, file=sys.stderr);" + "print('o' * {0}, file=sys.stdout);" + "time.sleep(0.5);" + "print('a' * {0}, file=sys.stderr);" + "print('b' * {0}, file=sys.stdout);" + ).format(count), + ], + cwd=Path(), + env=os_env, + stdin=StdinSource.OFF, + run_id="", + ) + out_err = FakeOutErr() + with executor.call(request, show=False, out_err=out_err.out_err, env=MagicMock()) as status: + while status.exit_code is None: # pragma: no branch + status.wait() + outcome = status.outcome + assert outcome is not None + assert bool(outcome), outcome + expected_out = f"{'o' * count}{os.linesep}{'b' * count}{os.linesep}" + assert outcome.out == expected_out, expected_out[len(outcome.out) :] + expected_err = f"{'e' * count}{os.linesep}{'a' * count}{os.linesep}" + assert outcome.err == expected_err, expected_err[len(outcome.err) :] + + +def test_local_execute_basic_fail(capsys: CaptureFixture, caplog: LogCaptureFixture, monkeypatch: MonkeyPatch) -> None: + monkeypatch.chdir(Path(__file__).parents[3]) + caplog.set_level(logging.NOTSET) + executor = LocalSubProcessExecutor(colored=False) + cwd = Path().absolute() + cmd = [ + sys.executable, + "-c", + "import sys; print('out', end=''); print('err', file=sys.stderr, end=''); sys.exit(3)", + ] + request = ExecuteRequest(cmd=cmd, cwd=cwd, env=os.environ.copy(), stdin=StdinSource.OFF, run_id="") + + # run test + out_err = FakeOutErr() + with executor.call(request, show=False, out_err=out_err.out_err, env=MagicMock()) as status: + while status.exit_code is None: # pragma: no branch + status.wait() + outcome = status.outcome + assert outcome is not None + + assert repr(outcome) + + # assert no output, no logs + out, err = out_err.read_out_err() + assert not out + assert not err + assert not caplog.records + + # assert return object + assert bool(outcome) is False, outcome + assert outcome.exit_code == 3 + assert outcome.err == "err" + assert outcome.out == "out" + assert outcome.request == request + + # asset fail + with pytest.raises(SystemExit) as context: + outcome.assert_success() + # asset fail + assert context.value.code == 3 + + out, err = capsys.readouterr() + assert out == "out\n" + expected = f"{Fore.RED}err{Fore.RESET}\n" + assert err == expected + + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.levelno == logging.CRITICAL + assert record.msg == "exit %s (%.2f seconds) %s> %s%s" + assert record.args is not None + _code, _duration, _cwd, _cmd, _metadata = record.args + assert _code == 3 + assert _cwd == cwd + assert _cmd == request.shell_cmd + assert isinstance(_duration, float) + assert _duration > 0 + assert isinstance(_metadata, str) + assert _metadata.startswith(" pid=") + + +def test_command_does_not_exist(caplog: LogCaptureFixture, os_env: dict[str, str]) -> None: + caplog.set_level(logging.NOTSET) + executor = LocalSubProcessExecutor(colored=False) + request = ExecuteRequest( + cmd=["sys-must-be-missing"], + cwd=Path().absolute(), + env=os_env, + stdin=StdinSource.OFF, + run_id="", + ) + out_err = FakeOutErr() + with executor.call(request, show=False, out_err=out_err.out_err, env=MagicMock()) as status: + while status.exit_code is None: # pragma: no branch + status.wait() # pragma: no cover + outcome = status.outcome + assert outcome is not None + + assert bool(outcome) is False, outcome + assert outcome.exit_code != Outcome.OK + assert outcome.out == "" + assert outcome.err == "" + assert not caplog.records + + +@flaky # type: ignore +@pytest.mark.skipif(sys.platform == "win32", reason="You need a conhost shell for keyboard interrupt") +def test_command_keyboard_interrupt(tmp_path: Path, monkeypatch: MonkeyPatch, capfd: CaptureFixture) -> None: + monkeypatch.chdir(tmp_path) + process_up_signal = tmp_path / "signal" + cmd = [sys.executable, str(Path(__file__).parent / "local_subprocess_sigint.py"), str(process_up_signal)] + process = subprocess.Popen(cmd) + while not process_up_signal.exists(): + assert process.poll() is None + root = process.pid + try: + child = next(iter(psutil.Process(pid=root).children())).pid + except AccessDenied as exc: # pragma: no cover # on termux for example + pytest.skip(str(exc)) # pragma: no cover + raise # pragma: no cover + + print(f"test running in {os.getpid()} and sending CTRL+C to {process.pid}", file=sys.stderr) + process.send_signal(SIG_INTERRUPT) + try: + process.communicate(timeout=3) + except subprocess.TimeoutExpired: # pragma: no cover + process.kill() + raise + + out, err = capfd.readouterr() + assert f"W requested interrupt of {child} from {root}, activate in 0.01" in err, err + assert f"W send signal SIGINT(2) to {child} from {root} with timeout 0.05" in err, err + assert f"W send signal SIGTERM(15) to {child} from {root} with timeout 0.07" in err, err + assert f"W send signal SIGKILL(9) to {child} from {root}" in err, err + + outs = out.split("\n") + + exit_code = int(outs[0]) + assert exit_code == -9 + assert float(outs[3]) > 0 # duration + assert "how about no signal 2" in outs[1], outs[1] # 2 - Interrupt + assert "how about no signal 15" in outs[1], outs[1] # 15 - Terminated + + +@pytest.mark.parametrize("tty_mode", ["on", "off"]) +def test_local_subprocess_tty(monkeypatch: MonkeyPatch, mocker: MockerFixture, tty_mode: str) -> None: + monkeypatch.setenv("COLUMNS", "100") + monkeypatch.setenv("LINES", "100") + tty = tty_mode == "on" + mocker.patch("sys.stdout.isatty", return_value=tty) + mocker.patch("sys.stderr.isatty", return_value=tty) + + executor = LocalSubProcessExecutor(colored=False) + cmd: list[str] = [sys.executable, str(Path(__file__).parent / "tty_check.py")] + request = ExecuteRequest(cmd=cmd, stdin=StdinSource.API, cwd=Path.cwd(), env=dict(os.environ), run_id="") + out_err = FakeOutErr() + with executor.call(request, show=False, out_err=out_err.out_err, env=MagicMock()) as status: + while status.exit_code is None: # pragma: no branch + status.wait() + outcome = status.outcome + assert outcome is not None + + assert outcome + info = json.loads(outcome.out) + assert info == { + "stdout": False, + "stderr": False, + "stdin": False, + "terminal": [100, 100], + } + + +@pytest.mark.parametrize("mode", ["stem", "full", "stem-pattern", "full-pattern", "all"]) +def test_allow_list_external_ok(fake_exe_on_path: Path, mode: str) -> None: + exe = f"{fake_exe_on_path}{'.EXE' if sys.platform == 'win32' else ''}" + allow = exe if "full" in mode else fake_exe_on_path.stem + allow = f"{allow[:-2]}*" if "pattern" in mode else allow + allow = "*" if mode == "all" else allow + + request = ExecuteRequest( + cmd=[fake_exe_on_path.stem], + cwd=Path.cwd(), + env={"PATH": os.environ["PATH"]}, + stdin=StdinSource.OFF, + run_id="run-id", + allow=[allow], + ) + inst = LocalSubProcessExecuteInstance(request, MagicMock(), out=SyncWrite("out", None), err=SyncWrite("err", None)) + + assert inst.cmd == [exe] + + +def test_shebang_limited_on(tmp_path: Path) -> None: + exe, script, instance = _create_shebang_test(tmp_path, env={"TOX_LIMITED_SHEBANG": "1"}) + if sys.platform == "win32": # pragma: win32 cover + assert instance.cmd == [str(script), "--magic"] + else: + assert instance.cmd == [exe, "-s", str(script), "--magic"] + + +@pytest.mark.parametrize("env", [{}, {"TOX_LIMITED_SHEBANG": ""}]) +def test_shebang_limited_off(tmp_path: Path, env: dict[str, str]) -> None: + _, script, instance = _create_shebang_test(tmp_path, env=env) + assert instance.cmd == [str(script), "--magic"] + + +def test_shebang_failed_to_parse(tmp_path: Path) -> None: + _, script, instance = _create_shebang_test(tmp_path, env={"TOX_LIMITED_SHEBANG": "yes"}) + script.write_text("") + assert instance.cmd == [str(script), "--magic"] + + +def _create_shebang_test(tmp_path: Path, env: dict[str, str]) -> tuple[str, Path, LocalSubProcessExecuteInstance]: + exe = shutil.which("python") + assert exe is not None + script = tmp_path / f"s{'.EXE' if sys.platform == 'win32' else ''}" + script.write_text(f"#!{exe} -s") + script.chmod(script.stat().st_mode | stat.S_IEXEC) # mark it executable + env["PATH"] = str(script.parent) + request = create_autospec(ExecuteRequest, cmd=["s", "--magic"], env=env, allow=None) + writer = create_autospec(SyncWrite) + instance = LocalSubProcessExecuteInstance(request, create_autospec(ExecuteOptions), writer, writer) + return exe, script, instance + + +@pytest.mark.parametrize("key", ["COLUMNS", "ROWS"]) +def test_local_execute_does_not_overwrite(key: str, mocker: MockerFixture) -> None: + mocker.patch("shutil.get_terminal_size", return_value=(101, 102)) + env = dict(os.environ) + env[key] = key + executor = LocalSubProcessExecutor(colored=False) + cmd = [sys.executable, "-c", f"import os; print(os.environ['{key}'], end='')"] + request = ExecuteRequest(cmd=cmd, stdin=StdinSource.API, cwd=Path.cwd(), env=env, run_id="") + out_err = FakeOutErr() + with executor.call(request, show=False, out_err=out_err.out_err, env=MagicMock()) as status: + while status.exit_code is None: # pragma: no branch + status.wait() + outcome = status.outcome + + assert outcome is not None + assert outcome.out == key diff --git a/tests/execute/local_subprocess/tty_check.py b/tests/execute/local_subprocess/tty_check.py new file mode 100644 index 000000000..7659abcb4 --- /dev/null +++ b/tests/execute/local_subprocess/tty_check.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import json +import shutil +import sys + +args = { + "stdout": sys.stdout.isatty(), + "stderr": sys.stderr.isatty(), + "stdin": sys.stdin.isatty(), + "terminal": shutil.get_terminal_size(fallback=(-1, -1)), +} +result = json.dumps(args) +print(result) diff --git a/tests/execute/test_request.py b/tests/execute/test_request.py new file mode 100644 index 000000000..25e665b95 --- /dev/null +++ b/tests/execute/test_request.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +from tox.execute.request import ExecuteRequest, StdinSource + + +def test_execute_request_raise_on_empty_cmd(os_env: dict[str, str]) -> None: + with pytest.raises(ValueError, match="cannot execute an empty command"): + ExecuteRequest(cmd=[], cwd=Path().absolute(), env=os_env, stdin=StdinSource.OFF, run_id="") + + +def test_request_allow_star_is_none() -> None: + request = ExecuteRequest( + cmd=[sys.executable], + cwd=Path.cwd(), + env={"PATH": os.environ["PATH"]}, + stdin=StdinSource.OFF, + run_id="run-id", + allow=["*", "magic"], + ) + assert request.allow is None diff --git a/tests/execute/test_stream.py b/tests/execute/test_stream.py new file mode 100644 index 000000000..6e870826d --- /dev/null +++ b/tests/execute/test_stream.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from colorama import Fore + +from tox.execute.stream import SyncWrite + + +def test_sync_write_repr() -> None: + sync_write = SyncWrite(name="a", target=None, color=Fore.RED) + assert repr(sync_write) == f"SyncWrite(name='a', target=None, color={Fore.RED!r})" diff --git a/tests/integration/test_jython_env_create.py b/tests/integration/test_jython_env_create.py deleted file mode 100644 index 43b6f3b3e..000000000 --- a/tests/integration/test_jython_env_create.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest - - -# TODO -@pytest.mark.skip(reason="needs jython and dev cut of virtualenv") -def test_jython_create(initproj, cmd): - initproj( - "py_jython-0.1", - filedefs={ - "tox.ini": """ - [tox] - skipsdist = true - envlist = jython - commands = python -c 'import sys; print(sys.executable)' - """, - }, - ) - result = cmd("--notest", "-vvv") - result.assert_success() diff --git a/tests/integration/test_package_int.py b/tests/integration/test_package_int.py deleted file mode 100644 index 01c59f6d7..000000000 --- a/tests/integration/test_package_int.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Tests that require external access (e.g. pip install, virtualenv creation)""" -import os -import subprocess -import sys - -import pytest - -if sys.version_info[:2] >= (3, 4): - from pathlib import Path -else: - from pathlib2 import Path - -from tests.lib import need_git - - -@pytest.mark.network -def test_package_setuptools(initproj, cmd): - initproj( - "magic-0.1", - filedefs={ - "tox.ini": """\ - [tox] - isolated_build = true - [testenv:.package] - basepython = {} - """.format( - sys.executable, - ), - "pyproject.toml": """\ - [build-system] - requires = ["setuptools >= 35.0.2", "setuptools_scm >= 2.0.0, <3"] - build-backend = "setuptools.build_meta" - """, - }, - ) - run(cmd, "magic-0.1.tar.gz") - - -@pytest.mark.network -@need_git -@pytest.mark.skipif(sys.version_info < (3, 0), reason="flit is Python 3 only") -def test_package_flit(initproj, cmd): - initproj( - "magic-0.1", - filedefs={ - "tox.ini": """\ - [tox] - isolated_build = true - [testenv:.package] - basepython = {} - """.format( - sys.executable, - ), - "pyproject.toml": """\ - [build-system] - requires = ["flit"] - build-backend = "flit.buildapi" - - [tool.flit.metadata] - module = "magic" - author = "Happy Harry" - author-email = "happy@harry.com" - home-page = "https://github.com/happy-harry/is" - requires = [ - "tox", - ] - """, - ".gitignore": ".tox", - }, - add_missing_setup_py=False, - ) - env = os.environ.copy() - env["GIT_COMMITTER_NAME"] = "committer joe" - env["GIT_AUTHOR_NAME"] = "author joe" - env["EMAIL"] = "joe@example.com" - subprocess.check_call(["git", "init"], env=env) - subprocess.check_call(["git", "add", "-A", "."], env=env) - subprocess.check_call(["git", "commit", "-m", "first commit", "--no-gpg-sign"], env=env) - - run(cmd, "magic-0.1.tar.gz") - - -@pytest.mark.network -@pytest.mark.skipif(sys.version_info < (3, 0), reason="poetry is Python 3 only") -def test_package_poetry(initproj, cmd): - initproj( - "magic-0.1", - filedefs={ - "tox.ini": """\ - [tox] - isolated_build = true - [testenv:.package] - basepython = {} - """.format( - sys.executable, - ), - "pyproject.toml": """\ - [build-system] - requires = ["poetry>=0.12"] - build-backend = "poetry.masonry.api" - - [tool.poetry] - name = "magic" - version = "0.1.0" - description = "" - authors = ["Name "] - """, - ".gitignore": ".tox", - }, - add_missing_setup_py=False, - ) - run(cmd, "magic-0.1.0.tar.gz") - - -def run(cmd, package): - result = cmd("--sdistonly", "-e", "py", "-v", "-v") - result.assert_success(is_run_test_env=False) - package_venv = (Path() / ".tox" / ".package").resolve() - assert ".package create: {}".format(package_venv) in result.outlines, result.out - assert "write config to {}".format(package_venv / ".tox-config1") in result.out, result.out - package_path = (Path() / ".tox" / "dist" / package).resolve() - assert package_path.exists() - - package_path.unlink() - - # second call re-uses - result2 = cmd("--sdistonly", "-e", "py", "-v", "-v") - - result2.assert_success(is_run_test_env=False) - assert ( - ".package reusing: {}".format(package_venv) in result2.outlines - ), "Second call output:\n{}First call output:\n{}".format(result2.out, result.out) - assert package_path.exists() diff --git a/tests/integration/test_parallel_inception.py b/tests/integration/test_parallel_inception.py deleted file mode 100644 index 8fd3e0faa..000000000 --- a/tests/integration/test_parallel_inception.py +++ /dev/null @@ -1,52 +0,0 @@ -def test_parallel_inception(initproj, cmd): - initproj( - "inception-1.2.3", - filedefs={ - # the outer config just has one env: graham - "tox.ini": """ - [tox] - envlist = graham - skipsdist = True - - [testenv] - commands = - python runner.py - """, - # the inner config has 3 different envs, 1 of them is graham - "inner": { - "tox.ini": """ - [tox] - envlist = graham,john,terry - skipsdist = True - - [testenv] - commands = - python -c 'pass' - """, - }, - # the outer test runs the inner tox and asserts all 3 envs were run - "runner.py": """ - import os - import subprocess - import sys - - os.chdir("inner") - p = subprocess.Popen(("tox"), stdout=subprocess.PIPE, universal_newlines=True) - stdout, _ = p.communicate() - sys.stdout.write(stdout) - assert "graham" in stdout - assert "john" in stdout - assert "terry" in stdout - """, - }, - add_missing_setup_py=False, - ) - - result = cmd("-p", "all", "-o") - result.assert_success() - - # 1 from the outer, 1 from the inner - assert result.out.count("graham: commands succeeded") == 2 - # those gentlemen are only inside - assert result.out.count("john: commands succeeded") == 1 - assert result.out.count("terry: commands succeeded") == 1 diff --git a/tests/integration/test_parallel_interrupt.py b/tests/integration/test_parallel_interrupt.py deleted file mode 100644 index b89072a54..000000000 --- a/tests/integration/test_parallel_interrupt.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import signal -import subprocess -import sys -from datetime import datetime - -import pytest -from flaky import flaky - -if sys.version_info[:2] >= (3, 4): - from pathlib import Path -else: - from pathlib2 import Path - -from tox.constants import INFO -from tox.util.main import MAIN_FILE - - -@flaky(max_runs=3) -@pytest.mark.skipif(INFO.IS_PYPY, reason="TODO: process numbers work differently on pypy") -@pytest.mark.skipif( - "sys.platform == 'win32'", - reason="triggering SIGINT reliably on Windows is hard", -) -def test_parallel_interrupt(initproj, monkeypatch, capfd): - monkeypatch.setenv(str("_TOX_SKIP_ENV_CREATION_TEST"), str("1")) - monkeypatch.setenv(str("TOX_REPORTER_TIMESTAMP"), str("1")) - start = datetime.now() - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - envlist = a, b - - [testenv] - skip_install = True - commands = python -c "open('{{envname}}', 'w').write('done'); \ - import time; time.sleep(100)" - allowlist_externals = {} - - """.format( - sys.executable, - ), - }, - ) - process = subprocess.Popen( - [sys.executable, MAIN_FILE, "-p", "all"], - creationflags=( - subprocess.CREATE_NEW_PROCESS_GROUP - if sys.platform == "win32" - else 0 - # needed for Windows signal send ability (CTRL+C) - ), - ) - try: - import psutil - - current_process = psutil.Process(process.pid) - except ImportError: - current_process = None - - wait_for_env_startup(process) - - all_children = [] - if current_process is not None: - all_children.append(current_process) - all_children.extend(current_process.children(recursive=True)) - assert len(all_children) >= 1 + 2 + 2, all_children - end = datetime.now() - start - assert end - process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal.SIGINT) - process.wait() - out, err = capfd.readouterr() - output = "{}\n{}".format(out, err) - assert "KeyboardInterrupt parallel - stopping children" in output, output - assert "ERROR: a: parallel child exit code " in output, output - assert "ERROR: b: parallel child exit code " in output, output - for process in all_children: - msg = "{}{}".format(output, "\n".join(repr(i) for i in all_children)) - assert not process.is_running(), msg - - -def wait_for_env_startup(process): - """the environments will write files once they are up""" - signal_files = [Path() / "a", Path() / "b"] - found = False - while True: - if process.poll() is not None: - break - for signal_file in signal_files: - if not signal_file.exists(): - break - else: - found = True - break - if not found or process.poll() is not None: - missing = [f for f in signal_files if not f.exists()] - out, _ = process.communicate() - assert len(missing), out - assert False, out diff --git a/tests/integration/test_path_utils_removal.py b/tests/integration/test_path_utils_removal.py deleted file mode 100644 index 2fc46e7fd..000000000 --- a/tests/integration/test_path_utils_removal.py +++ /dev/null @@ -1,19 +0,0 @@ -import os -from stat import S_IREAD - -from tox.util.path import ensure_empty_dir - - -def test_remove_read_only(tmpdir): - nested_dir = tmpdir / "nested_dir" - nested_dir.mkdir() - - # create read-only file - read_only_file = nested_dir / "tmpfile.txt" - with open(str(read_only_file), "w"): - pass - os.chmod(str(read_only_file), S_IREAD) - - ensure_empty_dir(nested_dir) - - assert not os.listdir(str(nested_dir)) diff --git a/tests/integration/test_provision_int.py b/tests/integration/test_provision_int.py deleted file mode 100644 index f1763e494..000000000 --- a/tests/integration/test_provision_int.py +++ /dev/null @@ -1,167 +0,0 @@ -import signal -import subprocess -import sys -import time - -import pytest - -if sys.version_info[:2] >= (3, 4): - from pathlib import Path -else: - from pathlib2 import Path - -from tox.constants import INFO -from tox.util.main import MAIN_FILE - - -@pytest.mark.skipif( - "sys.platform == 'win32' and sys.version_info < (3,)", - reason="does not run on windows with py2", -) -def test_provision_missing(initproj, cmd): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """\ - [tox] - skipsdist=True - minversion = 3.7.0 - requires = - setuptools == 40.6.3 - [testenv] - commands=python -c "import sys; print(sys.executable); raise SystemExit(1)" - """, - }, - ) - result = cmd("-e", "py") - result.assert_fail() - assert "tox.exception.InvocationError" not in result.output() - assert not result.err - assert ".tox create: " in result.out - assert ".tox installdeps: " in result.out - assert "py create: " in result.out - - at = next(at for at, l in enumerate(result.outlines) if l.startswith("py run-test: ")) + 1 - meta_python = Path(result.outlines[at]) - assert meta_python.exists() - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="pyenv does not exists on Windows") -def test_provision_from_pyvenv(initproj, cmd, monkeypatch): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """\ - [tox] - skipsdist=True - minversion = 3.7.0 - requires = - setuptools == 40.6.3 - [testenv] - commands=python -c "import sys; print(sys.executable); raise SystemExit(1)" - """, - }, - ) - monkeypatch.setenv(str("__PYVENV_LAUNCHER__"), sys.executable) - result = cmd("-e", "py", "-vv") - result.assert_fail() - assert ".tox/.tox/bin/python -m virtualenv" in result.out - - -@pytest.mark.skipif(INFO.IS_PYPY, reason="TODO: process numbers work differently on pypy") -@pytest.mark.skipif( - "sys.platform == 'win32'", - reason="triggering SIGINT reliably on Windows is hard", -) -@pytest.mark.parametrize("signal_type", [signal.SIGINT, signal.SIGTERM]) -def test_provision_interrupt_child(initproj, monkeypatch, capfd, signal_type): - monkeypatch.delenv(str("PYTHONPATH"), raising=False) - monkeypatch.setenv(str("TOX_REPORTER_TIMESTAMP"), str("1")) - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - skipsdist=True - minversion = 3.7.0 - requires = setuptools == 40.6.3 - tox == 3.7.0 - [testenv:b] - commands=python -c "import time; open('a', 'w').write('content'); \ - time.sleep(10)" - basepython = python - """, - }, - ) - cmd = [sys.executable, MAIN_FILE, "-v", "-v", "-e", "b"] - process = subprocess.Popen( - cmd, - creationflags=( - subprocess.CREATE_NEW_PROCESS_GROUP - if sys.platform == "win32" - else 0 - # needed for Windows signal send ability (CTRL+C) - ), - ) - try: - import psutil - - current_process = psutil.Process(process.pid) - except ImportError: - current_process = None - - signal_file = Path() / "a" - while not signal_file.exists() and process.poll() is None: - time.sleep(0.1) - if process.poll() is not None: - out, err = process.communicate() - assert False, out - - all_process = [] - if current_process is not None: - all_process.append(current_process) - all_process.extend(current_process.children(recursive=False)) - # 1 process for the host tox, 1 for the provisioned - assert len(all_process) >= 2, all_process - - process.send_signal(signal.CTRL_C_EVENT if sys.platform == "win32" else signal_type) - process.communicate() - out, err = capfd.readouterr() - assert ".tox KeyboardInterrupt: from" in out, out - - for process in all_process: - assert not process.is_running(), "{}{}".format( - out, - "\n".join(repr(i) for i in all_process), - ) - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="pyenv does not exists on Windows") -def test_provision_race(initproj, cmd, monkeypatch): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """\ - [tox] - skipsdist=True - minversion = 3.7.0 - requires = - setuptools == 40.6.3 - [testenv] - commands=python -c "import sys; print(sys.executable); raise SystemExit(1)" - [testenv:x2] - """, - }, - ) - - procs = [ - subprocess.Popen( - [sys.executable, "-m", "tox", "-e", "py", "-vv"], - stdout=subprocess.PIPE, - ) - for _ in range(2) - ] - for proc in procs: - stdout, stderr = proc.communicate() - assert proc.returncode != 0 - assert b".tox/.tox/bin/python -m virtualenv" in stdout diff --git a/tests/journal/__init__.py b/tests/journal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/journal/test_main_journal.py b/tests/journal/test_main_journal.py new file mode 100644 index 000000000..d8a80b130 --- /dev/null +++ b/tests/journal/test_main_journal.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import socket +import sys +from typing import Any + +import pytest + +from tox import __version__ +from tox.journal.main import Journal + + +@pytest.fixture() +def base_info() -> dict[str, Any]: + return { + "reportversion": "1", + "toxversion": __version__, + "platform": sys.platform, + "host": socket.getfqdn(), + } + + +def test_journal_enabled_default(base_info: dict[str, Any]) -> None: + journal = Journal(enabled=True) + assert bool(journal) is True + assert journal.content == base_info + + +def test_journal_disabled_default() -> None: + journal = Journal(enabled=False) + assert bool(journal) is False + assert journal.content == {} + + +def test_env_journal_enabled(base_info: dict[str, Any]) -> None: + journal = Journal(enabled=True) + env = journal.get_env_journal("a") + assert journal.get_env_journal("a") is env + env["demo"] = 1 + + assert bool(env) is True + base_info["testenvs"] = {"a": {"demo": 1}} + assert journal.content == base_info + + +def test_env_journal_disabled() -> None: + journal = Journal(enabled=False) + env = journal.get_env_journal("a") + assert bool(env) is False + + env["demo"] = 2 + assert journal.content == {"testenvs": {"a": {"demo": 2}}} diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py deleted file mode 100644 index 0f711b20e..000000000 --- a/tests/lib/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -import subprocess - -import pytest - - -def need_executable(name, check_cmd): - def wrapper(fn): - try: - subprocess.check_output(check_cmd) - except OSError: - return pytest.mark.skip(reason="{} is not available".format(name))(fn) - return fn - - return wrapper - - -def need_git(fn): - return pytest.mark.git(need_executable("git", ("git", "--version"))(fn)) diff --git a/tests/plugin/conftest.py b/tests/plugin/conftest.py new file mode 100644 index 000000000..e9aeac36c --- /dev/null +++ b/tests/plugin/conftest.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Sequence + +HERE = Path(__file__).parent + + +def pytest_collection_modifyitems(items: Sequence[Any]) -> None: + """automatically apply plugin test to all the test in this suite""" + root = str(HERE) + for item in items: + if item.module.__file__.startswith(root): + item.add_marker("plugin_test") diff --git a/tests/plugin/test_inline.py b/tests/plugin/test_inline.py new file mode 100644 index 000000000..3ea04cc9b --- /dev/null +++ b/tests/plugin/test_inline.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from tox.pytest import ToxProjectCreator + + +def test_inline_tox_py(tox_project: ToxProjectCreator) -> None: + def plugin() -> None: # pragma: no cover # the code is copied to a python file + import logging + + from tox.config.cli.parser import ToxParser + from tox.plugin import impl + + @impl + def tox_add_option(parser: ToxParser) -> None: + logging.warning("Add magic") + parser.add_argument("--magic", action="https://github.com/store_true") + + project = tox_project({"toxfile.py": plugin}) + result = project.run("-h") + result.assert_success() + assert "--magic" in result.out diff --git a/tests/plugin/test_plugin.py b/tests/plugin/test_plugin.py new file mode 100644 index 000000000..bdef1f9ce --- /dev/null +++ b/tests/plugin/test_plugin.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import logging +import os +import sys +from unittest.mock import patch + +import pytest +from pytest_mock import MockerFixture + +from tox.config.cli.parser import ToxParser +from tox.config.loader.memory import MemoryLoader +from tox.config.sets import ConfigSet, CoreConfigSet, EnvConfigSet +from tox.execute import Outcome +from tox.plugin import impl +from tox.pytest import ToxProjectCreator, register_inline_plugin +from tox.session.state import State +from tox.tox_env.api import ToxEnv +from tox.tox_env.register import ToxEnvRegister + + +def test_plugin_hooks_and_order(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + @impl + def tox_register_tox_env(register: ToxEnvRegister) -> None: + assert isinstance(register, ToxEnvRegister) + logging.warning("tox_register_tox_env") + + @impl + def tox_add_option(parser: ToxParser) -> None: + assert isinstance(parser, ToxParser) + logging.warning("tox_add_option") + + @impl + def tox_add_core_config(core_conf: CoreConfigSet, state: State) -> None: + assert isinstance(core_conf, CoreConfigSet) + assert isinstance(state, State) + logging.warning("tox_add_core_config") + + @impl + def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: + assert isinstance(env_conf, EnvConfigSet) + assert isinstance(state, State) + logging.warning("tox_add_env_config") + + @impl + def tox_before_run_commands(tox_env: ToxEnv) -> None: + assert isinstance(tox_env, ToxEnv) + logging.warning("tox_before_run_commands") + + @impl + def tox_after_run_commands(tox_env: ToxEnv, exit_code: int, outcomes: list[Outcome]) -> None: + assert isinstance(tox_env, ToxEnv) + assert exit_code == 0 + assert isinstance(outcomes, list) + assert all(isinstance(i, Outcome) for i in outcomes) + logging.warning("tox_after_run_commands") + + plugins = tuple(v for v in locals().values() if callable(v) and hasattr(v, "tox_impl")) + assert len(plugins) == 6 + register_inline_plugin(mocker, *plugins) + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(1)'"}) + result = project.run("r", "-e", "a,b") + result.assert_success() + cmd = "print(1)" if sys.platform == "win32" else "'print(1)'" + expected = [ + "ROOT: tox_register_tox_env", + "ROOT: tox_add_option", + "ROOT: tox_add_core_config", + "a: tox_add_env_config", + "b: tox_add_env_config", + "a: tox_before_run_commands", + f"a: commands[0]> python -c {cmd}", + mocker.ANY, # output a + "a: tox_after_run_commands", + mocker.ANY, # report finished A + "b: tox_before_run_commands", + f"b: commands[0]> python -c {cmd}", + mocker.ANY, # output b + "b: tox_after_run_commands", + mocker.ANY, # report a + mocker.ANY, # report b + mocker.ANY, # overall report + ] + assert result.out.splitlines() == expected, result.out + + +def test_plugin_can_read_env_list(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + @impl + def tox_add_core_config(core_conf: CoreConfigSet, state: State) -> None: # noqa: U100 + logging.warning("All envs: %s", ", ".join(state.envs.iter(only_active=False))) + logging.warning("Default envs: %s", ", ".join(state.envs.iter(only_active=True))) + + register_inline_plugin(mocker, tox_add_core_config) + ini = """ + [tox] + env_list = explicit + [testenv] + package = skip + set_env = + implicit: A=1 + [testenv:section] + """ + project = tox_project({"tox.ini": ini}) + result = project.run() + assert "ROOT: All envs: explicit, section, implicit" in result.out + assert "ROOT: Default envs: explicit" in result.out + + +def test_plugin_can_read_sections(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + @impl + def tox_add_core_config(core_conf: CoreConfigSet, state: State) -> None: # noqa: U100 + logging.warning("Sections: %s", ", ".join(i.key for i in state.conf.sections())) + + register_inline_plugin(mocker, tox_add_core_config) + ini = """ + [tox] + [testenv] + package = skip + [testenv:section] + [other:section] + """ + project = tox_project({"tox.ini": ini}) + result = project.run() + result.assert_success() + assert "ROOT: Sections: tox, testenv, testenv:section, other:section" in result.out + + +def test_plugin_injects_invalid_python_run(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + @impl + def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: # noqa: U100 + env_conf.loaders.insert(0, MemoryLoader(deps=[1])) + with pytest.raises(TypeError, match="1"): + assert env_conf["deps"] + + register_inline_plugin(mocker, tox_add_env_config) + project = tox_project({"tox.ini": "[testenv]\npackage=skip"}) + result = project.run() + result.assert_failed() + assert "raise TypeError(raw)" in result.out + + +def test_plugin_extend_pass_env(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + @impl + def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: # noqa: U100 + env_conf["pass_env"].append("MAGIC_*") + + register_inline_plugin(mocker, tox_add_env_config) + ini = """ + [testenv] + package=skip + commands=python -c 'import os; print(os.environ["MAGIC_1"]); print(os.environ["MAGIC_2"])' + """ + project = tox_project({"tox.ini": ini}) + with patch.dict(os.environ, {"MAGIC_1": "magic_1", "MAGIC_2": "magic_2"}): + result = project.run("r") + result.assert_success() + assert "magic_1" in result.out + assert "magic_2" in result.out + + result_conf = project.run("c", "-e", "py", "-k", "pass_env") + result_conf.assert_success() + assert "MAGIC_*" in result_conf.out + + +def test_plugin_extend_set_env(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + @impl + def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: # noqa: U100 + env_conf["set_env"].update({"MAGI_CAL": "magi_cal"}) + + register_inline_plugin(mocker, tox_add_env_config) + ini = """ + [testenv] + package=skip + commands=python -c 'import os; print(os.environ["MAGI_CAL"])' + """ + project = tox_project({"tox.ini": ini}) + result = project.run("r") + result.assert_success() + assert "magi_cal" in result.out + + result_conf = project.run("c", "-e", "py", "-k", "set_env") + result_conf.assert_success() + assert "MAGI_CAL=magi_cal" in result_conf.out + + +def test_plugin_config_frozen_past_add_env(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + def _cannot_extend_config(config_set: ConfigSet) -> None: + for _conf in ( + lambda c: c.add_constant("c", "desc", "v"), + lambda c: c.add_config("c", of_type=str, default="c", desc="d"), + ): + try: + _conf(config_set) # type: ignore # call to not typed function + raise NotImplementedError + except RuntimeError as exc: + assert str(exc) == "config set has been marked final and cannot be extended" + + @impl + def tox_before_run_commands(tox_env: ToxEnv) -> None: + _cannot_extend_config(tox_env.conf) + _cannot_extend_config(tox_env.core) + + @impl + def tox_after_run_commands(tox_env: ToxEnv, exit_code: int, outcomes: list[Outcome]) -> None: # noqa: U100 + _cannot_extend_config(tox_env.conf) + _cannot_extend_config(tox_env.core) + + register_inline_plugin(mocker, tox_before_run_commands, tox_after_run_commands) + + project = tox_project({"tox.ini": "[testenv]\npackage=skip"}) + result = project.run("r") + result.assert_success() diff --git a/tests/plugin/test_plugin_custom_config_set.py b/tests/plugin/test_plugin_custom_config_set.py new file mode 100644 index 000000000..dc197469a --- /dev/null +++ b/tests/plugin/test_plugin_custom_config_set.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import logging +from functools import partial +from typing import Optional + +import pytest +from pytest_mock import MockerFixture + +from tox.config.loader.section import Section +from tox.config.sets import ConfigSet, EnvConfigSet +from tox.plugin import impl +from tox.pytest import ToxProjectCreator, register_inline_plugin +from tox.session.state import State +from tox.tox_env.api import ToxEnv + + +@pytest.fixture(autouse=True) +def _custom_config_set(mocker: MockerFixture) -> None: + class DockerConfigSet(ConfigSet): + def register_config(self) -> None: + self.add_config(keys="A", of_type=int, default=0, desc="a config") + + @impl + def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: + def factory(for_env: str, raw: object) -> DockerConfigSet: + assert isinstance(raw, str) + section = Section("docker", raw) + conf_set = state.conf.get_section_config(section, base=["docker"], of_type=DockerConfigSet, for_env=for_env) + return conf_set + + env_conf.add_config( + "docker", + of_type=Optional[DockerConfigSet], # type: ignore[arg-type] # mypy fails to understand the type info + default=None, + desc="docker env", + factory=partial(factory, env_conf.name), + ) + + @impl + def tox_before_run_commands(tox_env: ToxEnv) -> None: + docker: DockerConfigSet | None = tox_env.conf["docker"] + assert docker is not None + logging.warning("Name=%s env=%s A=%d", docker.name, docker.env_name, docker["A"]) + + register_inline_plugin(mocker, tox_add_env_config, tox_before_run_commands) + + +def test_define_custom_config_set(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ndocker=a\n[docker:a]\nA=2"}) + result = project.run() + result.assert_success() + assert "py: Name=a env=py A=2" in result.out + + +def test_define_custom_config_base(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ndocker=a\n[docker]\nA=2"}) + result = project.run() + result.assert_success() + assert "py: Name=a env=py A=2" in result.out + + +def test_define_custom_config_override_base(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ndocker=a\n[M]\nA=2\n[docker:a]\nbase=M"}) + result = project.run() + result.assert_success() + assert "py: Name=a env=py A=2" in result.out + + +def test_define_custom_config_override_base_implicit(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ndocker=a\n[docker:M]\nA=2\n[docker:a]\nbase=M"}) + result = project.run() + result.assert_success() + assert "py: Name=a env=py A=2" in result.out + + +def test_define_custom_config_replace(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=skip\ndocker=a\n[docker]\nA={[docker]B}\nB=2"}) + result = project.run() + result.assert_success() + assert "py: Name=a env=py A=2" in result.out + + +def test_define_custom_config_factor_filter(tox_project: ToxProjectCreator) -> None: + ini = """ + [tox] + env_list = + a + b + [testenv] + package = skip + docker = db + [docker:db] + A = + a: 1 + b: 2""" + project = tox_project({"tox.ini": ini}) + result = project.run("r", "-e", "a,b") + result.assert_success() + assert "a: Name=db env=a A=1" in result.out + assert "b: Name=db env=b A=2" in result.out diff --git a/tests/pytest_/__init__.py b/tests/pytest_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/pytest_/test_init.py b/tests/pytest_/test_init.py new file mode 100644 index 000000000..1bb4eed49 --- /dev/null +++ b/tests/pytest_/test_init.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import os +import sys +from itertools import chain, combinations +from pathlib import Path +from textwrap import dedent +from typing import Sequence + +import pytest +from pytest_mock import MockerFixture + +from tox.pytest import MonkeyPatch, ToxProjectCreator, check_os_environ +from tox.report import HandledError + + +def test_tox_project_no_base(tox_project: ToxProjectCreator) -> None: + project = tox_project( + { + "tox.ini": "[tox]", + "src": {"__init__.py": "pass", "a": "out", "b": {"c": "out"}, "e": {"f": ""}}, + }, + ) + assert str(project.path) in repr(project) + assert project.path.exists() + assert project.structure == { + "tox.ini": "[tox]", + "src": {"__init__.py": "pass", "a": "out", "e": {"f": ""}, "b": {"c": "out"}}, + } + + +def test_tox_project_base(tmp_path: Path, tox_project: ToxProjectCreator) -> None: + base = tmp_path / "base" + base.mkdir() + (base / "out").write_text("a") + project = tox_project({"tox.ini": "[tox]"}, base=base) + assert project.structure + + +COMB = list(chain.from_iterable(combinations(["DIFF", "MISS", "EXTRA"], i) for i in range(4))) + + +@pytest.mark.parametrize("ops", COMB, ids=["-".join(i) for i in COMB]) +def test_env_var(monkeypatch: MonkeyPatch, ops: list[str]) -> None: + with monkeypatch.context() as m: + if "DIFF" in ops: + m.setenv("DIFF", "B") + if "MISS" in ops: + m.setenv("MISS", "1") + m.setenv("NO_CHANGE", "yes") + m.setenv("PYTHONPATH", "yes") # values to clean before run + + with check_os_environ(): + assert "PYTHONPATH" not in os.environ + if "EXTRA" in ops: + m.setenv("EXTRA", "A") + if "DIFF" in ops: + m.setenv("DIFF", "D") + if "MISS" in ops: + m.delenv("MISS") + + from tox.pytest import pytest as tox_pytest # type: ignore[attr-defined] + + exp = "test changed environ" + if "EXTRA" in ops: + exp += " extra {'EXTRA': 'A'}" + if "MISS" in ops: + exp += " miss {'MISS': '1'}" + if "DIFF" in ops: + exp += " diff {'DIFF = B vs D'}" + + def fail(msg: str) -> None: + assert msg == exp + + m.setattr(tox_pytest, "fail", fail) + assert "PYTHONPATH" in os.environ + + +def test_tox_run_does_not_return_exit_code(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + project = tox_project({"tox.ini": ""}) + mocker.patch("tox.run.main", return_value=None) + with pytest.raises(RuntimeError, match="exit code not set"): + project.run("c") + + +def test_tox_run_fails_before_state_setup(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + project = tox_project({"tox.ini": ""}) + mocker.patch("tox.run.main", side_effect=HandledError("something went bad")) + outcome = project.run("c") + with pytest.raises(RuntimeError, match="no state"): + assert outcome.state + + +def test_tox_run_outcome_repr(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": ""}) + outcome = project.run("l") + msg = dedent( + f""" + code: 0 + cmd: {sys.executable} -m tox l + cwd: {project.path} + standard output + default environments: + py -> [no description] + """, + ).lstrip() + assert repr(outcome) == msg + assert outcome.shell_cmd == f"{sys.executable} -m tox l" + + +def test_tox_run_assert_out_err_no_dedent(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + project = tox_project({"tox.ini": ""}) + + def _main(args: Sequence[str]) -> int: # noqa: U100 + print(" goes on out", file=sys.stdout) + print(" goes on err", file=sys.stderr) + return 0 + + mocker.patch("tox.run.main", side_effect=_main) + outcome = project.run("c") + outcome.assert_out_err(" goes on out\n", " goes on err\n", dedent=False) diff --git a/tests/session/__init__.py b/tests/session/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/session/cmd/__init__.py b/tests/session/cmd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/session/cmd/run/__init__.py b/tests/session/cmd/run/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/session/cmd/run/test_common.py b/tests/session/cmd/run/test_common.py new file mode 100644 index 000000000..5f8cf476f --- /dev/null +++ b/tests/session/cmd/run/test_common.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import re +from argparse import ArgumentError, ArgumentParser, Namespace +from pathlib import Path + +import pytest + +from tox.session.cmd.run.common import InstallPackageAction, SkipMissingInterpreterAction + + +@pytest.mark.parametrize("values", ["config", None, "true", "false"]) +def test_skip_missing_interpreter_action_ok(values: str | None) -> None: + args_namespace = Namespace() + SkipMissingInterpreterAction(option_strings=["-i"], dest="into")(ArgumentParser(), args_namespace, values) + expected = "true" if values is None else values + assert args_namespace.into == expected + + +def test_skip_missing_interpreter_action_nok() -> None: + argument_parser = ArgumentParser() + with pytest.raises(ArgumentError, match=r"value must be 'config', 'true', or 'false' \(got 'bad value'\)"): + SkipMissingInterpreterAction(option_strings=["-i"], dest="into")(argument_parser, Namespace(), "bad value") + + +def test_install_pkg_ok(tmp_path: Path) -> None: + argument_parser = ArgumentParser() + path = tmp_path / "a" + path.write_text("") + namespace = Namespace() + InstallPackageAction(option_strings=["--install-pkg"], dest="into")(argument_parser, namespace, str(path)) + assert namespace.into == path + + +def test_install_pkg_does_not_exist(tmp_path: Path) -> None: + argument_parser = ArgumentParser() + path = str(tmp_path / "a") + with pytest.raises(ArgumentError, match=re.escape(f"argument --install-pkg: {path} does not exist")): + InstallPackageAction(option_strings=["--install-pkg"], dest="into")(argument_parser, Namespace(), path) + + +def test_install_pkg_not_file(tmp_path: Path) -> None: + argument_parser = ArgumentParser() + path = str(tmp_path) + with pytest.raises(ArgumentError, match=re.escape(f"argument --install-pkg: {path} is not a file")): + InstallPackageAction(option_strings=["--install-pkg"], dest="into")(argument_parser, Namespace(), path) + + +def test_install_pkg_empty() -> None: + argument_parser = ArgumentParser() + with pytest.raises(ArgumentError, match=re.escape("argument --install-pkg: cannot be empty")): + InstallPackageAction(option_strings=["--install-pkg"], dest="into")(argument_parser, Namespace(), "") diff --git a/tests/session/cmd/test_depends.py b/tests/session/cmd/test_depends.py new file mode 100644 index 000000000..6b27d3bc9 --- /dev/null +++ b/tests/session/cmd/test_depends.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import sys +from textwrap import dedent +from typing import Callable + +from tox.pytest import ToxProjectCreator + + +def test_depends(tox_project: ToxProjectCreator, patch_prev_py: Callable[[bool], tuple[str, str]]) -> None: + prev_ver, impl = patch_prev_py(True) # has previous python + ver = sys.version_info[0:2] + py = f"py{''.join(str(i) for i in ver)}" + prev_py = f"py{prev_ver}" + ini = f""" + [tox] + env_list = py,{py},{prev_py},py31,cov2,cov + [testenv] + package = wheel + [testenv:cov] + depends = py,{py},{prev_py},py31 + skip_install = true + [testenv:cov2] + depends = cov + skip_install = true + """ + project = tox_project({"tox.ini": ini, "pyproject.toml": ""}) + outcome = project.run("de") + outcome.assert_success() + + expected = f""" + Execution order: py, {py}, {prev_py}, py31, cov, cov2 + ALL + py ~ .pkg + {py} ~ .pkg + {prev_py} ~ .pkg | .pkg-{impl}{prev_ver} + py31 ~ .pkg | ... (could not resolve base python with py31) + cov2 + cov + py ~ .pkg + {py} ~ .pkg + {prev_py} ~ .pkg | .pkg-{impl}{prev_ver} + py31 ~ .pkg | ... (could not resolve base python with py31) + cov + py ~ .pkg + {py} ~ .pkg + {prev_py} ~ .pkg | .pkg-{impl}{prev_ver} + py31 ~ .pkg | ... (could not resolve base python with py31) + """ + assert outcome.out == dedent(expected).lstrip() + + +def test_depends_help(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("de", "-h") + outcome.assert_success() diff --git a/tests/session/cmd/test_devenv.py b/tests/session/cmd/test_devenv.py new file mode 100644 index 000000000..aba3e261a --- /dev/null +++ b/tests/session/cmd/test_devenv.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import pytest + +from tox.pytest import ToxProjectCreator + + +def test_devenv_fail_multiple_target(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("d", "-e", "py39,py38") + outcome.assert_failed() + msg = "ROOT: HandledError| exactly one target environment allowed in devenv mode but found py39, py38\n" + outcome.assert_out_err(msg, "") + + +@pytest.mark.integration() +def test_devenv_ok(tox_project: ToxProjectCreator, enable_pip_pypi_access: str | None) -> None: # noqa: U100 + content = {"setup.py": "from setuptools import setup\nsetup(name='demo', version='1.0')"} + project = tox_project(content) + outcome = project.run("d", "-e", "py") + + outcome.assert_success() + assert (project.path / "venv").exists() + assert f"created development environment under {project.path / 'venv'}" in outcome.out + + +def test_devenv_help(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("d", "-h") + outcome.assert_success() diff --git a/tests/session/cmd/test_exec_.py b/tests/session/cmd/test_exec_.py new file mode 100644 index 000000000..1011ab61c --- /dev/null +++ b/tests/session/cmd/test_exec_.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import sys + +import pytest + +from tox.pytest import ToxProjectCreator + + +@pytest.mark.parametrize("trail", [[], ["--"]], ids=["no_posargs", "empty_posargs"]) +def test_exec_fail_no_posargs(tox_project: ToxProjectCreator, trail: list[str]) -> None: + outcome = tox_project({"tox.ini": ""}).run("e", "-e", "py39", *trail) + outcome.assert_failed() + msg = "ROOT: HandledError| You must specify a command as positional arguments, use -- \n" + outcome.assert_out_err(msg, "") + + +def test_exec_fail_multiple_target(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("e", "-e", "py39,py38", "--", "py") + outcome.assert_failed() + msg = "ROOT: HandledError| exactly one target environment allowed in exec mode but found py39, py38\n" + outcome.assert_out_err(msg, "") + + +@pytest.mark.parametrize("exit_code", [1, 0]) +def test_exec(tox_project: ToxProjectCreator, exit_code: int) -> None: + prj = tox_project({"tox.ini": "[testenv]\npackage=skip"}) + py_cmd = f"import sys; print(sys.version); raise SystemExit({exit_code})" + outcome = prj.run("e", "-e", "py", "--", "python", "-c", py_cmd) + if exit_code: + outcome.assert_failed() + else: + outcome.assert_success() + assert sys.version in outcome.out + + +def test_exec_help(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("e", "-h") + outcome.assert_success() diff --git a/tests/session/cmd/test_legacy.py b/tests/session/cmd/test_legacy.py new file mode 100644 index 000000000..c34518903 --- /dev/null +++ b/tests/session/cmd/test_legacy.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from pytest_mock import MockerFixture + +from tox.pytest import ToxProjectCreator + + +def test_legacy_show_config(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + show_config = mocker.patch("tox.session.cmd.legacy.show_config") + + outcome = tox_project({"tox.ini": ""}).run("le", "--showconfig") + + assert show_config.call_count == 1 + assert outcome.state.conf.options.list_keys_only == [] + assert outcome.state.conf.options.show_core is True + + +def test_legacy_show_config_with_env(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + show_config = mocker.patch("tox.session.cmd.legacy.show_config") + + outcome = tox_project({"tox.ini": ""}).run("le", "--showconfig", "-e", "py") + + assert show_config.call_count == 1 + assert outcome.state.conf.options.list_keys_only == [] + assert outcome.state.conf.options.show_core is False + + +@pytest.mark.parametrize("verbose", range(3)) +def test_legacy_list_default(tox_project: ToxProjectCreator, mocker: MockerFixture, verbose: int) -> None: + list_env = mocker.patch("tox.session.cmd.legacy.list_env") + + outcome = tox_project({"tox.ini": ""}).run("le", "-l", *(["-v"] * verbose)) + + assert list_env.call_count == 1 + assert outcome.state.conf.options.list_no_description is (verbose < 1) + assert outcome.state.conf.options.list_default_only is True + assert outcome.state.conf.options.show_core is False + + +@pytest.mark.parametrize( + "configuration", + [ + pytest.param("", id="missing toxenv section"), + pytest.param("[toxenv]", id="missing envlist"), + pytest.param("[toxenv]\nenv_list=", id="empty envlist"), + ], +) +def test_legacy_list_env_with_empty_or_missing_env_list(tox_project: ToxProjectCreator, configuration: str) -> None: + """we want to stay backwards compatible with tox 3 and show no output""" + outcome = tox_project({"tox.ini": configuration}).run("le", "-l") + + outcome.assert_success() + outcome.assert_out_err("", "") + + +def test_legacy_list_env_with_no_tox_file(tox_project: ToxProjectCreator) -> None: + project = tox_project({}) + outcome = project.run("le", "-l") + + outcome.assert_success() + out = f"ROOT: No tox.ini or setup.cfg or pyproject.toml found, assuming empty tox.ini at {project.path}\n" + outcome.assert_out_err(out, "") + + +@pytest.mark.parametrize("verbose", range(3)) +def test_legacy_list_all(tox_project: ToxProjectCreator, mocker: MockerFixture, verbose: int) -> None: + list_env = mocker.patch("tox.session.cmd.legacy.list_env") + + outcome = tox_project({"tox.ini": ""}).run("le", "-a", *(["-v"] * verbose)) + + assert list_env.call_count == 1 + assert outcome.state.conf.options.list_no_description is (verbose < 1) + assert outcome.state.conf.options.list_default_only is False + assert outcome.state.conf.options.show_core is False + + +def test_legacy_devenv(tox_project: ToxProjectCreator, mocker: MockerFixture, tmp_path: Path) -> None: + devenv = mocker.patch("tox.session.cmd.legacy.devenv") + into = tmp_path / "b" + + outcome = tox_project({"tox.ini": ""}).run("le", "--devenv", str(into), "-e", "py") + + assert devenv.call_count == 1 + assert outcome.state.conf.options.devenv_path == into + + +def test_legacy_run_parallel(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + run_parallel = mocker.patch("tox.session.cmd.legacy.run_parallel") + + tox_project({"tox.ini": ""}).run("le", "-p", "all", "-e", "py") + + assert run_parallel.call_count == 1 + + +def test_legacy_run_sequential(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + run_sequential = mocker.patch("tox.session.cmd.legacy.run_sequential") + + tox_project({"tox.ini": ""}).run("le", "-e", "py") + + assert run_sequential.call_count == 1 + + +def test_legacy_help(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("le", "-h") + outcome.assert_success() diff --git a/tests/session/cmd/test_list_envs.py b/tests/session/cmd/test_list_envs.py new file mode 100644 index 000000000..3d9b693a2 --- /dev/null +++ b/tests/session/cmd/test_list_envs.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import pytest + +from tox.pytest import ToxProject, ToxProjectCreator + + +@pytest.fixture() +def project(tox_project: ToxProjectCreator) -> ToxProject: + ini = """ + [tox] + env_list=py32,py31,py + [testenv] + package = wheel + wheel_build_env = pkg + description = with {basepython} + deps = pypy: + [testenv:py] + basepython=py32,py31 + [testenv:fix] + description = fix it + [testenv:pkg] + """ + return tox_project({"tox.ini": ini}) + + +def test_list_env(project: ToxProject) -> None: + outcome = project.run("l") + + outcome.assert_success() + expected = """ + default environments: + py32 -> with py32 + py31 -> with py31 + py -> with py32 py31 + + additional environments: + fix -> fix it + """ + outcome.assert_out_err(expected, "") + + +def test_list_env_default(project: ToxProject) -> None: + outcome = project.run("l", "-d") + + outcome.assert_success() + expected = """ + default environments: + py32 -> with py32 + py31 -> with py31 + py -> with py32 py31 + """ + outcome.assert_out_err(expected, "") + + +def test_list_env_quiet(project: ToxProject) -> None: + outcome = project.run("l", "--no-desc") + + outcome.assert_success() + expected = """ + py32 + py31 + py + fix + """ + outcome.assert_out_err(expected, "") + + +def test_list_env_quiet_default(project: ToxProject) -> None: + outcome = project.run("l", "--no-desc", "-d") + + outcome.assert_success() + expected = """ + py32 + py31 + py + """ + outcome.assert_out_err(expected, "") + + +def test_list_env_package_env_before_run(tox_project: ToxProjectCreator) -> None: + ini = """ + [testenv:pkg] + [testenv:run] + package = wheel + wheel_build_env = pkg + """ + project = tox_project({"tox.ini": ini}) + outcome = project.run("l") + + outcome.assert_success() + expected = """ + default environments: + py -> [no description] + + additional environments: + run -> [no description] + """ + outcome.assert_out_err(expected, "") + + +def test_list_env_package_self(tox_project: ToxProjectCreator) -> None: + ini = """ + [tox] + env_list = pkg + [testenv:pkg] + package = wheel + wheel_build_env = pkg + """ + project = tox_project({"tox.ini": ini}) + outcome = project.run("l") + + outcome.assert_failed() + assert outcome.out.splitlines() == ["ROOT: HandledError| pkg cannot self-package"] + + +def test_list_envs_help(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("l", "-h") + outcome.assert_success() diff --git a/tests/session/cmd/test_parallel.py b/tests/session/cmd/test_parallel.py new file mode 100644 index 000000000..5e57ec433 --- /dev/null +++ b/tests/session/cmd/test_parallel.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import sys +from argparse import ArgumentTypeError +from pathlib import Path +from signal import SIGINT +from subprocess import PIPE, Popen +from time import sleep + +import pytest +from pytest_mock import MockerFixture + +from tox.pytest import MonkeyPatch, ToxProjectCreator +from tox.session.cmd.run.parallel import parse_num_processes +from tox.tox_env.api import ToxEnv +from tox.tox_env.errors import Fail + + +def test_parse_num_processes_all() -> None: + assert parse_num_processes("all") is None + + +def test_parse_num_processes_auto() -> None: + auto = parse_num_processes("auto") + assert isinstance(auto, int) + assert auto > 0 + + +def test_parse_num_processes_exact() -> None: + assert parse_num_processes("3") == 3 + + +def test_parse_num_processes_not_number() -> None: + with pytest.raises(ArgumentTypeError, match="value must be a positive number"): + parse_num_processes("3df") + + +def test_parse_num_processes_minus_one() -> None: + with pytest.raises(ArgumentTypeError, match="value must be positive"): + parse_num_processes("-1") + + +def test_parallel_general(tox_project: ToxProjectCreator, monkeypatch: MonkeyPatch, mocker: MockerFixture) -> None: + def setup(self: ToxEnv) -> None: + if self.name == "f": + raise Fail("something bad happened") + return prev_setup(self) + + prev_setup = ToxEnv._setup_env + mocker.patch.object(ToxEnv, "_setup_env", autospec=True, side_effect=setup) + monkeypatch.setenv("PATH", "") + + ini = """ + [tox] + no_package=true + skip_missing_interpreters = true + env_list= a, b, c, d, e, f + [testenv] + commands=python -c 'print("run {env_name}")' + depends = !c: c + parallel_show_output = c: true + [testenv:d] + base_python = missing_skip + [testenv:e] + commands=python -c 'import sys; print("run {env_name}"); sys.exit(1)' + """ + project = tox_project({"tox.ini": ini}) + outcome = project.run("p", "-p", "all") + outcome.assert_failed() + + out = outcome.out + oks, skips, fails = {"a", "b", "c"}, {"d"}, {"e", "f"} + missing = set() + for env in "a", "b", "c", "d", "e", "f": + if env in ("c", "e"): + assert "run c" in out, out + elif env == "f": + assert "f: failed with something bad happened" in out, out + else: + assert f"run {env}" not in out, out + of_type = "OK" if env in oks else ("SKIP" if env in skips else "FAIL") + of_type_icon = "✔" if env in oks else ("⚠" if env in skips else "✖") + env_done = f"{env}: {of_type} {of_type_icon}" + is_missing = env_done not in out + if is_missing: + missing.add(env_done) + env_report = f" {env}: {of_type} {'code 1 ' if env in fails else ''}(" + assert env_report in out, out + if not is_missing: + assert out.index(env_done) < out.index(env_report), out + assert len(missing) == 1, out + + +def test_parallel_run_live_out(tox_project: ToxProjectCreator) -> None: + ini = """ + [tox] + no_package=true + env_list= a, b + [testenv] + commands=python -c 'print("run {env_name}")' + """ + project = tox_project({"tox.ini": ini}) + outcome = project.run("p", "-p", "2", "--parallel-live") + outcome.assert_success() + assert "python -c" in outcome.out + assert "run a" in outcome.out + assert "run b" in outcome.out + + +def test_parallel_show_output_with_pkg(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + ini = "[testenv]\nparallel_show_output=True\ncommands=python -c 'print(\"r {env_name}\")'" + project = tox_project({"tox.ini": ini}) + result = project.run("p", "--root", str(demo_pkg_inline)) + assert "r py" in result.out + + +@pytest.mark.skipif(sys.platform == "win32", reason="You need a conhost shell for keyboard interrupt") +def test_keyboard_interrupt(tox_project: ToxProjectCreator, demo_pkg_inline: Path, tmp_path: Path) -> None: + marker = tmp_path / "a" + ini = f""" + [testenv] + package=wheel + commands=python -c 'from time import sleep; from pathlib import Path; \ + p = Path("{str(marker)}"); p.write_text(""); sleep(100)' + [testenv:dep] + depends=py + """ + proj = tox_project( + { + "tox.ini": ini, + "pyproject.toml": (demo_pkg_inline / "pyproject.toml").read_text(), + "build.py": (demo_pkg_inline / "build.py").read_text(), + }, + ) + cmd = ["-c", str(proj.path / "tox.ini"), "p", "-p", "1", "-e", f"py,py{sys.version_info[0]},dep"] + process = Popen([sys.executable, "-m", "tox"] + cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True) + while not marker.exists(): + sleep(0.05) + process.send_signal(SIGINT) + out, err = process.communicate() + assert process.returncode != 0 + assert "KeyboardInterrupt" in err, err + assert "KeyboardInterrupt - teardown started\n" in out, out + assert "interrupt tox environment: py\n" in out, out + assert "requested interrupt of" in out, out + assert "send signal SIGINT" in out, out + assert "interrupt finished with success" in out, out + assert "interrupt tox environment: .pkg" in out, out + + +def test_parallels_help(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("p", "-h") + outcome.assert_success() + + +def test_parallel_legacy_accepts_no_arg(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("-p", "-h") + outcome.assert_success() + + +def test_parallel_requires_arg(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("p", "-p", "-h") + outcome.assert_failed() + assert "argument -p/--parallel: expected one argument" in outcome.err diff --git a/tests/session/cmd/test_quickstart.py b/tests/session/cmd/test_quickstart.py new file mode 100644 index 000000000..2ccc290d0 --- /dev/null +++ b/tests/session/cmd/test_quickstart.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import sys +from textwrap import dedent + +from packaging.version import Version + +from tox.pytest import ToxProjectCreator +from tox.version import version as __version__ + + +def test_quickstart_ok(tox_project: ToxProjectCreator) -> None: + project = tox_project({}) + tox_ini = project.path / "demo" / "tox.ini" + assert not tox_ini.exists() + + outcome = project.run("q", str(tox_ini.parent)) + outcome.assert_success() + + assert tox_ini.exists() + found = tox_ini.read_text() + + version = str(Version(__version__.split("+")[0])) + text = f""" + [tox] + env_list = + py{''.join(str(i) for i in sys.version_info[0:2])} + minversion = {version} + + [testenv] + description = run the tests with pytest + package = wheel + wheel_build_env = .pkg + deps = + pytest>=6 + commands = + pytest {{tty:--color=yes}} {{posargs}} + """ + content = dedent(text).lstrip() + assert found == content + + +def test_quickstart_refuse(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": ""}) + outcome = project.run("q", str(project.path)) + outcome.assert_failed(code=1) + assert "tox.ini already exist, refusing to overwrite" in outcome.out + + +def test_quickstart_help(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("q", "-h") + outcome.assert_success() diff --git a/tests/session/cmd/test_sequential.py b/tests/session/cmd/test_sequential.py new file mode 100644 index 000000000..044e8bc5d --- /dev/null +++ b/tests/session/cmd/test_sequential.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path +from typing import Any + +import pytest +from re_assert import Matches +from virtualenv.discovery.py_info import PythonInfo + +from tox import __version__ +from tox.pytest import ToxProjectCreator +from tox.tox_env.api import ToxEnv +from tox.tox_env.info import Info + + +@pytest.mark.parametrize("prefix", ["-", "- "]) +def test_run_ignore_cmd_exit_code(tox_project: ToxProjectCreator, prefix: str) -> None: + cmd = [ + f"{prefix}python -c 'import sys; print(\"magic fail\", file=sys.stderr); sys.exit(1)'", + "python -c 'import sys; print(\"magic pass\", file=sys.stdout); sys.exit(0)'", + ] + project = tox_project({"tox.ini": f"[tox]\nno_package=true\n[testenv]\ncommands={cmd[0]}\n {cmd[1]}"}) + outcome = project.run("r", "-e", "py") + outcome.assert_success() + assert "magic pass" in outcome.out + assert "magic fail" in outcome.err + + +def test_run_sequential_fail(tox_project: ToxProjectCreator) -> None: + def _cmd(value: int) -> str: + return f"python -c 'import sys; print(\"exit {value}\"); sys.exit({value})'" + + ini = f"[tox]\nenv_list=a,b\nno_package=true\n[testenv:a]\ncommands={_cmd(1)}\n[testenv:b]\ncommands={_cmd(0)}" + project = tox_project({"tox.ini": ini}) + outcome = project.run("r", "-e", "a,b") + outcome.assert_failed() + reports = outcome.out.splitlines()[-3:] + assert Matches(r" evaluation failed :\( \(.* seconds\)") == reports[-1] + assert Matches(r" b: OK \(.*=setup\[.*\]\+cmd\[.*\] seconds\)") == reports[-2] + assert Matches(r" a: FAIL code 1 \(.*=setup\[.*\]\+cmd\[.*\] seconds\)") == reports[-3] + + +@pytest.mark.integration() +def test_result_json_sequential( + tox_project: ToxProjectCreator, + enable_pip_pypi_access: str | None, # noqa: U100 +) -> None: + cmd = [ + "- python -c 'import sys; print(\"magic fail\", file=sys.stderr); sys.exit(1)'", + "python -c 'import sys; print(\"magic pass\"); sys.exit(0)'", + ] + project = tox_project( + { + "tox.ini": f"[tox]\nenvlist=py\n[testenv]\npackage=wheel\ncommands={cmd[0]}\n {cmd[1]}", + "setup.py": "from setuptools import setup\nsetup(name='a', version='1.0', py_modules=['run']," + "install_requires=['setuptools>44'])", + "run.py": "print('run')", + "pyproject.toml": '[build-system]\nrequires=["setuptools","wheel"]\nbuild-backend="setuptools.build_meta"', + }, + ) + log = project.path / "log.json" + outcome = project.run("r", "-vv", "-e", "py", "--result-json", str(log)) + outcome.assert_success() + with log.open("rt") as file_handler: + log_report = json.load(file_handler) + + py_info = PythonInfo.current_system() + host_python = { + "executable": py_info.system_executable, + "extra_version_info": None, + "implementation": py_info.implementation, + "is_64": py_info.architecture == 64, + "sysplatform": py_info.platform, + "version": py_info.version, + "version_info": list(py_info.version_info), + } + packaging_setup = get_cmd_exit_run_id(log_report, ".pkg", "setup") + assert "result" not in log_report["testenvs"][".pkg"] + + assert packaging_setup == [ + (0, "install_requires"), + (None, "_optional_hooks"), + (None, "get_requires_for_build_wheel"), + (0, "install_requires_for_build_wheel"), + (0, "freeze"), + (None, "_exit"), + ] + packaging_test = get_cmd_exit_run_id(log_report, ".pkg", "test") + assert packaging_test == [(None, "build_wheel")] + packaging_installed = log_report["testenvs"][".pkg"].pop("installed_packages") + assert {i[: i.find("==")] for i in packaging_installed} == {"pip", "setuptools", "wheel"} + + result_py = log_report["testenvs"]["py"].pop("result") + assert result_py.pop("duration") > 0 + assert result_py == {"success": True, "exit_code": 0} + + py_setup = get_cmd_exit_run_id(log_report, "py", "setup") + assert py_setup == [(0, "install_package_deps"), (0, "install_package"), (0, "freeze")] + py_test = get_cmd_exit_run_id(log_report, "py", "test") + assert py_test == [(1, "commands[0]"), (0, "commands[1]")] + packaging_installed = log_report["testenvs"]["py"].pop("installed_packages") + expected_pkg = {"pip", "setuptools", "wheel", "a"} + assert {i[: i.find("==")] if "@" not in i else "a" for i in packaging_installed} == expected_pkg + install_package = log_report["testenvs"]["py"].pop("installpkg") + assert re.match("^[a-fA-F0-9]{64}$", install_package.pop("sha256")) + assert install_package == {"basename": "a-1.0-py3-none-any.whl", "type": "file"} + + expected = { + "reportversion": "1", + "toxversion": __version__, + "platform": sys.platform, + "testenvs": { + "py": {"python": host_python}, + ".pkg": {"python": host_python}, + }, + } + assert "host" in log_report + assert log_report.pop("host") + assert log_report == expected + + +def get_cmd_exit_run_id(report: dict[str, Any], name: str, group: str) -> list[tuple[int | None, str]]: + return [(i["retcode"], i["run_id"]) for i in report["testenvs"][name].pop(group)] + + +def test_rerun_sequential_skip(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=python -c 'print(1)'"}) + result_first = proj.run("--root", str(demo_pkg_inline)) + result_first.assert_success() + result_rerun = proj.run("--root", str(demo_pkg_inline)) + result_rerun.assert_success() + + +def test_rerun_sequential_wheel(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + proj = tox_project( + {"tox.ini": "[testenv]\npackage=wheel\ncommands=python -c 'from demo_pkg_inline import do; do()'"}, + ) + result_first = proj.run("--root", str(demo_pkg_inline)) + result_first.assert_success() + result_rerun = proj.run("--root", str(demo_pkg_inline)) + result_rerun.assert_success() + + +@pytest.mark.integration() +def test_rerun_sequential_sdist(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + proj = tox_project( + {"tox.ini": "[testenv]\npackage=sdist\ncommands=python -c 'from demo_pkg_inline import do; do()'"}, + ) + result_first = proj.run("--root", str(demo_pkg_inline)) + result_first.assert_success() + result_rerun = proj.run("--root", str(demo_pkg_inline)) + result_rerun.assert_success() + + +def test_recreate_package(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + proj = tox_project( + {"tox.ini": "[testenv]\npackage=wheel\ncommands=python -c 'from demo_pkg_inline import do; do()'"}, + ) + result_first = proj.run("--root", str(demo_pkg_inline), "-r") + result_first.assert_success() + + result_rerun = proj.run("-r", "--root", str(demo_pkg_inline), "--no-recreate-pkg") + result_rerun.assert_success() + + +def test_package_deps_change(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + toml = (demo_pkg_inline / "pyproject.toml").read_text() + build = (demo_pkg_inline / "build.py").read_text() + proj = tox_project({"tox.ini": "[testenv]\npackage=wheel", "pyproject.toml": toml, "build.py": build}) + proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + + result_first = proj.run("r") + result_first.assert_success() + assert ".pkg: install" not in result_first.out # no deps initially + + # new deps are picked up + (proj.path / "pyproject.toml").write_text(toml.replace("requires = []", 'requires = ["wheel"]')) + (proj.path / "build.py").write_text(build.replace("return []", "return ['setuptools']")) + + result_rerun = proj.run("r") + result_rerun.assert_success() + + # and installed + rerun_install = [i for i in result_rerun.out.splitlines() if i.startswith(".pkg: install")] + assert len(rerun_install) == 2 + assert rerun_install[0].endswith("wheel") + assert rerun_install[1].endswith("setuptools") + + +def test_package_build_fails(tox_project: ToxProjectCreator) -> None: + proj = tox_project( + { + "tox.ini": "[testenv]\npackage=wheel", + "pyproject.toml": '[build-system]\nrequires=[]\nbuild-backend="build"\nbackend-path=["."]', + "build.py": "", + }, + ) + + result = proj.run("r") + result.assert_failed(code=1) + assert "has no attribute 'build_wheel'" in result.out, result.out + + +def test_backend_not_found(tox_project: ToxProjectCreator) -> None: + proj = tox_project( + { + "tox.ini": "[testenv]\npackage=wheel", + "pyproject.toml": '[build-system]\nrequires=[]\nbuild-backend="build"', + "build.py": "", + }, + ) + + result = proj.run("r") + result.assert_failed(code=-5) + assert "packaging backend failed (code=-5), with FailedToStart: could not start backend" in result.out, result.out + + +def test_missing_interpreter_skip_on(tox_project: ToxProjectCreator) -> None: + ini = "[tox]\nskip_missing_interpreters=true\n[testenv]\npackage=skip\nbase_python=missing-interpreter" + proj = tox_project({"tox.ini": ini}) + + result = proj.run("r") + result.assert_success() + assert "py: SKIP" in result.out + + +def test_missing_interpreter_skip_off(tox_project: ToxProjectCreator) -> None: + ini = "[tox]\nskip_missing_interpreters=false\n[testenv]\npackage=skip\nbase_python=missing-interpreter" + proj = tox_project({"tox.ini": ini}) + + result = proj.run("r") + result.assert_failed() + exp = "py: failed with could not find python interpreter matching any of the specs missing-interpreter" + assert exp in result.out + + +def test_env_tmp_dir_reset(tox_project: ToxProjectCreator) -> None: + ini = '[testenv]\npackage=skip\ncommands=python -c \'import os; os.mkdir(os.path.join( r"{env_tmp_dir}", "a"))\'' + proj = tox_project({"tox.ini": ini}) + result_first = proj.run("r") + result_first.assert_success() + + result_second = proj.run("r", "-v", "-v") + result_second.assert_success() + assert "D clear env temp folder " in result_second.out, result_second.out + + +def test_env_name_change_recreate(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=skip\ncommands=\n"}) + result_first = proj.run("r") + result_first.assert_success() + + tox_env = result_first.state.envs["py"] + assert repr(tox_env) == "VirtualEnvRunner(name=py)" + path = tox_env.env_dir + with Info(path).compare({"name": "p", "type": "magical"}, ToxEnv.__name__): + pass + + result_second = proj.run("r") + result_second.assert_success() + output = ( + "recreate env because env type changed from {'name': 'p', 'type': 'magical'} " + "to {'name': 'py', 'type': 'VirtualEnvRunner'}" + ) + assert output in result_second.out + assert "py: remove tox env folder" in result_second.out + + +def test_skip_pkg_install(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=wheel\n"}) + result_first = proj.run("--root", str(demo_pkg_inline), "--skip-pkg-install") + result_first.assert_success() + assert result_first.out.startswith("py: skip building and installing the package"), result_first.out + + +def test_skip_develop_mode(tox_project: ToxProjectCreator, demo_pkg_setuptools: Path) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=wheel\n"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("--root", str(demo_pkg_setuptools), "--develop") + result.assert_success() + calls = [(i[0][0].conf.name, i[0][3].run_id) for i in execute_calls.call_args_list] + expected = [ + (".pkg", "install_requires"), + (".pkg", "_optional_hooks"), + (".pkg", "get_requires_for_build_editable"), + (".pkg", "install_requires_for_build_editable"), + (".pkg", "build_editable"), + ("py", "install_package"), + (".pkg", "_exit"), + ] + assert calls == expected + + +def _c(code: int) -> str: + return f"python -c 'raise SystemExit({code})'" + + +def test_commands_pre_fail_post_runs(tox_project: ToxProjectCreator) -> None: + ini = f"[testenv]\npackage=skip\ncommands_pre={_c(8)}\ncommands={_c(0)}\ncommands_post={_c(9)}" + proj = tox_project({"tox.ini": ini}) + result = proj.run() + result.assert_failed(code=8) + assert "commands_pre[0]" in result.out + assert "commands[0]" not in result.out + assert "commands_post[0]" in result.out + + +def test_commands_pre_pass_post_runs_main_fails(tox_project: ToxProjectCreator) -> None: + ini = f"[testenv]\npackage=skip\ncommands_pre={_c(0)}\ncommands={_c(8)}\ncommands_post={_c(9)}" + proj = tox_project({"tox.ini": ini}) + result = proj.run() + result.assert_failed(code=8) + assert "commands_pre[0]" in result.out + assert "commands[0]" in result.out + assert "commands_post[0]" in result.out + + +def test_commands_post_fails_exit_code(tox_project: ToxProjectCreator) -> None: + ini = f"[testenv]\npackage=skip\ncommands_pre={_c(0)}\ncommands={_c(0)}\ncommands_post={_c(9)}" + proj = tox_project({"tox.ini": ini}) + result = proj.run() + result.assert_failed(code=9) + assert "commands_pre[0]" in result.out + assert "commands[0]" in result.out + assert "commands_post[0]" in result.out + + +@pytest.mark.parametrize( + ("pre", "main", "post", "outcome"), + [ + (0, 8, 0, 8), + (0, 0, 8, 8), + (8, 0, 0, 8), + ], +) +def test_commands_ignore_errors(tox_project: ToxProjectCreator, pre: int, main: int, post: int, outcome: int) -> None: + def _s(key: str, code: int) -> str: + return f"\ncommands{key}=\n {_c(code)}\n {'' if code == 0 else _c(code + 1)}" + + ini = f"[testenv]\npackage=skip\nignore_errors=True{_s('_pre', pre)}{_s('', main)}{_s('_post', post)}" + proj = tox_project({"tox.ini": ini}) + result = proj.run() + result.assert_failed(code=outcome) + assert "commands_pre[0]" in result.out + assert "commands[0]" in result.out + assert "commands_post[0]" in result.out + + +def test_ignore_outcome(tox_project: ToxProjectCreator) -> None: + ini = "[tox]\nno_package=true\n[testenv]\ncommands=python -c 'exit(1)'\nignore_outcome=true" + project = tox_project({"tox.ini": ini}) + result = project.run("r") + + result.assert_success() + reports = result.out.splitlines() + + assert Matches(r" py: IGNORED FAIL code 1 .*") == reports[-2] + assert Matches(r" congratulations :\) .*") == reports[-1] + + +def test_platform_does_not_match_run_env(tox_project: ToxProjectCreator) -> None: + ini = "[testenv]\npackage=skip\nplatform=wrong_platform" + proj = tox_project({"tox.ini": ini}) + + result = proj.run("r") + result.assert_success() + exp = f"py: skipped because platform {sys.platform} does not match wrong_platform" + assert exp in result.out + + +def test_platform_matches_run_env(tox_project: ToxProjectCreator) -> None: + ini = f"[testenv]\npackage=skip\nplatform={sys.platform}" + proj = tox_project({"tox.ini": ini}) + result = proj.run("r") + result.assert_success() + + +def test_platform_does_not_match_package_env(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + toml = (demo_pkg_inline / "pyproject.toml").read_text() + build = (demo_pkg_inline / "build.py").read_text() + ini = "[testenv]\npackage=wheel\n[testenv:.pkg]\nplatform=wrong_platform" + proj = tox_project({"tox.ini": ini, "pyproject.toml": toml, "build.py": build}) + result = proj.run("r", "-e", "a,b") + result.assert_failed() # tox run fails as all envs are skipped + assert "a: SKIP" in result.out + assert "b: SKIP" in result.out + msg = f"skipped because platform {sys.platform} does not match wrong_platform for package environment .pkg" + assert f"a: {msg}" in result.out + assert f"b: {msg}" in result.out + + +def test_sequential_run_all(tox_project: ToxProjectCreator) -> None: + ini = "[tox]\nenv_list=a\n[testenv]\npackage=skip\n[testenv:b]" + outcome = tox_project({"tox.ini": ini}).run("r", "-e", "ALL") + assert "a: OK" in outcome.out + assert "b: OK" in outcome.out + + +def test_virtualenv_cache(tox_project: ToxProjectCreator) -> None: + ini = "[testenv]\npackage=skip" + proj = tox_project({"tox.ini": ini}) + result_first = proj.run("r", "-v", "-v") + result_first.assert_success() + assert " create virtual environment via " in result_first.out + + result_second = proj.run("r", "-v", "-v") + result_second.assert_success() + assert " create virtual environment via " not in result_second.out + + +def test_sequential_help(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("r", "-h") + outcome.assert_success() + + +def test_sequential_clears_pkg_at_most_once(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + project = tox_project({"tox.ini": ""}) + result = project.run("r", "--root", str(demo_pkg_inline), "-e", "a,b", "-r") + result.assert_success() + + +def test_sequential_inserted_env_vars(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + ini = """ + [testenv] + commands=python -c 'import os; [print(f"{k}={v}") for k, v in os.environ.items() if \ + k.startswith("TOX_") or k == "VIRTUAL_ENV"]' + """ + project = tox_project({"tox.ini": ini}) + result = project.run("r", "--root", str(demo_pkg_inline)) + result.assert_success() + + assert re.search(f"TOX_PACKAGE={re.escape(str(project.path))}.*.tar.gz{os.linesep}", result.out) + assert f"TOX_ENV_NAME=py{os.linesep}" in result.out + work_dir = project.path / ".tox" + assert f"TOX_WORK_DIR={work_dir}{os.linesep}" in result.out + env_dir = work_dir / "py" + assert f"TOX_ENV_DIR={env_dir}{os.linesep}" in result.out + assert f"VIRTUAL_ENV={env_dir}{os.linesep}" in result.out + + +def test_missing_command_success_if_ignored(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\ncommands= - missing-command\nskip_install=true"}) + result = project.run() + result.assert_success() + assert "py: command failed but is marked ignore outcome so handling it as success" in result.out diff --git a/tests/session/cmd/test_show_config.py b/tests/session/cmd/test_show_config.py new file mode 100644 index 000000000..0fa430291 --- /dev/null +++ b/tests/session/cmd/test_show_config.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import platform +import sys +from configparser import ConfigParser +from pathlib import Path +from textwrap import dedent +from typing import Callable + +import pytest +from pytest_mock import MockerFixture + +from tox.config.types import Command +from tox.pytest import MonkeyPatch, ToxProjectCreator + + +def test_show_config_default_run_env(tox_project: ToxProjectCreator, monkeypatch: MonkeyPatch) -> None: + py_ver = sys.version_info[0:2] + name = f"py{py_ver[0]}{py_ver[1]}" if platform.python_implementation() == "CPython" else "pypy3" + project = tox_project({"tox.ini": f"[tox]\nenv_list = {name}\n[testenv:{name}]\ncommands={{posargs}}"}) + result = project.run("c", "-e", name, "--core", "--", "magic") + state = result.state + assert state.args == ("c", "-e", name, "--core", "--", "magic") + outcome = list(state.envs.iter(only_active=False)) + assert outcome == [name] + monkeypatch.delenv("TERM", raising=False) # disable conditionally set flag + parser = ConfigParser(interpolation=None) + parser.read_string(result.out) + assert list(parser.sections()) == [f"testenv:{name}", "tox"] + + +def test_show_config_commands(tox_project: ToxProjectCreator) -> None: + project = tox_project( + { + "tox.ini": """ + [tox] + env_list = py + no_package = true + [testenv] + commands_pre = + python -c 'import sys; print("start", sys.executable)' + commands = + pip config list + pip list + commands_post = + python -c 'import sys; print("end", sys.executable)' + """, + }, + ) + outcome = project.run("c") + outcome.assert_success() + env_config = outcome.env_conf("py") + assert env_config["commands_pre"] == [Command(args=["python", "-c", 'import sys; print("start", sys.executable)'])] + assert env_config["commands"] == [ + Command(args=["pip", "config", "list"]), + Command(args=["pip", "list"]), + ] + assert env_config["commands_post"] == [Command(args=["python", "-c", 'import sys; print("end", sys.executable)'])] + + +def test_show_config_filter_keys(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\nmagic=yes"}) + outcome = project.run("c", "-e", "py", "-k", "no_package", "env_name", "--core") + outcome.assert_success() + outcome.assert_out_err("[testenv:py]\nenv_name = py\n\n[tox]\nno_package = False\n", "") + + +def test_show_config_unused(tox_project: ToxProjectCreator) -> None: + tox_ini = "[testenv]\nok=false\n[testenv:py]\nmagical=yes\nmagic=yes" + outcome = tox_project({"tox.ini": tox_ini}).run("c", "-e", "py") + outcome.assert_success() + assert "\n# !!! unused: magic, magical\n" in outcome.out + + +def test_show_config_exception(tox_project: ToxProjectCreator) -> None: + project = tox_project( + { + "tox.ini": """ + [testenv:a] + base_python = missing-python + """, + }, + ) + outcome = project.run("c", "-e", "a", "-k", "env_site_packages_dir") + outcome.assert_success() + txt = ( + "\nenv_site_packages_dir = # Exception: " + "RuntimeError(\"failed to find interpreter for Builtin discover of python_spec='missing-python'" + ) + assert txt in outcome.out + + +@pytest.mark.parametrize("stdout_is_atty", [True, False]) +def test_pass_env_config_default(tox_project: ToxProjectCreator, stdout_is_atty: bool, mocker: MockerFixture) -> None: + mocker.patch("sys.stdout.isatty", return_value=stdout_is_atty) + project = tox_project({"tox.ini": ""}) + outcome = project.run("c", "-e", "py", "-k", "pass_env") + pass_env = outcome.env_conf("py")["pass_env"] + is_win = sys.platform == "win32" + expected = ( + (["COMSPEC"] if is_win else []) + + ["CURL_CA_BUNDLE", "LANG", "LANGUAGE", "LD_LIBRARY_PATH"] + + (["MSYSTEM", "PATHEXT"] if is_win else []) + + ["PIP_*"] + + (["PROCESSOR_ARCHITECTURE"] if is_win else []) + + (["PROGRAMDATA"] if is_win else []) + + (["PROGRAMFILES"] if is_win else []) + + (["PROGRAMFILES(x86)"] if is_win else []) + + ["REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"] + + (["SYSTEMDRIVE", "SYSTEMROOT", "TEMP"] if is_win else []) + + (["TERM"] if stdout_is_atty else []) + + (["TMP", "USERPROFILE"] if is_win else ["TMPDIR"]) + + ["VIRTUALENV_*", "http_proxy", "https_proxy", "no_proxy"] + ) + assert pass_env == expected + + +def test_show_config_pkg_env_once( + tox_project: ToxProjectCreator, + patch_prev_py: Callable[[bool], tuple[str, str]], +) -> None: + prev_ver, impl = patch_prev_py(True) + project = tox_project( + {"tox.ini": f"[tox]\nenv_list=py{prev_ver},py\n[testenv]\npackage=wheel", "pyproject.toml": ""}, + ) + result = project.run("c") + result.assert_success() + parser = ConfigParser(interpolation=None) + parser.read_string(result.out) + sections = set(parser.sections()) + assert sections == {"testenv:.pkg", f"testenv:.pkg-{impl}{prev_ver}", f"testenv:py{prev_ver}", "testenv:py", "tox"} + + +def test_show_config_pkg_env_skip( + tox_project: ToxProjectCreator, + patch_prev_py: Callable[[bool], tuple[str, str]], +) -> None: + prev_ver, impl = patch_prev_py(False) + project = tox_project( + {"tox.ini": f"[tox]\nenv_list=py{prev_ver},py\n[testenv]\npackage=wheel", "pyproject.toml": ""}, + ) + result = project.run("c") + result.assert_success() + parser = ConfigParser(interpolation=None) + parser.read_string(result.out) + sections = set(parser.sections()) + assert sections == {"testenv:.pkg", "tox", "testenv:py", f"testenv:py{prev_ver}"} + + +def test_show_config_select_only(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[tox]\nenv_list=\n a\n b", "pyproject.toml": ""}) + result = project.run("c", "-e", ".pkg,b,.pkg") + result.assert_success() + parser = ConfigParser(interpolation=None) + parser.read_string(result.out) + sections = list(parser.sections()) + assert sections == ["testenv:.pkg", "testenv:b"] + + +def test_show_config_alias(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("c", "-e", "py", "-k", "setenv") + outcome.assert_success() + assert "set_env = " in outcome.out + + +def test_show_config_description_normalize(tox_project: ToxProjectCreator) -> None: + tox_ini = "[testenv]\ndescription = A magical\t pipe\n of\tthis" + outcome = tox_project({"tox.ini": tox_ini}).run("c", "-e", "py", "-k", "description") + outcome.assert_success() + assert outcome.out == "[testenv:py]\ndescription = A magical pipe of this\n" + + +def test_show_config_ini_comment_path(tox_project: ToxProjectCreator, tmp_path: Path) -> None: + prj_path = tmp_path / "#magic" + prj_path.mkdir() + ini = """ + [testenv] + package = skip + set_env = + A=1 # comment + # more comment + commands = {envpython} -c 'import os; print(os.linesep.join(f"{k}={v}" for k, v in os.environ.items()))' + [testenv:py] + set_env = + {[testenv]set_env} + B = {tox_root} # just some comment + """ + project = tox_project({"tox.ini": dedent(ini)}, prj_path=prj_path) + result = project.run("r", "-e", "py") + result.assert_success() + a_line = next(i for i in result.out.splitlines() if i.startswith("A=")) # pragma: no branch # not found raises + assert a_line == "A=1" + b_line = next(i for i in result.out.splitlines() if i.startswith("B=")) # pragma: no branch # not found raises + assert b_line == f"B={prj_path}" + + +def test_show_config_cli_flag(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "", "pyproject.toml": ""}) + result = project.run("c", "-e", "py,.pkg", "-k", "package", "recreate", "--develop", "-r", "--no-recreate-pkg") + expected = "[testenv:py]\npackage = editable\nrecreate = True\n\n[testenv:.pkg]\nrecreate = False\n" + assert result.out == expected + + +def test_show_config_timeout_default(tox_project: ToxProjectCreator) -> None: + project = tox_project({"tox.ini": "[testenv]\npakcage=skip"}) + result = project.run("c", "-e", "py", "-k", "suicide_timeout", "interrupt_timeout", "terminate_timeout") + expected = "[testenv:py]\nsuicide_timeout = 0.0\ninterrupt_timeout = 0.3\nterminate_timeout = 0.2\n" + assert result.out == expected + + +def test_show_config_timeout_custom(tox_project: ToxProjectCreator) -> None: + ini = "[testenv]\npakcage=skip\nsuicide_timeout = 1\ninterrupt_timeout = 2.222\nterminate_timeout = 3.0\n" + project = tox_project({"tox.ini": ini}) + result = project.run("c", "-e", "py", "-k", "suicide_timeout", "interrupt_timeout", "terminate_timeout") + expected = "[testenv:py]\nsuicide_timeout = 1.0\ninterrupt_timeout = 2.222\nterminate_timeout = 3.0\n" + assert result.out == expected + + +def test_show_config_help(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("c", "-h") + outcome.assert_success() diff --git a/tests/session/cmd/test_state.py b/tests/session/cmd/test_state.py new file mode 100644 index 000000000..d05ad8755 --- /dev/null +++ b/tests/session/cmd/test_state.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from tox.pytest import ToxProjectCreator + + +def test_env_already_packaging(tox_project: ToxProjectCreator) -> None: + proj = tox_project( + { + "tox.ini": "[testenv]\npackage=wheel", + "pyproject.toml": '[build-system]\nrequires=[]\nbuild-backend="build"', + }, + ) + result = proj.run("r", "-e", "py,.pkg") + result.assert_failed(code=-2) + assert "cannot run packaging environment(s) .pkg" in result.out, result.out + + +def test_env_run_cannot_be_packaging_too(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=wheel\npackage_env=py", "pyproject.toml": ""}) + result = proj.run("r", "-e", "py") + result.assert_failed(code=-2) + assert " py cannot self-package" in result.out, result.out diff --git a/tests/session/test_env_select.py b/tests/session/test_env_select.py new file mode 100644 index 000000000..90d72a430 --- /dev/null +++ b/tests/session/test_env_select.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from tox.pytest import ToxProjectCreator + + +def test_label_core_can_define(tox_project: ToxProjectCreator) -> None: + ini = """ + [tox] + labels = + test = py3{10,9} + static = flake8, type + """ + project = tox_project({"tox.ini": ini}) + outcome = project.run("l", "--no-desc") + outcome.assert_success() + outcome.assert_out_err("py\npy310\npy39\nflake8\ntype\n", "") + + +def test_label_core_select(tox_project: ToxProjectCreator) -> None: + ini = """ + [tox] + labels = + test = py3{10,9} + static = flake8, type + """ + project = tox_project({"tox.ini": ini}) + outcome = project.run("l", "--no-desc", "-m", "test") + outcome.assert_success() + outcome.assert_out_err("py310\npy39\n", "") + + +def test_label_select_trait(tox_project: ToxProjectCreator) -> None: + ini = """ + [tox] + env_list = py310, py39, flake8, type + [testenv] + labels = test + [testenv:flake8] + labels = static + [testenv:type] + labels = static + """ + project = tox_project({"tox.ini": ini}) + outcome = project.run("l", "--no-desc", "-m", "test") + outcome.assert_success() + outcome.assert_out_err("py310\npy39\n", "") + + +def test_label_core_and_trait(tox_project: ToxProjectCreator) -> None: + ini = """ + [tox] + env_list = py310, py39, flake8, type + labels = + static = flake8, type + [testenv] + labels = test + """ + project = tox_project({"tox.ini": ini}) + outcome = project.run("l", "--no-desc", "-m", "test", "static") + outcome.assert_success() + outcome.assert_out_err("py310\npy39\nflake8\ntype\n", "") + + +def test_factor_select(tox_project: ToxProjectCreator) -> None: + ini = """ + [tox] + env_list = py3{10,9}-{django20,django21}{-cov,} + """ + project = tox_project({"tox.ini": ini}) + outcome = project.run("l", "--no-desc", "-f", "cov", "django20") + outcome.assert_success() + outcome.assert_out_err("py310-django20-cov\npy39-django20-cov\n", "") diff --git a/tests/session/test_session_common.py b/tests/session/test_session_common.py new file mode 100644 index 000000000..7d8df6425 --- /dev/null +++ b/tests/session/test_session_common.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import pytest + +from tox.session.env_select import CliEnv + + +@pytest.mark.parametrize( + ("val", "exp"), + [ + (CliEnv(["a", "b"]), "CliEnv('a,b')"), + (CliEnv(["ALL", "b"]), "CliEnv('ALL')"), + (CliEnv([]), "CliEnv()"), + (CliEnv(), "CliEnv()"), + ], +) +def test_cli_env_repr(val: CliEnv, exp: str) -> None: + assert repr(val) == exp + + +@pytest.mark.parametrize( + ("val", "exp"), + [ + (CliEnv(["a", "b"]), "a,b"), + (CliEnv(["ALL", "b"]), "ALL"), + (CliEnv([]), ""), + (CliEnv(), ""), + ], +) +def test_cli_env_str(val: CliEnv, exp: str) -> None: + assert str(val) == exp diff --git a/tests/test_call_modes.py b/tests/test_call_modes.py new file mode 100644 index 000000000..7f7cce08a --- /dev/null +++ b/tests/test_call_modes.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from tox.pytest import ToxProject + + +def test_call_as_module(empty_project: ToxProject) -> None: # noqa: U100 + subprocess.check_output([sys.executable, "-m", "tox", "-h"]) + + +def test_call_as_exe(empty_project: ToxProject) -> None: # noqa: U100 + subprocess.check_output([str(Path(sys.executable).parent / "tox"), "-h"]) diff --git a/tests/test_provision.py b/tests/test_provision.py new file mode 100644 index 000000000..56952a184 --- /dev/null +++ b/tests/test_provision.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import json +import os +import sys +import time +from contextlib import contextmanager +from pathlib import Path +from subprocess import check_call +from typing import Callable, Iterator +from unittest import mock +from zipfile import ZipFile + +import pytest +from devpi_process import Index, IndexServer +from filelock import FileLock +from packaging.requirements import Requirement + +from tox.pytest import MonkeyPatch, TempPathFactory, ToxProjectCreator + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from importlib.metadata import Distribution +else: # pragma: no cover ( Iterator[None]: + start = time.monotonic() + try: + yield + finally: + print(f"done in {time.monotonic() - start}s {msg}") + + +@pytest.fixture(scope="session") +def tox_wheel( + tmp_path_factory: TempPathFactory, + worker_id: str, + pkg_builder: Callable[[Path, Path, list[str], bool], Path], +) -> Path: + if worker_id == "master": # if not running under xdist we can just return + return _make_tox_wheel(tmp_path_factory, pkg_builder) # pragma: no cover + # otherwise we need to ensure only one worker creates the wheel, and the rest reuses + root_tmp_dir = tmp_path_factory.getbasetemp().parent + cache_file = root_tmp_dir / "tox_wheel.json" + with FileLock(f"{cache_file}.lock"): + if cache_file.is_file(): + data = Path(json.loads(cache_file.read_text())) # pragma: no cover + else: + data = _make_tox_wheel(tmp_path_factory, pkg_builder) + cache_file.write_text(json.dumps(str(data))) + return data + + +def _make_tox_wheel( + tmp_path_factory: TempPathFactory, + pkg_builder: Callable[[Path, Path, list[str], bool], Path], +) -> Path: + with elapsed("acquire current tox wheel"): # takes around 3.2s on build + into = tmp_path_factory.mktemp("dist") # pragma: no cover + from tox.version import version_tuple + + version = f"{version_tuple[0]}.{version_tuple[1]}.{version_tuple[2] +1}" + with mock.patch.dict(os.environ, {"SETUPTOOLS_SCM_PRETEND_VERSION": version}): + package = pkg_builder(into, Path(__file__).parents[1], ["wheel"], False) # pragma: no cover + return package + + +@pytest.fixture(scope="session") +def tox_wheels(tox_wheel: Path, tmp_path_factory: TempPathFactory) -> list[Path]: + with elapsed("acquire dependencies for current tox"): # takes around 1.5s if already cached + result: list[Path] = [tox_wheel] + info = tmp_path_factory.mktemp("info") + with ZipFile(str(tox_wheel), "r") as zip_file: + zip_file.extractall(path=info) + dist_info = next((i for i in info.iterdir() if i.suffix == ".dist-info"), None) + if dist_info is None: # pragma: no cover + raise RuntimeError(f"no tox.dist-info inside {tox_wheel}") + distribution = Distribution.at(dist_info) + wheel_cache = ROOT / ".wheel_cache" / f"{sys.version_info.major}.{sys.version_info.minor}" + wheel_cache.mkdir(parents=True, exist_ok=True) + cmd = [sys.executable, "-I", "-m", "pip", "download", "-d", str(wheel_cache)] + for req in distribution.requires: + requirement = Requirement(req) + if not requirement.extras: # pragma: no branch # we don't need to install any extras (tests/docs/etc) + cmd.append(req) + check_call(cmd) + result.extend(wheel_cache.iterdir()) + return result + + +@pytest.fixture(scope="session") +def pypi_index_self(pypi_server: IndexServer, tox_wheels: list[Path], demo_pkg_inline_wheel: Path) -> Index: + with elapsed("start devpi and create index"): # takes around 1s + self_index = pypi_server.create_index("self", "volatile=False") + with elapsed("upload tox and its wheels to devpi"): # takes around 3.2s on build + self_index.upload(*tox_wheels, demo_pkg_inline_wheel) + return self_index + + +@pytest.fixture() +def _pypi_index_self(pypi_index_self: Index, monkeypatch: MonkeyPatch) -> None: + pypi_index_self.use() + monkeypatch.setenv("PIP_INDEX_URL", pypi_index_self.url) + monkeypatch.setenv("PIP_RETRIES", str(2)) + monkeypatch.setenv("PIP_TIMEOUT", str(5)) + + +def test_provision_requires_nok(tox_project: ToxProjectCreator) -> None: + ini = "[tox]\nrequires = pkg-does-not-exist\n setuptools==1\nskipsdist=true\n" + outcome = tox_project({"tox.ini": ini}).run("c", "-e", "py") + outcome.assert_failed() + outcome.assert_out_err( + r".*will run in automatically provisioned tox, host .* is missing \[requires \(has\)\]:" + r" pkg-does-not-exist, setuptools==1 \(.*\).*", + r".*", + regex=True, + ) + + +@pytest.mark.integration() +@pytest.mark.usefixtures("_pypi_index_self") +def test_provision_requires_ok(tox_project: ToxProjectCreator, tmp_path: Path) -> None: + proj = tox_project({"tox.ini": "[tox]\nrequires=demo-pkg-inline\n[testenv]\npackage=skip"}) + log = tmp_path / "out.log" + + # initial run + result_first = proj.run("r", "--result-json", str(log)) + result_first.assert_success() + prov_msg = ( + f"ROOT: will run in automatically provisioned tox, host {sys.executable} is missing" + f" [requires (has)]: demo-pkg-inline" + ) + assert prov_msg in result_first.out + + with log.open("rt") as file_handler: + log_report = json.load(file_handler) + assert "py" in log_report["testenvs"] + + # recreate without recreating the provisioned env + provision_env = result_first.env_conf(".tox")["env_dir"] + result_recreate_no_pr = proj.run("r", "--recreate", "--no-recreate-provision") + result_recreate_no_pr.assert_success() + assert prov_msg in result_recreate_no_pr.out + assert f"ROOT: remove tox env folder {provision_env}" not in result_recreate_no_pr.out, result_recreate_no_pr.out + + # recreate with recreating the provisioned env + result_recreate = proj.run("r", "--recreate") + result_recreate.assert_success() + assert prov_msg in result_recreate.out + assert f"ROOT: remove tox env folder {provision_env}" in result_recreate.out, result_recreate.out + + +@pytest.mark.integration() +@pytest.mark.usefixtures("_pypi_index_self") +def test_provision_platform_check(tox_project: ToxProjectCreator) -> None: + ini = "[tox]\nrequires=demo-pkg-inline\n[testenv]\npackage=skip\n[testenv:.tox]\nplatform=wrong_platform" + proj = tox_project({"tox.ini": ini}) + + result = proj.run("r") + result.assert_failed(-2) + msg = f"cannot provision tox environment .tox because platform {sys.platform} does not match wrong_platform" + assert msg in result.out + + +def test_provision_no_recreate(tox_project: ToxProjectCreator) -> None: + ini = "[tox]\nrequires = p\nskipsdist=true\n" + result = tox_project({"tox.ini": ini}).run("c", "-e", "py", "--no-provision") + result.assert_failed() + assert f"provisioning explicitly disabled within {sys.executable}, but is missing [requires (has)]: p" in result.out + + +def test_provision_no_recreate_json(tox_project: ToxProjectCreator) -> None: + ini = "[tox]\nrequires = p\nskipsdist=true\n" + project = tox_project({"tox.ini": ini}) + result = project.run("c", "-e", "py", "--no-provision", "out.json") + result.assert_failed() + msg = ( + f"provisioning explicitly disabled within {sys.executable}, " + f"but is missing [requires (has)]: p and wrote to out.json" + ) + assert msg in result.out + with (project.path / "out.json").open() as file_handler: + requires = json.load(file_handler) + assert requires == {"minversion": "4.0", "requires": ["p", "tox>=4.0"]} diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 000000000..548262bd9 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import logging +import os + +import pytest +from colorama import Style, deinit +from pytest_mock import MockerFixture + +from tox.pytest import CaptureFixture +from tox.report import setup_report + + +@pytest.mark.parametrize("color", [True, False], ids=["on", "off"]) +@pytest.mark.parametrize("verbosity", range(7)) +def test_setup_report(mocker: MockerFixture, capsys: CaptureFixture, verbosity: int, color: bool) -> None: + color_init = mocker.patch("tox.report.init") + + setup_report(verbosity=verbosity, is_colored=color) + try: + logging.critical("critical") + logging.error("error") + # special warning line that should be auto-colored + logging.warning("%s%s> %s", "warning", "foo", "bar") + logging.info("info") + logging.debug("debug") + logging.log(logging.NOTSET, "not-set") # this should not be logged + lowered = "distlib.util", "filelock" + for name in lowered: + logger = logging.getLogger(name) + logger.warning(f"{name}-warn") + logger.info(f"{name}-info") + logger.debug(f"{name}-debug") + logger.log(logging.NOTSET, f"{name}-notset") + finally: + deinit() + + assert color_init.call_count == (1 if color else 0) + + msg_count = min(verbosity + 1, 5) + msg_count += (1 if verbosity >= 2 else 0) * len(lowered) # warning lowered + is_debug_or_more = verbosity >= 4 + if is_debug_or_more: + msg_count += 1 # we log at debug level setting up the logger + msg_count += (2 if verbosity >= 4 else 1) * len(lowered) + + out, err = capsys.readouterr() + assert not err + assert out + lines = out.splitlines() + assert len(lines) == msg_count, out + + if is_debug_or_more and lines: # assert we start with relative created, contain path + line = lines[0] + int(line.split(" ")[1]) # first element is an int number + assert f"[tox{os.sep}report.py" in line # relative file location + + if color: + assert f"{Style.RESET_ALL}" in out + # check that our Warning line using special format was colored + expected_warning_text = "W\x1b[0m\x1b[36m warning\x1b[22mfoo\x1b[2m>\x1b[0m bar\x1b[0m\x1b[2m" + else: + assert f"{Style.RESET_ALL}" not in out + expected_warning_text = "warningfoo> bar" + if verbosity >= 4: # where warnings are logged + assert expected_warning_text in lines[3] diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 000000000..ca36f41f9 --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from tox.pytest import ToxProjectCreator +from tox.report import HandledError +from tox.run import run + + +@pytest.mark.parametrize("exception", [HandledError, KeyboardInterrupt]) +def test_exit_code_minus_2_on_expected_exit(exception: Exception, mocker: MockerFixture) -> None: + mocker.patch("tox.run.main", side_effect=exception) + with pytest.raises(SystemExit) as system_exit: + run() + assert system_exit.value.code == -2 + + +def test_re_raises_on_unexpected_exit(mocker: MockerFixture) -> None: + mocker.patch("tox.run.main", side_effect=ValueError) + with pytest.raises(ValueError, match=""): # noqa: PT011 + run() + + +def test_custom_work_dir(tox_project: ToxProjectCreator) -> None: + project = tox_project({}) + outcome = project.run("c", "--workdir", str(project.path.parent)) + assert outcome.state.conf.options.work_dir == project.path.parent diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 000000000..142e72a35 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from pytest_mock import MockFixture + +from tox import __version__ +from tox.plugin.manager import MANAGER +from tox.pytest import ToxProjectCreator + + +def test_version() -> None: + assert __version__ + + +def test_version_without_plugin(tox_project: ToxProjectCreator) -> None: + outcome = tox_project({"tox.ini": ""}).run("--version") + outcome.assert_success() + assert __version__ in outcome.out + assert "plugin" not in outcome.out + + +def test_version_with_plugin(tox_project: ToxProjectCreator, mocker: MockFixture) -> None: + dist = [ + ( + mocker.create_autospec("types.ModuleType", __file__=f"{i}-path"), + SimpleNamespace(project_name=i, version=v), + ) + for i, v in (("B", "1.0"), ("A", "2.0")) + ] + mocker.patch.object(MANAGER.manager, "list_plugin_distinfo", return_value=dist) + + outcome = tox_project({"tox.ini": ""}).run("--version") + + outcome.assert_success() + assert not outcome.err + lines = outcome.out.splitlines() + assert lines[0].startswith(__version__) + + assert lines[1:] == [ + "registered plugins:", + " B-1.0 at B-path", + " A-2.0 at A-path", + ] diff --git a/tests/tox_env/__init__.py b/tests/tox_env/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tox_env/python/__init__.py b/tests/tox_env/python/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tox_env/python/pip/req/test_file.py b/tests/tox_env/python/pip/req/test_file.py new file mode 100644 index 000000000..a24394d77 --- /dev/null +++ b/tests/tox_env/python/pip/req/test_file.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +import os +import sys +from contextlib import contextmanager +from io import BytesIO +from pathlib import Path +from typing import IO, Any, Iterator + +import pytest +from pytest_mock import MockerFixture + +from tox.pytest import CaptureFixture, MonkeyPatch +from tox.tox_env.python.pip.req.file import ParsedRequirement, RequirementsFile + +_REQ_FILE_TEST_CASES = [ + pytest.param("--pre", {"pre": True}, [], ["--pre"], id="pre"), + pytest.param("--no-index", {"index_url": []}, [], ["--no-index"], id="no-index"), + pytest.param("--no-index\n-i a\n--no-index", {"index_url": []}, [], ["--no-index"], id="no-index overwrites index"), + pytest.param("--prefer-binary", {"prefer_binary": True}, [], ["--prefer-binary"], id="prefer-binary"), + pytest.param("--require-hashes", {"require_hashes": True}, [], ["--require-hashes"], id="requires-hashes"), + pytest.param("--pre ", {"pre": True}, [], ["--pre"], id="space after"), + pytest.param(" --pre", {"pre": True}, [], ["--pre"], id="space before"), + pytest.param("--pre\\\n", {"pre": True}, [], ["--pre"], id="newline after"), + pytest.param("--pre # magic", {"pre": True}, [], ["--pre"], id="comment after space"), + pytest.param("--pre\t# magic", {"pre": True}, [], ["--pre"], id="comment after tab"), + pytest.param( + "--find-links /my/local/archives", + {"find_links": ["/my/local/archives"]}, + [], + ["-f", "/my/local/archives"], + id="find-links path", + ), + pytest.param( + "--find-links /my/local/archives --find-links /my/local/archives", + {"find_links": ["/my/local/archives"]}, + [], + ["-f", "/my/local/archives"], + id="find-links duplicate same line", + ), + pytest.param( + "--find-links /my/local/archives\n--find-links /my/local/archives", + {"find_links": ["/my/local/archives"]}, + [], + ["-f", "/my/local/archives"], + id="find-links duplicate different line", + ), + pytest.param( + "--find-links \\\n/my/local/archives", + {"find_links": ["/my/local/archives"]}, + [], + ["-f", "/my/local/archives"], + id="find-links newline path", + ), + pytest.param( + "--find-links http://some.archives.com/archives", + {"find_links": ["http://some.archives.com/archives"]}, + [], + ["-f", "http://some.archives.com/archives"], + id="find-links url", + ), + pytest.param("-i a", {"index_url": ["a"]}, [], ["-i", "a"], id="index url short"), + pytest.param("--index-url a", {"index_url": ["a"]}, [], ["-i", "a"], id="index url long"), + pytest.param("-i a -i b\n-i c", {"index_url": ["c"]}, [], ["-i", "c"], id="index url multiple"), + pytest.param( + "--extra-index-url a", + {"index_url": ["https://pypi.org/simple", "a"]}, + [], + ["--extra-index-url", "a"], + id="extra-index-url", + ), + pytest.param( + "--extra-index-url a --extra-index-url a", + {"index_url": ["https://pypi.org/simple", "a"]}, + [], + ["--extra-index-url", "a"], + id="extra-index-url dup same line", + ), + pytest.param( + "--extra-index-url a\n--extra-index-url a", + {"index_url": ["https://pypi.org/simple", "a"]}, + [], + ["--extra-index-url", "a"], + id="extra-index-url dup different line", + ), + pytest.param("-e a", {}, ["-e a"], ["-e", "a"], id="e"), + pytest.param("--editable a", {}, ["-e a"], ["-e", "a"], id="editable"), + pytest.param("--editable .[2,1]", {}, ["-e .[1,2]"], ["-e", ".[1,2]"], id="editable extra"), + pytest.param(".[\t, a1. , B2-\t, C3_, ]", {}, [".[B2-,C3_,a1.]"], [".[B2-,C3_,a1.]"], id="path with extra"), + pytest.param(".[a.1]", {}, [f".{os.sep}.[a.1]"], [f".{os.sep}.[a.1]"], id="path with invalid extra is path"), + pytest.param("-f a", {"find_links": ["a"]}, [], ["-f", "a"], id="f"), + pytest.param("--find-links a", {"find_links": ["a"]}, [], ["-f", "a"], id="find-links"), + pytest.param("--trusted-host a", {"trusted_hosts": ["a"]}, [], ["--trusted-host", "a"], id="trusted-host"), + pytest.param( + "--trusted-host a --trusted-host a", + {"trusted_hosts": ["a"]}, + [], + ["--trusted-host", "a"], + id="trusted-host dup same line", + ), + pytest.param( + "--trusted-host a\n--trusted-host a", + {"trusted_hosts": ["a"]}, + [], + ["--trusted-host", "a"], + id="trusted-host dup different line", + ), + pytest.param( + "--use-feature 2020-resolver", + {"features_enabled": ["2020-resolver"]}, + [], + ["--use-feature", "2020-resolver"], + id="use-feature space", + ), + pytest.param( + "--use-feature=fast-deps", + {"features_enabled": ["fast-deps"]}, + [], + ["--use-feature", "fast-deps"], + id="use-feature equal", + ), + pytest.param( + "--use-feature=fast-deps --use-feature 2020-resolver", + {"features_enabled": ["2020-resolver", "fast-deps"]}, + [], + ["--use-feature", "2020-resolver", "--use-feature", "fast-deps"], + id="use-feature multiple same line", + ), + pytest.param( + "--use-feature=fast-deps\n--use-feature 2020-resolver", + {"features_enabled": ["2020-resolver", "fast-deps"]}, + [], + ["--use-feature", "2020-resolver", "--use-feature", "fast-deps"], + id="use-feature multiple different line", + ), + pytest.param( + "--use-feature=fast-deps\n--use-feature 2020-resolver\n" * 2, + {"features_enabled": ["2020-resolver", "fast-deps"]}, + [], + ["--use-feature", "2020-resolver", "--use-feature", "fast-deps"], + id="use-feature multiple duplicate different line", + ), + pytest.param("--no-binary :all:", {"no_binary": ":all:"}, [], ["--no-binary", ":all:"], id="no-binary all"), + pytest.param("--no-binary :none:", {"no_binary": ":none:"}, [], ["--no-binary", ":none:"], id="no-binary none"), + pytest.param("--only-binary :all:", {"only_binary": ":all:"}, [], ["--only-binary", ":all:"], id="only-binary all"), + pytest.param( + "--only-binary :none:", + {"only_binary": ":none:"}, + [], + ["--only-binary", ":none:"], + id="only-binary none", + ), + pytest.param("####### example-requirements.txt #######", {}, [], [], id="comment"), + pytest.param("\t##### Requirements without Version Specifiers ######", {}, [], [], id="tab and comment"), + pytest.param(" # start", {}, [], [], id="space and comment"), + pytest.param("nose", {}, ["nose"], ["nose"], id="req"), + pytest.param("nose\nnose", {}, ["nose"], ["nose"], id="req dup"), + pytest.param( + "numpy[2,1] @ file://./downloads/numpy-1.9.2-cp34-none-win32.whl", + {}, + ["numpy[1,2]@ file://./downloads/numpy-1.9.2-cp34-none-win32.whl"], + ["numpy[1,2]@ file://./downloads/numpy-1.9.2-cp34-none-win32.whl"], + id="path with name-extra-protocol", + ), + pytest.param( + "docopt == 0.6.1 # Version Matching. Must be version 0.6.1", + {}, + ["docopt==0.6.1"], + ["docopt==0.6.1"], + id="req equal comment", + ), + pytest.param( + "keyring >= 4.1.1 # Minimum version 4.1.1", + {}, + ["keyring>=4.1.1"], + ["keyring>=4.1.1"], + id="req ge comment", + ), + pytest.param( + "coverage != 3.5 # Version Exclusion. Anything except version 3.5", + {}, + ["coverage!=3.5"], + ["coverage!=3.5"], + id="req ne comment", + ), + pytest.param( + "Mopidy-Dirble ~= 1.1 # Compatible release. Same as >= 1.1, == 1.*", + {}, + ["Mopidy-Dirble~=1.1"], + ["Mopidy-Dirble~=1.1"], + id="req approx comment", + ), + pytest.param("b==1.3", {}, ["b==1.3"], ["b==1.3"], id="req eq"), + pytest.param("c >=1.2,<2.0", {}, ["c<2.0,>=1.2"], ["c<2.0,>=1.2"], id="req ge lt"), + pytest.param("d[bar,foo]", {}, ["d[bar,foo]"], ["d[bar,foo]"], id="req extras"), + pytest.param("d[foo, bar]", {}, ["d[bar,foo]"], ["d[bar,foo]"], id="req extras space"), + pytest.param("d[foo,\tbar]", {}, ["d[bar,foo]"], ["d[bar,foo]"], id="req extras tab"), + pytest.param("e~=1.4.2", {}, ["e~=1.4.2"], ["e~=1.4.2"], id="req approx"), + pytest.param( + "f ==5.4 ; python_version < '2.7'", + {}, + ['f==5.4; python_version < "2.7"'], + ['f==5.4; python_version < "2.7"'], + id="python version filter", + ), + pytest.param( + "g; sys_platform == 'win32'", + {}, + ['g; sys_platform == "win32"'], + ['g; sys_platform == "win32"'], + id="platform filter", + ), + pytest.param( + "http://w.org/w_P-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl", + {}, + ["http://w.org/w_P-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl"], + ["http://w.org/w_P-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl"], + id="http URI", + ), + pytest.param( + "git+https://git.example.com/MyProject#egg=MyProject", + {}, + ["git+https://git.example.com/MyProject#egg=MyProject"], + ["git+https://git.example.com/MyProject#egg=MyProject"], + id="vcs with https", + ), + pytest.param( + "git+ssh://git.example.com/MyProject#egg=MyProject", + {}, + ["git+ssh://git.example.com/MyProject#egg=MyProject"], + ["git+ssh://git.example.com/MyProject#egg=MyProject"], + id="vcs with ssh", + ), + pytest.param( + "git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709#egg=MyProject", + {}, + ["git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709#egg=MyProject"], + ["git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709#egg=MyProject"], + id="vcs with commit hash pin", + ), + pytest.param( + "attrs --hash sha384:142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5e2ff2c528ecae04" + "912224782ab\t--hash=sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152 # ok", + {}, + [ + "attrs --hash sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152 --hash sha384:" + "142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5e2ff2c528ecae04912224782ab", + ], + ["attrs"], + id="hash", + ), + pytest.param( + "attrs --hash=sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152\\\n " + "--hash sha384:142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5" + "e2ff2c528ecae04912224782ab\n", + {}, + [ + "attrs --hash sha256:af957b369adcd07e5b3c64d2cdb76d6808c5e0b16c35ca41c79c8eee34808152 --hash sha384:" + "142d9b02f3f4511ccabf6c14bd34d2b0a9ed043a898228b48343cfdf4eb10856ef7ad5e2ff2c528ecae04912224782ab", + ], + ["attrs"], + id="hash with escaped newline", + ), + pytest.param( + "attrs --hash=sha512:7a91e5a3d1a1238525e477385ef5ee6cecdc8f8fcc2a79d1b35a9f57ad15c814" + "dada670026f41fdd62e5e10b3fd75d6112704a9521c3df105f0b6f3bb11b128a", + {}, + [ + "attrs --hash sha512:7a91e5a3d1a1238525e477385ef5ee6cecdc8f8fcc2a79d1b35a9f57ad15c814" + "dada670026f41fdd62e5e10b3fd75d6112704a9521c3df105f0b6f3bb11b128a", + ], + ["attrs"], + id="sha512 hash is supported", + ), +] + + +@pytest.mark.parametrize(("req", "opts", "requirements", "as_args"), _REQ_FILE_TEST_CASES) +def test_req_file(tmp_path: Path, req: str, opts: dict[str, Any], requirements: list[str], as_args: list[str]) -> None: + requirements_txt = tmp_path / "req.txt" + requirements_txt.write_text(req) + req_file = RequirementsFile(requirements_txt, constraint=False) + assert req_file.as_root_args == as_args + assert str(req_file) == f"-r {requirements_txt}" + assert vars(req_file.options) == opts + found = [str(i) for i in req_file.requirements] + assert found == requirements + + +def test_requirements_env_var_present(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("ENV_VAR", "beta") + requirements_file = tmp_path / "req.txt" + requirements_file.write_text("${ENV_VAR} >= 1") + req_file = RequirementsFile(requirements_file, constraint=False) + assert vars(req_file.options) == {} + found = [str(i) for i in req_file.requirements] + assert found == ["beta>=1"] + + +def test_requirements_env_var_missing(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.delenv("ENV_VAR", raising=False) + requirements_file = tmp_path / "req.txt" + requirements_file.write_text("${ENV_VAR}") + req_file = RequirementsFile(requirements_file, constraint=False) + assert vars(req_file.options) == {} + found = [str(i) for i in req_file.requirements] + assert found == [f".{os.sep}${{ENV_VAR}}"] + + +@pytest.mark.parametrize("flag", ["-r", "--requirement"]) +def test_requirements_txt_transitive(tmp_path: Path, flag: str) -> None: + other_req = tmp_path / "other-requirements.txt" + other_req.write_text("magic\nmagical") + requirements_file = tmp_path / "req.txt" + requirements_file.write_text(f"{flag} other-requirements.txt\n{flag} other-requirements.txt") + req_file = RequirementsFile(requirements_file, constraint=False) + assert req_file.as_root_args == ["-r", "other-requirements.txt"] + assert req_file.as_root_args is req_file.as_root_args # check it's cached + assert vars(req_file.options) == {} + found = [str(i) for i in req_file.requirements] + assert found == ["magic", "magical"] + + +@pytest.mark.parametrize( + ("raw", "error"), + [ + ("--pre something", "unrecognized arguments: something"), + ("--missing", "unrecognized arguments: --missing"), + ("--index-url a b", "unrecognized arguments: b"), + ("--index-url", "argument -i/--index-url/--pypi-url: expected one argument"), + ("-k", "unrecognized arguments: -k"), + ], +) +def test_bad_line(tmp_path: Path, raw: str, capfd: CaptureFixture, error: str) -> None: + requirements_file = tmp_path / "req.txt" + requirements_file.write_text(raw) + req_file = RequirementsFile(requirements_file, constraint=False) + with pytest.raises(ValueError, match=f"^{error}$"): + assert req_file.options + out, err = capfd.readouterr() + assert not out + assert not err + + +def test_requirements_file_missing(tmp_path: Path) -> None: + requirements_file = tmp_path / "req.txt" + requirements_file.write_text("-r one.txt") + req_file = RequirementsFile(requirements_file, constraint=False) + with pytest.raises(ValueError, match="No such file or directory: .*one.txt"): + assert req_file.options + + +@pytest.mark.parametrize("flag", ["-c", "--constraint"]) +def test_constraint_txt_expanded(tmp_path: Path, flag: str) -> None: + other_req = tmp_path / "other.txt" + other_req.write_text("magic\nmagical\n-i a") + requirements_file = tmp_path / "req.txt" + requirements_file.write_text(f"{flag} other.txt\n{flag} other.txt") + req_file = RequirementsFile(requirements_file, constraint=True) + assert req_file.as_root_args == ["-c", "other.txt"] + assert vars(req_file.options) == {"index_url": ["a"]} + found = [str(i) for i in req_file.requirements] + assert found == ["-c magic", "-c magical"] + + +@pytest.mark.skipif(sys.platform == "win32", reason=r"on windows the escaped \ is overloaded by path separator") +def test_req_path_with_space_escape(tmp_path: Path) -> None: + dep_requirements_file = tmp_path / "a b" + dep_requirements_file.write_text("c") + path = f"-r {str(dep_requirements_file)}" + path = f'{path[:-len("a b")]}a\\ b' + + requirements_file = tmp_path / "req.txt" + requirements_file.write_text(path) + req_file = RequirementsFile(requirements_file, constraint=False) + + assert vars(req_file.options) == {} + found = [str(i) for i in req_file.requirements] + assert found == ["c"] + + +@pytest.mark.parametrize( + "hash_value", + [ + "sha256:a", + "sha256:xxxxxxxxxx123456789012345678901234567890123456789012345678901234", + "sha512:thisshouldfail8525e477385ef5ee6cecdc8f8fcc2a79d1b35a9f57ad15c814" + "dada670026f41fdd62e5e10b3fd75d6112704a9521c3df105f0b6f3bb11b128a", + ], +) +def test_bad_hash(hash_value: str, tmp_path: Path) -> None: + requirements_txt = tmp_path / "req.txt" + requirements_txt.write_text(f"attrs --hash {hash_value}") + req_file = RequirementsFile(requirements_txt, constraint=False) + with pytest.raises(ValueError, match=f"^argument --hash: {hash_value}$"): + assert req_file.requirements + + +@pytest.mark.parametrize("codec", ["utf-8", "utf-16", "utf-32"]) +def test_custom_file_encoding(codec: str, tmp_path: Path) -> None: + requirements_file = tmp_path / "r.txt" + raw = "art".encode(codec) + requirements_file.write_bytes(raw) + req_file = RequirementsFile(requirements_file, constraint=False) + assert [str(i) for i in req_file.requirements] == ["art"] + + +def test_parsed_requirement_properties(tmp_path: Path) -> None: + req = ParsedRequirement("a", {"b": 1}, str(tmp_path), 1) + assert req.options == {"b": 1} + assert str(req.requirement) == "a" + assert req.from_file == str(tmp_path) + assert req.lineno == 1 + + +def test_parsed_requirement_repr_with_opt(tmp_path: Path) -> None: + req = ParsedRequirement("a", {"b": 1}, str(tmp_path), 1) + assert repr(req) == "ParsedRequirement(requirement=a, options={'b': 1})" + + +def test_parsed_requirement_repr_no_opt(tmp_path: Path) -> None: + assert repr(ParsedRequirement("a", {}, str(tmp_path), 2)) == "ParsedRequirement(requirement=a)" + + +@pytest.mark.parametrize("flag", ["-r", "--requirement", "-c", "--constraint"]) +def test_req_over_http(tmp_path: Path, flag: str, mocker: MockerFixture) -> None: + is_constraint = flag in ("-c", "--constraint") + url_open = mocker.patch("tox.tox_env.python.pip.req.file.urlopen", autospec=True) + url_open.return_value.__enter__.return_value = BytesIO(b"-i i\na") + requirements_txt = tmp_path / "req.txt" + requirements_txt.write_text(f"{flag} https://zopefoundation.github.io/Zope/releases/4.5.5/requirements-full.txt") + req_file = RequirementsFile(requirements_txt, constraint=is_constraint) + assert str(req_file) == f"-{'c' if is_constraint else 'r'} {requirements_txt}" + assert vars(req_file.options) == {"index_url": ["i"]} + found = [str(i) for i in req_file.requirements] + assert found == [f"{'-c ' if is_constraint else ''}a"] + + +def test_req_over_http_has_req(tmp_path: Path, mocker: MockerFixture) -> None: + @contextmanager + def enter(url: str) -> Iterator[IO[bytes]]: + if url == "https://root.org/a.txt": + yield BytesIO(b"-r b.txt") + elif url == "https://root.org/b.txt": + yield BytesIO(b"-i i\na") + else: # pragma: no cover + raise RuntimeError # pragma: no cover + + mocker.patch("tox.tox_env.python.pip.req.file.urlopen", autospec=True, side_effect=enter) + + requirements_txt = tmp_path / "req.txt" + requirements_txt.write_text("-r https://root.org/a.txt") + req_file = RequirementsFile(requirements_txt, constraint=False) + + assert vars(req_file.options) == {"index_url": ["i"]} + found = [str(i) for i in req_file.requirements] + assert found == ["a"] + + +@pytest.mark.parametrize( + "loc", + ["file://", "file://localhost"], +) +def test_requirement_via_file_protocol(tmp_path: Path, loc: str) -> None: + other_req = tmp_path / "other-requirements.txt" + other_req.write_text("-i i\na") + requirements_text = tmp_path / "req.txt" + requirements_text.write_text(f"-r {loc}{'/' if sys.platform == 'win32' else ''}{other_req}") + + req_file = RequirementsFile(requirements_text, constraint=False) + + assert vars(req_file.options) == {"index_url": ["i"]} + found = [str(i) for i in req_file.requirements] + assert found == ["a"] + + +def test_requirement_via_file_protocol_na(tmp_path: Path) -> None: + other_req = tmp_path / "other-requirements.txt" + other_req.write_text("-i i\na") + requirements_text = tmp_path / "req.txt" + requirements_text.write_text(f"-r file://magic.com{'/' if sys.platform == 'win32' else ''}{other_req}") + + req_file = RequirementsFile(requirements_text, constraint=False) + pattern = r"non-local file URIs are not supported on this platform: 'file://magic\.com\.*" + with pytest.raises(ValueError, match=pattern): + assert req_file.options + + +def test_requirement_to_path_one_level_up(tmp_path: Path) -> None: + other_req = tmp_path / "other.txt" + other_req.write_text("-e ..") + req_file = RequirementsFile(other_req, constraint=False) + result = req_file.requirements + assert result[0].requirement == str(tmp_path.parent.resolve()) diff --git a/tests/tox_env/python/pip/test_pip_install.py b/tests/tox_env/python/pip/test_pip_install.py new file mode 100644 index 000000000..bc4705738 --- /dev/null +++ b/tests/tox_env/python/pip/test_pip_install.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import pytest + +from tox.pytest import CaptureFixture, ToxProjectCreator + + +@pytest.mark.parametrize("arg", [object, [object]]) +def test_pip_install_bad_type(tox_project: ToxProjectCreator, capfd: CaptureFixture, arg: Any) -> None: + proj = tox_project({"tox.ini": ""}) + result = proj.run("l") + result.assert_success() + pip = result.state.envs["py"].installer + + with pytest.raises(SystemExit, match="1"): + pip.install(arg, "section", "type") + out, err = capfd.readouterr() + assert not err + assert f"pip cannot install {object!r}" in out + + +def test_pip_install_empty_list(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": ""}) + result = proj.run("l") + result.assert_success() + + pip = result.state.envs["py"].installer + execute_calls = proj.patch_execute(Mock()) + pip.install([], "section", "type") + assert execute_calls.call_count == 0 + + +def test_pip_install_flags_only_error(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv:py]\ndeps=-i a"}) + result = proj.run("r") + result.assert_failed() + assert "no dependencies for tox env py within deps" in result.out + + +def test_pip_install_new_flag_recreates(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv:py]\ndeps=a\nskip_install=true"}) + proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + + result = proj.run("r") + result.assert_success() + + (proj.path / "tox.ini").write_text("[testenv:py]\ndeps=a\n -i i\nskip_install=true") + result_second = proj.run("r") + result_second.assert_success() + assert "recreate env because changed install flag(s) added index_url=['i']" in result_second.out + assert "install_deps> python -I -m pip install a -i i" in result_second.out + + +def test_pip_install_path(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv:py]\ndeps=.{/}a\nskip_install=true"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + + result = proj.run("r") + result.assert_success() + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", f".{os.sep}a"] + + +@pytest.mark.parametrize( + ("content", "args"), + [ + pytest.param("-e .", ["-e", "."], id="short editable"), + pytest.param("--editable .", ["-e", "."], id="long editable"), + pytest.param( + "git+ssh://git.example.com/MyProject\\#egg=MyProject", + ["git+ssh://git.example.com/MyProject#egg=MyProject"], + id="vcs with ssh", + ), + pytest.param( + "git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709\\#egg=MyProject", + ["git+https://git.example.com/MyProject.git@da39a3ee5e6b4b0d3255bfef95601890afd80709#egg=MyProject"], + id="vcs with commit hash pin", + ), + ], +) +def test_pip_install_req_file_req_like(tox_project: ToxProjectCreator, content: str, args: list[str]) -> None: + proj = tox_project({"tox.ini": f"[testenv:py]\ndeps={content}\nskip_install=true"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + + result = proj.run("r") + result.assert_success() + + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install"] + args + + # check that adding a new dependency correctly finds the previous one + (proj.path / "tox.ini").write_text(f"[testenv:py]\ndeps={content}\n a\nskip_install=true") + execute_calls.reset_mock() + + result_second = proj.run("r") + result_second.assert_success() + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a"] + args + + +def test_pip_req_path(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv:py]\ndeps=.\nskip_install=true"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + + result = proj.run("r") + result.assert_success() + + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "."] + + +def test_deps_remove_recreate(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=skip\ndeps=wheel\n setuptools"}) + execute_calls = proj.patch_execute(lambda request: 0) # noqa: U100 + result_first = proj.run("r") + result_first.assert_success() + assert execute_calls.call_count == 1 + + (proj.path / "tox.ini").write_text("[testenv]\npackage=skip\ndeps=setuptools\n") + result_second = proj.run("r") + result_second.assert_success() + assert "py: recreate env because requirements removed: wheel" in result_second.out, result_second.out + assert execute_calls.call_count == 2 + + +def test_pkg_dep_remove_recreate(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + build = (demo_pkg_inline / "build.py").read_text() + build_with_dep = build.replace("Summary: UNKNOWN\n", "Summary: UNKNOWN\n Requires-Dist: wheel\n") + proj = tox_project( + { + "tox.ini": "[testenv]\npackage=wheel", + "pyproject.toml": (demo_pkg_inline / "pyproject.toml").read_text(), + "build.py": build_with_dep, + }, + ) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + + result_first = proj.run("r") + result_first.assert_success() + run_ids = [i[0][3].run_id for i in execute_calls.call_args_list] + assert run_ids == [ + "_optional_hooks", + "get_requires_for_build_wheel", + "build_wheel", + "install_package_deps", + "install_package", + "_exit", + ] + execute_calls.reset_mock() + + (proj.path / "build.py").write_text(build) + result_second = proj.run("r") + result_second.assert_success() + assert "py: recreate env because dependencies removed: wheel" in result_second.out, result_second.out + run_ids = [i[0][3].run_id for i in execute_calls.call_args_list] + assert run_ids == ["_optional_hooks", "get_requires_for_build_wheel", "build_wheel", "install_package", "_exit"] + + +def test_pkg_env_dep_remove_recreate(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + toml = (demo_pkg_inline / "pyproject.toml").read_text() + proj = tox_project( + { + "tox.ini": "[testenv]\npackage=wheel", + "pyproject.toml": toml.replace("requires = []", 'requires = ["setuptools"]'), + "build.py": (demo_pkg_inline / "build.py").read_text(), + }, + ) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result_first = proj.run("r") + result_first.assert_success() + run_ids = [i[0][3].run_id for i in execute_calls.call_args_list] + assert run_ids == [ + "install_requires", + "_optional_hooks", + "get_requires_for_build_wheel", + "build_wheel", + "install_package", + "_exit", + ] + execute_calls.reset_mock() + + (proj.path / "pyproject.toml").write_text(toml) + result_second = proj.run("r") + result_second.assert_success() + assert ".pkg: recreate env because dependencies removed: setuptools" in result_second.out, result_second.out + run_ids = [i[0][3].run_id for i in execute_calls.call_args_list] + assert run_ids == ["_optional_hooks", "get_requires_for_build_wheel", "build_wheel", "install_package", "_exit"] + + +def test_pip_install_requirements_file_deps(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv]\ndeps=-r r.txt\nskip_install=true", "r.txt": "a"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r") + result.assert_success() + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "-r", "r.txt"] + + # check that adding a new dependency correctly finds the previous one + (proj.path / "tox.ini").write_text("[testenv]\ndeps=-r r.txt\n b\nskip_install=true") + execute_calls.reset_mock() + result_second = proj.run("r") + result_second.assert_success() + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "b", "-r", "r.txt"] + + # if the requirement file changes recreate + (proj.path / "r.txt").write_text("c\nd") + execute_calls.reset_mock() + result_third = proj.run("r") + result_third.assert_success() + assert "py: recreate env because requirements removed: a" in result_third.out, result_third.out + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "b", "-r", "r.txt"] + + +def test_pip_install_constraint_file_create_change(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv]\ndeps=-c c.txt\n a\nskip_install=true", "c.txt": "b"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r") + result.assert_success() + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a", "-c", "c.txt"] + + # a new dependency triggers an install + (proj.path / "tox.ini").write_text("[testenv]\ndeps=-c c.txt\n a\n d\nskip_install=true") + execute_calls.reset_mock() + result_second = proj.run("r") + result_second.assert_success() + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a", "d", "-c", "c.txt"] + + # a new constraints triggers a recreate + (proj.path / "c.txt").write_text("") + execute_calls.reset_mock() + result_third = proj.run("r") + result_third.assert_success() + assert "py: recreate env because changed constraint(s) removed b" in result_third.out, result_third.out + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a", "d", "-c", "c.txt"] + + +def test_pip_install_constraint_file_new(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv]\ndeps=a\nskip_install=true"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r") + result.assert_success() + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a"] + + (proj.path / "c.txt").write_text("a") + (proj.path / "tox.ini").write_text("[testenv]\ndeps=a\n -c c.txt\nskip_install=true") + execute_calls.reset_mock() + result_second = proj.run("r") + result_second.assert_success() + assert "py: recreate env because changed constraint(s) added a" in result_second.out, result_second.out + assert execute_calls.call_count == 1 + assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a", "-c", "c.txt"] diff --git a/tests/tox_env/python/pip/test_req_file.py b/tests/tox_env/python/pip/test_req_file.py new file mode 100644 index 000000000..0abc6151e --- /dev/null +++ b/tests/tox_env/python/pip/test_req_file.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tox.tox_env.python.pip.req_file import PythonDeps + + +@pytest.mark.parametrize("legacy_flag", ["-r", "-c"]) +def test_legacy_requirement_file(tmp_path: Path, legacy_flag: str) -> None: + python_deps = PythonDeps(f"{legacy_flag}a.txt", tmp_path) + (tmp_path / "a.txt").write_text("b") + assert python_deps.as_root_args == [legacy_flag, "a.txt"] + assert vars(python_deps.options) == {} + assert [str(i) for i in python_deps.requirements] == ["b" if legacy_flag == "-r" else "-c b"] + + +def test_deps_with_hash(tmp_path: Path) -> None: + """deps with --hash should raise an exception.""" + python_deps = PythonDeps( + raw="foo==1 --hash sha256:97a702083b0d906517b79672d8501eee470d60ae55df0fa9d4cfba56c7f65a82", + root=tmp_path, + ) + with pytest.raises(ValueError, match="Cannot use --hash in deps list"): + _ = python_deps.requirements + + +def test_deps_with_requirements_with_hash(tmp_path: Path) -> None: + """deps can point to a requirements file that has --hash.""" + exp_hash = "sha256:97a702083b0d906517b79672d8501eee470d60ae55df0fa9d4cfba56c7f65a82" + requirements = tmp_path / "requirements.txt" + requirements.write_text(f"foo==1 --hash {exp_hash}") + python_deps = PythonDeps(raw="-r requirements.txt", root=tmp_path) + assert len(python_deps.requirements) == 1 + parsed_req = python_deps.requirements[0] + assert str(parsed_req.requirement) == "foo==1" + assert parsed_req.options == {"hash": [exp_hash]} + assert parsed_req.from_file == str(requirements) diff --git a/tests/tox_env/python/test-pkg/pyproject.toml b/tests/tox_env/python/test-pkg/pyproject.toml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tox_env/python/test_python_api.py b/tests/tox_env/python/test_python_api.py new file mode 100644 index 000000000..aa4c1470b --- /dev/null +++ b/tests/tox_env/python/test_python_api.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Callable + +import pytest +from pytest_mock import MockerFixture + +from tox.pytest import ToxProjectCreator +from tox.tox_env.errors import Fail +from tox.tox_env.python.api import Python + + +def test_requirements_txt(tox_project: ToxProjectCreator) -> None: + prj = tox_project( + { + "tox.ini": "[testenv]\npackage=skip\ndeps=-rrequirements.txt", + "requirements.txt": "nose", + }, + ) + execute_calls = prj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = prj.run("r", "-e", "py") + result.assert_success() + + assert execute_calls.call_count == 1 + exp = ["python", "-I", "-m", "pip", "install", "-r", "requirements.txt"] + got_cmd = execute_calls.call_args[0][3].cmd + + assert got_cmd == exp + + +def test_conflicting_base_python_factor() -> None: + major, minor = sys.version_info[0:2] + name = f"py{major}{minor}-py{major}{minor-1}" + with pytest.raises(ValueError, match=f"conflicting factors py{major}{minor}, py{major}{minor-1} in {name}"): + Python.extract_base_python(name) + + +def test_build_wheel_in_non_base_pkg_env( + tox_project: ToxProjectCreator, + patch_prev_py: Callable[[bool], tuple[str, str]], + demo_pkg_inline: Path, + mocker: MockerFixture, +) -> None: + mocker.patch("tox.tox_env.python.virtual_env.api.session_via_cli") + prev_ver, impl = patch_prev_py(True) + prev_py = f"py{prev_ver}" + prj = tox_project({"tox.ini": f"[tox]\nenv_list= {prev_py}\n[testenv]\npackage=wheel"}) + execute_calls = prj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = prj.run("-r", "--root", str(demo_pkg_inline)) + result.assert_success() + calls = [(i[0][0].conf.name, i[0][3].run_id) for i in execute_calls.call_args_list] + assert calls == [ + (f".pkg-{impl}{prev_ver}", "_optional_hooks"), + (f".pkg-{impl}{prev_ver}", "get_requires_for_build_wheel"), + (f".pkg-{impl}{prev_ver}", "build_wheel"), + (f"py{prev_ver}", "install_package"), + (f".pkg-{impl}{prev_ver}", "_exit"), + ] + + +def test_diff_msg_added_removed_changed() -> None: + before = {"A": "1", "F": "8", "C": "3", "D": "4", "E": "6"} + after = {"G": "9", "B": "2", "C": "3", "D": "5", "E": "7"} + expected = "python added A='1' | F='8', removed G='9' | B='2', changed D='5'->'4' | E='7'->'6'" + assert Python._diff_msg(before, after) == expected + + +def test_diff_msg_no_diff() -> None: + assert Python._diff_msg({}, {}) == "python " + + +@pytest.mark.parametrize("ignore_conflict", [True, False]) +@pytest.mark.parametrize( + ("env", "base_python"), + [ + ("magic", ["pypy"]), + ("magic", ["py39"]), + ], + ids=lambda a: "|".join(a) if isinstance(a, list) else str(a), +) +def test_base_python_env_no_conflict(env: str, base_python: list[str], ignore_conflict: bool) -> None: + result = Python._validate_base_python(env, base_python, ignore_conflict) + assert result is base_python + + +@pytest.mark.parametrize("ignore_conflict", [True, False]) +@pytest.mark.parametrize( + ("env", "base_python", "conflict"), + [ + ("cpython", ["pypy"], ["pypy"]), + ("pypy", ["cpython"], ["cpython"]), + ("pypy2", ["pypy3"], ["pypy3"]), + ("py3", ["py2"], ["py2"]), + ("py38", ["py39"], ["py39"]), + ("py38", ["py38", "py39"], ["py39"]), + ("py310", ["py38", "py39"], ["py38", "py39"]), + ("py3.11.1", ["py3.11.2"], ["py3.11.2"]), + ("py3-64", ["py3-32"], ["py3-32"]), + ("py310-magic", ["py39"], ["py39"]), + ], + ids=lambda a: "|".join(a) if isinstance(a, list) else str(a), +) +def test_base_python_env_conflict(env: str, base_python: list[str], conflict: list[str], ignore_conflict: bool) -> None: + if ignore_conflict: + result = Python._validate_base_python(env, base_python, ignore_conflict) + assert result == [env] + else: + msg = f"env name {env} conflicting with base python {conflict[0]}" + with pytest.raises(Fail, match=msg): + Python._validate_base_python(env, base_python, ignore_conflict) + + +@pytest.mark.parametrize("ignore_conflict", [True, False, None]) +def test_base_python_env_conflict_show_conf(tox_project: ToxProjectCreator, ignore_conflict: bool) -> None: + py_ver = "".join(str(i) for i in sys.version_info[0:2]) + py_ver_next = "".join(str(i) for i in (sys.version_info[0], sys.version_info[1] + 2)) + ini = f"[testenv]\npackage=skip\nbase_python=py{py_ver_next}" + if ignore_conflict is not None: + ini += f"\n[tox]\nignore_base_python_conflict={ignore_conflict}" + project = tox_project({"tox.ini": ini}) + result = project.run("c", "-e", f"py{py_ver}", "-k", "base_python") + result.assert_success() + if ignore_conflict: + out = f"[testenv:py{py_ver}]\nbase_python = py{py_ver}\n" + else: + comma_in_exc = sys.version_info[0:2] <= (3, 6) + out = ( + f"[testenv:py{py_ver}]\nbase_python = # Exception: Fail('env name py{py_ver} conflicting with" + f" base python py{py_ver_next}'{',' if comma_in_exc else ''})\n" + ) + result.assert_out_err(out, "") diff --git a/tests/tox_env/python/test_python_runner.py b/tests/tox_env/python/test_python_runner.py new file mode 100644 index 000000000..797ad638d --- /dev/null +++ b/tests/tox_env/python/test_python_runner.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from pathlib import Path + +from tox.journal import EnvJournal +from tox.pytest import ToxProjectCreator +from tox.tox_env.package import PathPackage +from tox.tox_env.python.runner import PythonRun + + +def test_deps_config_path_req(tox_project: ToxProjectCreator) -> None: + project = tox_project( + { + "tox.ini": "[testenv:py]\ndeps =-rpath.txt\n -r {toxinidir}{/}path2.txt\n pytest", + "path.txt": "alpha", + "path2.txt": "beta", + }, + ) + result = project.run("c", "-e", "py") + result.assert_success() + deps = result.state.conf.get_env("py")["deps"] + assert deps.unroll() == ([], ["alpha", "beta", "pytest"]) + assert deps.as_root_args == ["pytest", "-r", "path.txt", "-r", str(project.path / "path2.txt")] + assert str(deps) == f"-r {project.path / 'tox.ini'}" + + +def test_journal_package_empty() -> None: + journal = EnvJournal(enabled=True, name="a") + + PythonRun._handle_journal_package(journal, []) + + content = journal.content + assert content == {} + + +def test_journal_one_wheel_file(tmp_path: Path) -> None: + wheel = tmp_path / "a.whl" + wheel.write_bytes(b"magical") + journal = EnvJournal(enabled=True, name="a") + + PythonRun._handle_journal_package(journal, [PathPackage(wheel)]) + + content = journal.content + assert content == { + "installpkg": { + "basename": "a.whl", + "sha256": "0ce2d4c7087733c06b1087b28db95e114d7caeb515b841c6cdec8960cf884654", + "type": "file", + }, + } + + +def test_journal_multiple_wheel_file(tmp_path: Path) -> None: + wheel_1 = tmp_path / "a.whl" + wheel_1.write_bytes(b"magical") + wheel_2 = tmp_path / "b.whl" + wheel_2.write_bytes(b"magic") + journal = EnvJournal(enabled=True, name="a") + + PythonRun._handle_journal_package(journal, [PathPackage(wheel_1), PathPackage(wheel_2)]) + + content = journal.content + assert content == { + "installpkg": [ + { + "basename": "a.whl", + "sha256": "0ce2d4c7087733c06b1087b28db95e114d7caeb515b841c6cdec8960cf884654", + "type": "file", + }, + { + "basename": "b.whl", + "sha256": "3be7a505483c0050243c5cbad4700da13925aa4137a55e9e33efd8bc4d05850f", + "type": "file", + }, + ], + } + + +def test_journal_package_dir(tmp_path: Path) -> None: + journal = EnvJournal(enabled=True, name="a") + + PythonRun._handle_journal_package(journal, [PathPackage(tmp_path)]) + + content = journal.content + assert content == { + "installpkg": { + "basename": tmp_path.name, + "type": "dir", + }, + } + + +def test_package_temp_dir_view(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + project = tox_project({"tox.ini": "[testenv]\npackage=wheel"}) + result = project.run("r", "-vv", "-e", "py", "--root", str(demo_pkg_inline)) + result.assert_success() + wheel_name = "demo_pkg_inline-1.0.0-py3-none-any.whl" + session_path = Path(".tmp") / "package" / "1" / wheel_name + msg = f" D package {session_path} links to {Path('.pkg') / 'dist'/ wheel_name} ({project.path/ '.tox'}) " + assert msg in result.out + assert f" D delete package {project.path / '.tox' / session_path}" in result.out diff --git a/tests/tox_env/python/virtual_env/__init__.py b/tests/tox_env/python/virtual_env/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tox_env/python/virtual_env/package/__init__.py b/tests/tox_env/python/virtual_env/package/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tox_env/python/virtual_env/package/conftest.py b/tests/tox_env/python/virtual_env/package/conftest.py new file mode 100644 index 000000000..a7e77d5cf --- /dev/null +++ b/tests/tox_env/python/virtual_env/package/conftest.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from textwrap import dedent + +import pytest +from _pytest.tmpdir import TempPathFactory + + +@pytest.fixture(scope="session") +def pkg_with_extras_project(tmp_path_factory: TempPathFactory) -> Path: + py_ver = ".".join(str(i) for i in sys.version_info[0:2]) + setup_cfg = f""" + [metadata] + name = demo + version = 1.0.0 + [options] + packages = find: + install_requires = + platformdirs>=2.1 + colorama>=0.4.3 + + [options.extras_require] + testing = + covdefaults>=1.2; python_version == '2.7' or python_version == '{py_ver}' + pytest>=5.4.1; python_version == '{py_ver}' + docs = + sphinx>=3 + sphinx-rtd-theme>=0.4.3,<1 + format = + black>=3 + flake8 + """ + tmp_path = tmp_path_factory.mktemp("prj") + (tmp_path / "setup.cfg").write_text(dedent(setup_cfg)) + (tmp_path / "setup.py").write_text("from setuptools import setup; setup()") + toml = '[build-system]\nrequires=["setuptools", "wheel"]\nbuild-backend = "setuptools.build_meta"' + (tmp_path / "pyproject.toml").write_text(toml) + return tmp_path diff --git a/tests/tox_env/python/virtual_env/package/test_package_cmd_builder.py b/tests/tox_env/python/virtual_env/package/test_package_cmd_builder.py new file mode 100644 index 000000000..f3cc82972 --- /dev/null +++ b/tests/tox_env/python/virtual_env/package/test_package_cmd_builder.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Callable +from zipfile import ZipFile + +import pytest + +from tox.pytest import ToxProjectCreator + + +@pytest.fixture(scope="session") +def pkg_with_extras_project_wheel( + pkg_with_extras_project: Path, + pkg_builder: Callable[[Path, Path, list[str], bool], Path], +) -> Path: + dist = pkg_with_extras_project / "dist" + pkg_builder(dist, pkg_with_extras_project, ["wheel"], False) + return next(dist.iterdir()) + + +def test_tox_install_pkg_wheel(tox_project: ToxProjectCreator, pkg_with_extras_project_wheel: Path) -> None: + proj = tox_project({"tox.ini": "[testenv]\nextras=docs,format"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r", "-e", "py", "--installpkg", str(pkg_with_extras_project_wheel)) + result.assert_success() + calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd[5:]) for i in execute_calls.call_args_list] + deps = ["black>=3", "colorama>=0.4.3", "flake8", "platformdirs>=2.1", "sphinx-rtd-theme<1,>=0.4.3", "sphinx>=3"] + expected = [ + ("py", "install_package_deps", deps), + ("py", "install_package", ["--force-reinstall", "--no-deps", str(pkg_with_extras_project_wheel)]), + ] + assert calls == expected + + +@pytest.fixture() +def pkg_with_extras_project_sdist( + pkg_with_extras_project: Path, + pkg_builder: Callable[[Path, Path, list[str], bool], Path], +) -> Path: + dist = pkg_with_extras_project / "sdist" + pkg_builder(dist, pkg_with_extras_project, ["sdist"], False) + return next(dist.iterdir()) + + +def test_tox_install_pkg_sdist(tox_project: ToxProjectCreator, pkg_with_extras_project_sdist: Path) -> None: + proj = tox_project({"tox.ini": "[testenv]\nextras=docs,format"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r", "-e", "py", "--installpkg", str(pkg_with_extras_project_sdist)) + result.assert_success() + calls = [(i[0][0].conf.name, i[0][3].run_id, i[0][3].cmd[5:]) for i in execute_calls.call_args_list] + deps = ["black>=3", "colorama>=0.4.3", "flake8", "platformdirs>=2.1", "sphinx-rtd-theme<1,>=0.4.3", "sphinx>=3"] + assert calls == [ + (".pkg_external_sdist_meta", "install_requires", ["setuptools", "wheel"]), + (".pkg_external_sdist_meta", "_optional_hooks", []), + (".pkg_external_sdist_meta", "get_requires_for_build_sdist", []), + (".pkg_external_sdist_meta", "prepare_metadata_for_build_wheel", []), + ("py", "install_package_deps", deps), + ("py", "install_package", ["--force-reinstall", "--no-deps", str(pkg_with_extras_project_sdist)]), + (".pkg_external_sdist_meta", "_exit", []), + ] + + +@pytest.mark.parametrize("mode", ["p", "le"]) # no need for r as is tested above +def test_install_pkg_via(tox_project: ToxProjectCreator, mode: str, pkg_with_extras_project_wheel: Path) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=wheel"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + + result = proj.run(mode, "--installpkg", str(pkg_with_extras_project_wheel)) + + result.assert_success() + calls = [(i[0][0].conf.name, i[0][3].run_id) for i in execute_calls.call_args_list] + assert calls == [("py", "install_package_deps"), ("py", "install_package")] + + +@pytest.mark.usefixtures("enable_pip_pypi_access") +def test_build_wheel_external(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + ini = """ + [testenv] + package = external + package_env = .ext + commands = + python -c 'from demo_pkg_inline import do; do()' + + [testenv:.ext] + deps = build + package_glob = {envtmpdir}{/}dist{/}*.whl + commands = + pyproject-build -w . -o {envtmpdir}{/}dist + """ + project = tox_project({"tox.ini": ini}) + result = project.run("r", "--root", str(demo_pkg_inline)) + + result.assert_success() + assert "greetings from demo_pkg_inline" in result.out + + +def test_build_wheel_external_fail_build(tox_project: ToxProjectCreator) -> None: + ini = """ + [testenv] + package = external + [testenv:.pkg_external] + commands = xyz + """ + project = tox_project({"tox.ini": ini}) + result = project.run("r") + + result.assert_failed() + assert "stopping as failed to build package" in result.out, result.out + + +def test_build_wheel_external_fail_no_pkg(tox_project: ToxProjectCreator) -> None: + ini = """ + [testenv] + package = external + """ + project = tox_project({"tox.ini": ini}) + result = project.run("r") + + result.assert_failed() + assert "failed with no package found in " in result.out, result.out + + +def test_build_wheel_external_fail_many_pkg(tox_project: ToxProjectCreator) -> None: + ini = """ + [testenv] + package = external + [testenv:.pkg_external] + commands = + python -c 'from pathlib import Path; (Path(r"{env_tmp_dir}") / "dist").mkdir()' + python -c 'from pathlib import Path; (Path(r"{env_tmp_dir}") / "dist" / "a").write_text("")' + python -c 'from pathlib import Path; (Path(r"{env_tmp_dir}") / "dist" / "b").write_text("")' + """ + project = tox_project({"tox.ini": ini}) + result = project.run("r") + + result.assert_failed() + assert "failed with found more than one package " in result.out, result.out + + +def test_tox_install_pkg_bad_wheel(tox_project: ToxProjectCreator, tmp_path: Path) -> None: + wheel = tmp_path / "w.whl" + with ZipFile(str(wheel), "w"): + pass + proj = tox_project({"tox.ini": "[testenv]"}) + result = proj.run("r", "-e", "py", "--installpkg", str(wheel)) + + result.assert_failed() + assert "failed with no .dist-info inside " in result.out, result.out diff --git a/tests/tox_env/python/virtual_env/package/test_package_pyproject.py b/tests/tox_env/python/virtual_env/package/test_package_pyproject.py new file mode 100644 index 000000000..839225b98 --- /dev/null +++ b/tests/tox_env/python/virtual_env/package/test_package_pyproject.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tox.pytest import ToxProjectCreator + + +@pytest.mark.parametrize( + "pkg_type", + ["editable-legacy", "editable", "sdist", "wheel"], +) +def test_tox_ini_package_type_valid(tox_project: ToxProjectCreator, pkg_type: str) -> None: + proj = tox_project({"tox.ini": f"[testenv]\npackage={pkg_type}", "pyproject.toml": ""}) + result = proj.run("c", "-k", "package_tox_env_type") + result.assert_success() + res = result.env_conf("py")["package"] + assert res == pkg_type + got_type = result.env_conf("py")["package_tox_env_type"] + assert got_type == "virtualenv-pep-517" + + +def test_tox_ini_package_type_invalid(tox_project: ToxProjectCreator) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=bad", "pyproject.toml": ""}) + result = proj.run("c", "-k", "package_tox_env_type") + result.assert_failed() + msg = " invalid package config type bad requested, must be one of wheel, sdist, editable, editable-legacy, skip" + assert msg in result.out + + +def test_get_package_deps_different_extras(pkg_with_extras_project: Path, tox_project: ToxProjectCreator) -> None: + ini = "[testenv:a]\npackage=editable-legacy\nextras=docs\n[testenv:b]\npackage=sdist\nextras=format" + proj = tox_project({"tox.ini": ini}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r", "--root", str(pkg_with_extras_project), "-e", "a,b") + result.assert_success() + installs = { + i[0][0].conf.name: i[0][3].cmd[5:] + for i in execute_calls.call_args_list + if i[0][3].run_id.startswith("install_package_deps") + } + assert installs == { + "a": ["colorama>=0.4.3", "platformdirs>=2.1", "setuptools", "sphinx-rtd-theme<1,>=0.4.3", "sphinx>=3", "wheel"], + "b": ["black>=3", "colorama>=0.4.3", "flake8", "platformdirs>=2.1"], + } + + +def test_package_root_via_root(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + ini = f"[tox]\npackage_root={demo_pkg_inline}\n[testenv]\npackage=wheel\nwheel_build_env=.pkg" + proj = tox_project({"tox.ini": ini, "pyproject.toml": ""}) + proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r", "--notest") + result.assert_success() + + +def test_package_root_via_testenv(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + ini = f"[testenv]\npackage=wheel\nwheel_build_env=.pkg\npackage_root={demo_pkg_inline}" + proj = tox_project({"tox.ini": ini, "pyproject.toml": ""}) + proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r", "--notest") + result.assert_success() + + +@pytest.mark.parametrize( + ("conf", "extra", "deps"), + [ + pytest.param("[project]", "", [], id="no_deps"), + pytest.param("[project]", "alpha", [], id="no_deps_with_extra"), + pytest.param("[project]\ndependencies=['B', 'A']", "", ["A", "B"], id="deps"), + pytest.param( + "[project]\ndependencies=['A']\noptional-dependencies.alpha=['B']\noptional-dependencies.beta=['C']", + "alpha", + ["A", "B"], + id="deps_with_one_extra", + ), + pytest.param( + "[project]\ndependencies=['A']\noptional-dependencies.alpha=['B']\noptional-dependencies.beta=['C']", + "alpha,beta", + ["A", "B", "C"], + id="deps_with_two_extra", + ), + pytest.param( + "[project]\ndependencies=['A']\noptional-dependencies.alpha=[]", + "alpha,beta", + ["A"], + id="deps_with_one_empty_extra", + ), + pytest.param( + "[project]\ndependencies=['A']\ndynamic=['optional-dependencies']", + "", + ["A"], + id="deps_with_dynamic_optional_no_extra", + ), + ], +) +def test_pyproject_deps_from_static( + tox_project: ToxProjectCreator, + demo_pkg_inline: Path, + conf: str, + extra: str, + deps: list[str], +) -> None: + toml = f"{(demo_pkg_inline / 'pyproject.toml').read_text()}{conf}" + proj = tox_project({"tox.ini": f"[testenv]\nextras={extra}", "pyproject.toml": toml}, base=demo_pkg_inline) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r", "--notest") + result.assert_success() + + expected_calls = [(".pkg", "_optional_hooks"), (".pkg", "get_requires_for_build_sdist"), (".pkg", "build_sdist")] + if deps: + expected_calls.append(("py", "install_package_deps")) + expected_calls.extend((("py", "install_package"), (".pkg", "_exit"))) + found_calls = [(i[0][0].conf.name, i[0][3].run_id) for i in execute_calls.call_args_list] + assert found_calls == expected_calls + + if deps: + expected_args = ["python", "-I", "-m", "pip", "install"] + deps + args = execute_calls.call_args_list[-3][0][3].cmd + assert expected_args == args + + +@pytest.mark.parametrize( + ("metadata", "dynamic", "deps"), + [ + pytest.param("Requires-Dist: A", "['dependencies']", ["A"], id="deps"), + pytest.param( + "Requires-Dist: A\nRequires-Dist: B;extra=='alpha'", + "['dependencies']", + ["A", "B"], + id="deps_extra", + ), + pytest.param( + "Requires-Dist: A\nRequires-Dist: B;extra=='alpha'", + "['optional-dependencies']", + ["A", "B"], + id="deps_extra_dynamic_opt", + ), + ], +) +def test_pyproject_deps_static_with_dynamic( + tox_project: ToxProjectCreator, + demo_pkg_inline: Path, + monkeypatch: pytest.MonkeyPatch, + metadata: str, + dynamic: str, + deps: list[str], +) -> None: + + monkeypatch.setenv("METADATA_EXTRA", metadata) + toml = f"{(demo_pkg_inline / 'pyproject.toml').read_text()}[project]\ndynamic={dynamic}" + ini = "[testenv]\nextras=alpha\n[testenv:.pkg]\npass_env=METADATA_EXTRA" + proj = tox_project({"tox.ini": ini, "pyproject.toml": toml}, base=demo_pkg_inline) + + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r", "--notest") + result.assert_success() + + expected_calls = [ + (".pkg", "_optional_hooks"), + (".pkg", "get_requires_for_build_sdist"), + (".pkg", "build_wheel"), + (".pkg", "build_sdist"), + ("py", "install_package_deps"), + ("py", "install_package"), + (".pkg", "_exit"), + ] + found_calls = [(i[0][0].conf.name, i[0][3].run_id) for i in execute_calls.call_args_list] + assert found_calls == expected_calls + + args = execute_calls.call_args_list[-3][0][3].cmd + assert args == ["python", "-I", "-m", "pip", "install", *deps] + + +def test_pyproject_no_build_editable_fallback(tox_project: ToxProjectCreator, demo_pkg_inline: Path) -> None: + proj = tox_project({"tox.ini": ""}, base=demo_pkg_inline) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r", "-e", "a,b", "--notest", "--develop") + result.assert_success() + warning = ( + ".pkg: package config for a, b is editable, however the build backend build does not support PEP-660, " + "falling back to editable-legacy - change your configuration to it" + ) + assert warning in result.out.splitlines() + + expected_calls = [ + (".pkg", "_optional_hooks"), + (".pkg", "build_wheel"), + (".pkg", "get_requires_for_build_sdist"), + ("a", "install_package"), + (".pkg", "get_requires_for_build_sdist"), + ("b", "install_package"), + (".pkg", "_exit"), + ] + found_calls = [(i[0][0].conf.name, i[0][3].run_id) for i in execute_calls.call_args_list] + assert found_calls == expected_calls diff --git a/tests/tox_env/python/virtual_env/package/test_python_package_util.py b/tests/tox_env/python/virtual_env/package/test_python_package_util.py new file mode 100644 index 000000000..25d84283e --- /dev/null +++ b/tests/tox_env/python/virtual_env/package/test_python_package_util.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import sys +from itertools import zip_longest +from pathlib import Path + +import pytest +from packaging.requirements import Requirement +from pyproject_api import SubprocessFrontend + +from tox.tox_env.python.virtual_env.package.util import dependencies_with_extras + +if sys.version_info >= (3, 8): # pragma: no cover (py38+) + from importlib.metadata import Distribution, PathDistribution +else: # pragma: no cover ( PathDistribution: + frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(pkg_with_extras_project)[:-1]) + meta = pkg_with_extras_project / "meta" + result = frontend.prepare_metadata_for_build_wheel(meta) + return Distribution.at(result.metadata) + + +def test_load_dependency_no_extra(pkg_with_extras: PathDistribution) -> None: + result = dependencies_with_extras([Requirement(i) for i in pkg_with_extras.requires], set(), "") + for left, right in zip_longest(result, (Requirement("platformdirs>=2.1"), Requirement("colorama>=0.4.3"))): + assert isinstance(right, Requirement) + assert str(left) == str(right) + + +def test_load_dependency_many_extra(pkg_with_extras: PathDistribution) -> None: + py_ver = ".".join(str(i) for i in sys.version_info[0:2]) + result = dependencies_with_extras([Requirement(i) for i in pkg_with_extras.requires], {"docs", "testing"}, "") + exp = [ + Requirement("platformdirs>=2.1"), + Requirement("colorama>=0.4.3"), + Requirement("sphinx>=3"), + Requirement("sphinx-rtd-theme<1,>=0.4.3"), + Requirement(f'covdefaults>=1.2; python_version == "2.7" or python_version == "{py_ver}"'), + Requirement(f'pytest>=5.4.1; python_version == "{py_ver}"'), + ] + for left, right in zip_longest(result, exp): + assert isinstance(right, Requirement) + assert str(left) == str(right) + + +def test_loads_deps_recursive_extras() -> None: + requires = [ + Requirement("no-extra"), + Requirement('dep1[magic]; extra=="dev"'), + Requirement('dep1; extra=="test"'), + Requirement('dep2[a,b]; extra=="test"'), + Requirement('dep3; extra=="docs"'), + Requirement('name; extra=="dev"'), + Requirement('name[test]; extra=="dev"'), + ] + result = dependencies_with_extras(requires, {"dev"}, "name") + assert [str(i) for i in result] == ["no-extra", "dep1[magic]", "dep1", "dep2[a,b]"] diff --git a/tests/tox_env/python/virtual_env/test_setuptools.py b/tests/tox_env/python/virtual_env/test_setuptools.py new file mode 100644 index 000000000..d96cc7fe2 --- /dev/null +++ b/tests/tox_env/python/virtual_env/test_setuptools.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import cast + +import pytest + +from tox.pytest import ToxProjectCreator +from tox.tox_env.python.package import WheelPackage +from tox.tox_env.python.virtual_env.package.pyproject import Pep517VirtualEnvPackager +from tox.tox_env.runner import RunToxEnv + + +@pytest.mark.integration() +def test_setuptools_package( + tox_project: ToxProjectCreator, + demo_pkg_setuptools: Path, + enable_pip_pypi_access: str | None, # noqa: U100 +) -> None: + tox_ini = """ + [testenv] + package = wheel + commands_pre = python -c 'import sys; print("start", sys.executable)' + commands = python -c 'from demo_pkg_setuptools import do; do()' + commands_post = python -c 'import sys; print("end", sys.executable)' + """ + project = tox_project({"tox.ini": tox_ini}, base=demo_pkg_setuptools) + + outcome = project.run("r", "-e", "py") + + outcome.assert_success() + assert f"\ngreetings from demo_pkg_setuptools{os.linesep}" in outcome.out + tox_env = cast(RunToxEnv, outcome.state.envs["py"]) + + (package_env,) = list(tox_env.package_envs) + assert isinstance(package_env, Pep517VirtualEnvPackager) + packages = package_env.perform_packaging(tox_env.conf) + assert len(packages) == 1 + package = packages[0] + assert isinstance(package, WheelPackage) + assert str(package) == str(package.path) + assert package.path.name == f"demo_pkg_setuptools-1.2.3-py{sys.version_info.major}-none-any.whl" + + result = outcome.out.split("\n") + py_messages = [i for i in result if "py: " in i] + assert len(py_messages) == 5, "\n".join(py_messages) # 1 install wheel + 3 command + 1 final report + + package_messages = [i for i in result if ".pkg: " in i] + # 1 optional hooks + 1 install requires + 1 build requires + 1 build meta + 1 build isolated + 1 exit + assert len(package_messages) == 6, "\n".join(package_messages) diff --git a/tests/tox_env/python/virtual_env/test_virtualenv_api.py b/tests/tox_env/python/virtual_env/test_virtualenv_api.py new file mode 100644 index 000000000..2d84dafde --- /dev/null +++ b/tests/tox_env/python/virtual_env/test_virtualenv_api.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import os +import sys + +import pytest +from pytest_mock import MockerFixture +from virtualenv import __version__ as virtualenv_version +from virtualenv import session_via_cli +from virtualenv.config.cli.parser import VirtualEnvOptions + +from tox.execute import ExecuteRequest +from tox.pytest import MonkeyPatch, ToxProject, ToxProjectCreator + + +@pytest.fixture() +def virtualenv_opt(monkeypatch: MonkeyPatch, mocker: MockerFixture) -> VirtualEnvOptions: + for key in os.environ: + if key.startswith("VIRTUALENV_"): # pragma: no cover + monkeypatch.delenv(key) # pragma: no cover + opts = VirtualEnvOptions() + mocker.patch( + "tox.tox_env.python.virtual_env.api.session_via_cli", + side_effect=lambda args, options, setup_logging, env: session_via_cli( # noqa: U100 + args, + opts, + setup_logging, + env, + ), + ) + return opts + + +def test_virtualenv_default_settings(tox_project: ToxProjectCreator, virtualenv_opt: VirtualEnvOptions) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=skip"}) + result = proj.run("r", "-e", "py", "--discover", sys.executable, str(proj.path / "a")) + result.assert_success() + + conf = result.env_conf("py") + assert conf["system_site_packages"] is False + assert conf["always_copy"] is False + assert conf["download"] is False + + assert virtualenv_opt.clear is False + assert virtualenv_opt.system_site is False + assert virtualenv_opt.download is False + assert virtualenv_opt.copies is False + assert virtualenv_opt.no_periodic_update is True + assert virtualenv_opt.python == ["py"] + assert virtualenv_opt.try_first_with == [str(sys.executable), str(proj.path / "a")] + + +def test_virtualenv_flipped_settings( + tox_project: ToxProjectCreator, + virtualenv_opt: VirtualEnvOptions, + monkeypatch: MonkeyPatch, +) -> None: + proj = tox_project( + {"tox.ini": "[testenv]\npackage=skip\nsystem_site_packages=True\nalways_copy=True\ndownload=True"}, + ) + monkeypatch.setenv("VIRTUALENV_CLEAR", "0") + + result = proj.run("r", "-e", "py") + result.assert_success() + + conf = result.env_conf("py") + assert conf["system_site_packages"] is True + assert conf["always_copy"] is True + assert conf["download"] is True + + assert virtualenv_opt.clear is False + assert virtualenv_opt.system_site is True + assert virtualenv_opt.download is True + assert virtualenv_opt.copies is True + assert virtualenv_opt.python == ["py"] + + +def test_virtualenv_env_ignored_if_set( + tox_project: ToxProjectCreator, + virtualenv_opt: VirtualEnvOptions, + monkeypatch: MonkeyPatch, +) -> None: + ini = "[testenv]\npackage=skip\nsystem_site_packages=True\nalways_copy=True\ndownload=True" + proj = tox_project({"tox.ini": ini}) + monkeypatch.setenv("VIRTUALENV_COPIES", "0") + monkeypatch.setenv("VIRTUALENV_DOWNLOAD", "0") + monkeypatch.setenv("VIRTUALENV_SYSTEM_SITE_PACKAGES", "0") + run_and_check_set(proj, virtualenv_opt) + + +def test_virtualenv_env_used_if_not_set( + tox_project: ToxProjectCreator, + virtualenv_opt: VirtualEnvOptions, + monkeypatch: MonkeyPatch, +) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=skip"}) + monkeypatch.setenv("VIRTUALENV_COPIES", "1") + monkeypatch.setenv("VIRTUALENV_DOWNLOAD", "1") + monkeypatch.setenv("VIRTUALENV_SYSTEM_SITE_PACKAGES", "1") + run_and_check_set(proj, virtualenv_opt) + + +def run_and_check_set(proj: ToxProject, virtualenv_opt: VirtualEnvOptions) -> None: + result = proj.run("r", "-e", "py") + result.assert_success() + conf = result.env_conf("py") + assert conf["system_site_packages"] is True + assert conf["always_copy"] is True + assert conf["download"] is True + assert virtualenv_opt.system_site is True + assert virtualenv_opt.download is True + assert virtualenv_opt.copies is True + + +def test_honor_set_env_for_clear_periodic_update( + tox_project: ToxProjectCreator, + virtualenv_opt: VirtualEnvOptions, +) -> None: + ini = "[testenv]\npackage=skip\nset_env=\n VIRTUALENV_CLEAR=0\n VIRTUALENV_NO_PERIODIC_UPDATE=0" + proj = tox_project({"tox.ini": ini}) + result = proj.run("r", "-e", "py") + result.assert_success() + + assert virtualenv_opt.clear is False + assert virtualenv_opt.no_periodic_update is False + + +def test_recreate_when_virtualenv_changes(tox_project: ToxProjectCreator, mocker: MockerFixture) -> None: + proj = tox_project({"tox.ini": "[testenv]\npackage=skip"}) + proj.run("r") + + from tox.tox_env.python.virtual_env import api + + mocker.patch.object(api, "virtualenv_version", "1.0") + result = proj.run("r") + assert f"recreate env because python changed virtualenv version='{virtualenv_version}'->'1.0'" in result.out + assert "remove tox env folder" in result.out + + +@pytest.mark.parametrize("on", [True, False]) +def test_pip_pre(tox_project: ToxProjectCreator, on: bool) -> None: + proj = tox_project({"tox.ini": f"[testenv]\npackage=skip\npip_pre={on}\ndeps=magic"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r", "-e", "py") + result.assert_success() + if on: + assert "--pre" in execute_calls.call_args[0][3].cmd + else: + assert "--pre" not in execute_calls.call_args[0][3].cmd + + +def test_install_command_no_packages(tox_project: ToxProjectCreator, disable_pip_pypi_access: tuple[str, str]) -> None: + install_cmd = "python -m pip install -i {env:PIP_INDEX_URL}" + proj = tox_project({"tox.ini": f"[testenv]\npackage=skip\ninstall_command={install_cmd}\npip_pre=true\ndeps=magic"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r") + result.assert_success() + request: ExecuteRequest = execute_calls.call_args[0][3] + found_cmd = request.cmd + assert found_cmd == ["python", "-m", "pip", "install", "-i", disable_pip_pypi_access[0], "--pre", "magic"] + + +def test_list_dependencies_command(tox_project: ToxProjectCreator) -> None: + install_cmd = "python -m pip freeze" + proj = tox_project({"tox.ini": f"[testenv]\npackage=skip\nlist_dependencies_command={install_cmd}"}) + execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) + result = proj.run("r", "--result-json", str(proj.path / "out.json")) + result.assert_success() + request: ExecuteRequest = execute_calls.call_args[0][3] + assert request.cmd == ["python", "-m", "pip", "freeze"] diff --git a/tests/tox_env/test_info.py b/tests/tox_env/test_info.py new file mode 100644 index 000000000..39fa29bcb --- /dev/null +++ b/tests/tox_env/test_info.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from pathlib import Path + +from tox.tox_env.info import Info + + +def test_info_repr() -> None: + at_loc = Path().absolute() + info_object = Info(at_loc) + assert repr(info_object) == f"Info(path={ at_loc / '.tox-info.json' })" diff --git a/tests/tox_env/test_register.py b/tests/tox_env/test_register.py new file mode 100644 index 000000000..2b7b85ab4 --- /dev/null +++ b/tests/tox_env/test_register.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import pytest + +from tox.tox_env.python.virtual_env.runner import VirtualEnvRunner +from tox.tox_env.register import ToxEnvRegister + + +def test_register_set_new_default_no_register() -> None: + register = ToxEnvRegister() + with pytest.raises(ValueError, match="run env must be registered before setting it as default"): + register.default_env_runner = "new-env" + + +def test_register_set_new_default_with_register() -> None: + class B(VirtualEnvRunner): + @staticmethod + def id() -> str: + return "B" + + register = ToxEnvRegister() + register.add_run_env(VirtualEnvRunner) + assert register.default_env_runner == VirtualEnvRunner.id() + register.add_run_env(B) + assert register.default_env_runner == VirtualEnvRunner.id() + register.default_env_runner = B.id() + assert register.default_env_runner == "B" diff --git a/tests/tox_env/test_tox_env_api.py b/tests/tox_env/test_tox_env_api.py new file mode 100644 index 000000000..c5542dbcd --- /dev/null +++ b/tests/tox_env/test_tox_env_api.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent +from unittest.mock import patch + +import pytest + +from tox.pytest import ToxProjectCreator +from tox.tox_env.api import ToxEnv + + +def test_recreate(tox_project: ToxProjectCreator) -> None: + prj = tox_project({"tox.ini": "[testenv]\npackage=skip\nrecreate=True"}) + result_first = prj.run("r") + result_first.assert_success() + + result_second = prj.run("r") + result_second.assert_success() + assert "remove tox env folder" in result_second.out + + +def test_allow_list_external_fail(tox_project: ToxProjectCreator, fake_exe_on_path: Path) -> None: + prj = tox_project({"tox.ini": f"[testenv]\npackage=skip\ncommands={fake_exe_on_path.stem}"}) + execute_calls = prj.patch_execute(lambda r: 0 if "cmd" in r.run_id else None) + + result = prj.run("r") + + result.assert_failed(1) + out = rf".*py: failed with {fake_exe_on_path.stem} is not allowed, use allowlist_externals to allow it.*" + result.assert_out_err(out=out, err="", regex=True) + execute_calls.assert_called() + + +def test_env_log(tox_project: ToxProjectCreator) -> None: + cmd = "commands=python -c 'import sys; print(1); print(2); print(3, file=sys.stderr); print(4, file=sys.stderr)'" + prj = tox_project({"tox.ini": f"[testenv]\npackage=skip\n{cmd}"}) + result_first = prj.run("r") + result_first.assert_success() + + log_dir = prj.path / ".tox" / "py" / "log" + assert log_dir.exists(), result_first.out + + filename = {i.name for i in log_dir.iterdir()} + assert filename == {"1-commands[0].log"} + content = (log_dir / "1-commands[0].log").read_text() + + assert f"cwd: {prj.path}" in content + assert f"allow: {prj.path}" in content + assert "metadata " in content + assert "env PATH: " in content + assert content.startswith("name: py\nrun_id: commands[0]") + assert "cmd: python -c" in content + ending = """ + exit_code: 0 + 1 + 2 + + standard error: + 3 + 4 + """ + assert content.endswith(dedent(ending).lstrip()), content + + result_second = prj.run("r") # second run overwrites, so no new files + result_second.assert_success() + filename = {i.name for i in log_dir.iterdir()} + assert filename == {"1-commands[0].log"} + + +def test_tox_env_pass_env_literal_exist() -> None: + with patch("os.environ", {"A": "1"}): + env = ToxEnv._load_pass_env(["A"]) + assert env == {"A": "1"} + + +def test_tox_env_pass_env_literal_miss() -> None: + with patch("os.environ", {}): + env = ToxEnv._load_pass_env(["A"]) + assert not env + + +@pytest.mark.parametrize("glob", ["*", "?"]) +@pytest.mark.parametrize("char", ["a", "A"]) +def test_tox_env_pass_env_match_ignore_case(char: str, glob: str) -> None: + with patch("os.environ", {"A1": "1", "a2": "2", "A2": "3", "B": "4"}): + env = ToxEnv._load_pass_env([f"{char}{glob}"]) + assert env == {"A1": "1", "a2": "2", "A2": "3"} diff --git a/tests/type_check/add_config_container_factory.py b/tests/type_check/add_config_container_factory.py new file mode 100644 index 000000000..06c6d3b53 --- /dev/null +++ b/tests/type_check/add_config_container_factory.py @@ -0,0 +1,20 @@ +"""check that factory for a container works""" +from __future__ import annotations + +from typing import List + +from tox.config.sets import ConfigSet + + +class EnvDockerConfigSet(ConfigSet): + def register_config(self) -> None: + def factory(container_name: object) -> str: # noqa: U100 + raise NotImplementedError + + self.add_config( + keys=["k"], + of_type=List[str], + default=[], + desc="desc", + factory=factory, + ) diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py deleted file mode 100644 index a45472606..000000000 --- a/tests/unit/config/test_config.py +++ /dev/null @@ -1,3770 +0,0 @@ -# coding=utf-8 -import os -import re -import sys -from textwrap import dedent - -import py -import pytest -from pluggy import PluginManager -from six import PY2 -from virtualenv.info import IS_PYPY - -import tox -from tox.config import ( - CommandParser, - DepOption, - PosargsOption, - SectionReader, - get_homedir, - get_version_info, - getcontextname, - is_section_substitution, - parseconfig, -) -from tox.config.parallel import ENV_VAR_KEY_PRIVATE as PARALLEL_ENV_VAR_KEY_PRIVATE -from tox.config.parallel import ENV_VAR_KEY_PUBLIC as PARALLEL_ENV_VAR_KEY_PUBLIC - - -class TestVenvConfig: - def test_config_parsing_minimal(self, tmpdir, newconfig): - config = newconfig( - [], - """ - [testenv:py1] - """, - ) - assert len(config.envconfigs) == 1 - assert config.toxworkdir.realpath() == tmpdir.join(".tox").realpath() - assert config.envconfigs["py1"].basepython == sys.executable - assert config.envconfigs["py1"].deps == [] - assert config.envconfigs["py1"].platform == ".*" - - def test_config_parsing_multienv(self, tmpdir, newconfig): - config = newconfig( - [], - """ - [tox] - toxworkdir = {} - indexserver = - xyz = xyz_repo - [testenv:py1] - deps=hello - [testenv:py2] - deps= - world1 - :xyz:http://hello/world - """.format( - tmpdir, - ), - ) - assert config.toxworkdir == tmpdir - assert len(config.envconfigs) == 2 - assert config.envconfigs["py1"].envdir == tmpdir.join("py1") - dep = config.envconfigs["py1"].deps[0] - assert dep.name == "hello" - assert dep.indexserver is None - assert config.envconfigs["py2"].envdir == tmpdir.join("py2") - dep1, dep2 = config.envconfigs["py2"].deps - assert dep1.name == "world1" - assert dep2.name == "http://hello/world" - assert dep2.indexserver.name == "xyz" - assert dep2.indexserver.url == "xyz_repo" - - def test_envdir_set_manually(self, tmpdir, newconfig): - config = newconfig( - [], - """ - [testenv:dev] - envdir = dev - """, - ) - envconfig = config.envconfigs["dev"] - assert envconfig.envdir == tmpdir.join("dev") - - def test_envdir_set_manually_with_substitutions(self, newconfig): - config = newconfig( - [], - """ - [testenv:dev] - envdir = {toxworkdir}/foobar - """, - ) - envconfig = config.envconfigs["dev"] - assert envconfig.envdir == config.toxworkdir.join("foobar") - - def test_envdir_set_manually_setup_cfg(self, tmpdir, newconfig): - config = newconfig( - [], - """ - [tox:tox] - envlist = py36,py37 - [testenv] - envdir = dev - [testenv:py36] - envdir = dev36 - """, - filename="setup.cfg", - ) - envconfig = config.envconfigs["py36"] - assert envconfig.envdir == tmpdir.join("dev36") - envconfig = config.envconfigs["py37"] - assert envconfig.envdir == tmpdir.join("dev") - - def test_force_dep_version(self, initproj): - """ - Make sure we can override dependencies configured in tox.ini when using the command line - option --force-dep. - """ - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [tox] - - [testenv] - deps= - dep1==1.0 - dep2>=2.0 - dep3 - dep4==4.0 - """, - }, - ) - config = parseconfig( - ["--force-dep=dep1==1.5", "--force-dep=dep2==2.1", "--force-dep=dep3==3.0"], - ) - assert config.option.force_dep == ["dep1==1.5", "dep2==2.1", "dep3==3.0"] - expected_deps = ["dep1==1.5", "dep2==2.1", "dep3==3.0", "dep4==4.0"] - assert expected_deps == [str(x) for x in config.envconfigs["python"].deps] - - def test_force_dep_with_url(https://github.com/self,%20initproj): - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [tox] - - [testenv] - deps= - dep1==1.0 - https://pypi.org/xyz/pkg1.tar.gz - """, - }, - ) - config = parseconfig(["--force-dep=dep1==1.5"]) - assert config.option.force_dep == ["dep1==1.5"] - expected_deps = ["dep1==1.5", "https://pypi.org/xyz/pkg1.tar.gz"] - assert [str(x) for x in config.envconfigs["python"].deps] == expected_deps - - def test_process_deps(self, newconfig): - config = newconfig( - [], - """ - [testenv] - deps = - -r requirements.txt - yapf>=0.25.0,<0.27 # pyup: < 0.27 # disable updates - --index-url https://pypi.org/simple - pywin32 >=1.0 ; sys_platform == '#my-magic-platform' # so what now - -fhttps://pypi.org/packages - --global-option=foo - -v dep1 - --help dep2 - """, - ) # note that those last two are invalid - expected_deps = [ - "-rrequirements.txt", - "yapf>=0.25.0,<0.27", - "--index-url=https://pypi.org/simple", - "pywin32 >=1.0 ; sys_platform == '#my-magic-platform'", - "-fhttps://pypi.org/packages", - "--global-option=foo", - "-v dep1", - "--help dep2", - ] - assert [str(x) for x in config.envconfigs["python"].deps] == expected_deps - - def test_is_same_dep(self): - """ - Ensure correct parseini._is_same_dep is working with a few samples. - """ - assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3") - assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3>=2.0") - assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3>2.0") - assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3<2.0") - assert DepOption._is_same_dep("pkg_hello-world3==1.0", "pkg_hello-world3<=2.0") - assert not DepOption._is_same_dep("pkg_hello-world3==1.0", "otherpkg>=2.0") - - def test_suicide_interrupt_terminate_timeout_set_manually(self, newconfig): - config = newconfig( - [], - """ - [testenv:dev] - suicide_timeout = 30.0 - interrupt_timeout = 5.0 - terminate_timeout = 10.0 - - [testenv:other] - """, - ) - envconfig = config.envconfigs["other"] - assert 0.0 == envconfig.suicide_timeout - assert 0.3 == envconfig.interrupt_timeout - assert 0.2 == envconfig.terminate_timeout - - envconfig = config.envconfigs["dev"] - assert 30.0 == envconfig.suicide_timeout - assert 5.0 == envconfig.interrupt_timeout - assert 10.0 == envconfig.terminate_timeout - - -class TestConfigPlatform: - def test_config_parse_platform(self, newconfig): - config = newconfig( - [], - """ - [testenv:py1] - platform = linux2 - """, - ) - assert len(config.envconfigs) == 1 - assert config.envconfigs["py1"].platform == "linux2" - - def test_config_parse_platform_rex(self, newconfig, mocksession, monkeypatch): - config = newconfig( - [], - """ - [testenv:py1] - platform = a123|b123 - """, - ) - mocksession.config = config - assert len(config.envconfigs) == 1 - venv = mocksession.getvenv("py1") - assert not venv.matching_platform() - monkeypatch.setattr(sys, "platform", "a123") - assert venv.matching_platform() - monkeypatch.setattr(sys, "platform", "b123") - assert venv.matching_platform() - monkeypatch.undo() - assert not venv.matching_platform() - - @pytest.mark.parametrize("plat", ["win", "lin", "osx"]) - def test_config_parse_platform_with_factors(self, newconfig, plat): - config = newconfig( - [], - """ - [tox] - envlist = py27-{win, lin,osx } - [testenv] - platform = - win: win32 - lin: linux2 - """, - ) - assert len(config.envconfigs) == 3 - platform = config.envconfigs["py27-" + plat].platform - expected = {"win": "win32", "lin": "linux2", "osx": ""}.get(plat) - assert platform == expected - - def test_platform_install_command(self, newconfig, mocksession, monkeypatch): - # Expanded from docs/example/platform.html - config = newconfig( - [], - """ - [tox] - envlist = py{27,36}-{mylinux,mymacos,mywindows} - - [testenv] - platform = - mylinux: linux - mymacos: darwin - mywindows: win32 - - deps = - mylinux,mymacos: py==1.4.32 - mywindows: py==1.4.30 - - install_command = - mylinux: python -m pip install {packages} distro - mywindows: python -m pip install {packages} pywin32 - - commands= - mylinux: echo Linus - mymacos: echo Steve - mywindows: echo Bill - """, - ) - mocksession.config = config - assert len(config.envconfigs) == 6 - - monkeypatch.setattr(sys, "platform", "linux") - - venv = mocksession.getvenv("py27-mylinux") - assert venv.envconfig._reader.factors == {"py27", "mylinux"} - assert venv.matching_platform() - assert str(venv.envconfig.deps[0]) == "py==1.4.32" - assert venv.envconfig.install_command == [ - "python", - "-m", - "pip", - "install", - "{packages}", - "distro", - ] - assert venv.envconfig.commands[0] == ["echo", "Linus"] - - venv = mocksession.getvenv("py27-mymacos") - assert venv.envconfig._reader.factors == {"py27", "mymacos"} - assert not venv.matching_platform() - assert str(venv.envconfig.deps[0]) == "py==1.4.32" - assert venv.envconfig.install_command == [ - "python", - "-m", - "pip", - "install", - "{opts}", - "{packages}", - ] - assert venv.envconfig.commands[0] == ["echo", "Steve"] - - venv = mocksession.getvenv("py27-mywindows") - assert venv.envconfig._reader.factors == {"py27", "mywindows"} - assert not venv.matching_platform() - assert str(venv.envconfig.deps[0]) == "py==1.4.30" - assert venv.envconfig.install_command == [ - "python", - "-m", - "pip", - "install", - "{packages}", - "pywin32", - ] - assert venv.envconfig.commands[0] == ["echo", "Bill"] - - monkeypatch.undo() - - monkeypatch.setattr(sys, "platform", "darwin") - - venv = mocksession.getvenv("py27-mymacos") - assert venv.envconfig.install_command == [ - "python", - "-m", - "pip", - "install", - "{opts}", - "{packages}", - ] - - monkeypatch.undo() - - -class TestConfigPackage: - def test_defaults(self, tmpdir, newconfig): - config = newconfig([], "") - assert config.setupdir.realpath() == tmpdir.realpath() - assert config.toxworkdir.realpath() == tmpdir.join(".tox").realpath() - envconfig = config.envconfigs["python"] - assert envconfig.args_are_paths - assert not envconfig.recreate - assert not envconfig.pip_pre - - def test_defaults_distshare(self, newconfig): - config = newconfig([], "") - assert config.distshare == config.homedir.join(".tox", "distshare") - - def test_defaults_changed_dir(self, tmpdir, newconfig): - with tmpdir.mkdir("abc").as_cwd(): - config = newconfig([], "") - assert config.setupdir.realpath() == tmpdir.realpath() - assert config.toxworkdir.realpath() == tmpdir.join(".tox").realpath() - - def test_defaults_isolated_build(self, newconfig): - config = newconfig( - [], - """ - [tox] - isolated_build = true - """, - ) - assert "python" in config.envconfigs - - def test_project_paths(self, tmpdir, newconfig): - config = newconfig( - """ - [tox] - toxworkdir={} - """.format( - tmpdir, - ), - ) - assert config.toxworkdir == tmpdir - - -class TestParseconfig: - def test_search_parents(self, tmpdir): - b = tmpdir.mkdir("a").mkdir("b") - toxinipath = tmpdir.ensure("tox.ini") - with b.as_cwd(): - config = parseconfig([]) - assert config.toxinipath == toxinipath - - def test_explicit_config_path(self, tmpdir): - """ - Test explicitly setting config path, both with and without the filename - """ - path = tmpdir.mkdir("tox_tmp_directory") - config_file_path = path.ensure("tox.ini") - - config = parseconfig(["-c", str(config_file_path)]) - assert config.toxinipath == config_file_path - - # Passing directory of the config file should also be possible - # ('tox.ini' filename is assumed) - config = parseconfig(["-c", str(path)]) - assert config.toxinipath == config_file_path - - @pytest.mark.skipif(sys.platform == "win32", reason="no symlinks on Windows") - def test_workdir_gets_resolved(self, tmp_path, monkeypatch): - """ - Test explicitly setting config path, both with and without the filename - """ - real = tmp_path / "real" - real.mkdir() - symlink = tmp_path / "link" - symlink.symlink_to(real) - - (tmp_path / "tox.ini").touch() - monkeypatch.chdir(tmp_path) - config = parseconfig(["--workdir", str(symlink)]) - assert config.toxworkdir == real - - -def test_get_homedir(monkeypatch): - monkeypatch.setattr(py.path.local, "_gethomedir", classmethod(lambda x: {}[1])) - assert not get_homedir() - monkeypatch.setattr(py.path.local, "_gethomedir", classmethod(lambda x: 0 / 0)) - assert not get_homedir() - monkeypatch.setattr(py.path.local, "_gethomedir", classmethod(lambda x: "123")) - assert get_homedir() == "123" - - -class TestGetcontextname: - def test_blank(self, monkeypatch): - monkeypatch.setattr(os, "environ", {}) - assert getcontextname() is None - - def test_jenkins(self, monkeypatch): - monkeypatch.setattr(os, "environ", {"JENKINS_URL": "xyz"}) - assert getcontextname() == "jenkins" - - def test_hudson_legacy(self, monkeypatch): - monkeypatch.setattr(os, "environ", {"HUDSON_URL": "xyz"}) - assert getcontextname() == "jenkins" - - -class TestIniParserAgainstCommandsKey: - """Test parsing commands with substitutions""" - - def test_command_substitution_recursion_error_same_section(self, newconfig): - expected = r"\('testenv:a', 'commands'\) already in \[\('testenv:a', 'commands'\)\]" - with pytest.raises(tox.exception.ConfigError, match=expected): - newconfig( - """ - [testenv:a] - commands = {[testenv:a]commands} - """, - ) - - def test_command_substitution_recursion_error_other_section(self, newconfig): - expected = ( - r"\('testenv:py27', 'commands'\) already in " - r"\[\('testenv:py27', 'commands'\), " - r"\('testenv:base', 'foo'\)\]" - ) - with pytest.raises(tox.exception.ConfigError, match=expected): - newconfig( - """ - [testenv:base] - foo = {[testenv:py27]commands} - - [testenv:py27] - commands = {[testenv:base]foo} - """, - ) - - def test_command_substitution_recursion_error_unnecessary(self, newconfig): - # TODO: There is no reason for this recursion error to occur, so it - # could be optimised away, or emit a warning, or give a custom error - expected = ( - r"\('testenv:base', 'foo'\) already in " - r"\[\('testenv:py27', 'commands'\), \('testenv:base', 'foo'\)\]" - ) - with pytest.raises(tox.exception.ConfigError, match=expected): - newconfig( - """ - [testenv:base] - foo = {[testenv:base]foo} - - [testenv:py27] - bar = {[testenv:base]foo} - setenv = - FOO = foo - commands = {env:FOO:{[testenv:base]foo}} - """, - ) - - def test_command_missing_substitution_simple(self, newconfig): - config = newconfig( - """ - [testenv:py27] - commands = {env:{env:FOO}} - """, - ) - envconfig = config.envconfigs["py27"] - - expected = "MissingSubstitution: FOO" - - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.commands - - def test_command_missing_substitution_setenv(self, newconfig): - config = newconfig( - """ - [testenv:py27] - setenv = - FOO = {env:{env:FOO}} - commands = {env:FOO} - """, - ) - envconfig = config.envconfigs["py27"] - - expected = "MissingSubstitution: FOO" - - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.setenv["FOO"] - - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.commands - - def test_command_missing_substitution_inherit(self, newconfig): - config = newconfig( - """ - [testenv] - setenv = - FOO = {[testenv:py27]commands} - - [testenv:py27] - commands = {env:FOO} - """, - ) - envconfig = config.envconfigs["py27"] - - expected = "MissingSubstitution: FOO" - - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.commands - - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.setenv["FOO"] - - def test_command_missing_substitution_other_section(self, newconfig): - config = newconfig( - """ - [testenv:base] - bar = {[testenv:py27]foo} - - [testenv:py27] - foo = {env:FOO} - setenv = - FOO = {[testenv:base]bar} - commands = {env:FOO} - """, - ) - envconfig = config.envconfigs["py27"] - - expected = "MissingSubstitution: FOO" - - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.commands - - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.setenv["FOO"] - - def test_command_missing_substitution_multi_env(self, newconfig): - config = newconfig( - """ - [testenv:py27] - setenv = - FOO = {env:BAR} - BAR = {env:FOO} - commands = {env:BAR} - """, - ) - envconfig = config.envconfigs["py27"] - - expected = "MissingSubstitution: BAR" - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.commands - - expected = "MissingSubstitution: FOO" - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.setenv["FOO"] - - def test_command_missing_substitution_complex(self, newconfig): - config = newconfig( - """ - [testenv:base] - bar = {env:BAR} - setenv = - BAR = {[testenv:py27]foo} - - [testenv:py27] - foo = {env:FOO} - setenv = - FOO = {[testenv:base]bar} - commands = {env:FOO} - """, - ) - envconfig = config.envconfigs["py27"] - - expected = "MissingSubstitution: BAR" - - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.setenv["FOO"] - - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - envconfig.commands - - def test_command_substitution_from_other_section(self, newconfig): - config = newconfig( - """ - [section] - key = whatever - [testenv] - commands = - echo {[section]key} - """, - ) - reader = SectionReader("testenv", config._cfg) - x = reader.getargvlist("commands") - assert x == [["echo", "whatever"]] - - def test_command_substitution_from_other_section_multiline(self, newconfig): - """Ensure referenced multiline commands form from other section injected - as multiple commands.""" - config = newconfig( - """ - [section] - commands = - cmd1 param11 param12 - # comment is omitted - cmd2 param21 \ - param22 - [base] - commands = cmd 1 \ - 2 3 4 - cmd 2 - [testenv] - commands = - {[section]commands} - {[section]commands} - # comment is omitted - echo {[base]commands} - """, - ) - reader = SectionReader("testenv", config._cfg) - x = reader.getargvlist("commands") - expected_deps = [ - "cmd1 param11 param12".split(), - "cmd2 param21 param22".split(), - "cmd1 param11 param12".split(), - "cmd2 param21 param22".split(), - ["echo", "cmd", "1", "2", "3", "4", "cmd", "2"], - ] - assert x == expected_deps - - def test_command_substitution_from_other_section_posargs(self, newconfig): - """Ensure subsitition from other section with posargs succeeds""" - config = newconfig( - """ - [section] - key = thing {posargs} arg2 - [testenv] - commands = - {[section]key} - """, - ) - reader = SectionReader("testenv", config._cfg) - reader.addsubstitutions(["argpos"]) - x = reader.getargvlist("commands") - assert x == [["thing", "argpos", "arg2"]] - - def test_command_section_and_posargs_substitution(self, newconfig): - """Ensure subsitition from other section with posargs succeeds""" - config = newconfig( - """ - [section] - key = thing arg1 - [testenv] - commands = - {[section]key} {posargs} endarg - """, - ) - reader = SectionReader("testenv", config._cfg) - reader.addsubstitutions(["argpos"]) - x = reader.getargvlist("commands") - assert x == [["thing", "arg1", "argpos", "endarg"]] - - def test_command_posargs_with_colon(self, newconfig): - """Ensure posargs with default containing : succeeds""" - config = newconfig( - r""" - [testenv] - commands = - pytest {posargs:default with : colon after} - """, - ) - reader = SectionReader("testenv", config._cfg) - x = reader.getargvlist("commands") - assert x[0] == ["pytest", "default", "with", ":", "colon", "after"] - - def test_command_missing_substitution(self, newconfig): - config = newconfig( - """ - [testenv:a] - setenv = - FOO = foo - commands = {env:FOO} - """, - ) - reader = SectionReader("testenv:a", config._cfg) - - expected = "MissingSubstitution: FOO" - with pytest.raises(tox.exception.MissingSubstitution, match=expected): - reader.getargvlist("commands") - - def test_command_env_substitution(self, newconfig): - """Ensure referenced {env:key:default} values are substituted correctly.""" - config = newconfig( - """ - [testenv:py27] - setenv = - TEST=testvalue - commands = - ls {env:TEST} - """, - ) - envconfig = config.envconfigs["py27"] - assert envconfig.commands == [["ls", "testvalue"]] - assert envconfig.setenv["TEST"] == "testvalue" - - def test_command_env_substitution_posargs(self, newconfig): - """Ensure {posargs} values are substituted correctly.""" - config = newconfig( - """ - [testenv:py27] - setenv = - TEST={posargs:default} - commands = - ls {env:TEST} - """, - ) - envconfig = config.envconfigs["py27"] - assert envconfig.setenv["TEST"] == "default" - assert envconfig.commands == [["ls", "default"]] - - def test_command_env_substitution_posargs_with_colon(self, newconfig): - """Ensure {posargs} values are substituted correctly.""" - config = newconfig( - """ - [testenv:py27] - setenv = - TEST=pytest {posargs:default with:colon after} - commands = - ls {env:TEST} - """, - ) - envconfig = config.envconfigs["py27"] - assert envconfig.setenv["TEST"] == "pytest default with:colon after" - assert envconfig.commands == [["ls", "pytest", "default", "with:colon", "after"]] - - def test_command_env_substitution_posargs_with_spaced_colon(self, newconfig): - """Ensure {posargs} values are substituted correctly.""" - config = newconfig( - """ - [testenv:py27] - setenv = - TEST=pytest {posargs:default with : colon after} - commands = - ls {env:TEST} - """, - ) - envconfig = config.envconfigs["py27"] - assert envconfig.setenv["TEST"] == "pytest default with : colon after" - assert envconfig.commands == [["ls", "pytest", "default", "with", ":", "colon", "after"]] - - def test_command_env_substitution_global(self, newconfig): - """Ensure referenced {env:key:default} values are substituted correctly.""" - config = newconfig( - """ - [testenv] - setenv = FOO = bar - commands = echo {env:FOO} - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.commands == [["echo", "bar"]] - - def test_command_env_substitution_default_escape(self, newconfig): - """Ensure literal { and } in default of {env:key:default} values.""" - config = newconfig( - r""" - [testenv] - commands = echo {env:FOO:\{bar\}} - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.commands == [["echo", "{bar}"]] - - def test_regression_issue595(self, newconfig): - config = newconfig( - """ - [tox] - envlist = foo - [testenv] - setenv = VAR = x - [testenv:bar] - setenv = {[testenv]setenv} - [testenv:baz] - setenv = - """, - ) - assert config.envconfigs["foo"].setenv["VAR"] == "x" - assert config.envconfigs["bar"].setenv["VAR"] == "x" - assert "VAR" not in config.envconfigs["baz"].setenv - - -class TestIniParser: - def test_getstring_single(self, newconfig): - config = newconfig( - """ - [section] - key=value - """, - ) - reader = SectionReader("section", config._cfg) - x = reader.getstring("key") - assert x == "value" - assert not reader.getstring("hello") - x = reader.getstring("hello", "world") - assert x == "world" - - def test_substitution_empty(self, newconfig): - config = newconfig( - """ - [mydefault] - key2={} - """, - ) - reader = SectionReader("mydefault", config._cfg, fallbacksections=["mydefault"]) - assert reader is not None - with pytest.raises(tox.exception.ConfigError, match="no substitution type provided"): - reader.getstring("key2") - - def test_substitution_colon_prefix(self, newconfig): - config = newconfig( - """ - [mydefault] - key2={:abc} - """, - ) - reader = SectionReader("mydefault", config._cfg, fallbacksections=["mydefault"]) - assert reader is not None - with pytest.raises( - tox.exception.ConfigError, - match="Malformed substitution with prefix ':'", - ): - reader.getstring("key2") - - def test_missing_substitution(self, newconfig): - config = newconfig( - """ - [mydefault] - key2={xyz} - """, - ) - reader = SectionReader("mydefault", config._cfg, fallbacksections=["mydefault"]) - assert reader is not None - with pytest.raises(tox.exception.ConfigError, match="substitution key '.*' not found"): - reader.getstring("key2") - - def test_getstring_fallback_sections(self, newconfig): - config = newconfig( - """ - [mydefault] - key2=value2 - [section] - key=value - """, - ) - reader = SectionReader("section", config._cfg, fallbacksections=["mydefault"]) - x = reader.getstring("key2") - assert x == "value2" - x = reader.getstring("key3") - assert not x - x = reader.getstring("key3", "world") - assert x == "world" - - def test_getstring_substitution(self, newconfig): - config = newconfig( - """ - [mydefault] - key2={value2} - [section] - key={value} - """, - ) - reader = SectionReader("section", config._cfg, fallbacksections=["mydefault"]) - reader.addsubstitutions(value="newvalue", value2="newvalue2") - x = reader.getstring("key2") - assert x == "newvalue2" - x = reader.getstring("key3") - assert not x - x = reader.getstring("key3", "{value2}") - assert x == "newvalue2" - - def test_getlist(self, newconfig): - config = newconfig( - """ - [section] - key2= - item1 - {item2} - """, - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(item1="not", item2="grr") - x = reader.getlist("key2") - assert x == ["item1", "grr"] - - def test_getdict(self, newconfig): - config = newconfig( - """ - [section] - key2= - key1=item1 - key2={item2} - """, - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(item1="not", item2="grr") - x = reader.getdict("key2") - assert "key1" in x - assert "key2" in x - assert x["key1"] == "item1" - assert x["key2"] == "grr" - - x = reader.getdict("key3", {1: 2}) - assert x == {1: 2} - - def test_normal_env_sub_works(self, monkeypatch, newconfig): - monkeypatch.setenv("VAR", "hello") - config = newconfig("[section]\nkey={env:VAR}") - assert SectionReader("section", config._cfg).getstring("key") == "hello" - - def test_missing_env_sub_raises_config_error_in_non_testenv(self, newconfig): - config = newconfig("[section]\nkey={env:VAR}") - with pytest.raises(tox.exception.ConfigError): - SectionReader("section", config._cfg).getstring("key") - - def test_missing_env_sub_populates_missing_subs(self, newconfig): - config = newconfig("[testenv:foo]\ncommands={env:VAR}") - print(SectionReader("section", config._cfg).getstring("commands")) - - assert "commands" in config.envconfigs["foo"]._missing_subs - missing_exception = config.envconfigs["foo"]._missing_subs["commands"] - assert missing_exception.name == "VAR" - - def test_getstring_environment_substitution_with_default(self, monkeypatch, newconfig): - monkeypatch.setenv("KEY1", "hello") - config = newconfig( - """ - [section] - key1={env:KEY1:DEFAULT_VALUE} - key2={env:KEY2:DEFAULT_VALUE} - key3={env:KEY3:} - """, - ) - reader = SectionReader("section", config._cfg) - x = reader.getstring("key1") - assert x == "hello" - x = reader.getstring("key2") - assert x == "DEFAULT_VALUE" - x = reader.getstring("key3") - assert x == "" - - def test_value_matches_section_substitution(self): - assert is_section_substitution("{[setup]commands}") - - def test_value_doesn_match_section_substitution(self): - assert is_section_substitution("{[ ]commands}") is None - assert is_section_substitution("{[setup]}") is None - assert is_section_substitution("{[setup] commands}") is None - - def test_getstring_other_section_substitution(self, newconfig): - config = newconfig( - """ - [section] - key = rue - [testenv] - key = t{[section]key} - """, - ) - reader = SectionReader("testenv", config._cfg) - x = reader.getstring("key") - assert x == "true" - - def test_argvlist(self, newconfig): - config = newconfig( - """ - [section] - key2= - cmd1 {item1} {item2} - cmd2 {item2} - """, - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(item1="with space", item2="grr") - assert reader.getargvlist("key1") == [] - x = reader.getargvlist("key2") - assert x == [["cmd1", "with", "space", "grr"], ["cmd2", "grr"]] - - def test_argvlist_windows_escaping(self, newconfig): - config = newconfig( - """ - [section] - comm = pytest {posargs} - """, - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions([r"hello\this"]) - argv = reader.getargv("comm") - assert argv == ["pytest", "hello\\this"] - - def test_argvlist_multiline(self, newconfig): - config = newconfig( - """ - [section] - key2= - cmd1 {item1} \ - {item2} - """, - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(item1="with space", item2="grr") - assert reader.getargvlist("key1") == [] - x = reader.getargvlist("key2") - assert x == [["cmd1", "with", "space", "grr"]] - - def test_argvlist_quoting_in_command(self, newconfig): - config = newconfig( - """ - [section] - key1= - cmd1 'part one' \ - 'part two' - """, - ) - reader = SectionReader("section", config._cfg) - x = reader.getargvlist("key1") - assert x == [["cmd1", "part one", "part two"]] - - def test_argvlist_comment_after_command(self, newconfig): - config = newconfig( - """ - [section] - key1= - cmd1 --flag # run the flag on the command - """, - ) - reader = SectionReader("section", config._cfg) - x = reader.getargvlist("key1") - assert x == [["cmd1", "--flag"]] - - def test_argvlist_command_contains_hash(self, newconfig): - config = newconfig( - """ - [section] - key1= - cmd1 --re "use the # symbol for an arg" - """, - ) - reader = SectionReader("section", config._cfg) - x = reader.getargvlist("key1") - assert x == [["cmd1", "--re", "use the # symbol for an arg"]] - - def test_argvlist_positional_substitution(self, newconfig): - config = newconfig( - """ - [section] - key2= - cmd1 [] - cmd2 {posargs:{item2} \ - other} - """, - ) - reader = SectionReader("section", config._cfg) - posargs = ["hello", "world"] - reader.addsubstitutions(posargs, item2="value2") - assert reader.getargvlist("key1") == [] - argvlist = reader.getargvlist("key2") - assert argvlist[0] == ["cmd1"] + posargs - assert argvlist[1] == ["cmd2"] + posargs - - reader = SectionReader("section", config._cfg) - reader.addsubstitutions([], item2="value2") - assert reader.getargvlist("key1") == [] - argvlist = reader.getargvlist("key2") - assert argvlist[0] == ["cmd1"] - assert argvlist[1] == ["cmd2", "value2", "other"] - - def test_argvlist_quoted_posargs(self, newconfig): - config = newconfig( - """ - [section] - key2= - cmd1 --foo-args='{posargs}' - cmd2 -f '{posargs}' - cmd3 -f {posargs} - """, - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(["foo", "bar"]) - assert reader.getargvlist("key1") == [] - x = reader.getargvlist("key2") - expected_deps = [ - ["cmd1", "--foo-args=foo bar"], - ["cmd2", "-f", "foo bar"], - ["cmd3", "-f", "foo", "bar"], - ] - assert x == expected_deps - - def test_argvlist_posargs_with_quotes(self, newconfig): - config = newconfig( - """ - [section] - key2= - cmd1 -f {posargs} - """, - ) - # The operating system APIs for launching processes differ between - # Windows and other OSs. On Windows, the command line is passed as a - # string (and not a list of strings). Python uses the MS C runtime - # rules for splitting this string into `sys.argv`, and those rules - # differ from POSIX shell rules in their treatment of quoted arguments. - if sys.platform.startswith("win"): - substitutions = ["foo", "'bar", "baz'"] - else: - substitutions = ["foo", "bar baz"] - - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(substitutions) - assert reader.getargvlist("key1") == [] - x = reader.getargvlist("key2") - assert x == [["cmd1", "-f", "foo", "bar baz"]] - - def test_positional_arguments_are_only_replaced_when_standing_alone(self, newconfig): - config = newconfig( - """ - [section] - key= - cmd0 [] - cmd1 -m '[abc]' - cmd2 -m '\'something\'' [] - cmd3 something[]else - """, - ) - reader = SectionReader("section", config._cfg) - posargs = ["hello", "world"] - reader.addsubstitutions(posargs) - - argvlist = reader.getargvlist("key") - assert argvlist[0] == ["cmd0"] + posargs - assert argvlist[1] == ["cmd1", "-m", "[abc]"] - assert argvlist[2] == ["cmd2", "-m", "something"] + posargs - assert argvlist[3] == ["cmd3", "something[]else"] - - def test_posargs_are_added_escaped_issue310(self, newconfig): - config = newconfig( - """ - [section] - key= cmd0 {posargs} - """, - ) - reader = SectionReader("section", config._cfg) - posargs = ["hello world", "--x==y z", "--format=%(code)s: %(text)s"] - reader.addsubstitutions(posargs) - argvlist = reader.getargvlist("key") - assert argvlist[0] == ["cmd0"] + posargs - - def test_substitution_with_multiple_words(self, newconfig): - inisource = """ - [section] - key = pytest -n5 --junitxml={envlogdir}/junit-{envname}.xml [] - """ - config = newconfig(inisource) - reader = SectionReader("section", config._cfg) - posargs = ["hello", "world"] - reader.addsubstitutions(posargs, envlogdir="ENV_LOG_DIR", envname="ENV_NAME") - - expected = ["pytest", "-n5", "--junitxml=ENV_LOG_DIR/junit-ENV_NAME.xml", "hello", "world"] - assert reader.getargvlist("key")[0] == expected - - def test_getargv(self, newconfig): - config = newconfig( - """ - [section] - key=some command "with quoting" - """, - ) - reader = SectionReader("section", config._cfg) - expected = ["some", "command", "with quoting"] - assert reader.getargv("key") == expected - - def test_getpath(self, tmpdir, newconfig): - config = newconfig( - """ - [section] - path1={HELLO} - """, - ) - reader = SectionReader("section", config._cfg) - reader.addsubstitutions(toxinidir=tmpdir, HELLO="mypath") - x = reader.getpath("path1", tmpdir) - assert x == tmpdir.join("mypath") - - def test_getbool(self, newconfig): - config = newconfig( - """ - [section] - key1=True - key2=False - key1a=true - key2a=falsE - key5=yes - """, - ) - reader = SectionReader("section", config._cfg) - assert reader.getbool("key1") is True - assert reader.getbool("key1a") is True - assert reader.getbool("key2") is False - assert reader.getbool("key2a") is False - with pytest.raises(KeyError): - reader.getbool("key3") - with pytest.raises(tox.exception.ConfigError) as excinfo: - reader.getbool("key5") - (msg,) = excinfo.value.args - assert msg == "key5: boolean value 'yes' needs to be 'True' or 'False'" - - def test_expand_section_name(self, newconfig): - config = newconfig( - """ - [testenv:custom{,-one,-two,-three}-{four,five}-six] - """, - ) - assert "testenv:custom-one-five-six" in config._cfg.sections - assert "testenv:custom-four-six" in config._cfg.sections - assert "testenv:custom-{one,two,three}-{four,five}-six" not in config._cfg.sections - - -class TestIniParserPrefix: - def test_basic_section_access(self, newconfig): - config = newconfig( - """ - [p:section] - key=value - """, - ) - reader = SectionReader("section", config._cfg, prefix="p") - x = reader.getstring("key") - assert x == "value" - assert not reader.getstring("hello") - x = reader.getstring("hello", "world") - assert x == "world" - - def test_fallback_sections(self, newconfig): - config = newconfig( - """ - [p:mydefault] - key2=value2 - [p:section] - key=value - """, - ) - reader = SectionReader( - "section", - config._cfg, - prefix="p", - fallbacksections=["p:mydefault"], - ) - x = reader.getstring("key2") - assert x == "value2" - x = reader.getstring("key3") - assert not x - x = reader.getstring("key3", "world") - assert x == "world" - - def test_value_matches_prefixed_section_substitution(self): - assert is_section_substitution("{[p:setup]commands}") - - def test_value_doesn_match_prefixed_section_substitution(self): - assert is_section_substitution("{[p: ]commands}") is None - assert is_section_substitution("{[p:setup]}") is None - assert is_section_substitution("{[p:setup] commands}") is None - - def test_other_section_substitution(self, newconfig): - config = newconfig( - """ - [p:section] - key = rue - [p:testenv] - key = t{[p:section]key} - """, - ) - reader = SectionReader("testenv", config._cfg, prefix="p") - x = reader.getstring("key") - assert x == "true" - - -class TestConfigTestEnv: - def test_commentchars_issue33(self, newconfig): - config = newconfig( - """ - [testenv] # hello - deps = http://abc#123 - commands= - python -c "x ; y" - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.deps[0].name == "http://abc/#123" - assert envconfig.commands[0] == ["python", "-c", "x ; y"] - - def test_defaults(self, newconfig): - config = newconfig( - """ - [testenv] - commands= - xyz --abc - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert envconfig.commands == [["xyz", "--abc"]] - assert envconfig.changedir == config.setupdir - assert envconfig.sitepackages is False - assert envconfig.usedevelop is False - assert envconfig.ignore_errors is False - assert envconfig.envlogdir == envconfig.envdir.join("log") - assert set(envconfig.setenv.definitions.keys()) == { - "PYTHONHASHSEED", - "TOX_ENV_NAME", - "TOX_ENV_DIR", - } - hashseed = envconfig.setenv["PYTHONHASHSEED"] - assert isinstance(hashseed, str) - # The following line checks that hashseed parses to an integer. - int_hashseed = int(hashseed) - # hashseed is random by default, so we can't assert a specific value. - assert int_hashseed > 0 - assert envconfig.ignore_outcome is False - - def test_sitepackages_switch(self, newconfig): - config = newconfig(["--sitepackages"], "") - envconfig = config.envconfigs["python"] - assert envconfig.sitepackages is True - - def test_installpkg_tops_develop(self, newconfig): - config = newconfig( - ["--installpkg=abc"], - """ - [testenv] - usedevelop = True - """, - ) - assert not config.envconfigs["python"].usedevelop - - def test_specific_command_overrides(self, newconfig): - config = newconfig( - """ - [testenv] - commands=xyz - [testenv:py] - commands=abc - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["py"] - assert envconfig.commands == [["abc"]] - - def test_allowlist_externals(self, newconfig): - config = newconfig( - """ - [testenv] - allowlist_externals = xyz - commands=xyz - [testenv:x] - - [testenv:py] - allowlist_externals = xyz2 - commands=abc - """, - ) - assert len(config.envconfigs) == 2 - envconfig = config.envconfigs["py"] - assert envconfig.commands == [["abc"]] - assert envconfig.allowlist_externals == ["xyz2"] - envconfig = config.envconfigs["x"] - assert envconfig.allowlist_externals == ["xyz"] - - def test_changedir(self, newconfig): - config = newconfig( - """ - [testenv] - changedir=xyz - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert envconfig.changedir.basename == "xyz" - assert envconfig.changedir == config.toxinidir.join("xyz") - - def test_ignore_errors(self, newconfig): - config = newconfig( - """ - [testenv] - ignore_errors=True - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert envconfig.ignore_errors is True - - def test_envbindir(self, newconfig): - config = newconfig( - """ - [testenv] - basepython=python - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert envconfig.envpython == envconfig.envbindir.join("python") - - @pytest.mark.parametrize("bp", ["jython", "pypy", "pypy3"]) - @pytest.mark.skipif(IS_PYPY, reason="fails on pypy") - def test_envbindir_jython(self, newconfig, bp): - config = newconfig( - """ - [testenv] - basepython={} - """.format( - bp, - ), - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - # on win32 and linux virtualenv uses "bin" for pypy/jython - assert envconfig.envbindir.basename == "bin" - if bp == "jython": - assert envconfig.envpython == envconfig.envbindir.join(bp) - - @pytest.mark.skipif(tox.INFO.IS_PYPY, reason="only applies to CPython") - @pytest.mark.parametrize("sep, bindir", [("\\", "Scripts"), ("/", "bin")]) - def test_envbindir_win(self, newconfig, monkeypatch, sep, bindir): - monkeypatch.setattr(tox.INFO, "IS_WIN", True) - config = newconfig( - """ - [testenv] - basepython=python - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - envconfig.python_info.os_sep = sep # force os.sep result - # on win32 with msys2, virtualenv uses "bin" for python - assert envconfig.envbindir.basename == bindir - assert envconfig.envpython == envconfig.envbindir.join("python") - - @pytest.mark.parametrize("plat", ["win32", "linux2"]) - def test_passenv_as_multiline_list(self, newconfig, monkeypatch, plat): - monkeypatch.setattr(tox.INFO, "IS_WIN", plat == "win32") - monkeypatch.setenv("A123A", "a") - monkeypatch.setenv("A123B", "b") - monkeypatch.setenv("BX23", "0") - if plat == "linux2": - monkeypatch.setenv("http_proxy", "c") - config = newconfig( - """ - [testenv] - passenv = - A123* - # isolated comment - B?23 - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - if plat == "win32": - assert "APPDATA" in envconfig.passenv - assert "PATHEXT" in envconfig.passenv - assert "SYSTEMDRIVE" in envconfig.passenv - assert "SYSTEMROOT" in envconfig.passenv - assert "COMSPEC" in envconfig.passenv - assert "TEMP" in envconfig.passenv - assert "TMP" in envconfig.passenv - assert "NUMBER_OF_PROCESSORS" in envconfig.passenv - assert "PROCESSOR_ARCHITECTURE" in envconfig.passenv - assert "USERPROFILE" in envconfig.passenv - assert "MSYSTEM" in envconfig.passenv - assert "PROGRAMFILES" in envconfig.passenv - assert "PROGRAMFILES(X86)" in envconfig.passenv - assert "PROGRAMDATA" in envconfig.passenv - else: - assert "TMPDIR" in envconfig.passenv - if sys.platform != "win32": - # this cannot be emulated on win - it doesn't support lowercase env vars - assert "http_proxy" in envconfig.passenv - assert "CURL_CA_BUNDLE" in envconfig.passenv - assert "PATH" in envconfig.passenv - assert "PIP_INDEX_URL" in envconfig.passenv - assert "PIP_EXTRA_INDEX_URL" in envconfig.passenv - assert "REQUESTS_CA_BUNDLE" in envconfig.passenv - assert "SSL_CERT_FILE" in envconfig.passenv - assert "LANG" in envconfig.passenv - assert "LANGUAGE" in envconfig.passenv - assert "LD_LIBRARY_PATH" in envconfig.passenv - assert "HTTP_PROXY" in envconfig.passenv - assert "HTTPS_PROXY" in envconfig.passenv - assert "NO_PROXY" in envconfig.passenv - assert PARALLEL_ENV_VAR_KEY_PUBLIC in envconfig.passenv - assert PARALLEL_ENV_VAR_KEY_PRIVATE not in envconfig.passenv - assert "A123A" in envconfig.passenv - assert "A123B" in envconfig.passenv - - @pytest.mark.parametrize("plat", ["win32", "linux2"]) - def test_passenv_as_space_separated_list(self, newconfig, monkeypatch, plat): - monkeypatch.setattr(tox.INFO, "IS_WIN", plat == "win32") - monkeypatch.setenv("A123A", "a") - monkeypatch.setenv("A123B", "b") - monkeypatch.setenv("BX23", "0") - config = newconfig( - """ - [testenv] - passenv = - # comment - A123* B?23 - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - if plat == "win32": - assert "APPDATA" in envconfig.passenv - assert "PATHEXT" in envconfig.passenv - assert "SYSTEMDRIVE" in envconfig.passenv - assert "SYSTEMROOT" in envconfig.passenv - assert "TEMP" in envconfig.passenv - assert "TMP" in envconfig.passenv - assert "PROGRAMFILES" in envconfig.passenv - assert "PROGRAMFILES(X86)" in envconfig.passenv - assert "PROGRAMDATA" in envconfig.passenv - else: - assert "TMPDIR" in envconfig.passenv - assert "PATH" in envconfig.passenv - assert "PIP_INDEX_URL" in envconfig.passenv - assert "LANG" in envconfig.passenv - assert "LANGUAGE" in envconfig.passenv - assert "A123A" in envconfig.passenv - assert "A123B" in envconfig.passenv - - def test_passenv_with_factor(self, newconfig, monkeypatch): - monkeypatch.setenv("A123A", "a") - monkeypatch.setenv("A123B", "b") - monkeypatch.setenv("A123C", "c") - monkeypatch.setenv("A123D", "d") - monkeypatch.setenv("BX23", "0") - monkeypatch.setenv("CCA43", "3") - monkeypatch.setenv("CB21", "4") - config = newconfig( - """ - [tox] - envlist = {x1,x2} - [testenv] - passenv = - x1: A123A CC* - x1: CB21 - # passed to both environments - A123C - x2: A123B A123D - """, - ) - assert len(config.envconfigs) == 2 - - assert "A123A" in config.envconfigs["x1"].passenv - assert "A123C" in config.envconfigs["x1"].passenv - assert "CCA43" in config.envconfigs["x1"].passenv - assert "CB21" in config.envconfigs["x1"].passenv - assert "A123B" not in config.envconfigs["x1"].passenv - assert "A123D" not in config.envconfigs["x1"].passenv - assert "BX23" not in config.envconfigs["x1"].passenv - - assert "A123B" in config.envconfigs["x2"].passenv - assert "A123D" in config.envconfigs["x2"].passenv - assert "A123A" not in config.envconfigs["x2"].passenv - assert "A123C" in config.envconfigs["x2"].passenv - assert "CCA43" not in config.envconfigs["x2"].passenv - assert "CB21" not in config.envconfigs["x2"].passenv - assert "BX23" not in config.envconfigs["x2"].passenv - - def test_passenv_from_global_env(self, newconfig, monkeypatch): - monkeypatch.setenv("A1", "a1") - monkeypatch.setenv("A2", "a2") - monkeypatch.setenv("TOX_TESTENV_PASSENV", "A1") - config = newconfig( - """ - [testenv] - passenv = A2 - """, - ) - env = config.envconfigs["python"] - assert "A1" in env.passenv - assert "A2" in env.passenv - - def test_passenv_glob_from_global_env(self, newconfig, monkeypatch): - monkeypatch.setenv("A1", "a1") - monkeypatch.setenv("A2", "a2") - monkeypatch.setenv("TOX_TESTENV_PASSENV", "A*") - config = newconfig( - """ - [testenv] - """, - ) - env = config.envconfigs["python"] - assert "A1" in env.passenv - assert "A2" in env.passenv - - def test_no_spinner(self, newconfig, monkeypatch): - monkeypatch.setenv("TOX_PARALLEL_NO_SPINNER", "1") - config = newconfig( - """ - [testenv] - passenv = TOX_PARALLEL_NO_SPINNER - """, - ) - env = config.envconfigs["python"] - assert "TOX_PARALLEL_NO_SPINNER" in env.passenv - - def test_changedir_override(self, newconfig): - config = newconfig( - """ - [testenv] - changedir=xyz - [testenv:python] - changedir=abc - basepython=python3.6 - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert envconfig.changedir.basename == "abc" - assert envconfig.changedir == config.setupdir.join("abc") - - def test_install_command_setting(self, newconfig): - config = newconfig( - """ - [testenv] - install_command=some_install {packages} - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.install_command == ["some_install", "{packages}"] - - def test_install_command_must_contain_packages(self, newconfig): - with pytest.raises(tox.exception.ConfigError): - newconfig("[testenv]\ninstall_command=pip install") - - def test_install_command_substitutions(self, newconfig): - config = newconfig( - """ - [testenv] - install_command=some_install --arg={toxinidir}/foo \ - {envname} {opts} {packages} - """, - ) - envconfig = config.envconfigs["python"] - expected_deps = [ - "some_install", - "--arg={}/foo".format(config.toxinidir), - "python", - "{opts}", - "{packages}", - ] - assert envconfig.install_command == expected_deps - - def test_install_command_substitutions_other_section(self, newconfig): - config = newconfig( - """ - [base] - install_command=some_install --arg={toxinidir}/foo \ - {envname} {opts} {packages} - [testenv] - install_command={[base]install_command} - """, - ) - envconfig = config.envconfigs["python"] - expected_deps = [ - "some_install", - "--arg={}/foo".format(config.toxinidir), - "python", - "{opts}", - "{packages}", - ] - assert envconfig.install_command == expected_deps - - def test_pip_pre(self, newconfig): - config = newconfig( - """ - [testenv] - pip_pre=true - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.pip_pre - - def test_pip_pre_cmdline_override(self, newconfig): - config = newconfig( - ["--pre"], - """ - [testenv] - pip_pre=false - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.pip_pre - - def test_simple(self, newconfig): - config = newconfig( - """ - [testenv:py36] - basepython=python3.6 - [testenv:py27] - basepython=python2.7 - """, - ) - assert len(config.envconfigs) == 2 - assert "py36" in config.envconfigs - assert "py27" in config.envconfigs - - def test_substitution_error(self, newconfig): - with pytest.raises(tox.exception.ConfigError): - newconfig("[testenv:py27]\nbasepython={xyz}") - - def test_substitution_defaults(self, newconfig): - config = newconfig( - """ - [testenv:py27] - commands = - {toxinidir} - {toxworkdir} - {envdir} - {envbindir} - {envtmpdir} - {envpython} - {homedir} - {distshare} - {envlogdir} - """, - ) - conf = config.envconfigs["py27"] - argv = conf.commands - assert argv[0][0] == config.toxinidir - assert argv[1][0] == config.toxworkdir - assert argv[2][0] == conf.envdir - assert argv[3][0] == conf.envbindir - assert argv[4][0] == conf.envtmpdir - assert argv[5][0] == conf.envpython - assert argv[6][0] == str(config.homedir) - assert argv[7][0] == config.homedir.join(".tox", "distshare") - assert argv[8][0] == conf.envlogdir - - def test_substitution_notfound_issue246(self, newconfig): - config = newconfig( - """ - [testenv:py27] - setenv = - FOO={envbindir} - BAR={envsitepackagesdir} - """, - ) - conf = config.envconfigs["py27"] - env = conf.setenv - assert "FOO" in env - assert "BAR" in env - - def test_substitution_notfound_issue515(self, newconfig): - config = newconfig( - """ - [tox] - envlist = standard-greeting - - [testenv:standard-greeting] - commands = - python -c 'print("Hello, world!")' - - [testenv:custom-greeting] - passenv = - NAME - commands = - python -c 'print("Hello, {env:NAME}!")' - """, - ) - conf = config.envconfigs["standard-greeting"] - assert conf.commands == [["python", "-c", 'print("Hello, world!")']] - - def test_substitution_nested_env_defaults(self, newconfig, monkeypatch): - monkeypatch.setenv("IGNORE_STATIC_DEFAULT", "env") - monkeypatch.setenv("IGNORE_DYNAMIC_DEFAULT", "env") - config = newconfig( - """ - [testenv:py27] - passenv = - IGNORE_STATIC_DEFAULT - USE_STATIC_DEFAULT - IGNORE_DYNAMIC_DEFAULT - USE_DYNAMIC_DEFAULT - setenv = - OTHER_VAR=other - IGNORE_STATIC_DEFAULT={env:IGNORE_STATIC_DEFAULT:default} - USE_STATIC_DEFAULT={env:USE_STATIC_DEFAULT:default} - IGNORE_DYNAMIC_DEFAULT={env:IGNORE_DYNAMIC_DEFAULT:{env:OTHER_VAR}+default} - USE_DYNAMIC_DEFAULT={env:USE_DYNAMIC_DEFAULT:{env:OTHER_VAR}+default} - IGNORE_OTHER_DEFAULT={env:OTHER_VAR:{env:OTHER_VAR}+default} - USE_OTHER_DEFAULT={env:NON_EXISTENT_VAR:{env:OTHER_VAR}+default} - """, - ) - conf = config.envconfigs["py27"] - env = conf.setenv - assert env["IGNORE_STATIC_DEFAULT"] == "env" - assert env["USE_STATIC_DEFAULT"] == "default" - assert env["IGNORE_OTHER_DEFAULT"] == "other" - assert env["USE_OTHER_DEFAULT"] == "other+default" - assert env["IGNORE_DYNAMIC_DEFAULT"] == "env" - assert env["USE_DYNAMIC_DEFAULT"] == "other+default" - - def test_substitution_positional(self, newconfig): - inisource = """ - [testenv:py27] - commands = - cmd1 [hello] \ - world - cmd1 {posargs:hello} \ - world - """ - conf = newconfig([], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "[hello]", "world"] - assert argv[1] == ["cmd1", "hello", "world"] - conf = newconfig(["brave", "new"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "[hello]", "world"] - assert argv[1] == ["cmd1", "brave", "new", "world"] - - def test_substitution_noargs_issue240(self, newconfig): - inisource = """ - [testenv] - commands = echo {posargs:foo} - """ - conf = newconfig([""], inisource).envconfigs["python"] - argv = conf.commands - assert argv[0] == ["echo"] - - def test_substitution_double(self, newconfig): - inisource = """ - [params] - foo = bah - foo2 = [params]foo - - [testenv:py27] - commands = - echo {{[params]foo2}} - """ - conf = newconfig([], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["echo", "bah"] - - def test_posargs_backslashed_or_quoted(self, newconfig): - inisource = r""" - [testenv:py27] - commands = - echo "\{posargs\}" = {posargs} - echo "posargs = " "{posargs}" - """ - conf = newconfig([], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["echo", "{posargs}", "="] - assert argv[1] == ["echo", "posargs = ", ""] - - conf = newconfig(["dog", "cat"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["echo", "{posargs}", "=", "dog", "cat"] - assert argv[1] == ["echo", "posargs = ", "dog cat"] - - def test_rewrite_posargs(self, tmpdir, newconfig): - inisource = """ - [testenv:py27] - args_are_paths = True - changedir = tests - commands = cmd1 {posargs:hello} - """ - conf = newconfig([], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "hello"] - - conf = newconfig(["tests/hello"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "tests/hello"] - - tmpdir.ensure("tests", "hello") - conf = newconfig(["tests/hello"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "hello"] - - def test_rewrite_simple_posargs(self, tmpdir, newconfig): - inisource = """ - [testenv:py27] - args_are_paths = True - changedir = tests - commands = cmd1 {posargs} - """ - conf = newconfig([], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1"] - - conf = newconfig(["tests/hello"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "tests/hello"] - - tmpdir.ensure("tests", "hello") - conf = newconfig(["tests/hello"], inisource).envconfigs["py27"] - argv = conf.commands - assert argv[0] == ["cmd1", "hello"] - - @pytest.mark.parametrize( - "envlist, deps", - [ - (["py27"], ("pytest", "pytest-cov")), - (["py27", "py34"], ("pytest", "py{27,34}: pytest-cov")), - ], - ) - def test_take_dependencies_from_other_testenv(self, newconfig, envlist, deps): - inisource = """ - [tox] - envlist = {envlist} - [testenv] - deps={deps} - [testenv:py27] - deps= - {{[testenv]deps}} - fun - frob{{env:ENV_VAR:>1.0,<2.0}} - """.format( - envlist=",".join(envlist), - deps="\n" + "\n".join(" " * 17 + d for d in deps), - ) - conf = newconfig([], inisource).envconfigs["py27"] - packages = [dep.name for dep in conf.deps] - assert packages == ["pytest", "pytest-cov", "fun", "frob>1.0,<2.0"] - - # https://github.com/tox-dev/tox/issues/706 - @pytest.mark.parametrize("envlist", [["py27", "coverage", "other"]]) - def test_regression_test_issue_706(self, newconfig, envlist): - inisource = """ - [tox] - envlist = {envlist} - [testenv] - deps= - flake8 - coverage: coverage - [testenv:py27] - deps= - {{[testenv]deps}} - fun - """.format( - envlist=",".join(envlist), - ) - conf = newconfig([], inisource).envconfigs["coverage"] - packages = [dep.name for dep in conf.deps] - assert packages == ["flake8", "coverage"] - - conf = newconfig([], inisource).envconfigs["other"] - packages = [dep.name for dep in conf.deps] - assert packages == ["flake8"] - - conf = newconfig([], inisource).envconfigs["py27"] - packages = [dep.name for dep in conf.deps] - assert packages == ["flake8", "fun"] - - def test_factor_expansion(self, newconfig): - inisource = """ - [tox] - envlist = {py27, py37}-cover - [testenv] - deps= - {py27}: foo - {py37}: bar - """ - conf = newconfig([], inisource).envconfigs["py27-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == ["foo"] - - conf = newconfig([], inisource).envconfigs["py37-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == ["bar"] - - # Regression test https://github.com/tox-dev/tox/issues/899 - def test_factors_support_curly_braces(self, newconfig): - inisource = """ - [tox] - envlist = - style - sdist - bdist_wheel - {py27,py34,py35,py36,pypy,pypy3}-cover - {py27,py34,py35,py36,pypy,pypy3}-nocov - - [testenv] - deps = - cover: coverage - cover: codecov - {py27}: unittest2 - {py27}: mysql-python - {py27,py36}: mmtf-python - {py27,py35}: reportlab - {py27,py34,py35,py36}: psycopg2-binary - {py27,py34,py35,py35}: mysql-connector-python-rf - {py27,py35,pypy}: rdflib - {pypy,pypy3}: numpy==1.12.1 - {py27,py34,py36}: numpy - {py36}: scipy - {py27}: networkx - {py36}: matplotlib - """ - conf = newconfig([], inisource).envconfigs["style"] - packages = [dep.name for dep in conf.deps] - assert packages == [] - - conf = newconfig([], inisource).envconfigs["py27-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - "coverage", - "codecov", - "unittest2", - "mysql-python", - "mmtf-python", - "reportlab", - "psycopg2-binary", - "mysql-connector-python-rf", - "rdflib", - "numpy", - "networkx", - ] - - conf = newconfig([], inisource).envconfigs["py34-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - "coverage", - "codecov", - "psycopg2-binary", - "mysql-connector-python-rf", - "numpy", - ] - - conf = newconfig([], inisource).envconfigs["py35-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - "coverage", - "codecov", - "reportlab", - "psycopg2-binary", - "mysql-connector-python-rf", - "rdflib", - ] - - conf = newconfig([], inisource).envconfigs["py36-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - "coverage", - "codecov", - "mmtf-python", - "psycopg2-binary", - "numpy", - "scipy", - "matplotlib", - ] - - conf = newconfig([], inisource).envconfigs["pypy-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == ["coverage", "codecov", "rdflib", "numpy==1.12.1"] - - conf = newconfig([], inisource).envconfigs["pypy3-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == ["coverage", "codecov", "numpy==1.12.1"] - - conf = newconfig([], inisource).envconfigs["py27-nocov"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - "unittest2", - "mysql-python", - "mmtf-python", - "reportlab", - "psycopg2-binary", - "mysql-connector-python-rf", - "rdflib", - "numpy", - "networkx", - ] - - conf = newconfig([], inisource).envconfigs["py34-nocov"] - packages = [dep.name for dep in conf.deps] - assert packages == ["psycopg2-binary", "mysql-connector-python-rf", "numpy"] - - conf = newconfig([], inisource).envconfigs["py35-nocov"] - packages = [dep.name for dep in conf.deps] - assert packages == ["reportlab", "psycopg2-binary", "mysql-connector-python-rf", "rdflib"] - - conf = newconfig([], inisource).envconfigs["py36-nocov"] - packages = [dep.name for dep in conf.deps] - assert packages == ["mmtf-python", "psycopg2-binary", "numpy", "scipy", "matplotlib"] - - conf = newconfig([], inisource).envconfigs["pypy-nocov"] - packages = [dep.name for dep in conf.deps] - assert packages == ["rdflib", "numpy==1.12.1"] - - conf = newconfig([], inisource).envconfigs["pypy3-cover"] - packages = [dep.name for dep in conf.deps] - assert packages == ["coverage", "codecov", "numpy==1.12.1"] - - # Regression test https://github.com/tox-dev/tox/issues/906 - def test_do_not_substitute_more_than_needed(self, newconfig): - inisource = """ - [tox] - envlist = - django_master-py{36,35} - django20-py{36,35,34,py3} - django111-py{36,35,34,27,py} - django18-py{35,34,27,py} - lint - docs - - [testenv] - deps = - .[test] - django18: {[django]1.8.x} - django111: {[django]1.11.x} - django20: {[django]2.0.x} - django_master: {[django]master} - - [django] - 1.8.x = - Django>=1.8.0,<1.9.0 - django-reversion==1.10.0 - djangorestframework>=3.3.3,<3.7.0 - 1.11.x = - Django>=1.11.0,<2.0.0 - django-reversion>=2.0.8 - djangorestframework>=3.6.2 - 2.0.x = - Django>=2.0,<2.1 - django-reversion>=2.0.8 - djangorestframework>=3.7.3 - master = - https://github.com/django/django/tarball/master - django-reversion>=2.0.8 - djangorestframework>=3.6.2 - """ - conf = newconfig([], inisource).envconfigs["django_master-py36"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - ".[test]", - "https://github.com/django/django/tarball/master", - "django-reversion>=2.0.8", - "djangorestframework>=3.6.2", - ] - - conf = newconfig([], inisource).envconfigs["django20-pypy3"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - ".[test]", - "Django>=2.0,<2.1", - "django-reversion>=2.0.8", - "djangorestframework>=3.7.3", - ] - - conf = newconfig([], inisource).envconfigs["django111-py34"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - ".[test]", - "Django>=1.11.0,<2.0.0", - "django-reversion>=2.0.8", - "djangorestframework>=3.6.2", - ] - - conf = newconfig([], inisource).envconfigs["django18-py27"] - packages = [dep.name for dep in conf.deps] - assert packages == [ - ".[test]", - "Django>=1.8.0,<1.9.0", - "django-reversion==1.10.0", - "djangorestframework>=3.3.3,<3.7.0", - ] - - conf = newconfig([], inisource).envconfigs["lint"] - packages = [dep.name for dep in conf.deps] - assert packages == [".[test]"] - - conf = newconfig([], inisource).envconfigs["docs"] - packages = [dep.name for dep in conf.deps] - assert packages == [".[test]"] - - def test_take_dependencies_from_other_section(self, newconfig): - inisource = """ - [testing:pytest] - deps= - pytest - pytest-cov - [testing:mock] - deps= - mock - [testenv] - deps= - {[testing:pytest]deps} - {[testing:mock]deps} - fun - """ - conf = newconfig([], inisource) - env = conf.envconfigs["python"] - packages = [dep.name for dep in env.deps] - assert packages == ["pytest", "pytest-cov", "mock", "fun"] - - def test_multilevel_substitution(self, newconfig): - inisource = """ - [testing:pytest] - deps= - pytest - pytest-cov - [testing:mock] - deps= - mock - - [testing] - deps= - {[testing:pytest]deps} - {[testing:mock]deps} - - [testenv] - deps= - {[testing]deps} - fun - """ - conf = newconfig([], inisource) - env = conf.envconfigs["python"] - packages = [dep.name for dep in env.deps] - assert packages == ["pytest", "pytest-cov", "mock", "fun"] - - def test_recursive_substitution_cycle_fails(self, newconfig): - inisource = """ - [testing:pytest] - deps= - {[testing:mock]deps} - [testing:mock] - deps= - {[testing:pytest]deps} - - [testenv] - deps= - {[testing:pytest]deps} - """ - with pytest.raises(tox.exception.ConfigError): - newconfig([], inisource) - - def test_single_value_from_other_secton(self, newconfig, tmpdir): - inisource = """ - [common] - changedir = testing - [testenv] - changedir = {[common]changedir} - """ - conf = newconfig([], inisource).envconfigs["python"] - assert conf.changedir.basename == "testing" - assert conf.changedir.dirpath().realpath() == tmpdir.realpath() - - def test_factors(self, newconfig): - inisource = """ - [tox] - envlist = a-x,b - - [testenv] - deps= - dep-all - a: dep-a - b: dep-b - x: dep-x - !a: dep-!a - !b: dep-!b - !x: dep-!x - """ - conf = newconfig([], inisource) - configs = conf.envconfigs - expected = ["dep-all", "dep-a", "dep-x", "dep-!b"] - assert [dep.name for dep in configs["a-x"].deps] == expected - expected = ["dep-all", "dep-b", "dep-!a", "dep-!x"] - assert [dep.name for dep in configs["b"].deps] == expected - expected = ["dep-all", "dep-a", "dep-x", "dep-!b"] - assert [dep.name for dep in configs["a-x"].deps] == expected - expected = ["dep-all", "dep-b", "dep-!a", "dep-!x"] - assert [dep.name for dep in configs["b"].deps] == expected - - def test_factor_ops(self, newconfig): - inisource = """ - [tox] - envlist = {a,b}-{x,y} - - [testenv] - deps= - a,b: dep-a-or-b - a-x: dep-a-and-x - {a,b}-y: dep-ab-and-y - a-!x: dep-a-and-!x - a,!x: dep-a-or-!x - !a-!x: dep-!a-and-!x - !a,!x: dep-!a-or-!x - !a-!b: dep-!a-and-!b - !a-!b-!x-!y: dep-!a-and-!b-and-!x-and-!y - """ - configs = newconfig([], inisource).envconfigs - - def get_deps(env): - return [dep.name for dep in configs[env].deps] - - assert get_deps("a-x") == ["dep-a-or-b", "dep-a-and-x", "dep-a-or-!x"] - expected = ["dep-a-or-b", "dep-ab-and-y", "dep-a-and-!x", "dep-a-or-!x", "dep-!a-or-!x"] - assert get_deps("a-y") == expected - assert get_deps("b-x") == ["dep-a-or-b", "dep-!a-or-!x"] - expected = ["dep-a-or-b", "dep-ab-and-y", "dep-a-or-!x", "dep-!a-and-!x", "dep-!a-or-!x"] - assert get_deps("b-y") == expected - - def test_envconfigs_based_on_factors(self, newconfig): - inisource = """ - [testenv] - some-setting= - a: something - b,c: something - d-e: something - !f: something - !g,!h: something - !i-!j: something - - [unknown-section] - some-setting= - eggs: something - """ - config = newconfig(["-e spam"], inisource) - assert not config.envconfigs - assert config.envlist == ["spam"] - config = newconfig(["-e eggs"], inisource) - assert not config.envconfigs - assert config.envlist == ["eggs"] - config = newconfig(["-e py3-spam"], inisource) - assert not config.envconfigs - assert config.envlist == ["py3-spam"] - for x in "abcdefghij": - env = "py3-{}".format(x) - config = newconfig(["-e {}".format(env)], inisource) - assert sorted(config.envconfigs) == [env] - assert config.envlist == [env] - - def test_default_factors(self, newconfig): - inisource = """ - [tox] - envlist = py{27,34,36}-dep - - [testenv] - deps= - dep: dep - """ - conf = newconfig([], inisource) - configs = conf.envconfigs - for name, config in configs.items(): - assert config.basepython == "python{}.{}".format(name[2], name[3]) - - @pytest.mark.skipif(IS_PYPY, reason="fails on pypy") - def test_default_factors_conflict(self, newconfig, capsys): - with pytest.warns(UserWarning, match=r"conflicting basepython .*"): - env = "pypy27" if tox.INFO.IS_PYPY else "py27" - config = newconfig( - """\ - [testenv] - basepython=python3 - [testenv:{}] - commands = python --version - """.format( - env - ), - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs[env] - assert envconfig.basepython == "python3" - - def test_default_factors_conflict_lying_name( - self, - newconfig, - capsys, - tmpdir, - recwarn, - monkeypatch, - ): - # we first need to create a lying Python here, let's mock out here - from tox.interpreters import Interpreters - - def get_executable(self, envconfig): - return sys.executable - - monkeypatch.setattr(Interpreters, "get_executable", get_executable) - - major, minor = sys.version_info[0:2] - config = newconfig( - """ - [testenv:py{0}{1}] - basepython=python{0}.{2} - commands = python --version - """.format( - major, - minor, - minor - 1, - ), - ) - env_config = config.envconfigs["py{}{}".format(major, minor)] - assert env_config.basepython == "python{}.{}".format(major, minor - 1) - assert not recwarn.list, "\n".join(repr(i.message) for i in recwarn.list) - - def test_default_single_digit_factors(self, newconfig, monkeypatch): - from tox.interpreters import Interpreters - - def get_executable(self, envconfig): - return sys.executable - - monkeypatch.setattr(Interpreters, "get_executable", get_executable) - - major, minor = sys.version_info[0:2] - - with pytest.warns(None) as lying: - config = newconfig( - """ - [testenv:py{0}] - basepython=python{0}.{1} - commands = python --version - """.format( - major, - minor - 1, - ), - ) - - env_config = config.envconfigs["py{}".format(major)] - assert env_config.basepython == "python{}.{}".format(major, minor - 1) - assert len(lying) == 0, "\n".join(repr(r.message) for r in lying) - - with pytest.warns(None) as truthful: - config = newconfig( - """ - [testenv:py{0}] - basepython=python{0}.{1} - commands = python --version - """.format( - major, - minor, - ), - ) - - env_config = config.envconfigs["py{}".format(major)] - assert env_config.basepython == "python{}.{}".format(major, minor) - assert len(truthful) == 0, "\n".join(repr(r.message) for r in truthful) - - def test_default_factors_conflict_ignore(self, newconfig, capsys): - with pytest.warns(None) as record: - config = newconfig( - """ - [tox] - ignore_basepython_conflict=True - [testenv] - basepython=python3 - [testenv:py27] - commands = python --version - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["py27"] - assert envconfig.basepython == "python2.7" - assert len(record) == 0, "\n".join(repr(r.message) for r in record) - - def test_factors_in_boolean(self, newconfig): - inisource = """ - [tox] - envlist = py{27,36} - - [testenv] - recreate = - py27: True - """ - configs = newconfig([], inisource).envconfigs - assert configs["py27"].recreate - assert not configs["py36"].recreate - - def test_factors_in_setenv(self, newconfig): - inisource = """ - [tox] - envlist = py27,py36 - - [testenv] - setenv = - py27: X = 1 - """ - configs = newconfig([], inisource).envconfigs - assert configs["py27"].setenv["X"] == "1" - assert "X" not in configs["py36"].setenv - - def test_curly_braces_in_setenv(self, newconfig): - inisource = r""" - [testenv] - setenv = - VAR = \{val\} - commands = - {env:VAR} - """ - configs = newconfig([], inisource).envconfigs - assert configs["python"].setenv["VAR"] == r"\{val\}" - assert configs["python"].commands[0] == ["{val}"] - - def test_factor_use_not_checked(self, newconfig): - inisource = """ - [tox] - envlist = py27-{a,b} - - [testenv] - deps = b: test - """ - configs = newconfig([], inisource).envconfigs - assert set(configs.keys()) == {"py27-a", "py27-b"} - - def test_factors_groups_touch(self, newconfig): - inisource = """ - [tox] - envlist = {a,b}{-x,} - - [testenv] - deps= - a,b,x,y: dep - """ - configs = newconfig([], inisource).envconfigs - assert set(configs.keys()) == {"a", "a-x", "b", "b-x"} - - def test_period_in_factor(self, newconfig): - inisource = """ - [tox] - envlist = py27-{django1.6,django1.7} - - [testenv] - deps = - django1.6: Django==1.6 - django1.7: Django==1.7 - """ - configs = newconfig([], inisource).envconfigs - assert sorted(configs) == ["py27-django1.6", "py27-django1.7"] - assert [d.name for d in configs["py27-django1.6"].deps] == ["Django==1.6"] - - def test_ignore_outcome(self, newconfig): - inisource = """ - [testenv] - ignore_outcome=True - """ - config = newconfig([], inisource).envconfigs - assert config["python"].ignore_outcome is True - - -class TestGlobalOptions: - def test_notest(self, newconfig): - config = newconfig([], "") - assert not config.option.notest - config = newconfig(["--notest"], "") - assert config.option.notest - - @pytest.mark.parametrize( - "args, expected", - [([], 0), (["-v"], 1), (["-vv"], 2), (["--verbose", "--verbose"], 2), (["-vvv"], 3)], - ) - def test_verbosity(self, args, expected, newconfig): - config = newconfig(args, "") - assert config.option.verbose_level == expected - - @pytest.mark.parametrize( - "args, expected", - [([], 0), (["-q"], 1), (["-qq"], 2), (["--quiet", "--quiet"], 2), (["-qqq"], 3)], - ) - def test_quiet(self, args, expected, newconfig): - config = newconfig(args, "") - assert config.option.quiet_level == expected - - def test_substitution_jenkins_global(self, monkeypatch, newconfig): - monkeypatch.setenv("HUDSON_URL", "xyz") - config = newconfig( - """ - [tox:tox] - envlist = py37 - """, - filename="setup.cfg", - ) - assert "py37" in config.envconfigs - - def test_substitution_jenkins_default(self, monkeypatch, newconfig): - monkeypatch.setenv("HUDSON_URL", "xyz") - config = newconfig( - """ - [testenv:py27] - commands = - {distshare} - """, - ) - conf = config.envconfigs["py27"] - argv = conf.commands - expect_path = config.toxworkdir.join("distshare") - assert argv[0][0] == expect_path - - def test_substitution_jenkins_context(self, tmpdir, monkeypatch, newconfig): - monkeypatch.setenv("HUDSON_URL", "xyz") - monkeypatch.setenv("WORKSPACE", str(tmpdir)) - config = newconfig( - """ - [tox:jenkins] - distshare = {env:WORKSPACE}/hello - [testenv:py27] - commands = - {distshare} - """, - ) - conf = config.envconfigs["py27"] - argv = conf.commands - assert argv[0][0] == config.distshare - assert config.distshare == tmpdir.join("hello") - - def test_sdist_specification(self, newconfig): - config = newconfig( - """ - [tox] - sdistsrc = {distshare}/xyz.zip - """, - ) - assert config.sdistsrc == config.distshare.join("xyz.zip") - config = newconfig([], "") - assert not config.sdistsrc - - def test_env_selection_with_section_name(self, newconfig, monkeypatch): - inisource = """ - [tox] - envlist = py36 - [testenv:py36] - basepython=python3.6 - [testenv:py35] - basepython=python3.5 - [testenv:py27] - basepython=python2.7 - """ - config = newconfig([], inisource) - assert config.envlist == ["py36"] - config = newconfig(["-epy35"], inisource) - assert config.envlist == ["py35"] - monkeypatch.setenv("TOXENV", "py35,py36") - config = newconfig([], inisource) - assert config.envlist == ["py35", "py36"] - monkeypatch.setenv("TOXENV", "ALL") - config = newconfig([], inisource) - assert config.envlist == ["py36", "py35", "py27"] - config = newconfig(["-eALL"], inisource) - assert config.envlist == ["py36", "py35", "py27"] - config = newconfig(["-espam"], inisource) - assert config.envlist == ["spam"] - - def test_env_selection_expanded_envlist(self, newconfig, monkeypatch): - inisource = """ - [tox] - envlist = py{36,35,27} - [testenv:py36] - basepython=python3.6 - """ - config = newconfig([], inisource) - assert config.envlist == ["py36", "py35", "py27"] - config = newconfig(["-eALL"], inisource) - assert config.envlist == ["py36", "py35", "py27"] - - def test_py_venv(self, newconfig): - config = newconfig(["-epy"], "") - env = config.envconfigs["py"] - assert str(env.basepython) == sys.executable - - def test_no_implicit_venv_from_cli_with_envlist(self, newconfig): - # See issue 1160. - inisource = """ - [tox] - envlist = stated-factors - """ - config = newconfig(["-etypo-factor"], inisource) - assert "typo-factor" not in config.envconfigs - - def test_correct_basepython_chosen_from_default_factors(self, newconfig): - envs = { - "py": sys.executable, - "py2": "python2", - "py3": "python3", - "py27": "python2.7", - "py36": "python3.6", - "py310": "python3.10", - "pypy": "pypy", - "pypy2": "pypy2", - "pypy3": "pypy3", - "pypy36": "pypy3.6", - "jython": "jython", - } - config = newconfig([], "[tox]\nenvlist={}".format(", ".join(envs))) - assert set(config.envlist) == set(envs) - for name in config.envlist: - basepython = config.envconfigs[name].basepython - assert basepython == envs[name] - - def test_envlist_expansion(self, newconfig): - inisource = """ - [tox] - envlist = py{36,27},docs - """ - config = newconfig([], inisource) - assert config.envlist == ["py36", "py27", "docs"] - - def test_envlist_cross_product(self, newconfig): - inisource = """ - [tox] - envlist = py{36,27}-dep{1,2} - """ - config = newconfig([], inisource) - envs = ["py36-dep1", "py36-dep2", "py27-dep1", "py27-dep2"] - assert config.envlist == envs - - def test_envlist_multiline(self, newconfig): - inisource = """ - [tox] - envlist = - py27 - py34 - """ - config = newconfig([], inisource) - assert config.envlist == ["py27", "py34"] - - def test_skip_missing_interpreters_true(self, newconfig): - ini_source = """ - [tox] - skip_missing_interpreters = True - """ - config = newconfig([], ini_source) - assert config.option.skip_missing_interpreters == "true" - - def test_skip_missing_interpreters_false(self, newconfig): - ini_source = """ - [tox] - skip_missing_interpreters = False - """ - config = newconfig([], ini_source) - assert config.option.skip_missing_interpreters == "false" - - def test_skip_missing_interpreters_cli_no_arg(self, newconfig): - ini_source = """ - [tox] - skip_missing_interpreters = False - """ - config = newconfig(["--skip-missing-interpreters"], ini_source) - assert config.option.skip_missing_interpreters == "true" - - def test_skip_missing_interpreters_cli_not_specified(self, newconfig): - config = newconfig([], "") - assert config.option.skip_missing_interpreters == "false" - - def test_skip_missing_interpreters_cli_overrides_true(self, newconfig): - ini_source = """ - [tox] - skip_missing_interpreters = False - """ - config = newconfig(["--skip-missing-interpreters", "true"], ini_source) - assert config.option.skip_missing_interpreters == "true" - - def test_skip_missing_interpreters_cli_overrides_false(self, newconfig): - ini_source = """ - [tox] - skip_missing_interpreters = True - """ - config = newconfig(["--skip-missing-interpreters", "false"], ini_source) - assert config.option.skip_missing_interpreters == "false" - - def test_defaultenv_commandline(self, newconfig): - config = newconfig(["-epy27"], "") - env = config.envconfigs["py27"] - assert env.basepython == "python2.7" - assert not env.commands - - def test_defaultenv_partial_override(self, newconfig): - inisource = """ - [tox] - envlist = py27 - [testenv:py27] - commands= xyz - """ - config = newconfig([], inisource) - env = config.envconfigs["py27"] - assert env.basepython == "python2.7" - assert env.commands == [["xyz"]] - - -class TestHashseedOption: - def _get_envconfigs(self, newconfig, args=None, tox_ini=None, make_hashseed=None): - if args is None: - args = [] - if tox_ini is None: - tox_ini = """ - [testenv] - """ - if make_hashseed is None: - - def make_hashseed(): - return "123456789" - - original_make_hashseed = tox.config.make_hashseed - tox.config.make_hashseed = make_hashseed - try: - config = newconfig(args, tox_ini) - finally: - tox.config.make_hashseed = original_make_hashseed - return config.envconfigs - - def _get_envconfig(self, newconfig, args=None, tox_ini=None): - envconfigs = self._get_envconfigs(newconfig, args=args, tox_ini=tox_ini) - return envconfigs["python"] - - def _check_hashseed(self, envconfig, expected): - assert envconfig.setenv["PYTHONHASHSEED"] == expected - - def _check_testenv(self, newconfig, expected, args=None, tox_ini=None): - envconfig = self._get_envconfig(newconfig, args=args, tox_ini=tox_ini) - self._check_hashseed(envconfig, expected) - - def test_default(self, newconfig): - self._check_testenv(newconfig, "123456789") - - def test_passing_integer(self, newconfig): - args = ["--hashseed", "1"] - self._check_testenv(newconfig, "1", args=args) - - def test_passing_string(self, newconfig): - args = ["--hashseed", "random"] - self._check_testenv(newconfig, "random", args=args) - - def test_passing_empty_string(self, newconfig): - args = ["--hashseed", ""] - self._check_testenv(newconfig, "", args=args) - - def test_passing_no_argument(self, newconfig): - """Test that passing no arguments to --hashseed is not allowed.""" - args = ["--hashseed"] - try: - self._check_testenv(newconfig, "", args=args) - except SystemExit as exception: - assert exception.code == 2 - return - assert False # getting here means we failed the test. - - def test_setenv(self, newconfig): - """Check that setenv takes precedence.""" - tox_ini = """ - [testenv] - setenv = - PYTHONHASHSEED = 2 - """ - self._check_testenv(newconfig, "2", tox_ini=tox_ini) - args = ["--hashseed", "1"] - self._check_testenv(newconfig, "2", args=args, tox_ini=tox_ini) - - def test_noset(self, newconfig): - args = ["--hashseed", "noset"] - envconfig = self._get_envconfig(newconfig, args=args) - assert set(envconfig.setenv.definitions.keys()) == {"TOX_ENV_DIR", "TOX_ENV_NAME"} - - def test_noset_with_setenv(self, newconfig): - tox_ini = """ - [testenv] - setenv = - PYTHONHASHSEED = 2 - """ - args = ["--hashseed", "noset"] - self._check_testenv(newconfig, "2", args=args, tox_ini=tox_ini) - - def test_one_random_hashseed(self, newconfig): - """Check that different testenvs use the same random seed.""" - tox_ini = """ - [testenv:hash1] - [testenv:hash2] - """ - next_seed = [1000] - - # This function is guaranteed to generate a different value each time. - - def make_hashseed(): - next_seed[0] += 1 - return str(next_seed[0]) - - # Check that make_hashseed() works. - assert make_hashseed() == "1001" - envconfigs = self._get_envconfigs(newconfig, tox_ini=tox_ini, make_hashseed=make_hashseed) - self._check_hashseed(envconfigs["hash1"], "1002") - # Check that hash2's value is not '1003', for example. - self._check_hashseed(envconfigs["hash2"], "1002") - - def test_setenv_in_one_testenv(self, newconfig): - """Check using setenv in one of multiple testenvs.""" - tox_ini = """ - [testenv:hash1] - setenv = - PYTHONHASHSEED = 2 - [testenv:hash2] - """ - envconfigs = self._get_envconfigs(newconfig, tox_ini=tox_ini) - self._check_hashseed(envconfigs["hash1"], "2") - self._check_hashseed(envconfigs["hash2"], "123456789") - - -class TestSetenv: - def test_getdict_lazy(self, newconfig, monkeypatch): - monkeypatch.setenv("X", "2") - config = newconfig( - """ - [testenv:X] - key0 = - key1 = {env:X} - key2 = {env:Y:1} - """, - ) - envconfig = config.envconfigs["X"] - val = envconfig._reader.getdict_setenv("key0") - assert val["key1"] == "2" - assert val["key2"] == "1" - - def test_getdict_lazy_update(self, newconfig, monkeypatch): - monkeypatch.setenv("X", "2") - config = newconfig( - """ - [testenv:X] - key0 = - key1 = {env:X} - key2 = {env:Y:1} - """, - ) - envconfig = config.envconfigs["X"] - val = envconfig._reader.getdict_setenv("key0") - d = {} - d.update(val) - assert d == {"key1": "2", "key2": "1"} - - def test_setenv_uses_os_environ(self, newconfig, monkeypatch): - monkeypatch.setenv("X", "1") - config = newconfig( - """ - [testenv:env1] - setenv = - X = {env:X} - """, - ) - assert config.envconfigs["env1"].setenv["X"] == "1" - - def test_setenv_default_os_environ(self, newconfig, monkeypatch): - monkeypatch.delenv("X", raising=False) - config = newconfig( - """ - [testenv:env1] - setenv = - X = {env:X:2} - """, - ) - assert config.envconfigs["env1"].setenv["X"] == "2" - - def test_setenv_uses_other_setenv(self, newconfig): - config = newconfig( - """ - [testenv:env1] - setenv = - Y = 5 - X = {env:Y} - """, - ) - assert config.envconfigs["env1"].setenv["X"] == "5" - - def test_setenv_recursive_direct_with_default(self, newconfig): - config = newconfig( - """ - [testenv:env1] - setenv = - X = {env:X:3} - """, - ) - assert config.envconfigs["env1"].setenv["X"] == "3" - - def test_setenv_recursive_direct_with_default_nested(self, newconfig): - config = newconfig( - """ - [testenv:env1] - setenv = - X = {env:X:{env:X:3}} - """, - ) - assert config.envconfigs["env1"].setenv["X"] == "3" - - def test_setenv_recursive_direct_without_default(self, newconfig): - config = newconfig( - """ - [testenv:env1] - setenv = - X = {env:X} - """, - ) - with pytest.raises(tox.exception.MissingSubstitution): - config.envconfigs["env1"].setenv["X"] - - def test_setenv_overrides(self, newconfig): - config = newconfig( - """ - [testenv] - setenv = - PYTHONPATH = something - ANOTHER_VAL=else - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert "PYTHONPATH" in envconfig.setenv - assert "ANOTHER_VAL" in envconfig.setenv - assert envconfig.setenv["PYTHONPATH"] == "something" - assert envconfig.setenv["ANOTHER_VAL"] == "else" - - def test_setenv_with_envdir_and_basepython(self, newconfig): - config = newconfig( - """ - [testenv] - setenv = - VAL = {envdir} - basepython = {env:VAL} - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert "VAL" in envconfig.setenv - assert envconfig.setenv["VAL"] == envconfig.envdir - assert envconfig.basepython == envconfig.envdir - - def test_setenv_ordering_1(self, newconfig): - config = newconfig( - """ - [testenv] - setenv= - VAL={envdir} - commands=echo {env:VAL} - """, - ) - assert len(config.envconfigs) == 1 - envconfig = config.envconfigs["python"] - assert "VAL" in envconfig.setenv - assert envconfig.setenv["VAL"] == envconfig.envdir - assert str(envconfig.envdir) in envconfig.commands[0] - - def test_setenv_cross_section_subst_issue294(self, monkeypatch, newconfig): - """test that we can do cross-section substitution with setenv""" - monkeypatch.delenv("TEST", raising=False) - config = newconfig( - """ - [section] - x = - NOT_TEST={env:TEST:defaultvalue} - - [testenv] - setenv = {[section]x} - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.setenv["NOT_TEST"] == "defaultvalue" - - def test_setenv_cross_section_subst_twice(self, monkeypatch, newconfig): - """test that we can do cross-section substitution with setenv""" - monkeypatch.delenv("TEST", raising=False) - config = newconfig( - """ - [section] - x = NOT_TEST={env:TEST:defaultvalue} - [section1] - y = {[section]x} - - [testenv] - setenv = {[section1]y} - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.setenv["NOT_TEST"] == "defaultvalue" - - def test_setenv_cross_section_mixed(self, monkeypatch, newconfig): - """test that we can do cross-section substitution with setenv""" - monkeypatch.delenv("TEST", raising=False) - config = newconfig( - """ - [section] - x = NOT_TEST={env:TEST:defaultvalue} - - [testenv] - setenv = {[section]x} - y = 7 - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.setenv["NOT_TEST"] == "defaultvalue" - assert envconfig.setenv["y"] == "7" - - def test_setenv_comment(self, newconfig): - """Check that setenv ignores comments.""" - envconfig = newconfig( - """ - [testenv] - setenv = - # MAGIC = yes - """, - ).envconfigs["python"] - assert "MAGIC" not in envconfig.setenv - - @pytest.mark.parametrize( - "content, has_magic", - [ - (None, False), - ("\n", False), - ("#MAGIC = yes", False), - ("MAGIC=yes", True), - ("\nMAGIC = yes", True), - ], - ) - def test_setenv_env_file(self, newconfig, content, has_magic, tmp_path): - """Check that setenv handles env files.""" - env_path = tmp_path / ".env" if content else None - if content: - env_path.write_text(content.decode() if PY2 else content) - env_config = newconfig( - """ - [testenv] - setenv = - ALPHA = 1 - file| {} - """.format( - env_path, - ), - ).envconfigs["python"] - - envs = env_config.setenv.definitions - - assert envs["ALPHA"] == "1" - if has_magic: - assert envs["MAGIC"] == "yes" - else: - assert "MAGIC" not in envs - - expected_vars = ["ALPHA", "PYTHONHASHSEED", "TOX_ENV_DIR", "TOX_ENV_NAME"] - if has_magic: - expected_vars = sorted(expected_vars + ["MAGIC"]) - - exported = env_config.setenv.export() - assert sorted(exported) == expected_vars - - -class TestIndexServer: - def test_indexserver(self, newconfig): - config = newconfig( - """ - [tox] - indexserver = - name1 = XYZ - name2 = ABC - """, - ) - assert config.indexserver["default"].url is None - assert config.indexserver["name1"].url == "XYZ" - assert config.indexserver["name2"].url == "ABC" - - def test_parse_indexserver(self, newconfig): - inisource = """ - [tox] - indexserver = - default = https://pypi.somewhere.org - name1 = whatever - """ - config = newconfig([], inisource) - assert config.indexserver["default"].url == "https://pypi.somewhere.org/" - assert config.indexserver["name1"].url == "whatever" - config = newconfig(["-i", "qwe"], inisource) - assert config.indexserver["default"].url == "qwe" - assert config.indexserver["name1"].url == "whatever" - config = newconfig(["-i", "name1=abc", "-i", "qwe2"], inisource) - assert config.indexserver["default"].url == "qwe2" - assert config.indexserver["name1"].url == "abc" - - config = newconfig(["-i", "ALL=xzy"], inisource) - assert len(config.indexserver) == 2 - assert config.indexserver["default"].url == "xzy" - assert config.indexserver["name1"].url == "xzy" - - def test_multiple_homedir_relative_local_indexservers(self, newconfig): - inisource = """ - [tox] - indexserver = - default = file://{homedir}/.pip/downloads/simple - local1 = file://{homedir}/.pip/downloads/simple - local2 = file://{toxinidir}/downloads/simple - pypi = https://pypi.org/simple - """ - config = newconfig([], inisource) - expected = "file://{}/.pip/downloads/simple".format(config.homedir) - assert config.indexserver["default"].url == expected - assert config.indexserver["local1"].url == config.indexserver["default"].url - - -class TestConfigConstSubstitutions: - @pytest.mark.parametrize("pathsep", [":", ";"]) - def test_replace_pathsep(self, monkeypatch, newconfig, pathsep): - """Replace {:} with OS path separator.""" - monkeypatch.setattr("os.pathsep", pathsep) - config = newconfig( - """ - [testenv] - setenv = - PATH = dira{:}dirb{:}dirc - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.setenv["PATH"] == pathsep.join(["dira", "dirb", "dirc"]) - - def test_pathsep_regex(self): - """Sanity check for regex behavior for empty colon.""" - regex = tox.config.Replacer.RE_ITEM_REF - match = next(regex.finditer("{:}")) - mdict = match.groupdict() - assert mdict["sub_type"] is None - assert mdict["substitution_value"] == "" - assert mdict["default_value"] == "" - - @pytest.mark.parametrize("dirsep", ["\\", "\\\\"]) - def test_dirsep_replace(self, monkeypatch, newconfig, dirsep): - """Replace {/} with OS directory separator.""" - monkeypatch.setattr("os.sep", dirsep) - config = newconfig( - """ - [testenv] - setenv = - VAR = dira{/}subdirb{/}subdirc - """, - ) - envconfig = config.envconfigs["python"] - assert envconfig.setenv["VAR"] == dirsep.join(["dira", "subdirb", "subdirc"]) - - def test_dirsep_regex(self): - """Sanity check for regex behavior for directory separator.""" - regex = tox.config.Replacer.RE_ITEM_REF - match = next(regex.finditer("{/}")) - mdict = match.groupdict() - assert mdict["sub_type"] is None - assert mdict["substitution_value"] == "/" - assert mdict["default_value"] is None - - -class TestParseEnv: - def test_parse_recreate(self, newconfig): - inisource = "" - config = newconfig([], inisource) - assert not config.envconfigs["python"].recreate - config = newconfig(["--recreate"], inisource) - assert config.envconfigs["python"].recreate - config = newconfig(["-r"], inisource) - assert config.envconfigs["python"].recreate - inisource = """ - [testenv:hello] - recreate = True - """ - config = newconfig([], inisource) - assert config.envconfigs["hello"].recreate - - -class TestCmdInvocation: - def test_help(self, cmd, initproj): - initproj("help", filedefs={"tox.ini": ""}) - result = cmd("-h") - assert not result.ret - assert re.match(r"usage:.*help.*", result.out, re.DOTALL) - - def test_version_simple(self, cmd, initproj): - initproj("help", filedefs={"tox.ini": ""}) - result = cmd("--version") - assert not result.ret - assert "{} imported from".format(tox.__version__) in result.out - - def test_version_no_plugins(self): - pm = PluginManager("fakeprject") - version_info = get_version_info(pm) - assert "imported from" in version_info - assert "registered plugins:" not in version_info - - def test_version_with_normal_plugin(self, monkeypatch): - def fake_normal_plugin_distinfo(): - class MockModule: - __file__ = "some-file" - - class MockEggInfo: - project_name = "some-project" - version = "1.0" - - return [(MockModule, MockEggInfo)] - - pm = PluginManager("fakeproject") - monkeypatch.setattr(pm, "list_plugin_distinfo", fake_normal_plugin_distinfo) - version_info = get_version_info(pm) - assert "registered plugins:" in version_info - assert "some-file" in version_info - assert "some-project" in version_info - assert "1.0" in version_info - - def test_version_with_fileless_module(self, monkeypatch): - def fake_no_file_plugin_distinfo(): - class MockModule: - def __repr__(self): - return "some-repr" - - class MockEggInfo: - project_name = "some-project" - version = "1.0" - - return [(MockModule(), MockEggInfo)] - - pm = PluginManager("fakeproject") - monkeypatch.setattr(pm, "list_plugin_distinfo", fake_no_file_plugin_distinfo) - version_info = get_version_info(pm) - assert "registered plugins:" in version_info - assert "some-project" in version_info - assert "some-repr" in version_info - assert "1.0" in version_info - - def test_no_tox_ini(self, cmd, initproj): - initproj("noini-0.5") - result = cmd() - result.assert_fail() - msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" - assert result.err == msg - assert not result.out - - -@pytest.mark.parametrize( - "cli_args,run_envlist", - [ - ("-e py36", ["py36"]), - ("-e py36,py34", ["py36", "py34"]), - ("-e py36,py36", ["py36", "py36"]), - ("-e py36,py34 -e py34,py27", ["py36", "py34", "py34", "py27"]), - ], -) -def test_env_spec(initproj, cli_args, run_envlist): - initproj( - "env_spec", - filedefs={ - "tox.ini": """ - [tox] - envlist = - - [testenv] - commands = python -c "" - """, - }, - ) - args = cli_args.split() - config = parseconfig(args) - assert config.envlist == run_envlist - - -class TestCommandParser: - def test_command_parser_for_word(self): - p = CommandParser("word") - assert list(p.words()) == ["word"] - - def test_command_parser_for_posargs(self): - p = CommandParser("[]") - assert list(p.words()) == ["[]"] - - def test_command_parser_for_multiple_words(self): - p = CommandParser("w1 w2 w3 ") - assert list(p.words()) == ["w1", " ", "w2", " ", "w3"] - - def test_command_parser_for_substitution_with_spaces(self): - p = CommandParser("{sub:something with spaces}") - assert list(p.words()) == ["{sub:something with spaces}"] - - def test_command_parser_with_complex_word_set(self): - complex_case = ( - "word [] [literal] {something} {some:other thing} w{ord} w{or}d w{ord} " - "w{o:rd} w{o:r}d {w:or}d w[]ord {posargs:{a key}}" - ) - p = CommandParser(complex_case) - parsed = list(p.words()) - expected = [ - "word", - " ", - "[]", - " ", - "[literal]", - " ", - "{something}", - " ", - "{some:other thing}", - " ", - "w", - "{ord}", - " ", - "w", - "{or}", - "d", - " ", - "w", - "{ord}", - " ", - "w", - "{o:rd}", - " ", - "w", - "{o:r}", - "d", - " ", - "{w:or}", - "d", - " ", - "w[]ord", - " ", - "{posargs:{a key}}", - ] - - assert parsed == expected - - def test_command_with_runs_of_whitespace(self): - cmd = "cmd1 {item1}\n {item2}" - p = CommandParser(cmd) - parsed = list(p.words()) - assert parsed == ["cmd1", " ", "{item1}", "\n ", "{item2}"] - - def test_command_with_split_line_in_subst_arguments(self): - cmd = dedent( - """ cmd2 {posargs:{item2} - other}""", - ) - p = CommandParser(cmd) - parsed = list(p.words()) - expected = ["cmd2", " ", "{posargs:{item2}\n other}"] - assert parsed == expected - - def test_command_parsing_for_issue_10(self): - cmd = "nosetests -v -a !deferred --with-doctest []" - p = CommandParser(cmd) - parsed = list(p.words()) - expected = [ - "nosetests", - " ", - "-v", - " ", - "-a", - " ", - "!deferred", - " ", - "--with-doctest", - " ", - "[]", - ] - assert parsed == expected - - # @mark_dont_run_on_windows - def test_commands_with_backslash(self, newconfig): - config = newconfig( - [r"hello\world"], - """ - [testenv:py36] - commands = some {posargs} - """, - ) - envconfig = config.envconfigs["py36"] - assert envconfig.commands[0] == ["some", r"hello\world"] - - -def test_provision_tox_env_cannot_be_in_envlist(newconfig, capsys): - inisource = """ - [tox] - envlist = py36,.tox - """ - with pytest.raises( - tox.exception.ConfigError, - match="provision_tox_env .tox cannot be part of envlist", - ): - newconfig([], inisource) - - out, err = capsys.readouterr() - assert not err - assert not out - - -def test_isolated_build_env_cannot_be_in_envlist(newconfig, capsys): - inisource = """ - [tox] - envlist = py36,package - isolated_build = True - isolated_build_env = package - """ - with pytest.raises( - tox.exception.ConfigError, - match="isolated_build_env package cannot be part of envlist", - ): - newconfig([], inisource) - - out, err = capsys.readouterr() - assert not err - assert not out - - -def test_isolated_build_overrides(newconfig, capsys): - inisource = """ - [tox] - isolated_build = True - - [testenv] - deps = something crazy here - - [testenv:.package] - deps = - """ - config = newconfig([], inisource) - deps = config.envconfigs.get(".package").deps - assert deps == [] - - -@pytest.mark.parametrize( - "key, set_value, default", - [("deps", "crazy", []), ("sitepackages", "True", False)], -) -def test_isolated_build_ignores(newconfig, capsys, key, set_value, default): - config = newconfig( - [], - """ - [tox] - isolated_build = True - - [testenv] - {} = {} - """.format( - key, - set_value, - ), - ) - package_env = config.envconfigs.get(".package") - value = getattr(package_env, key) - assert value == default - - -def test_config_via_pyproject_legacy(initproj): - initproj( - "config_via_pyproject_legacy-0.5", - filedefs={ - "pyproject.toml": ''' - [project] - description = "Factory ⸻ A code generator 🏭" - authors = [{name = "Łukasz Langa"}] - [tool.tox] - legacy_tox_ini = """ - [tox] - envlist = py27 - """ - ''', - }, - ) - config = parseconfig([]) - assert config.envlist == ["py27"] - - -def test_config_bad_pyproject_specified(initproj, capsys): - base = initproj("config_via_pyproject_legacy-0.5", filedefs={"pyproject.toml": ""}) - with pytest.raises(SystemExit): - parseconfig(["-c", str(base.join("pyproject.toml"))]) - - out, err = capsys.readouterr() - msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" - assert err == msg - assert "ERROR:" not in out - - -def test_config_setup_cfg_no_tox_section(initproj, capsys): - setup_cfg = """ - [nope:nope] - envlist = py37 - """ - initproj("setup_cfg_no_tox-0.1", filedefs={"setup.cfg": setup_cfg}) - with pytest.raises(SystemExit): - parseconfig([]) - - out, err = capsys.readouterr() - msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" - assert err == msg - assert "ERROR:" not in out - - -def test_config_file_not_required_with_devenv(initproj, capsys): - initproj("no_tox_config-0.7") - config = parseconfig(["--devenv", "myenv"]) - - out, err = capsys.readouterr() - assert "ERROR:" not in err - assert "ERROR:" not in out - assert config.option.devenv == "myenv" - assert config.option.notest is True - - -@pytest.mark.skipif(sys.platform == "win32", reason="no named pipes on Windows") -def test_config_bad_config_type_specified(monkeypatch, tmpdir, capsys): - monkeypatch.chdir(tmpdir) - name = tmpdir.join("named_pipe") - os.mkfifo(str(name)) - with pytest.raises(SystemExit): - parseconfig(["-c", str(name)]) - - out, err = capsys.readouterr() - notes = ( - "ERROR: {} is neither file or directory".format(name), - "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found", - ) - msg = "\n".join(notes) + "\n" - assert err == msg - assert "ERROR:" not in out - - -def test_interactive_na(newconfig, monkeypatch): - monkeypatch.setattr(tox.config, "is_interactive", lambda: False) - config = newconfig( - """ - [testenv:py] - setenv = A = {tty:X:Y} - """, - ) - assert config.envconfigs["py"].setenv["A"] == "Y" - - -def test_interactive_available(newconfig, monkeypatch): - monkeypatch.setattr(tox.config, "is_interactive", lambda: True) - config = newconfig( - """ - [testenv:py] - setenv = A = {tty:X:Y} - """, - ) - assert config.envconfigs["py"].setenv["A"] == "X" - - -def test_interactive(): - tox.config.is_interactive() - - -def test_config_current_py(newconfig, current_tox_py, cmd, tmpdir, monkeypatch): - monkeypatch.chdir(tmpdir) - config = newconfig( - """ - [tox] - envlist = {0} - skipsdist = True - - [testenv:{0}] - commands = python -c "print('all')" - """.format( - current_tox_py, - ), - ) - assert config.envconfigs[current_tox_py] - result = cmd() - result.assert_success() - - -def test_posargs_relative_changedir(newconfig, tmpdir): - dir1 = tmpdir.join("dir1").ensure() - tmpdir.join("dir2").ensure() - with tmpdir.as_cwd(): - config = newconfig( - """\ - [tox] - [testenv] - changedir = dir2 - commands = - echo {posargs} - """, - ) - config.option.args = ["dir1", dir1.strpath, "dir3"] - testenv = config.envconfigs["python"] - PosargsOption().postprocess(testenv, config.option.args) - - assert testenv._reader.posargs == [ - # should have relative-ized - os.path.join("..", "dir1"), - # should have stayed the same, - dir1.strpath, - "dir3", - ] - - -def test_config_no_version_data_in__name(newconfig, capsys): - newconfig( - """ - [tox] - envlist = py, pypy, jython - [testenv] - basepython = python - """, - ) - out, err = capsys.readouterr() - assert not out - assert not err - - -def test_overwrite_skip_install_override(newconfig): - source = """ - [tox] - envlist = py, skip - [testenv:skip] - skip_install = True - """ - config = newconfig(args=[], source=source) - assert config.envconfigs["py"].skip_install is False # by default do not skip - assert config.envconfigs["skip"].skip_install is True - - config = newconfig(args=["--skip-pkg-install"], source=source) - assert config.envconfigs["py"].skip_install is True # skip if the flag is passed - assert config.envconfigs["skip"].skip_install is True diff --git a/tests/unit/config/test_config_parallel.py b/tests/unit/config/test_config_parallel.py deleted file mode 100644 index f070ca806..000000000 --- a/tests/unit/config/test_config_parallel.py +++ /dev/null @@ -1,88 +0,0 @@ -import pytest - -from tox.config.parallel import ENV_VAR_KEY_PRIVATE as PARALLEL_ENV_VAR_KEY_PRIVATE - - -def test_parallel_default(newconfig): - config = newconfig([], "") - assert isinstance(config.option.parallel, int) - assert config.option.parallel == 0 - assert config.option.parallel_live is False - - -def test_parallel_live_on(newconfig): - config = newconfig(["-o"], "") - assert config.option.parallel_live is True - - -def test_parallel_auto(newconfig): - config = newconfig(["-p", "auto"], "") - assert isinstance(config.option.parallel, int) - assert config.option.parallel > 0 - - -def test_parallel_all(newconfig): - config = newconfig(["-p", "all"], "") - assert config.option.parallel is None - - -def test_parallel_number(newconfig): - config = newconfig(["-p", "2"], "") - assert config.option.parallel == 2 - - -def test_parallel_number_negative(newconfig, capsys): - with pytest.raises(SystemExit): - newconfig(["-p", "-1"], "") - - out, err = capsys.readouterr() - assert not out - assert "value must be positive" in err - - -def test_depends(newconfig): - config = newconfig( - """\ - [tox] - [testenv:py] - depends = py37, py36 - """, - ) - assert config.envconfigs["py"].depends == ("py37", "py36") - - -def test_depends_multi_row_facotr(newconfig): - config = newconfig( - """\ - [tox] - [testenv:py] - depends = py37, - {py36}-{a,b} - """, - ) - assert config.envconfigs["py"].depends == ("py37", "py36-a", "py36-b") - - -def test_depends_factor(newconfig): - config = newconfig( - """\ - [tox] - [testenv:py] - depends = {py37, py36}-{cov,no} - """, - ) - assert config.envconfigs["py"].depends == ("py37-cov", "py37-no", "py36-cov", "py36-no") - - -def test_parallel_env_selection_with_ALL(newconfig, monkeypatch): - # Regression test for #2167 - inisource = """ - [tox] - envlist = py,lint - """ - monkeypatch.setenv(PARALLEL_ENV_VAR_KEY_PRIVATE, "py") - config = newconfig(["-eALL"], inisource) - assert config.envlist == ["py"] - monkeypatch.setenv(PARALLEL_ENV_VAR_KEY_PRIVATE, "lint") - config = newconfig(["-eALL"], inisource) - assert config.envlist == ["lint"] diff --git a/tests/unit/interpreters/test_interpreters.py b/tests/unit/interpreters/test_interpreters.py deleted file mode 100644 index 298a4aae4..000000000 --- a/tests/unit/interpreters/test_interpreters.py +++ /dev/null @@ -1,234 +0,0 @@ -from __future__ import unicode_literals - -import os -import platform -import stat -import subprocess -import sys - -import py -import pytest - -import tox -from tox import reporter -from tox.config import get_plugin_manager -from tox.interpreters import ( - ExecFailed, - InterpreterInfo, - Interpreters, - NoInterpreterInfo, - run_and_get_interpreter_info, - tox_get_python_executable, -) -from tox.reporter import Verbosity - - -@pytest.fixture(name="interpreters") -def create_interpreters_instance(): - pm = get_plugin_manager() - return Interpreters(hook=pm.hook) - - -@pytest.mark.skipif(tox.INFO.IS_PYPY, reason="testing cpython interpreter discovery") -def test_tox_get_python_executable(mocker): - class envconfig: - basepython = sys.executable - envname = "pyxx" - config = mocker.MagicMock() - config.return_value.option.return_value.discover = [] - - def get_exe(name): - envconfig.basepython = name - p = tox_get_python_executable(envconfig) - assert p - return str(p) - - def assert_version_in_output(exe, version): - out = subprocess.check_output((exe, "-V"), stderr=subprocess.STDOUT) - assert version in out.decode() - - p = tox_get_python_executable(envconfig) - assert p == py.path.local(sys.executable) - for major, minor in [(2, 7), (3, 5), (3, 6), (3, 7), (3, 8)]: - name = "python{}.{}".format(major, minor) - if tox.INFO.IS_WIN: - pydir = "python{}{}".format(major, minor) - x = py.path.local(r"c:\{}".format(pydir)) - if not x.check(): - continue - else: - if not py.path.local.sysfind(name) or subprocess.call((name, "-c", "")): - continue - exe = get_exe(name) - assert_version_in_output(exe, "{}.{}".format(major, minor)) - has_py_exe = py.path.local.sysfind("py") is not None - for major in (2, 3): - name = "python{}".format(major) - if has_py_exe: - error_code = subprocess.call(("py", "-{}".format(major), "-c", "")) - if error_code: - continue - elif not py.path.local.sysfind(name): - continue - - exe = get_exe(name) - assert_version_in_output(exe, str(major)) - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="symlink execution unreliable on Windows") -def test_find_alias_on_path(monkeypatch, tmp_path, mocker): - reporter.update_default_reporter(Verbosity.DEFAULT, Verbosity.DEBUG) - magic = tmp_path / "magic{}".format(os.path.splitext(sys.executable)[1]) - os.symlink(sys.executable, str(magic)) - monkeypatch.setenv( - str("PATH"), - os.pathsep.join([str(tmp_path)] + os.environ.get(str("PATH"), "").split(os.pathsep)), - ) - - class envconfig: - basepython = "magic" - envname = "pyxx" - config = mocker.MagicMock() - config.return_value.option.return_value.discover = [] - - detected = py.path.local.sysfind("magic") - assert detected - - t = tox_get_python_executable(envconfig).lower() - assert t == str(magic).lower() - - -def test_run_and_get_interpreter_info(): - name = os.path.basename(sys.executable) - info = run_and_get_interpreter_info(name, sys.executable) - assert info.version_info == tuple(sys.version_info) - assert info.implementation == platform.python_implementation() - assert info.executable == sys.executable - - -class TestInterpreters: - def test_get_executable(self, interpreters, mocker): - class envconfig: - basepython = sys.executable - envname = "pyxx" - config = mocker.MagicMock() - config.return_value.option.return_value.discover = [] - - x = interpreters.get_executable(envconfig) - assert x == sys.executable - info = interpreters.get_info(envconfig) - assert info.version_info == tuple(sys.version_info) - assert info.executable == sys.executable - assert isinstance(info, InterpreterInfo) - - def test_get_executable_no_exist(self, interpreters, mocker): - class envconfig: - basepython = "1lkj23" - envname = "pyxx" - config = mocker.MagicMock() - config.return_value.option.return_value.discover = [] - - assert not interpreters.get_executable(envconfig) - info = interpreters.get_info(envconfig) - assert not info.version_info - assert info.name == "1lkj23" - assert not info.executable - assert isinstance(info, NoInterpreterInfo) - - @pytest.mark.skipif("sys.platform == 'win32'", reason="Uses a unix only wrapper") - def test_get_info_uses_hook_path(self, tmp_path): - magic = tmp_path / "magic{}".format(os.path.splitext(sys.executable)[1]) - wrapper = ( - "#!{executable}\n" - "import subprocess\n" - "import sys\n" - 'sys.exit(subprocess.call(["{executable}"] + sys.argv[1:]))\n' - ).format(executable=sys.executable) - magic.write_text(wrapper) - magic.chmod(magic.stat().st_mode | stat.S_IEXEC) - - class MockHook: - def tox_get_python_executable(self, envconfig): - return str(magic) - - class envconfig: - basepython = sys.executable - envname = "magicpy" - - # Check that the wrapper is working first. - # If it isn't, the default is to return the passed path anyway. - subprocess.check_call([str(magic), "--help"]) - - interpreters = Interpreters(hook=MockHook()) - info = interpreters.get_info(envconfig) - assert info.executable == str(magic) - - def test_get_sitepackagesdir_error(self, interpreters, mocker): - class envconfig: - basepython = sys.executable - envname = "123" - config = mocker.MagicMock() - config.return_value.option.return_value.discover = [] - - info = interpreters.get_info(envconfig) - s = interpreters.get_sitepackagesdir(info, "") - assert s - - -def test_exec_failed(): - x = ExecFailed("my-executable", "my-source", "my-out", "my-err") - assert isinstance(x, Exception) - assert x.executable == "my-executable" - assert x.source == "my-source" - assert x.out == "my-out" - assert x.err == "my-err" - - -class TestInterpreterInfo: - @staticmethod - def info( - implementation="CPython", - executable="my-executable", - version_info="my-version-info", - sysplatform="my-sys-platform", - ): - return InterpreterInfo( - implementation, executable, version_info, sysplatform, True, "/", None - ) - - def test_data(self): - x = self.info("larry", "moe", "shemp", "curly") - assert x.implementation == "larry" - assert x.executable == "moe" - assert x.version_info == "shemp" - assert x.sysplatform == "curly" - - def test_str(self): - x = self.info(executable="foo", version_info="bar") - assert str(x) == "" - - -class TestNoInterpreterInfo: - def test_default_data(self): - x = NoInterpreterInfo("foo") - assert x.name == "foo" - assert x.executable is None - assert x.version_info is None - assert x.out is None - assert x.err == "not found" - - def test_set_data(self): - x = NoInterpreterInfo("migraine", executable="my-executable", out="my-out", err="my-err") - assert x.name == "migraine" - assert x.executable == "my-executable" - assert x.version_info is None - assert x.out == "my-out" - assert x.err == "my-err" - - def test_str_without_executable(self): - x = NoInterpreterInfo("coconut") - assert str(x) == "" - - def test_str_with_executable(self): - x = NoInterpreterInfo("coconut", executable="bang/em/together") - assert str(x) == "" diff --git a/tests/unit/interpreters/test_py_spec.py b/tests/unit/interpreters/test_py_spec.py deleted file mode 100644 index e008b253b..000000000 --- a/tests/unit/interpreters/test_py_spec.py +++ /dev/null @@ -1,16 +0,0 @@ -from tox.interpreters.py_spec import PythonSpec - - -def test_py_3_10(): - spec = PythonSpec.from_name("python3.10") - assert (spec.major, spec.minor) == (3, 10) - - -def test_debug_python(): - spec = PythonSpec.from_name("python3.10-dbg") - assert (spec.major, spec.minor) == (None, None) - - -def test_parse_architecture(): - spec = PythonSpec.from_name("python3.10-32") - assert (spec.major, spec.minor, spec.architecture) == (3, 10, 32) diff --git a/tests/unit/interpreters/windows/test_pep514.py b/tests/unit/interpreters/windows/test_pep514.py deleted file mode 100644 index c2e7b046f..000000000 --- a/tests/unit/interpreters/windows/test_pep514.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import unicode_literals - -import inspect -import subprocess -import sys - -from tox._pytestplugin import mark_dont_run_on_posix - - -@mark_dont_run_on_posix -def test_discover_winreg(): - from tox.interpreters.windows.pep514 import discover_pythons - - list(discover_pythons()) # raises no error - - -@mark_dont_run_on_posix -def test_run_pep514_main_no_warnings(): - # check we trigger no warnings - import tox.interpreters.windows.pep514 as pep514 - - out = subprocess.check_output( - [sys.executable, inspect.getsourcefile(pep514)], - universal_newlines=True, - ) - assert "PEP-514 violation in Windows Registry " not in out, out diff --git a/tests/unit/interpreters/windows/test_windows.py b/tests/unit/interpreters/windows/test_windows.py deleted file mode 100644 index 06e9bddae..000000000 --- a/tests/unit/interpreters/windows/test_windows.py +++ /dev/null @@ -1,20 +0,0 @@ -from tox._pytestplugin import mark_dont_run_on_posix - - -@mark_dont_run_on_posix -def test_locate_via_pep514(monkeypatch): - import tox.interpreters.windows - from tox.interpreters.py_spec import CURRENT - - del tox.interpreters.windows._PY_AVAILABLE[:] - exe = tox.interpreters.windows.locate_via_pep514(CURRENT) - assert exe - assert len(tox.interpreters.windows._PY_AVAILABLE) - - import tox.interpreters.windows.pep514 - - def raise_on_call(): - raise RuntimeError() - - monkeypatch.setattr(tox.interpreters.windows.pep514, "discover_pythons", raise_on_call) - assert tox.interpreters.windows.locate_via_pep514(CURRENT) diff --git a/tests/unit/package/builder/test_package_builder_isolated.py b/tests/unit/package/builder/test_package_builder_isolated.py deleted file mode 100644 index 8a41f2df8..000000000 --- a/tests/unit/package/builder/test_package_builder_isolated.py +++ /dev/null @@ -1,273 +0,0 @@ -import os -import subprocess - -import py -import pytest - -import tox.helper -from tox.package.builder.isolated import get_build_info -from tox.reporter import _INSTANCE - - -def test_verbose_isolated_build(initproj, mock_venv, cmd): - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'setuptools.build_meta' - """, - }, - ) - result = cmd("--sdistonly", "-v", "-v", "-v", "-e", "py") - assert "The arguments ['--formats=gztar'] were given via `--global-option`." not in result.err - assert "running sdist" in result.out, result.out - assert "running egg_info" in result.out, result.out - assert "example123-0.5.tar.gz" in result.out, result.out - - -def test_dist_exists_version_change(mock_venv, initproj, cmd): - base = initproj( - "package_toml-{}".format("0.1"), - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'setuptools.build_meta' - """, - }, - ) - result = cmd("-e", "py") - result.assert_success() - - new_code = base.join("setup.py").read_text("utf-8").replace("0.1", "0.2") - base.join("setup.py").write_text(new_code, "utf-8") - - result = cmd("-e", "py") - result.assert_success() - - -def test_package_isolated_no_pyproject_toml(initproj, cmd): - initproj( - "package_no_toml-0.1", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - """, - }, - ) - result = cmd("--sdistonly", "-e", "py") - result.assert_fail() - assert result.outlines == ["ERROR: missing {}".format(py.path.local().join("pyproject.toml"))] - - -def toml_file_check(initproj, version, message, toml): - initproj( - "package_toml-{}".format(version), - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - """, - "pyproject.toml": toml, - }, - ) - - with pytest.raises(SystemExit, match="1"): - get_build_info(py.path.local()) - toml_file = py.path.local().join("pyproject.toml") - msg = "ERROR: {} inside {}".format(message, toml_file) - assert _INSTANCE.messages == [msg] - - -def test_package_isolated_toml_no_build_system(initproj): - toml_file_check(initproj, 1, "build-system section missing", "") - - -def test_package_isolated_toml_no_requires(initproj): - toml_file_check( - initproj, - 2, - "missing requires key at build-system section", - """ - [build-system] - """, - ) - - -def test_package_isolated_toml_no_backend(initproj): - toml_file_check( - initproj, - 3, - "missing build-backend key at build-system section", - """ - [build-system] - requires = [] - """, - ) - - -def test_package_isolated_toml_bad_requires(initproj): - toml_file_check( - initproj, - 4, - "requires key at build-system section must be a list of string", - """ - [build-system] - requires = "" - build-backend = "" - """, - ) - - -def test_package_isolated_toml_bad_backend(initproj): - toml_file_check( - initproj, - 5, - "build-backend key at build-system section must be a string", - """ - [build-system] - requires = [] - build-backend = [] - """, - ) - - -def test_package_isolated_toml_bad_backend_path(initproj): - """Verify that a non-list 'backend-path' is forbidden.""" - toml_file_check( - initproj, - 6, - "backend-path key at build-system section must be a list, if specified", - """ - [build-system] - requires = [] - build-backend = 'setuptools.build_meta' - backend-path = 42 - """, - ) - - -def test_package_isolated_toml_backend_path_outside_root(initproj): - """Verify that a 'backend-path' outside the project root is forbidden.""" - toml_file_check( - initproj, - 6, - "backend-path must exist in the project root", - """ - [build-system] - requires = [] - build-backend = 'setuptools.build_meta' - backend-path = ['..'] - """, - ) - - -def test_verbose_isolated_build_in_tree(initproj, mock_venv, cmd): - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - """, - "build.py": """ - from setuptools.build_meta import * - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'build' - backend-path = ['.'] - """, - }, - ) - result = cmd("--sdistonly", "-v", "-v", "-v", "-e", "py") - assert "running sdist" in result.out, result.out - assert "running egg_info" in result.out, result.out - assert "example123-0.5.tar.gz" in result.out, result.out - - -def test_isolated_build_script_args(tmp_path): - """Verify that build_isolated.py can be called with only 2 argurments.""" - # cannot import build_isolated because of its side effects - script_path = os.path.join(os.path.dirname(tox.helper.__file__), "build_isolated.py") - subprocess.check_call(("python", script_path, str(tmp_path), "setuptools.build_meta")) - - -def test_isolated_build_backend_missing_hook(initproj, cmd): - """Verify that tox works with a backend missing optional hooks - - PEP 517 allows backends to omit get_requires_for_build_sdist hook, in which - case a default implementation that returns an empty list should be assumed - instead of raising an error. - """ - name = "ensconsproj" - version = "0.1" - src_root = "src" - - initproj( - (name, version), - filedefs={ - "pyproject.toml": """ - [build-system] - requires = ["pytoml>=0.1", "enscons==0.26.0"] - build-backend = "enscons.api" - - [tool.enscons] - name = "{name}" - version = "{version}" - description = "Example enscons project" - license = "MIT" - packages = ["{name}"] - src_root = "{src_root}" - """.format( - name=name, version=version, src_root=src_root - ), - "tox.ini": """ - [tox] - isolated_build = true - """, - "SConstruct": """ - import enscons - - env = Environment( - tools=["default", "packaging", enscons.generate], - PACKAGE_METADATA=dict( - name = "{name}", - version = "{version}" - ), - WHEEL_TAG="py2.py3-none-any" - ) - - py_source = env.Glob("src/{name}/*.py") - - purelib = env.Whl("purelib", py_source, root="{src_root}") - whl = env.WhlFile(purelib) - - sdist = env.SDist(source=FindSourceFiles() + ["PKG-INFO"]) - env.NoClean(sdist) - env.Alias("sdist", sdist) - - develop = env.Command("#DEVELOP", enscons.egg_info_targets(env), enscons.develop) - env.Alias("develop", develop) - - env.Default(whl, sdist) - """.format( - name=name, version=version, src_root=src_root - ), - }, - ) - - result = cmd("--sdistonly", "-v", "-v", "-e", "py") - assert "scons: done building targets" in result.out, result.out diff --git a/tests/unit/package/builder/test_package_builder_legacy.py b/tests/unit/package/builder/test_package_builder_legacy.py deleted file mode 100644 index 69b9509e2..000000000 --- a/tests/unit/package/builder/test_package_builder_legacy.py +++ /dev/null @@ -1,14 +0,0 @@ -def test_verbose_legacy_build(initproj, mock_venv, cmd): - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = false - """, - }, - ) - result = cmd("--sdistonly", "-vvv", "-e", "py") - assert "running sdist" in result.out, result.out - assert "running egg_info" in result.out, result.out - assert "removing 'example123-0.5'" in result.out, result.out diff --git a/tests/unit/package/test_package.py b/tests/unit/package/test_package.py deleted file mode 100644 index 5a196d56c..000000000 --- a/tests/unit/package/test_package.py +++ /dev/null @@ -1,170 +0,0 @@ -import re -import sys - -from tox.config import parseconfig -from tox.package import get_package -from tox.session import Session - - -def test_install_via_installpkg(mock_venv, initproj, cmd): - base = initproj( - "pkg-0.1", - filedefs={ - "tox.ini": """ - [tox] - install_cmd = python -m -c 'print("ok")' -- {opts} {packages}' - """, - }, - ) - fake_package = base.ensure(".tox", "dist", "pkg123-0.1.zip") - result = cmd("-e", "py", "--notest", "--installpkg", str(fake_package.relto(base))) - result.assert_success() - - -def test_installpkg(tmpdir, newconfig): - p = tmpdir.ensure("pkg123-1.0.zip") - config = newconfig(["--installpkg={}".format(p)], "") - session = Session(config) - _, sdist_path = get_package(session) - assert sdist_path == p - - -def test_sdist_latest(tmpdir, newconfig): - distshare = tmpdir.join("distshare") - config = newconfig( - [], - """ - [tox] - distshare={} - sdistsrc={{distshare}}/pkg123-* - """.format( - distshare, - ), - ) - p = distshare.ensure("pkg123-1.4.5.zip") - distshare.ensure("pkg123-1.4.5a1.zip") - session = Session(config) - _, dist = get_package(session) - assert dist == p - - -def test_separate_sdist_no_sdistfile(cmd, initproj, tmpdir): - distshare = tmpdir.join("distshare") - initproj( - ("pkg123-foo", "0.7"), - filedefs={ - "tox.ini": """ - [tox] - distshare={} - """.format( - distshare, - ), - }, - ) - result = cmd("--sdistonly", "-e", "py") - assert not result.ret - distshare_files = distshare.listdir() - assert len(distshare_files) == 1 - sdistfile = distshare_files[0] - assert "pkg123-foo-0.7.zip" in str(sdistfile) - - -def test_sdistonly(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - """, - }, - ) - result = cmd("-v", "--sdistonly", "-e", "py") - assert not result.ret - assert re.match(r".*sdist-make.*setup.py.*", result.out, re.DOTALL) - assert "-mvirtualenv" not in result.out - - -def test_make_sdist(initproj): - initproj( - "example123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - """, - }, - ) - config = parseconfig([]) - session = Session(config) - _, sdist = get_package(session) - assert sdist.check() - assert sdist.ext == ".zip" - assert sdist == config.distdir.join(sdist.basename) - _, sdist2 = get_package(session) - assert sdist2 == sdist - sdist.write("hello") - assert sdist.stat().size < 10 - _, sdist_new = get_package(Session(config)) - assert sdist_new == sdist - assert sdist_new.stat().size > 10 - - -def test_build_backend_without_submodule(initproj, cmd): - # The important part of this test is that the build backend - # "inline_backend" is just a base package without a submodule. - # (Regression test for #1344) - initproj( - "magic-0.1", - filedefs={ - "tox.ini": """\ - [tox] - isolated_build = true - [testenv:.package] - basepython = {} - [testenv] - setenv = PYTHONPATH = {{toxinidir}} - """.format( - sys.executable, - ), - "pyproject.toml": """\ - [build-system] - requires = [] - build-backend = "inline_backend" - """, - # To trigger original bug, must be package with __init__.py - "inline_backend": { - "__init__.py": """\ - import sys - def get_requires_for_build_sdist(*args, **kwargs): - return ["pathlib2;python_version<'3.4'"] - - def build_sdist(sdist_directory, config_settings=None): - if sys.version_info[:2] >= (3, 4): - import pathlib - else: - import pathlib2 as pathlib - - (pathlib.Path(sdist_directory) / "magic-0.1.0.tar.gz").touch() - return "magic-0.1.0.tar.gz" - """, - }, - ".gitignore": ".tox", - }, - add_missing_setup_py=False, - ) - result = cmd("--sdistonly", "-e", "py", "-v", "-v") - result.assert_success(is_run_test_env=False) - - -def test_package_inject(initproj, cmd, monkeypatch, tmp_path): - monkeypatch.delenv(str("PYTHONPATH"), raising=False) - initproj( - "example123-0.5", - filedefs={ - "tox.ini": """ - [testenv:py] - passenv = PYTHONPATH - commands = python -c 'import os; assert os.path.exists(os.environ["TOX_PACKAGE"])' - """, - }, - ) - result = cmd("-q") - assert result.session.getvenv("py").envconfig.setenv.get("TOX_PACKAGE") diff --git a/tests/unit/package/test_package_parallel.py b/tests/unit/package/test_package_parallel.py deleted file mode 100644 index 3acd5952e..000000000 --- a/tests/unit/package/test_package_parallel.py +++ /dev/null @@ -1,129 +0,0 @@ -import os -import traceback - -import py -from flaky import flaky - -from tox.session.commands.run import sequential - - -@flaky(max_runs=3) -def test_tox_parallel_build_safe(initproj, cmd, mock_venv, monkeypatch): - initproj( - "env_var_test", - filedefs={ - "tox.ini": """ - [tox] - envlist = py - install_cmd = python -m -c 'print("ok")' -- {opts} {packages}' - [testenv] - commands = python -c 'import sys; print(sys.version)' - """, - }, - ) - # we try to recreate the following situation - # t1 starts and performs build - # t2 starts, but is blocked from t1 build lock to build - # t1 gets unblocked, t2 can now enter - # t1 is artificially blocked to run test command until t2 finishes build - # (parallel build package present) - # t2 package build finishes both t1 and t2 can now finish and clean up their build packages - import threading - - import tox.package - - t1_build_started = threading.Event() - t1_build_blocker = threading.Event() - t2_build_started = threading.Event() - t2_build_finished = threading.Event() - - invoke_result = {} - - def invoke_tox_in_thread(thread_name): - try: - result = cmd("--parallel--safe-build", "-vv") - except Exception as exception: - result = exception, traceback.format_exc() - invoke_result[thread_name] = result - - prev_build_package = tox.package.build_package - - with monkeypatch.context() as m: - - def build_package(config, session): - t1_build_started.set() - t1_build_blocker.wait() - return prev_build_package(config, session) - - m.setattr(tox.package, "build_package", build_package) - - prev_run_test_env = sequential.runtestenv - - def run_test_env(venv, redirect=False): - t2_build_finished.wait() - return prev_run_test_env(venv, redirect) - - m.setattr(sequential, "runtestenv", run_test_env) - - t1 = threading.Thread(target=invoke_tox_in_thread, args=("t1",)) - t1.start() - t1_build_started.wait() - - with monkeypatch.context() as m: - - def build_package(config, session): - t2_build_started.set() - try: - return prev_build_package(config, session) - finally: - t2_build_finished.set() - - m.setattr(tox.package, "build_package", build_package) - - t2 = threading.Thread(target=invoke_tox_in_thread, args=("t2",)) - t2.start() - - # t2 should get blocked by t1 build lock - t2_build_started.wait(timeout=0.1) - assert not t2_build_started.is_set() - - t1_build_blocker.set() # release t1 blocker -> t1 can now finish - # t1 at this point should block at run test until t2 build finishes - t2_build_started.wait() - - t1.join() # wait for both t1 and t2 to finish - t2.join() - - # all threads finished without error - for val in invoke_result.values(): - if isinstance(val, tuple): - assert False, "{!r}\n{}".format(val[0], val[1]) - err = "\n".join( - "{}=\n{}".format(k, v.err).strip() for k, v in invoke_result.items() if v.err.strip() - ) - out = "\n".join( - "{}=\n{}".format(k, v.out).strip() for k, v in invoke_result.items() if v.out.strip() - ) - for val in invoke_result.values(): - assert not val.ret, "{}\n{}".format(err, out) - assert not err - - # when the lock is hit we notify - lock_file = py.path.local().join(".tox", ".package.lock") - msg = "lock file {} present, will block until released".format(lock_file) - assert msg in out - - # intermediate packages are removed at end of build - t1_package = invoke_result["t1"].session.getvenv("py").package - t2_package = invoke_result["t1"].session.getvenv("py").package - assert t1 != t2 - assert not t1_package.exists() - assert not t2_package.exists() - - # the final distribution remains - dist_after = invoke_result["t1"].session.config.distdir.listdir() - assert len(dist_after) == 1 - sdist = dist_after[0] - assert t1_package != sdist - # our set_os_env_var is not thread-safe so clean-up TOX_WORK_DIR - os.environ.pop("TOX_WORK_DIR", None) diff --git a/tests/unit/package/test_package_view.py b/tests/unit/package/test_package_view.py deleted file mode 100644 index a05443c93..000000000 --- a/tests/unit/package/test_package_view.py +++ /dev/null @@ -1,72 +0,0 @@ -import os - -import pytest -from virtualenv.info import IS_PYPY - -from tox.config import parseconfig -from tox.package import get_package -from tox.session import Session - - -@pytest.mark.skipif(IS_PYPY, reason="fails on pypy") -def test_make_sdist_distshare(tmpdir, initproj): - distshare = tmpdir.join("distshare") - initproj( - "example123-0.6", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [tox] - distshare={} - """.format( - distshare, - ), - }, - ) - config = parseconfig([]) - session = Session(config) - package, dist = get_package(session) - assert package.check() - assert package.ext == ".zip" - assert package == config.temp_dir.join("package", "1", package.basename) - - assert dist == config.distdir.join(package.basename) - assert dist.check() - assert os.stat(str(dist)).st_ino == os.stat(str(package)).st_ino - - sdist_share = config.distshare.join(package.basename) - assert sdist_share.check() - assert sdist_share.read("rb") == dist.read("rb"), (sdist_share, package) - - -def test_separate_sdist(cmd, initproj, tmpdir): - distshare = tmpdir.join("distshare") - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - distshare={} - sdistsrc={{distshare}}/pkg123-0.7.zip - """.format( - distshare, - ), - }, - ) - result = cmd("--sdistonly", "-e", "py") - assert not result.ret - dist_share_files = distshare.listdir() - assert len(dist_share_files) == 1 - assert dist_share_files[0].check() - - result = cmd("-v", "--notest") - result.assert_success() - msg = "python inst: {}".format(result.session.package) - assert msg in result.out, result.out - operation = "copied" if not hasattr(os, "link") else "links" - msg = "package {} {} to {}".format( - os.sep.join(("pkg123", ".tox", ".tmp", "package", "1", "pkg123-0.7.zip")), - operation, - os.sep.join(("distshare", "pkg123-0.7.zip")), - ) - assert msg in result.out, result.out diff --git a/tests/unit/session/plugin/a/__init__.py b/tests/unit/session/plugin/a/__init__.py deleted file mode 100644 index dbe246397..000000000 --- a/tests/unit/session/plugin/a/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -import pluggy - -hookimpl = pluggy.HookimplMarker("tox") - - -@hookimpl -def tox_addoption(parser): - parser.add_argument("--option", choices=["a", "b"], default="a", required=False) diff --git a/tests/unit/session/plugin/setup.cfg b/tests/unit/session/plugin/setup.cfg deleted file mode 100644 index 617e0ceaa..000000000 --- a/tests/unit/session/plugin/setup.cfg +++ /dev/null @@ -1,11 +0,0 @@ -[metadata] -name = plugin -version = 0.1 -description = test stuff - -[options] -packages = find: -zip_safe = True - -[options.entry_points] -tox = plugin = a diff --git a/tests/unit/session/plugin/setup.py b/tests/unit/session/plugin/setup.py deleted file mode 100644 index 606849326..000000000 --- a/tests/unit/session/plugin/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() diff --git a/tests/unit/session/test_list_env.py b/tests/unit/session/test_list_env.py deleted file mode 100644 index a4d5b999e..000000000 --- a/tests/unit/session/test_list_env.py +++ /dev/null @@ -1,229 +0,0 @@ -def test_listenvs(cmd, initproj, monkeypatch): - monkeypatch.delenv(str("TOXENV"), raising=False) - initproj( - "listenvs", - filedefs={ - "tox.ini": """ - [tox] - envlist=py36,py27,py37,pypi,docs - description= py27: run pytest on Python 2.7 - py37: run pytest on Python 3.6 - pypi: publish to PyPI - docs: document stuff - notincluded: random extra - - [testenv:notincluded] - changedir = whatever - - [testenv:docs] - changedir = docs - """, - }, - ) - - result = cmd("-l") - assert result.outlines == ["py36", "py27", "py37", "pypi", "docs"] - - result = cmd("-l", "-e", "py") - assert result.outlines == ["py36", "py27", "py37", "pypi", "docs"] - - monkeypatch.setenv(str("TOXENV"), str("py")) - result = cmd("-l") - assert result.outlines == ["py36", "py27", "py37", "pypi", "docs"] - - monkeypatch.setenv(str("TOXENV"), str("py36")) - result = cmd("-l") - assert result.outlines == ["py36", "py27", "py37", "pypi", "docs"] - - -def test_listenvs_verbose_description(cmd, initproj): - initproj( - "listenvs_verbose_description", - filedefs={ - "tox.ini": """ - [tox] - envlist=py36,py27,py37,pypi,docs - [testenv] - description= py36: run pytest on Python 3.6 - py27: run pytest on Python 2.7 - py37: run pytest on Python 3.7 - pypi: publish to PyPI - docs: document stuff - notincluded: random extra - - [testenv:notincluded] - changedir = whatever - - [testenv:docs] - changedir = docs - description = let me overwrite that - """, - }, - ) - result = cmd("-lv") - expected = [ - "default environments:", - "py36 -> run pytest on Python 3.6", - "py27 -> run pytest on Python 2.7", - "py37 -> run pytest on Python 3.7", - "pypi -> publish to PyPI", - "docs -> let me overwrite that", - ] - assert result.outlines[2:] == expected - - -def test_listenvs_all(cmd, initproj, monkeypatch): - initproj( - "listenvs_all", - filedefs={ - "tox.ini": """ - [tox] - envlist=py36,py27,py37,pypi,docs - - [testenv:notincluded] - changedir = whatever - - [testenv:docs] - changedir = docs - """, - }, - ) - result = cmd("-a") - expected = ["py36", "py27", "py37", "pypi", "docs", "notincluded"] - assert result.outlines == expected - - result = cmd("-a", "-e", "py") - assert result.outlines == ["py36", "py27", "py37", "pypi", "docs", "py", "notincluded"] - - monkeypatch.setenv(str("TOXENV"), str("py")) - result = cmd("-a") - assert result.outlines == ["py36", "py27", "py37", "pypi", "docs", "py", "notincluded"] - - monkeypatch.setenv(str("TOXENV"), str("py36")) - result = cmd("-a") - assert result.outlines == ["py36", "py27", "py37", "pypi", "docs", "notincluded"] - - -def test_listenvs_all_verbose_description(cmd, initproj): - initproj( - "listenvs_all_verbose_description", - filedefs={ - "tox.ini": """ - [tox] - envlist={py27,py36}-{windows,linux} # py35 - [testenv] - description= py27: run pytest on Python 2.7 - py36: run pytest on Python 3.6 - windows: on Windows platform - linux: on Linux platform - docs: generate documentation - commands=pytest {posargs} - - [testenv:docs] - changedir = docs - """, - }, - ) - result = cmd("-av") - expected = [ - "default environments:", - "py27-windows -> run pytest on Python 2.7 on Windows platform", - "py27-linux -> run pytest on Python 2.7 on Linux platform", - "py36-windows -> run pytest on Python 3.6 on Windows platform", - "py36-linux -> run pytest on Python 3.6 on Linux platform", - "", - "additional environments:", - "docs -> generate documentation", - ] - assert result.outlines[-len(expected) :] == expected - - -def test_listenvs_all_verbose_description_no_additional_environments(cmd, initproj): - initproj( - "listenvs_all_verbose_description", - filedefs={ - "tox.ini": """ - [tox] - envlist=py27,py36 - """, - }, - ) - result = cmd("-av") - expected = ["default environments:", "py27 -> [no description]", "py36 -> [no description]"] - assert result.out.splitlines()[-3:] == expected - assert "additional environments" not in result.out - - -def test_listenvs_packaging_excluded(cmd, initproj): - initproj( - "listenvs", - filedefs={ - "tox.ini": """ - [tox] - envlist = py36,py27,py37,pypi,docs - isolated_build = True - - [testenv:notincluded] - changedir = whatever - - [testenv:docs] - changedir = docs - """, - }, - ) - result = cmd("-a") - expected = ["py36", "py27", "py37", "pypi", "docs", "notincluded"] - assert result.outlines == expected, result.outlines - - -def test_listenvs_all_extra_definition_order_decreasing(cmd, initproj): - initproj( - "listenvs_all", - filedefs={ - "tox.ini": """ - [tox] - envlist=py36 - - [testenv:b] - changedir = whatever - - [testenv:a] - changedir = docs - """, - }, - ) - result = cmd("-a") - expected = ["py36", "b", "a"] - assert result.outlines == expected - - -def test_listenvs_all_extra_definition_order_increasing(cmd, initproj): - initproj( - "listenvs_all", - filedefs={ - "tox.ini": """ - [tox] - envlist=py36 - - [testenv:a] - changedir = whatever - - [testenv:b] - changedir = docs - """, - }, - ) - result = cmd("-a") - expected = ["py36", "a", "b"] - assert result.outlines == expected - - -def test_listenvs_without_default_envs(cmd, initproj): - """When running tox -l without any default envirinments, nothing happens.""" - initproj( - "logsnada", - filedefs={"tox.ini": ""}, - ) - result = cmd("-l") - assert result.ret == 0 - assert result.out == "" diff --git a/tests/unit/session/test_parallel.py b/tests/unit/session/test_parallel.py deleted file mode 100644 index e08ef1f0c..000000000 --- a/tests/unit/session/test_parallel.py +++ /dev/null @@ -1,293 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import json -import os -import subprocess -import sys -import threading - -import pytest -from flaky import flaky - -from tox._pytestplugin import RunResult - - -def test_parallel(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - envlist = a, b - isolated_build = true - [testenv] - commands=python -c "import sys; print(sys.executable)" - [testenv:b] - depends = a - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'setuptools.build_meta' - """, - }, - ) - result = cmd("-p", "all") - result.assert_success() - - -@flaky(max_runs=3) -def test_parallel_live(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - envlist = a, b - [testenv] - commands=python -c "import sys; print(sys.executable)" - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'setuptools.build_meta' - """, - }, - ) - result = cmd("-p", "all", "-o") - result.assert_success() - - -def test_parallel_circular(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - envlist = a, b - [testenv:a] - depends = b - [testenv:b] - depends = a - """, - "pyproject.toml": """ - [build-system] - requires = ["setuptools >= 35.0.2"] - build-backend = 'setuptools.build_meta' - """, - }, - ) - result = cmd("-p", "1") - result.assert_fail() - assert result.out == "ERROR: circular dependency detected: a | b\n" - - -@pytest.mark.parametrize("live", [True, False]) -def test_parallel_error_report(cmd, initproj, monkeypatch, live): - monkeypatch.setenv(str("_TOX_SKIP_ENV_CREATION_TEST"), str("1")) - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - isolated_build = true - envlist = a - [testenv] - skip_install = true - commands=python -c "import sys, os; sys.stderr.write(str(12345) + os.linesep);\ - raise SystemExit(17)" - allowlist_externals = {} - """.format( - sys.executable, - ), - }, - ) - args = ["-o"] if live else [] - result = cmd("-p", "all", *args) - result.assert_fail() - msg = result.out - # for live we print the failure logfile, otherwise just stream through (no logfile present) - assert "(exited with code 17)" in result.out, msg - if not live: - assert "ERROR: invocation failed (exit code 1), logfile:" in result.out, msg - assert any(line for line in result.outlines if line == "12345"), result.out - - # single summary at end - summary_lines = [j for j, l in enumerate(result.outlines) if " summary " in l] - assert len(summary_lines) == 1, msg - - assert result.outlines[summary_lines[0] + 1 :] == ["ERROR: a: parallel child exit code 1"] - - -def test_parallel_deadlock(cmd, initproj, monkeypatch): - monkeypatch.setenv(str("_TOX_SKIP_ENV_CREATION_TEST"), str("1")) - tox_ini = """\ -[tox] -envlist = e1,e2 -skipsdist = true - -[testenv] -allowlist_externals = {} -commands = - python -c '[print("hello world") for _ in range(5000)]' -""".format( - sys.executable, - ) - - initproj("pkg123-0.7", filedefs={"tox.ini": tox_ini}) - cmd("-p", "2") # used to hang indefinitely - - -def test_parallel_recreate(cmd, initproj, monkeypatch): - monkeypatch.setenv(str("_TOX_SKIP_ENV_CREATION_TEST"), str("1")) - tox_ini = """\ -[tox] -envlist = e1,e2 -skipsdist = true - -[testenv] -allowlist_externals = {} -commands = - python -c '[print("hello world") for _ in range(1)]' -""".format( - sys.executable, - ) - cwd = initproj("pkg123-0.7", filedefs={"tox.ini": tox_ini}) - log_dir = cwd / ".tox" / "e1" / "log" - assert not log_dir.exists() - cmd("-p", "2") - after = log_dir.listdir() - assert len(after) >= 2 - - res = cmd("-p", "2", "-rv") - assert res - end = log_dir.listdir() - assert len(end) >= 3 - assert not ({f.basename for f in after} - {f.basename for f in end}) - - -@flaky(max_runs=3) -def test_parallel_show_output(cmd, initproj, monkeypatch): - monkeypatch.setenv(str("_TOX_SKIP_ENV_CREATION_TEST"), str("1")) - tox_ini = """\ -[tox] -envlist = e1,e2,e3 -skipsdist = true - -[testenv] -allowlist_externals = {} -commands = - python -c 'import sys; sys.stderr.write("stderr env"); sys.stdout.write("stdout env")' - -[testenv:e3] -commands = - python -c 'import sys; sys.stderr.write("stderr always "); sys.stdout.write("stdout always ")' -parallel_show_output = True -""".format( - sys.executable, - ) - initproj("pkg123-0.7", filedefs={"tox.ini": tox_ini}) - result = cmd("-p", "all") - result.assert_success() - assert "stdout env" not in result.out, result.output() - assert "stderr env" not in result.out, result.output() - assert "stdout always" in result.out, result.output() - assert "stderr always" in result.out, result.output() - - -@pytest.fixture() -def parallel_project(initproj): - return initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - skipsdist = True - envlist = a, b - [testenv] - skip_install = True - commands=python -c "import sys; print(sys.executable)" - """, - }, - ) - - -def test_parallel_no_spinner_on(cmd, parallel_project, monkeypatch): - monkeypatch.setenv(str("TOX_PARALLEL_NO_SPINNER"), str("1")) - result = cmd("-p", "all") - result.assert_success() - assert "[2] a | b" not in result.out - - -def test_parallel_no_spinner_off(cmd, parallel_project, monkeypatch): - monkeypatch.setenv(str("TOX_PARALLEL_NO_SPINNER"), str("0")) - result = cmd("-p", "all") - result.assert_success() - assert "[2] a | b" in result.out - - -def test_parallel_no_spinner_not_set(cmd, parallel_project, monkeypatch): - monkeypatch.delenv(str("TOX_PARALLEL_NO_SPINNER"), raising=False) - result = cmd("-p", "all") - result.assert_success() - assert "[2] a | b" in result.out - - -def test_parallel_result_json(cmd, parallel_project, tmp_path): - parallel_result_json = tmp_path / "parallel.json" - result = cmd("-p", "all", "--result-json", "{}".format(parallel_result_json)) - ensure_result_json_ok(result, parallel_result_json) - - -def ensure_result_json_ok(result, json_path): - if isinstance(result, RunResult): - result.assert_success() - else: - assert not isinstance(result, subprocess.CalledProcessError) - assert json_path.exists() - serial_data = json.loads(json_path.read_text()) - ensure_key_in_env(serial_data) - - -def ensure_key_in_env(serial_data): - for env in ("a", "b"): - for key in ("setup", "test"): - assert key in serial_data["testenvs"][env], json.dumps( - serial_data["testenvs"], - indent=2, - ) - - -def test_parallel_result_json_concurrent(cmd, parallel_project, tmp_path): - # first run to set up the environments (env creation is not thread safe) - result = cmd("-p", "all") - result.assert_success() - - invoke_result = {} - - def invoke_tox_in_thread(thread_name, result_json): - try: - # needs to be process to have it's own stdout - invoke_result[thread_name] = subprocess.check_output( - [sys.executable, "-m", "tox", "-p", "all", "--result-json", str(result_json)], - universal_newlines=True, - ) - except subprocess.CalledProcessError as exception: - invoke_result[thread_name] = exception - - # now concurrently - parallel1_result_json = tmp_path / "parallel1.json" - parallel2_result_json = tmp_path / "parallel2.json" - threads = [ - threading.Thread(target=invoke_tox_in_thread, args=(k, p)) - for k, p in (("t1", parallel1_result_json), ("t2", parallel2_result_json)) - ] - [t.start() for t in threads] - [t.join() for t in threads] - - ensure_result_json_ok(invoke_result["t1"], parallel1_result_json) - ensure_result_json_ok(invoke_result["t2"], parallel2_result_json) - # our set_os_env_var is not thread-safe so clean-up TOX_WORK_DIR - os.environ.pop("TOX_WORK_DIR", None) diff --git a/tests/unit/session/test_provision.py b/tests/unit/session/test_provision.py deleted file mode 100644 index 24f7b3d80..000000000 --- a/tests/unit/session/test_provision.py +++ /dev/null @@ -1,406 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import json -import os -import shutil -import subprocess -import sys - -import py -import pytest -from virtualenv.info import IS_PYPY - -if sys.version_info[:2] >= (3, 4): - from pathlib import Path -else: - from pathlib2 import Path - -from six.moves.urllib.parse import urljoin -from six.moves.urllib.request import pathname2url - -from tox.exception import BadRequirement, MissingRequirement - - -@pytest.fixture(scope="session") -def next_tox_major(): - """a tox version we can guarantee to not be available""" - return "10.0.0" - - -def test_provision_min_version_is_requires(newconfig, next_tox_major): - with pytest.raises(MissingRequirement) as context: - newconfig( - [], - """\ - [tox] - minversion = {} - """.format( - next_tox_major, - ), - ) - config = context.value.config - - deps = [r.name for r in config.envconfigs[config.provision_tox_env].deps] - assert deps == ["tox >= {}".format(next_tox_major)] - assert config.run_provision is True - assert config.toxworkdir - assert config.toxinipath - assert config.provision_tox_env == ".tox" - assert config.ignore_basepython_conflict is False - - -def test_provision_config_has_minversion_and_requires(newconfig, next_tox_major): - with pytest.raises(MissingRequirement) as context: - newconfig( - [], - """\ - [tox] - minversion = {} - requires = - setuptools > 2 - pip > 3 - """.format( - next_tox_major, - ), - ) - config = context.value.config - - assert config.run_provision is True - assert config.minversion == next_tox_major - assert config.requires == ["setuptools > 2", "pip > 3"] - - -def test_provision_config_empty_minversion_and_requires(newconfig, next_tox_major): - config = newconfig([], "") - - assert config.run_provision is False - assert config.minversion is None - assert config.requires == [] - - -def test_provision_tox_change_name(newconfig): - config = newconfig( - [], - """\ - [tox] - provision_tox_env = magic - """, - ) - assert config.provision_tox_env == "magic" - - -def test_provision_basepython_global_only(newconfig, next_tox_major): - """we don't want to inherit basepython from global""" - with pytest.raises(MissingRequirement) as context: - newconfig( - [], - """\ - [tox] - minversion = {} - [testenv] - basepython = what - """.format( - next_tox_major, - ), - ) - config = context.value.config - base_python = config.envconfigs[".tox"].basepython - assert base_python == sys.executable - - -def test_provision_basepython_local(newconfig, next_tox_major): - """however adhere to basepython when explicitly set""" - with pytest.raises(MissingRequirement) as context: - newconfig( - [], - """\ - [tox] - minversion = {} - [testenv:.tox] - basepython = what - """.format( - next_tox_major, - ), - ) - config = context.value.config - base_python = config.envconfigs[".tox"].basepython - assert base_python == "what" - - -def test_provision_bad_requires(newconfig, capsys, monkeypatch): - with pytest.raises(BadRequirement): - newconfig( - [], - """\ - [tox] - requires = sad >sds d ok - """, - ) - out, err = capsys.readouterr() - assert "ERROR: failed to parse InvalidRequirement" in out - assert not err - - -@pytest.fixture() -def plugin(monkeypatch, tmp_path): - dest = tmp_path / "a" - shutil.copytree(str(py.path.local(__file__).dirpath().join("plugin")), str(dest)) - subprocess.check_output([sys.executable, "setup.py", "egg_info"], cwd=str(dest)) - monkeypatch.setenv(str("PYTHONPATH"), str(dest)) - - -def test_provision_cli_args_ignore(cmd, initproj, monkeypatch, plugin): - import tox.config - import tox.session - - prev_ensure = tox.config.ParseIni.ensure_requires_satisfied - - @staticmethod - def ensure_requires_satisfied(config, requires, min_version): - result = prev_ensure(config, requires, min_version) - config.run_provision = True - return result - - monkeypatch.setattr( - tox.config.ParseIni, - "ensure_requires_satisfied", - ensure_requires_satisfied, - ) - prev_get_venv = tox.session.Session.getvenv - - def getvenv(self, name): - venv = prev_get_venv(self, name) - venv.envconfig.envdir = py.path.local(sys.executable).dirpath().dirpath() - venv.setupenv = lambda: True - venv.finishvenv = lambda: True - return venv - - monkeypatch.setattr(tox.session.Session, "getvenv", getvenv) - initproj("test-0.1", {"tox.ini": "[tox]"}) - result = cmd("-a", "--option", "b") - result.assert_success(is_run_test_env=False) - - -def test_provision_cli_args_not_ignored_if_provision_false(cmd, initproj): - initproj("test-0.1", {"tox.ini": "[tox]"}) - result = cmd("-a", "--option", "b") - result.assert_fail(is_run_test_env=False) - - -parametrize_json_path = pytest.mark.parametrize("json_path", [None, "missing.json"]) - - -@parametrize_json_path -def test_provision_does_not_fail_with_no_provision_no_reason(cmd, initproj, json_path): - p = initproj("test-0.1", {"tox.ini": "[tox]"}) - result = cmd("--no-provision", *([json_path] if json_path else [])) - result.assert_success(is_run_test_env=True) - assert not (p / "missing.json").exists() - - -@parametrize_json_path -def test_provision_fails_with_no_provision_next_tox(cmd, initproj, next_tox_major, json_path): - p = initproj( - "test-0.1", - { - "tox.ini": """\ - [tox] - minversion = {} - """.format( - next_tox_major, - ) - }, - ) - result = cmd("--no-provision", *([json_path] if json_path else [])) - result.assert_fail(is_run_test_env=False) - if json_path: - missing = json.loads((p / json_path).read_text("utf-8")) - assert missing["minversion"] == next_tox_major - - -@parametrize_json_path -def test_provision_fails_with_no_provision_missing_requires(cmd, initproj, json_path): - p = initproj( - "test-0.1", - { - "tox.ini": """\ - [tox] - requires = - virtualenv > 99999999 - """ - }, - ) - result = cmd("--no-provision", *([json_path] if json_path else [])) - result.assert_fail(is_run_test_env=False) - if json_path: - missing = json.loads((p / json_path).read_text("utf-8")) - assert missing["requires"] == ["virtualenv > 99999999"] - - -@parametrize_json_path -def test_provision_does_not_fail_with_satisfied_requires(cmd, initproj, next_tox_major, json_path): - p = initproj( - "test-0.1", - { - "tox.ini": """\ - [tox] - minversion = 0 - requires = - setuptools > 2 - pip > 3 - """ - }, - ) - result = cmd("--no-provision", *([json_path] if json_path else [])) - result.assert_success(is_run_test_env=True) - assert not (p / "missing.json").exists() - - -@parametrize_json_path -def test_provision_fails_with_no_provision_combined(cmd, initproj, next_tox_major, json_path): - p = initproj( - "test-0.1", - { - "tox.ini": """\ - [tox] - minversion = {} - requires = - setuptools > 2 - pip > 3 - """.format( - next_tox_major, - ) - }, - ) - result = cmd("--no-provision", *([json_path] if json_path else [])) - result.assert_fail(is_run_test_env=False) - if json_path: - missing = json.loads((p / json_path).read_text("utf-8")) - assert missing["minversion"] == next_tox_major - assert missing["requires"] == ["setuptools > 2", "pip > 3"] - - -@pytest.fixture(scope="session") -def wheel(tmp_path_factory): - """create a wheel for a project""" - state = {"at": 0} - - def _wheel(path): - state["at"] += 1 - dest_path = tmp_path_factory.mktemp("wheel-{}-".format(state["at"])) - env = os.environ.copy() - try: - subprocess.check_output( - [ - sys.executable, - "-m", - "pip", - "wheel", - "-w", - str(dest_path), - "--no-deps", - str(path), - ], - universal_newlines=True, - stderr=subprocess.STDOUT, - env=env, - ) - except subprocess.CalledProcessError as exception: - assert not exception.returncode, exception.output - - wheels = list(dest_path.glob("*.whl")) - assert len(wheels) == 1 - wheel = wheels[0] - return wheel - - return _wheel - - -THIS_PROJECT_ROOT = Path(__file__).resolve().parents[3] - - -@pytest.fixture(scope="session") -def tox_wheel(wheel): - return wheel(THIS_PROJECT_ROOT) - - -@pytest.fixture(scope="session") -def magic_non_canonical_wheel(wheel, tmp_path_factory): - magic_proj = tmp_path_factory.mktemp("magic") - (magic_proj / "setup.py").write_text( - "from setuptools import setup\nsetup(name='com.magic.this-is-fun')", - ) - return wheel(magic_proj) - - -@pytest.mark.skipif(IS_PYPY and sys.version_info[0] > 2, reason="fails on pypy3") -def test_provision_non_canonical_dep( - cmd, - initproj, - monkeypatch, - tox_wheel, - magic_non_canonical_wheel, -): - initproj( - "w-0.1", - { - "tox.ini": """\ - [tox] - envlist = py - requires = - com.magic.this-is-fun - tox == {} - [testenv:.tox] - passenv = * - """.format( - tox_wheel.name.split("-")[1], - ), - }, - ) - find_links = " ".join( - space_path2url(https://github.com/d) for d in (tox_wheel.parent, magic_non_canonical_wheel.parent) - ) - - monkeypatch.setenv(str("PIP_FIND_LINKS"), str(find_links)) - - result = cmd("-a", "-v", "-v") - result.assert_success(is_run_test_env=False) - - -def test_provision_requirement_with_environment_marker(cmd, initproj): - initproj( - "proj", - { - "tox.ini": """\ - [tox] - requires = - package-that-does-not-exist;python_version=="1.0" - """, - }, - ) - result = cmd("-e", "py", "-vv") - result.assert_success(is_run_test_env=False) - - -def space_path2url(https://github.com/path): - at_path = str(path) - if " " not in at_path: - return at_path - return urljoin("file:", pathname2url(https://github.com/os.path.abspath(at_path))) - - -def test_provision_does_not_occur_in_devenv(newconfig, next_tox_major): - """Adding --devenv should not change the directory where provisioning occurs""" - with pytest.raises(MissingRequirement) as context: - newconfig( - ["--devenv", "my_devenv"], - """\ - [tox] - minversion = {} - """.format( - next_tox_major, - ), - ) - config = context.value.config - assert config.run_provision is True - assert config.envconfigs[".tox"].envdir.basename != "my_devenv" diff --git a/tests/unit/session/test_session.py b/tests/unit/session/test_session.py deleted file mode 100644 index 93ae21118..000000000 --- a/tests/unit/session/test_session.py +++ /dev/null @@ -1,380 +0,0 @@ -import os -import sys -import textwrap -from threading import Thread - -import pytest - -import tox -from tox.exception import MissingDependency, MissingDirectory -from tox.package import resolve_package -from tox.reporter import Verbosity - -if sys.version_info >= (3, 3): - from shlex import quote as shlex_quote -else: - from pipes import quote as shlex_quote - - -def test_resolve_pkg_missing_directory(tmpdir, mocksession): - distshare = tmpdir.join("distshare") - spec = distshare.join("pkg123-*") - with pytest.raises(MissingDirectory): - resolve_package(spec) - - -def test_resolve_pkg_missing_directory_in_distshare(tmpdir, mocksession): - distshare = tmpdir.join("distshare") - spec = distshare.join("pkg123-*") - distshare.ensure(dir=1) - with pytest.raises(MissingDependency): - resolve_package(spec) - - -def test_resolve_pkg_multiple_valid_versions(tmpdir, mocksession): - mocksession.logging_levels(quiet=Verbosity.DEFAULT, verbose=Verbosity.DEBUG) - distshare = tmpdir.join("distshare") - distshare.ensure("pkg123-1.3.5.zip") - p = distshare.ensure("pkg123-1.4.5.zip") - result = resolve_package(distshare.join("pkg123-*")) - assert result == p - mocksession.report.expect("info", "determin*pkg123*") - - -def test_resolve_pkg_with_invalid_version(tmpdir, mocksession): - distshare = tmpdir.join("distshare") - - distshare.ensure("pkg123-1.something_bad.zip") - distshare.ensure("pkg123-1.3.5.zip") - p = distshare.ensure("pkg123-1.4.5.zip") - - result = resolve_package(distshare.join("pkg123-*")) - mocksession.report.expect("warning", "*1.something_bad*") - assert result == p - - -def test_resolve_pkg_with_alpha_version(tmpdir, mocksession): - distshare = tmpdir.join("distshare") - distshare.ensure("pkg123-1.3.5.zip") - distshare.ensure("pkg123-1.4.5a1.tar.gz") - p = distshare.ensure("pkg123-1.4.5.zip") - result = resolve_package(distshare.join("pkg123-*")) - assert result == p - - -def test_resolve_pkg_doubledash(tmpdir, mocksession): - distshare = tmpdir.join("distshare") - p = distshare.ensure("pkg-mine-1.3.0.zip") - res = resolve_package(distshare.join("pkg-mine*")) - assert res == p - distshare.ensure("pkg-mine-1.3.0a1.zip") - res = resolve_package(distshare.join("pkg-mine*")) - assert res == p - - -def test_skip_sdist(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """ - syntax error - """, - "tox.ini": """ - [tox] - skipsdist=True - [testenv] - commands=python -c "print('done')" - """, - }, - ) - result = cmd() - result.assert_success() - - -def test_skip_install_skip_package(cmd, initproj, mock_venv): - initproj( - "pkg123-0.7", - filedefs={ - "setup.py": """raise RuntimeError""", - "tox.ini": """ - [tox] - envlist = py - - [testenv] - skip_install = true - """, - }, - ) - result = cmd("--notest") - result.assert_success() - - -@pytest.fixture() -def venv_filter_project(initproj, cmd): - def func(*args): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [tox] - envlist = {py27,py36}-{nocov,cov,diffcov}{,-extra} - skipsdist = true - - [testenv] - skip_install = true - commands = python -c 'print("{envname}")' - """, - }, - ) - result = cmd(*args) - result.assert_success(is_run_test_env=False) - active = [i.name for i in result.session.existing_venvs.values()] - return active, result - - yield func - - -def test_venv_filter_empty_all_active(venv_filter_project, monkeypatch): - monkeypatch.delenv("TOX_SKIP_ENV", raising=False) - active, result = venv_filter_project("-a") - assert result.outlines == [ - "py27-nocov", - "py27-nocov-extra", - "py27-cov", - "py27-cov-extra", - "py27-diffcov", - "py27-diffcov-extra", - "py36-nocov", - "py36-nocov-extra", - "py36-cov", - "py36-cov-extra", - "py36-diffcov", - "py36-diffcov-extra", - ] - assert active == result.outlines - - -def test_venv_filter_match_all_none_active(venv_filter_project, monkeypatch): - monkeypatch.setenv("TOX_SKIP_ENV", ".*") - active, result = venv_filter_project("-a") - assert not active - existing_envs = result.outlines - - _, result = venv_filter_project("-avv") - for name in existing_envs: - msg = "skip environment {}, matches filter '.*'".format(name) - assert msg in result.outlines - - -def test_venv_filter_match_some_some_active(venv_filter_project, monkeypatch): - monkeypatch.setenv("TOX_SKIP_ENV", "py27.*") - active, result = venv_filter_project("-avvv") - assert active == [ - "py36-nocov", - "py36-nocov-extra", - "py36-cov", - "py36-cov-extra", - "py36-diffcov", - "py36-diffcov-extra", - ] - - -@pytest.fixture() -def popen_env_test(initproj, cmd, monkeypatch): - def func(tox_env, isolated_build): - files = { - "tox.ini": """ - [tox] - isolated_build = {} - [testenv:{}] - commands = python -c "print('ok')" - """.format( - "True" if isolated_build else "False", - tox_env, - ), - } - if isolated_build: - files[ - "pyproject.toml" - ] = """ - [build-system] - requires = ["setuptools >= 35.0.2", "setuptools_scm >= 2.0.0, <3"] - build-backend = 'setuptools.build_meta' - """ - initproj("env_var_test", filedefs=files) - - class IsolatedResult(object): - def __init__(self): - self.popens = [] - self.cwd = None - - res = IsolatedResult() - - class EnvironmentTestRun(Thread): - """we wrap this invocation into a thread to avoid modifying in any way the - current threads environment variable (e.g. on failure of this test incorrect teardown) - """ - - def run(self): - prev_build = tox.session.build_session - - def build_session(config): - res.session = prev_build(config) - res._popen = res.session.popen - monkeypatch.setattr(res.session, "popen", popen) - return res.session - - monkeypatch.setattr(tox.session, "build_session", build_session) - - def popen(cmd, **kwargs): - activity_id = _actions[-1].name - activity_name = _actions[-1].activity - ret = "NOTSET" - try: - ret = res._popen(cmd, **kwargs) - except tox.exception.InvocationError as exception: - ret = exception - finally: - res.popens.append( - (activity_id, activity_name, kwargs.get("env"), ret, cmd), - ) - return ret - - _actions = [] - from tox.action import Action - - _prev_enter = Action.__enter__ - - def enter(self): - _actions.append(self) - return _prev_enter(self) - - monkeypatch.setattr(Action, "__enter__", enter) - - _prev_exit = Action.__exit__ - - def exit_func(self, *args, **kwargs): - del _actions[_actions.index(self)] - _prev_exit(self, *args, **kwargs) - - monkeypatch.setattr(Action, "__exit__", exit_func) - - res.result = cmd("-e", tox_env) - res.cwd = os.getcwd() - - thread = EnvironmentTestRun() - thread.start() - thread.join() - return res - - yield func - - -@pytest.mark.network -def test_tox_env_var_flags_inserted_non_isolated(popen_env_test): - res = popen_env_test("py", False) - assert_popen_env(res) - - -@pytest.mark.network -def test_tox_env_var_flags_inserted_isolated(popen_env_test): - res = popen_env_test("py", True) - assert_popen_env(res) - - -def assert_popen_env(res): - res.result.assert_success() - for tox_id, _, env, __, ___ in res.popens: - assert env["TOX_WORK_DIR"] == os.path.join(res.cwd, ".tox") - if tox_id != "GLOB": - assert env["TOX_ENV_NAME"] == tox_id - assert env["TOX_ENV_DIR"] == os.path.join(res.cwd, ".tox", tox_id) - # ensure native strings for environ for windows - for k, v in env.items(): - assert type(k) is str, (k, v, type(k)) - assert type(v) is str, (k, v, type(v)) - - -def test_command_prev_post_ok(cmd, initproj, mock_venv): - initproj( - "pkg_command_test_123-0.7", - filedefs={ - "tox.ini": """ - [tox] - envlist = py - - [testenv] - commands_pre = python -c 'print("pre")' - commands = python -c 'print("command")' - commands_post = python -c 'print("post")' - """, - }, - ) - result = cmd() - result.assert_success() - expected = textwrap.dedent( - """ - py run-test-pre: commands[0] | python -c 'print("pre")' - pre - py run-test: commands[0] | python -c 'print("command")' - command - py run-test-post: commands[0] | python -c 'print("post")' - post - ___________________________________ summary ___________________________________{} - py: commands succeeded - congratulations :) - """.format( - "_" if sys.platform != "win32" else "", - ), - ).lstrip() - have = result.out.replace(os.linesep, "\n") - actual = have[len(have) - len(expected) :] - assert actual == expected - - -def test_command_prev_fail_command_skip_post_run(cmd, initproj, mock_venv): - initproj( - "pkg_command_test_123-0.7", - filedefs={ - "tox.ini": """ - [tox] - envlist = py - - [testenv] - commands_pre = python -c 'raise SystemExit(2)' - commands = python -c 'print("command")' - commands_post = python -c 'print("post")' - """, - }, - ) - result = cmd() - result.assert_fail() - expected = textwrap.dedent( - """ - py run-test-pre: commands[0] | python -c 'raise SystemExit(2)' - ERROR: InvocationError for command {} -c 'raise SystemExit(2)' (exited with code 2) - py run-test-post: commands[0] | python -c 'print("post")' - post - ___________________________________ summary ___________________________________{} - ERROR: py: commands failed - """.format( - shlex_quote(sys.executable), - "_" if sys.platform != "win32" else "", - ), - ) - have = result.out.replace(os.linesep, "\n") - actual = have[len(have) - len(expected) :] - assert actual == expected - - -def test_help_compound_ve_works(cmd, initproj, monkeypatch): - initproj("test-0.1", {"tox.ini": ""}) - result = cmd("-ve", "py", "-a") - result.assert_success(is_run_test_env=False) - assert not result.err - assert result.outlines[0].startswith("using") - assert result.outlines[1].startswith("using") - assert result.outlines[2] == "additional environments:" - assert result.outlines[3] == "py -> [no description]" - assert len(result.outlines) == 4 diff --git a/tests/unit/session/test_show_config.py b/tests/unit/session/test_show_config.py deleted file mode 100644 index 7f754f7e6..000000000 --- a/tests/unit/session/test_show_config.py +++ /dev/null @@ -1,132 +0,0 @@ -import py -import pytest -from six import PY2, StringIO -from six.moves import configparser - - -def load_config(args, cmd): - result = cmd(*args) - result.assert_success(is_run_test_env=False) - parser = configparser.ConfigParser() - output = StringIO(result.out) - (parser.readfp if PY2 else parser.read_file)(output) - return parser - - -def test_showconfig_with_force_dep_version(cmd, initproj): - initproj( - "force_dep_version", - filedefs={ - "tox.ini": """ - [tox] - - [testenv] - deps= - dep1==2.3 - dep2 - """, - }, - ) - parser = load_config(("--showconfig",), cmd) - assert parser.get("testenv:python", "deps") == "[dep1==2.3, dep2]" - - parser = load_config(("--showconfig", "--force-dep=dep1", "--force-dep=dep2==5.0"), cmd) - assert parser.get("testenv:python", "deps") == "[dep1, dep2==5.0]" - - -@pytest.fixture() -def setup_mixed_conf(initproj): - initproj( - "force_dep_version", - filedefs={ - "tox.ini": """ - [tox] - envlist = py37,py27,pypi,docs - - [testenv:notincluded] - changedir = whatever - - [testenv:docs] - changedir = docs - """, - }, - ) - - -@pytest.mark.parametrize( - "args, expected", - [ - ( - ["--showconfig"], - [ - "tox", - "tox:versions", - "testenv:py37", - "testenv:py27", - "testenv:pypi", - "testenv:docs", - "testenv:notincluded", - ], - ), - ( - ["--showconfig", "-l"], - [ - "tox", - "tox:versions", - "testenv:py37", - "testenv:py27", - "testenv:pypi", - "testenv:docs", - ], - ), - (["--showconfig", "-e", "py37,py36"], ["testenv:py37", "testenv:py36"]), - ], - ids=["all", "default_only", "-e"], -) -def test_showconfig(cmd, setup_mixed_conf, args, expected): - parser = load_config(args, cmd) - found_sections = parser.sections() - assert found_sections == expected - - -def test_showconfig_interpolation(cmd, initproj): - initproj( - "no_interpolation", - filedefs={ - "tox.ini": """ - [tox] - envlist = %s - [testenv:%s] - commands = python -c "print('works')" - """, - }, - ) - load_config(("--showconfig",), cmd) - - -def test_config_specific_ini(tmpdir, cmd): - ini = tmpdir.ensure("hello.ini") - output = load_config(("-c", ini, "--showconfig"), cmd) - assert output.get("tox", "toxinipath") == ini - - -def test_override_workdir(cmd, initproj): - baddir = "badworkdir-123" - gooddir = "overridden-234" - initproj( - "overrideworkdir-0.5", - filedefs={ - "tox.ini": """ - [tox] - toxworkdir={} - """.format( - baddir, - ), - }, - ) - result = cmd("--workdir", gooddir, "--showconfig") - assert not result.ret - assert gooddir in result.out - assert baddir not in result.out - assert py.path.local(gooddir).check() - assert not py.path.local(baddir).check() diff --git a/tests/unit/test_docs.py b/tests/unit/test_docs.py deleted file mode 100644 index 029aadd4c..000000000 --- a/tests/unit/test_docs.py +++ /dev/null @@ -1,56 +0,0 @@ -import os.path -import re -import textwrap - -import pytest - -import tox -from tox.config import parseconfig - -INI_BLOCK_RE = re.compile( - r"(?P" - r"^(?P *)\.\. (code-block|sourcecode):: ini\n" - r"((?P=indent) +:.*\n)*" - r"\n*" - r")" - r"(?P(^((?P=indent) +.*)?\n)+)", - re.MULTILINE, -) - - -RST_FILES = [] -TOX_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) -for root, _, filenames in os.walk(os.path.join(TOX_ROOT, "docs")): - for filename in filenames: - if filename.endswith(".rst"): - RST_FILES.append(os.path.join(root, filename)) - - -def test_some_files_exist(): - assert RST_FILES - - -@pytest.mark.parametrize("filename", RST_FILES) -def test_all_rst_ini_blocks_parse(filename, tmpdir): - with open(filename) as f: - contents = f.read() - for match in INI_BLOCK_RE.finditer(contents): - code = textwrap.dedent(match.group("code")) - config_path = tmpdir / "tox.ini" - config_path.write(code) - try: - parseconfig(["-c", str(config_path)]) - except tox.exception.MissingRequirement: - pass - except Exception as e: - raise AssertionError( - "Error parsing ini block\n\n" - "{filename}:{lineno}\n\n" - "{code}\n\n" - "{error}\n\n{error!r}".format( - filename=filename, - lineno=contents[: match.start()].count("\n") + 1, - code="\t" + code.replace("\n", "\n\t").strip(), - error=e, - ), - ) diff --git a/tests/unit/test_pytest_plugins.py b/tests/unit/test_pytest_plugins.py deleted file mode 100644 index 30525fd3c..000000000 --- a/tests/unit/test_pytest_plugins.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -Test utility tests, intended to cover use-cases not used in the current -project test suite, e.g. as shown by the code coverage report. - -""" -import os -import sys - -import py.path -import pytest - -from tox._pytestplugin import RunResult, _filedefs_contains, _path_parts - - -class TestInitProj: - @pytest.mark.parametrize( - "kwargs", - ({}, {"src_root": None}, {"src_root": ""}, {"src_root": "."}), - ) - def test_no_src_root(self, kwargs, tmpdir, initproj): - initproj("black_knight-42", **kwargs) - init_file = tmpdir.join("black_knight", "black_knight", "__init__.py") - expected = b'""" module black_knight """' + linesep_bytes() + b"__version__ = '42'" - assert init_file.read_binary() == expected - - def test_existing_src_root(self, tmpdir, initproj): - initproj("spam-666", src_root="ham") - assert not tmpdir.join("spam", "spam").check(exists=1) - init_file = tmpdir.join("spam", "ham", "spam", "__init__.py") - expected = b'""" module spam """' + linesep_bytes() + b"__version__ = '666'" - assert init_file.read_binary() == expected - - def test_prebuilt_src_dir_with_no_src_root(self, tmpdir, initproj): - initproj("spam-1.0", filedefs={"spam": {}}) - src_dir = tmpdir.join("spam", "spam") - assert src_dir.check(dir=1) - assert not src_dir.join("__init__.py").check(exists=1) - - def test_prebuilt_src_dir_with_src_root(self, tmpdir, initproj): - initproj( - "spam-1.0", - filedefs={"incontinentia": {"spam": {"__init__.py": "buttocks"}}}, - src_root="incontinentia", - ) - assert not tmpdir.join("spam", "spam").check(exists=1) - init_file = tmpdir.join("spam", "incontinentia", "spam", "__init__.py") - assert init_file.read_binary() == b"buttocks" - - def test_broken_py_path_local_join_workaround_on_Windows(self, tmpdir, initproj, monkeypatch): - # construct an absolute folder path for our src_root folder without the - # Windows drive indicator - src_root = tmpdir.join("spam") - src_root = _path_parts(src_root) - src_root[0] = "" - src_root = "/".join(src_root) - - # make sure tmpdir drive is the current one so the constructed src_root - # folder path gets interpreted correctly on Windows - monkeypatch.chdir(tmpdir) - - # will throw an assertion error if the bug is not worked around - initproj("spam-666", src_root=src_root) - - init_file = tmpdir.join("spam", "spam", "__init__.py") - expected = b'""" module spam """' + linesep_bytes() + b"__version__ = '666'" - assert init_file.read_binary() == expected - - -def linesep_bytes(): - return os.linesep.encode() - - -class TestPathParts: - @pytest.mark.parametrize( - "input, expected", - ( - ("", []), - ("/", ["/"]), - ("//", ["//"]), - ("/a", ["/", "a"]), - ("/a/", ["/", "a"]), - ("/a/b", ["/", "a", "b"]), - ("a", ["a"]), - ("a/b", ["a", "b"]), - ), - ) - def test_path_parts(self, input, expected): - assert _path_parts(input) == expected - - def test_on_py_path(self): - cwd_parts = _path_parts(py.path.local()) - folder_parts = _path_parts(py.path.local("a/b/c")) - assert folder_parts[len(cwd_parts) :] == ["a", "b", "c"] - - -@pytest.mark.parametrize( - "base, filedefs, target, expected", - ( - ("/base", {}, "", False), - ("/base", {}, "/base", False), - ("/base", {"a": {"b": "data"}}, "", True), - ("/base", {"a": {"b": "data"}}, "a", True), - ("/base", {"a": {"b": "data"}}, "a/b", True), - ("/base", {"a": {"b": "data"}}, "a/x", False), - ("/base", {"a": {"b": "data"}}, "a/b/c", False), - ("/base", {"a": {"b": "data"}}, "/base", True), - ("/base", {"a": {"b": "data"}}, "/base/a", True), - ("/base", {"a": {"b": "data"}}, "/base/a/b", True), - ("/base", {"a": {"b": "data"}}, "/base/a/x", False), - ("/base", {"a": {"b": "data"}}, "/base/a/b/c", False), - ("/base", {"a": {"b": "data"}}, "/a", False), - ), -) -def test_filedefs_contains(base, filedefs, target, expected): - assert bool(_filedefs_contains(base, filedefs, target)) == expected - - -def test_run_result_repr(capfd): - with RunResult(["hello", "world"], capfd) as run_result: - # simulate tox writing some unicode output - stdout_buffer = getattr(sys.stdout, "buffer", sys.stdout) - stdout_buffer.write("\u2603".encode("UTF-8")) - - # must not `UnicodeError` on repr(...) - ret = repr(run_result) - # must be native `str`, (bytes in py2, str in py3) - assert isinstance(ret, str) diff --git a/tests/unit/test_quickstart.py b/tests/unit/test_quickstart.py deleted file mode 100644 index f52378e2b..000000000 --- a/tests/unit/test_quickstart.py +++ /dev/null @@ -1,268 +0,0 @@ -import os - -import pytest - -import tox -from tox._quickstart import ( - ALTERNATIVE_CONFIG_NAME, - QUICKSTART_CONF, - list_modificator, - main, - post_process_input, - prepare_content, -) - -ALL_PY_ENVS_AS_STRING = ", ".join(tox.PYTHON.QUICKSTART_PY_ENVS) -ALL_PY_ENVS_WO_LAST_AS_STRING = ", ".join(tox.PYTHON.QUICKSTART_PY_ENVS[:-1]) -SIGNS_OF_SANITY = ( - "tox.readthedocs.io", - "[tox]", - "[testenv]", - "envlist = ", - "deps =", - "commands =", -) -# A bunch of elements to be expected in the generated config as marker for basic sanity - - -class _answers: - """Simulate a series of terminal inputs by popping them from a list if called.""" - - def __init__(self, inputs): - self._inputs = [str(i) for i in inputs] - - def extend(self, items): - self._inputs.extend(items) - - def __str__(self): - return "|".join(self._inputs) - - def __call__(self, prompt): - print("prompt: '{}'".format(prompt)) - try: - answer = self._inputs.pop(0) - print("user answer: '{}'".format(answer)) - return answer - except IndexError: - pytest.fail("missing user answer for '{}'".format(prompt)) - - -class _cnf: - """Handle files and args for different test scenarios.""" - - SOME_CONTENT = "dontcare" - - def __init__(self, exists=False, names=None, pass_path=False): - self.original_name = tox.INFO.DEFAULT_CONFIG_NAME - self.names = names or [ALTERNATIVE_CONFIG_NAME] - self.exists = exists - self.pass_path = pass_path - - def __str__(self): - return self.original_name if not self.exists else str(self.names) - - @property - def argv(self): - argv = ["tox-quickstart"] - if self.pass_path: - argv.append(os.getcwd()) - return argv - - @property - def dpath(self): - return os.getcwd() if self.pass_path else "" - - def create(self): - paths_to_create = {self._original_path} - for name in self.names[:-1]: - paths_to_create.add(os.path.join(self.dpath, name)) - for path in paths_to_create: - with open(path, "w") as f: - f.write(self.SOME_CONTENT) - - @property - def generated_content(self): - return self._alternative_content if self.exists else self._original_content - - @property - def already_existing_content(self): - if not self.exists: - if os.path.exists(self._alternative_path): - pytest.fail("alternative path should never exist here") - pytest.fail("checking for already existing content makes not sense here") - return self._original_content - - @property - def path_to_generated(self): - return os.path.join(os.getcwd(), self.names[-1] if self.exists else self.original_name) - - @property - def _original_path(self): - return os.path.join(self.dpath, self.original_name) - - @property - def _alternative_path(self): - return os.path.join(self.dpath, self.names[-1]) - - @property - def _original_content(self): - with open(self._original_path) as f: - return f.read() - - @property - def _alternative_content(self): - with open(self._alternative_path) as f: - return f.read() - - -class _exp: - """Holds test expectations and a user scenario description.""" - - STANDARD_EPECTATIONS = [ALL_PY_ENVS_AS_STRING, "pytest", "pytest"] - - def __init__(self, name, exp=None): - self.name = name - exp = exp or self.STANDARD_EPECTATIONS - # NOTE extra mangling here ensures formatting is the same in file and exp - map_ = {"deps": list_modificator(exp[1]), "commands": list_modificator(exp[2])} - post_process_input(map_) - map_["envlist"] = exp[0] - self.content = prepare_content(QUICKSTART_CONF.format(**map_)) - - def __str__(self): - return self.name - - -@pytest.mark.usefixtures("work_in_clean_dir") -@pytest.mark.parametrize( - argnames="answers, exp, cnf", - ids=lambda param: str(param), - argvalues=( - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "pytest", "pytest"]), - _exp( - "choose versions individually and use pytest", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "pytest", "pytest"], - ), - _cnf(), - ), - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "py.test", ""]), - _exp( - "choose versions individually and use old fashioned py.test", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "pytest", "py.test"], - ), - _cnf(), - ), - ( - _answers([1, "pytest", ""]), - _exp( - "choose current release Python and pytest with default deps", - [tox.PYTHON.CURRENT_RELEASE_ENV, "pytest", "pytest"], - ), - _cnf(), - ), - ( - _answers([1, "pytest -n auto", "pytest-xdist"]), - _exp( - "choose current release Python and pytest with xdist and some args", - [tox.PYTHON.CURRENT_RELEASE_ENV, "pytest, pytest-xdist", "pytest -n auto"], - ), - _cnf(), - ), - ( - _answers([2, "pytest", ""]), - _exp( - "choose py27, current release Python and pytest with default deps", - ["py27, {}".format(tox.PYTHON.CURRENT_RELEASE_ENV), "pytest", "pytest"], - ), - _cnf(), - ), - ( - _answers([3, "pytest", ""]), - _exp("choose all supported version and pytest with default deps"), - _cnf(), - ), - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "py.test", ""]), - _exp( - "choose versions individually and use old fashioned py.test", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "pytest", "py.test"], - ), - _cnf(), - ), - ( - _answers([4, "", "", "", "", "", "", "", ""]), - _exp("choose no version individually and defaults"), - _cnf(), - ), - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "python -m unittest discover", ""]), - _exp( - "choose versions individually and use nose with default deps", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "", "python -m unittest discover"], - ), - _cnf(), - ), - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "nosetests", "nose"]), - _exp( - "choose versions individually and use nose with default deps", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "nose", "nosetests"], - ), - _cnf(), - ), - ( - _answers([4, "Y", "Y", "Y", "Y", "Y", "N", "trial", ""]), - _exp( - "choose versions individually and use twisted tests with default deps", - [ALL_PY_ENVS_WO_LAST_AS_STRING, "twisted", "trial"], - ), - _cnf(), - ), - ( - _answers([4, "", "", "", "", "", "", "", ""]), - _exp("existing not overridden, generated to alternative with default name"), - _cnf(exists=True), - ), - ( - _answers([4, "", "", "", "", "", "", "", ""]), - _exp("existing not overridden, generated to alternative with custom name"), - _cnf(exists=True, names=["some-other.ini"]), - ), - ( - _answers([4, "", "", "", "", "", "", "", ""]), - _exp("existing not override, generated to alternative"), - _cnf(exists=True, names=["tox.ini", "some-other.ini"]), - ), - ( - _answers([4, "", "", "", "", "", "", "", ""]), - _exp("existing alternatives are not overridden, generated to alternative"), - _cnf(exists=True, names=["tox.ini", "setup.py", "some-other.ini"]), - ), - ), -) -def test_quickstart(answers, cnf, exp, monkeypatch): - """Test quickstart script using some little helpers. - - :param _answers answers: user interaction simulation - :param _cnf cnf: helper for args and config file paths and contents - :param _exp exp: expectation helper - """ - monkeypatch.setattr("six.moves.input", answers) - monkeypatch.setattr("sys.argv", cnf.argv) - if cnf.exists: - answers.extend(cnf.names) - cnf.create() - main() - print("generated config at {}:\n{}\n".format(cnf.path_to_generated, cnf.generated_content)) - check_basic_sanity(cnf.generated_content, SIGNS_OF_SANITY) - assert cnf.generated_content == exp.content - if cnf.exists: - assert cnf.already_existing_content == cnf.SOME_CONTENT - - -def check_basic_sanity(content, signs): - for sign in signs: - if sign not in content: - pytest.fail("{} not in\n{}".format(sign, content)) diff --git a/tests/unit/test_result.py b/tests/unit/test_result.py deleted file mode 100644 index 3b7ee89b4..000000000 --- a/tests/unit/test_result.py +++ /dev/null @@ -1,110 +0,0 @@ -import functools -import os -import signal -import socket -import sys - -import py -import pytest - -import tox -from tox.logs import ResultLog - - -@pytest.fixture(name="pkg") -def create_fake_pkg(tmpdir): - pkg = tmpdir.join("hello-1.0.tar.gz") - pkg.write("whatever") - return pkg - - -@pytest.fixture() -def clean_hostname_envvar(monkeypatch): - monkeypatch.delenv("HOSTNAME", raising=False) - return functools.partial(monkeypatch.setenv, "HOSTNAME") - - -def test_pre_set_header(clean_hostname_envvar): - replog = ResultLog() - d = replog.dict - assert replog.dict == d - assert replog.dict["reportversion"] == "1" - assert replog.dict["toxversion"] == tox.__version__ - assert replog.dict["platform"] == sys.platform - assert replog.dict["host"] == socket.gethostname() - data = replog.dumps_json() - replog2 = ResultLog.from_json(data) - assert replog2.dict == replog.dict - - -def test_set_header(pkg, clean_hostname_envvar): - replog = ResultLog() - d = replog.dict - assert replog.dict == d - assert replog.dict["reportversion"] == "1" - assert replog.dict["toxversion"] == tox.__version__ - assert replog.dict["platform"] == sys.platform - assert replog.dict["host"] == socket.gethostname() - expected = {"basename": "hello-1.0.tar.gz", "sha256": pkg.computehash("sha256")} - env_log = replog.get_envlog("a") - env_log.set_header(installpkg=pkg) - assert env_log.dict["installpkg"] == expected - - data = replog.dumps_json() - replog2 = ResultLog.from_json(data) - assert replog2.dict == replog.dict - - -def test_hosname_via_envvar(clean_hostname_envvar): - clean_hostname_envvar("toxicity") - replog = ResultLog() - assert replog.dict["host"] == "toxicity" - - -def test_addenv_setpython(pkg): - replog = ResultLog() - envlog = replog.get_envlog("py36") - envlog.set_python_info(py.path.local(sys.executable)) - envlog.set_header(installpkg=pkg) - assert envlog.dict["python"]["version_info"] == list(sys.version_info) - assert envlog.dict["python"]["version"] == sys.version - assert envlog.dict["python"]["executable"] == sys.executable - - -def test_get_commandlog(pkg): - replog = ResultLog() - envlog = replog.get_envlog("py36") - assert "setup" not in envlog.dict - setuplog = envlog.get_commandlog("setup") - envlog.set_header(installpkg=pkg) - setuplog.add_command(["virtualenv", "..."], "venv created", 0) - expected = [{"command": ["virtualenv", "..."], "output": "venv created", "retcode": 0}] - assert setuplog.list == expected - assert envlog.dict["setup"] - setuplog2 = replog.get_envlog("py36").get_commandlog("setup") - assert setuplog2.list == setuplog.list - - -@pytest.mark.parametrize("exit_code", [None, 0, 5, 128 + signal.SIGTERM, 1234, -15]) -@pytest.mark.parametrize("os_name", ["posix", "nt"]) -def test_invocation_error(exit_code, os_name, mocker, monkeypatch): - monkeypatch.setattr(os, "name", value=os_name) - mocker.spy(tox.exception, "exit_code_str") - result = str(tox.exception.InvocationError("", exit_code=exit_code)) - # check that mocker works, because it will be our only test in - # test_z_cmdline.py::test_exit_code needs the mocker.spy above - assert tox.exception.exit_code_str.call_count == 1 - call_args = tox.exception.exit_code_str.call_args - assert call_args == mocker.call("InvocationError", "", exit_code) - if exit_code is None: - assert "(exited with code" not in result - elif exit_code == -15: - assert "(exited with code -15 (SIGTERM))" in result - else: - assert "(exited with code {})".format(exit_code) in result - note = "Note: this might indicate a fatal error signal" - if (os_name == "posix") and (exit_code == 128 + signal.SIGTERM): - assert note in result - assert "({} - 128 = {}: SIGTERM)".format(exit_code, signal.SIGTERM) in result - else: - assert note not in result diff --git a/tests/unit/test_venv.py b/tests/unit/test_venv.py deleted file mode 100644 index aa78a48b5..000000000 --- a/tests/unit/test_venv.py +++ /dev/null @@ -1,1235 +0,0 @@ -import os -import sys - -import py -import pytest -from six import PY2 - -import tox -from tox.interpreters import NoInterpreterInfo -from tox.session.commands.run.sequential import installpkg, runtestenv -from tox.venv import ( - MAXINTERP, - CreationConfig, - VirtualEnv, - getdigest, - prepend_shebang_interpreter, - tox_testenv_create, - tox_testenv_install_deps, -) - - -def test_getdigest(tmpdir): - assert getdigest(tmpdir) == "0" * 32 - - -def test_getsupportedinterpreter(monkeypatch, newconfig, mocksession): - config = newconfig( - [], - """\ - [testenv:python] - basepython={} - """.format( - sys.executable, - ), - ) - mocksession.new_config(config) - venv = mocksession.getvenv("python") - interp = venv.getsupportedinterpreter() - # realpath needed for debian symlinks - assert py.path.local(interp).realpath() == py.path.local(sys.executable).realpath() - monkeypatch.setattr(tox.INFO, "IS_WIN", True) - monkeypatch.setattr(venv.envconfig, "basepython", "jython") - with pytest.raises(tox.exception.UnsupportedInterpreter): - venv.getsupportedinterpreter() - monkeypatch.undo() - monkeypatch.setattr(venv.envconfig, "envname", "py1") - monkeypatch.setattr(venv.envconfig, "basepython", "notexisting") - with pytest.raises(tox.exception.InterpreterNotFound): - venv.getsupportedinterpreter() - monkeypatch.undo() - # check that we properly report when no version_info is present - info = NoInterpreterInfo(name=venv.name) - info.executable = "something" - monkeypatch.setattr(config.interpreters, "get_info", lambda *args, **kw: info) - with pytest.raises(tox.exception.InvocationError): - venv.getsupportedinterpreter() - - -def test_create(mocksession, newconfig): - config = newconfig( - [], - """\ - [testenv:py123] - """, - ) - envconfig = config.envconfigs["py123"] - mocksession.new_config(config) - venv = mocksession.getvenv("py123") - assert venv.path == envconfig.envdir - assert not venv.path.check() - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - venv.just_created = True - pcalls = mocksession._pcalls - assert len(pcalls) >= 1 - args = pcalls[0].args - assert "virtualenv" == str(args[2]) - if not tox.INFO.IS_WIN: - # realpath is needed for stuff like the debian symlinks - our_sys_path = py.path.local(sys.executable).realpath() - assert our_sys_path == py.path.local(args[0]).realpath() - # assert Envconfig.toxworkdir in args - assert venv.getcommandpath("pip", cwd=py.path.local()) - interp = venv._getliveconfig().base_resolved_python_path - assert interp == venv.envconfig.python_info.executable - assert venv.path_config.check(exists=False) - - -@pytest.mark.parametrize("patched_venv_methodname", ["_pcall", "update"]) -def test_create_KeyboardInterrupt(mocksession, newconfig, mocker, patched_venv_methodname): - config = newconfig( - [], - """\ - [testenv:py123] - deps = pip >= 19.3.1 - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("py123") - mocker.patch.object(venv, patched_venv_methodname, side_effect=KeyboardInterrupt) - with pytest.raises(KeyboardInterrupt): - venv.setupenv() - - assert venv.status == "keyboardinterrupt" - - -def test_commandpath_venv_precedence(tmpdir, monkeypatch, mocksession, newconfig): - config = newconfig( - [], - """\ - [testenv:py123] - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("py123") - envconfig = venv.envconfig - tmpdir.ensure("pip") - monkeypatch.setenv("PATH", str(tmpdir), prepend=os.pathsep) - envconfig.envbindir.ensure("pip") - p = venv.getcommandpath("pip") - assert py.path.local(p).relto(envconfig.envbindir), p - - -def test_create_sitepackages(mocksession, newconfig): - config = newconfig( - [], - """\ - [testenv:site] - sitepackages=True - - [testenv:nosite] - sitepackages=False - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("site") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - venv.just_created = True - pcalls = mocksession._pcalls - assert len(pcalls) >= 1 - args = pcalls[0].args - assert "--system-site-packages" in map(str, args) - mocksession._clearmocks() - - venv = mocksession.getvenv("nosite") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - venv.just_created = True - pcalls = mocksession._pcalls - assert len(pcalls) >= 1 - args = pcalls[0].args - assert "--system-site-packages" not in map(str, args) - assert "--no-site-packages" not in map(str, args) - - -def test_install_deps_wildcard(newmocksession): - mocksession = newmocksession( - [], - """\ - [tox] - distshare = {toxworkdir}/distshare - [testenv:py123] - deps= - {distshare}/dep1-* - """, - ) - venv = mocksession.getvenv("py123") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - venv.just_created = True - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - distshare = venv.envconfig.config.distshare - distshare.ensure("dep1-1.0.zip") - distshare.ensure("dep1-1.1.zip") - - tox_testenv_install_deps(action=action, venv=venv) - assert len(pcalls) == 2 - args = pcalls[-1].args - assert pcalls[-1].cwd == venv.envconfig.config.toxinidir - - assert py.path.local.sysfind("python") == args[0] - assert ["-m", "pip"] == args[1:3] - assert args[3] == "install" - args = [arg for arg in args if str(arg).endswith("dep1-1.1.zip")] - assert len(args) == 1 - - -def test_install_deps_indexserver(newmocksession): - mocksession = newmocksession( - [], - """\ - [tox] - indexserver = - abc = ABC - abc2 = ABC - [testenv:py123] - deps= - dep1 - :abc:dep2 - :abc2:dep3 - """, - ) - venv = mocksession.getvenv("py123") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - venv.just_created = True - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - pcalls[:] = [] - - tox_testenv_install_deps(action=action, venv=venv) - # two different index servers, two calls - assert len(pcalls) == 3 - args = " ".join(pcalls[0].args) - assert "-i " not in args - assert "dep1" in args - - args = " ".join(pcalls[1].args) - assert "-i ABC" in args - assert "dep2" in args - args = " ".join(pcalls[2].args) - assert "-i ABC" in args - assert "dep3" in args - - -def test_install_deps_pre(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - pip_pre=true - deps= - dep1 - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - venv.just_created = True - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - pcalls[:] = [] - - tox_testenv_install_deps(action=action, venv=venv) - assert len(pcalls) == 1 - args = " ".join(pcalls[0].args) - assert "--pre " in args - assert "dep1" in args - - -def test_installpkg_indexserver(newmocksession, tmpdir): - mocksession = newmocksession( - [], - """\ - [tox] - indexserver = - default = ABC - """, - ) - venv = mocksession.getvenv("python") - pcalls = mocksession._pcalls - p = tmpdir.ensure("distfile.tar.gz") - installpkg(venv, p) - # two different index servers, two calls - assert len(pcalls) == 1 - args = " ".join(pcalls[0].args) - assert "-i ABC" in args - - -def test_install_recreate(newmocksession, tmpdir): - pkg = tmpdir.ensure("package.tar.gz") - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - deps=xyz - """, - ) - venv = mocksession.getvenv("python") - - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - installpkg(venv, pkg) - mocksession.report.expect("verbosity0", "*create*") - venv.update(action) - mocksession.report.expect("verbosity0", "*recreate*") - - -def test_install_sdist_extras(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - extras = testing - development - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - venv.just_created = True - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - pcalls[:] = [] - - venv.installpkg("distfile.tar.gz", action=action) - assert "distfile.tar.gz[testing,development]" in pcalls[-1].args - - -def test_develop_extras(newmocksession, tmpdir): - mocksession = newmocksession( - [], - """\ - [testenv] - extras = testing - development - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - venv.just_created = True - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - pcalls[:] = [] - - venv.developpkg(tmpdir, action=action) - expected = "{}[testing,development]".format(tmpdir.strpath) - assert expected in pcalls[-1].args - - -def test_env_variables_added_to_needs_reinstall(tmpdir, mocksession, newconfig, monkeypatch): - tmpdir.ensure("setup.py") - monkeypatch.setenv("TEMP_PASS_VAR", "123") - monkeypatch.setenv("TEMP_NOPASS_VAR", "456") - config = newconfig( - [], - """\ - [testenv:python] - passenv = temp_pass_var - setenv = - CUSTOM_VAR = 789 - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "hello") as action: - venv._needs_reinstall(tmpdir, action) - - pcalls = mocksession._pcalls - assert len(pcalls) == 2 - env = pcalls[0].env - - # should have access to setenv vars - assert "CUSTOM_VAR" in env - assert env["CUSTOM_VAR"] == "789" - - # should have access to passenv vars - assert "TEMP_PASS_VAR" in env - assert env["TEMP_PASS_VAR"] == "123" - - # should also have access to full invocation environment, - # for backward compatibility, and to match behavior of venv.run_install_command() - assert "TEMP_NOPASS_VAR" in env - assert env["TEMP_NOPASS_VAR"] == "456" - - -def test_test_empty_commands(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - commands = - {posargs} - echo foo bar - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - venv.test() - # The first command is empty. It should be skipped. - # Therefore, echo foo bar has index 0. - mocksession.report.expect("verbosity0", "*run-test:*commands?0? | echo foo bar") - - -def test_test_hashseed_is_in_output(newmocksession, monkeypatch): - seed = "123456789" - monkeypatch.setattr("tox.config.make_hashseed", lambda: seed) - mocksession = newmocksession([], "") - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - tox.venv.tox_runtest_pre(venv) - mocksession.report.expect("verbosity0", "run-test-pre: PYTHONHASHSEED='{}'".format(seed)) - - -def test_test_runtests_action_command_is_in_output(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - commands = echo foo bar - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - venv.test() - mocksession.report.expect("verbosity0", "*run-test:*commands?0? | echo foo bar") - - -def test_install_error(newmocksession): - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - deps=xyz - commands= - qwelkqw - """, - ) - venv = mocksession.getvenv("python") - venv.test() - mocksession.report.expect("error", "*not find*qwelkqw*") - assert venv.status == "commands failed" - - -def test_install_command_not_installed(newmocksession): - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - commands= - pytest - """, - ) - venv = mocksession.getvenv("python") - venv.status = 0 - venv.test() - mocksession.report.expect("warning", "*test command found but not*") - assert venv.status == 0 - - -def test_install_command_whitelisted(newmocksession): - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - whitelist_externals = pytest - xy* - commands= - pytest - xyz - """, - ) - venv = mocksession.getvenv("python") - venv.test() - mocksession.report.expect("warning", "*test command found but not*", invert=True) - assert venv.status == "commands failed" - - -def test_install_command_allowlisted(newmocksession): - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - allowlist_externals = pytest - xy* - commands= - pytest - xyz - """, - ) - venv = mocksession.getvenv("python") - venv.test() - mocksession.report.expect("warning", "*test command found but not*", invert=True) - assert venv.status == "commands failed" - - -def test_install_command_allowlisted_exclusive(newmocksession): - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - allowlist_externals = pytest - whitelist_externals = xy* - commands= - pytest - xyz - """, - ) - venv = mocksession.getvenv("python") - with pytest.raises(tox.exception.ConfigError): - venv.test() - - -def test_install_command_not_installed_bash(newmocksession): - mocksession = newmocksession( - ["--recreate"], - """\ - [testenv] - commands= - bash - """, - ) - venv = mocksession.getvenv("python") - venv.test() - mocksession.report.expect("warning", "*test command found but not*") - - -def test_install_python3(newmocksession): - if not py.path.local.sysfind("python3") or tox.INFO.IS_PYPY: - pytest.skip("needs cpython3") - mocksession = newmocksession( - [], - """\ - [testenv:py123] - basepython=python3 - deps= - dep1 - dep2 - """, - ) - venv = mocksession.getvenv("py123") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - venv.just_created = True - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - args = pcalls[0].args - assert str(args[2]) == "virtualenv" - pcalls[:] = [] - with mocksession.newaction(venv.name, "hello") as action: - venv._install(["hello"], action=action) - assert len(pcalls) == 1 - args = pcalls[0].args - assert py.path.local.sysfind("python") == args[0] - assert ["-m", "pip"] == args[1:3] - for _ in args: - assert "--download-cache" not in args, args - - -class TestCreationConfig: - def test_basic(self, newconfig, mocksession, tmpdir): - config = newconfig([], "") - mocksession.new_config(config) - venv = mocksession.getvenv("python") - cconfig = venv._getliveconfig() - assert cconfig.matches(cconfig) - path = tmpdir.join("configdump") - cconfig.writeconfig(path) - newconfig = CreationConfig.readconfig(path) - assert newconfig.matches(cconfig) - assert cconfig.matches(newconfig) - - def test_matchingdependencies(self, newconfig, mocksession): - config = newconfig( - [], - """\ - [testenv] - deps=abc - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("python") - cconfig = venv._getliveconfig() - config = newconfig( - [], - """\ - [testenv] - deps=xyz - """, - ) - mocksession.new_config(config) - venv = mocksession.getvenv("python") - otherconfig = venv._getliveconfig() - assert not cconfig.matches(otherconfig) - - def test_matchingdependencies_file(self, newconfig, mocksession): - config = newconfig( - [], - """\ - [tox] - distshare={toxworkdir}/distshare - [testenv] - deps=abc - {distshare}/xyz.zip - """, - ) - xyz = config.distshare.join("xyz.zip") - xyz.ensure() - mocksession.new_config(config) - venv = mocksession.getvenv("python") - cconfig = venv._getliveconfig() - assert cconfig.matches(cconfig) - xyz.write("hello") - newconfig = venv._getliveconfig() - assert not cconfig.matches(newconfig) - - def test_matchingdependencies_latest(self, newconfig, mocksession): - config = newconfig( - [], - """\ - [tox] - distshare={toxworkdir}/distshare - [testenv] - deps={distshare}/xyz-* - """, - ) - config.distshare.ensure("xyz-1.2.0.zip") - xyz2 = config.distshare.ensure("xyz-1.2.1.zip") - mocksession.new_config(config) - venv = mocksession.getvenv("python") - cconfig = venv._getliveconfig() - sha256, path = cconfig.deps[0] - assert path == xyz2 - assert sha256 == path.computehash("sha256") - - def test_python_recreation(self, tmpdir, newconfig, mocksession): - pkg = tmpdir.ensure("package.tar.gz") - config = newconfig(["-v"], "") - mocksession.new_config(config) - venv = mocksession.getvenv("python") - create_config = venv._getliveconfig() - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - assert not venv.path_config.check() - installpkg(venv, pkg) - assert venv.path_config.check() - assert mocksession._pcalls - args1 = map(str, mocksession._pcalls[0].args) - assert "virtualenv" in " ".join(args1) - mocksession.report.expect("*", "*create*") - # modify config and check that recreation happens - mocksession._clearmocks() - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - mocksession.report.expect("*", "*reusing*") - mocksession._clearmocks() - with mocksession.newaction(venv.name, "update") as action: - create_config.base_resolved_python_path = py.path.local("balla") - create_config.writeconfig(venv.path_config) - venv.update(action) - mocksession.report.expect("verbosity0", "*recreate*") - - def test_dep_recreation(self, newconfig, mocksession): - config = newconfig([], "") - mocksession.new_config(config) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - cconfig = venv._getliveconfig() - cconfig.deps[:] = [("1" * 32, "xyz.zip")] - cconfig.writeconfig(venv.path_config) - mocksession._clearmocks() - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - mocksession.report.expect("*", "*recreate*") - - def test_develop_recreation(self, newconfig, mocksession): - config = newconfig([], "") - mocksession.new_config(config) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - cconfig = venv._getliveconfig() - cconfig.usedevelop = True - cconfig.writeconfig(venv.path_config) - mocksession._clearmocks() - with mocksession.newaction(venv.name, "update") as action: - venv.update(action) - mocksession.report.expect("verbosity0", "*recreate*") - - -class TestVenvTest: - def test_envbindir_path(self, newmocksession, monkeypatch): - monkeypatch.setenv("PIP_RESPECT_VIRTUALENV", "1") - mocksession = newmocksession( - [], - """\ - [testenv:python] - commands=abc - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - monkeypatch.setenv("PATH", "xyz") - sysfind_calls = [] - monkeypatch.setattr( - "py.path.local.sysfind", - classmethod(lambda *args, **kwargs: sysfind_calls.append(kwargs) or 0 / 0), - ) - - with pytest.raises(ZeroDivisionError): - venv._install(list("123"), action=action) - assert sysfind_calls.pop()["paths"] == [venv.envconfig.envbindir] - with pytest.raises(ZeroDivisionError): - venv.test(action) - assert sysfind_calls.pop()["paths"] == [venv.envconfig.envbindir] - with pytest.raises(ZeroDivisionError): - venv.run_install_command(["qwe"], action=action) - assert sysfind_calls.pop()["paths"] == [venv.envconfig.envbindir] - monkeypatch.setenv("PIP_RESPECT_VIRTUALENV", "1") - monkeypatch.setenv("PIP_REQUIRE_VIRTUALENV", "1") - monkeypatch.setenv("__PYVENV_LAUNCHER__", "1") - - prev_pcall = venv._pcall - - def collect(*args, **kwargs): - env = kwargs["env"] - assert "PIP_RESPECT_VIRTUALENV" not in env - assert "PIP_REQUIRE_VIRTUALENV" not in env - assert "__PYVENV_LAUNCHER__" not in env - assert env["PIP_USER"] == "0" - assert env["PIP_NO_DEPS"] == "0" - return prev_pcall(*args, **kwargs) - - monkeypatch.setattr(venv, "_pcall", collect) - with pytest.raises(ZeroDivisionError): - venv.run_install_command(["qwe"], action=action) - - def test_pythonpath_remove(self, newmocksession, monkeypatch, caplog): - monkeypatch.setenv("PYTHONPATH", "/my/awesome/library") - mocksession = newmocksession( - [], - """\ - [testenv:python] - commands=abc - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - venv.run_install_command(["qwe"], action=action) - mocksession.report.expect("warning", "*Discarding $PYTHONPATH from environment*") - - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - assert "PYTHONPATH" not in pcalls[0].env - - def test_pythonpath_keep(self, newmocksession, monkeypatch, caplog): - # passenv = PYTHONPATH allows PYTHONPATH to stay in environment - monkeypatch.setenv("PYTHONPATH", "/my/awesome/library") - mocksession = newmocksession( - [], - """\ - [testenv:python] - commands=abc - passenv = PYTHONPATH - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - venv.run_install_command(["qwe"], action=action) - mocksession.report.not_expect("warning", "*Discarding $PYTHONPATH from environment*") - assert "PYTHONPATH" in os.environ - - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - assert pcalls[0].env["PYTHONPATH"] == "/my/awesome/library" - - def test_pythonpath_empty(self, newmocksession, monkeypatch, caplog): - monkeypatch.setenv("PYTHONPATH", "") - mocksession = newmocksession( - [], - """\ - [testenv:python] - commands=abc - """, - ) - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - venv.run_install_command(["qwe"], action=action) - if sys.version_info < (3, 4): - mocksession.report.expect("warning", "*Discarding $PYTHONPATH from environment*") - else: - with pytest.raises(AssertionError): - mocksession.report.expect("warning", "*Discarding $PYTHONPATH from environment*") - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - assert "PYTHONPATH" not in pcalls[0].env - - -def test_env_variables_added_to_pcall(tmpdir, mocksession, newconfig, monkeypatch, tmp_path): - monkeypatch.delenv("PYTHONPATH", raising=False) - pkg = tmpdir.ensure("package.tar.gz") - monkeypatch.setenv("X123", "123") - monkeypatch.setenv("YY", "456") - env_path = tmp_path / ".env" - env_file_content = "ENV_FILE_VAR = file_value" - env_path.write_text(env_file_content.decode() if PY2 else env_file_content) - - config = newconfig( - [], - r""" - [base] - base_var = base_value - - [testenv:python] - commands=python -V - passenv = x123 - setenv = - ENV_VAR = value - ESCAPED_VAR = \{value\} - ESCAPED_VAR2 = \\{value\\} - BASE_VAR = {[base]base_var} - PYTHONPATH = value - TTY_VAR = {tty:ON_VALUE:OFF_VALUE} - COLON = {:} - REUSED_FILE_VAR = reused {env:ENV_FILE_VAR} - file| %s - """ - % env_path, - ) - mocksession._clearmocks() - mocksession.new_config(config) - venv = mocksession.getvenv("python") - installpkg(venv, pkg) - venv.test() - - pcalls = mocksession._pcalls - assert len(pcalls) == 2 - for x in pcalls: - env = x.env - assert env is not None - assert "ENV_VAR" in env - assert env["ENV_VAR"] == "value" - assert env["ESCAPED_VAR"] == "{value}" - assert env["ESCAPED_VAR2"] == r"\{value\}" - assert env["COLON"] == ";" if sys.platform == "win32" else ":" - assert env["TTY_VAR"] == "OFF_VALUE" - assert env["ENV_FILE_VAR"] == "file_value" - assert env["REUSED_FILE_VAR"] == "reused file_value" - assert env["BASE_VAR"] == "base_value" - assert env["VIRTUAL_ENV"] == str(venv.path) - assert env["X123"] == "123" - assert "PYTHONPATH" in env - assert env["PYTHONPATH"] == "value" - - # all env variables are passed for installation - assert pcalls[0].env["YY"] == "456" - assert "YY" not in pcalls[1].env - - assert {"ENV_VAR", "VIRTUAL_ENV", "PYTHONHASHSEED", "X123", "PATH"}.issubset(pcalls[1].env) - - # setenv does not trigger PYTHONPATH warnings - mocksession.report.not_expect("warning", "*Discarding $PYTHONPATH from environment*") - - # for e in os.environ: - # assert e in env - - -def test_installpkg_no_upgrade(tmpdir, newmocksession): - pkg = tmpdir.ensure("package.tar.gz") - mocksession = newmocksession([], "") - venv = mocksession.getvenv("python") - venv.just_created = True - venv.envconfig.envdir.ensure(dir=1) - installpkg(venv, pkg) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - assert pcalls[0].args[1:-1] == ["-m", "pip", "install", "--exists-action", "w"] - - -@pytest.mark.parametrize("count, level", [(0, 0), (1, 0), (2, 0), (3, 1), (4, 2), (5, 3), (6, 3)]) -def test_install_command_verbosity(tmpdir, newmocksession, count, level): - pkg = tmpdir.ensure("package.tar.gz") - mock_session = newmocksession(["-{}".format("v" * count)], "") - env = mock_session.getvenv("python") - env.just_created = True - env.envconfig.envdir.ensure(dir=1) - installpkg(env, pkg) - pcalls = mock_session._pcalls - assert len(pcalls) == 1 - expected = ["-m", "pip", "install", "--exists-action", "w"] + (["-v"] * level) - assert pcalls[0].args[1:-1] == expected - - -def test_installpkg_upgrade(newmocksession, tmpdir): - pkg = tmpdir.ensure("package.tar.gz") - mocksession = newmocksession([], "") - venv = mocksession.getvenv("python") - assert not hasattr(venv, "just_created") - installpkg(venv, pkg) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - index = pcalls[0].args.index(pkg.basename) - assert index >= 0 - assert "-U" in pcalls[0].args[:index] - assert "--no-deps" in pcalls[0].args[:index] - - -def test_run_install_command(newmocksession): - mocksession = newmocksession([], "") - venv = mocksession.getvenv("python") - venv.just_created = True - venv.envconfig.envdir.ensure(dir=1) - with mocksession.newaction(venv.name, "hello") as action: - venv.run_install_command(packages=["whatever"], action=action) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - args = pcalls[0].args - assert py.path.local.sysfind("python") == args[0] - assert ["-m", "pip"] == args[1:3] - assert "install" in args - env = pcalls[0].env - assert env is not None - - -def test_run_custom_install_command(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - install_command=cool-installer {opts} {packages} - """, - ) - venv = mocksession.getvenv("python") - venv.just_created = True - venv.envconfig.envdir.ensure(dir=1) - venv.envconfig.envbindir.ensure("cool-installer") - with mocksession.newaction(venv.name, "hello") as action: - venv.run_install_command(packages=["whatever"], action=action) - pcalls = mocksession._pcalls - assert len(pcalls) == 1 - assert "cool-installer" in pcalls[0].args[0] - assert pcalls[0].args[1:] == ["whatever"] - - -def test_run_install_command_handles_KeyboardInterrupt(newmocksession, mocker): - mocksession = newmocksession([], "") - venv = mocksession.getvenv("python") - venv.just_created = True - venv.envconfig.envdir.ensure(dir=1) - - mocker.patch.object(venv, "_pcall", side_effect=KeyboardInterrupt) - with mocksession.newaction(venv.name, "hello") as action: - with pytest.raises(KeyboardInterrupt): - venv.run_install_command(packages=["whatever"], action=action) - - assert venv.status == "keyboardinterrupt" - - -def test_command_relative_issue36(newmocksession, tmpdir, monkeypatch): - mocksession = newmocksession( - [], - """\ - [testenv] - """, - ) - x = tmpdir.ensure("x") - venv = mocksession.getvenv("python") - x2 = venv.getcommandpath("./x", cwd=tmpdir) - assert x == x2 - mocksession.report.not_expect("warning", "*test command found but not*") - x3 = venv.getcommandpath("/bin/bash", cwd=tmpdir) - assert x3 == "/bin/bash" - mocksession.report.not_expect("warning", "*test command found but not*") - monkeypatch.setenv("PATH", str(tmpdir)) - x4 = venv.getcommandpath("x", cwd=tmpdir) - assert x4.endswith(os.sep + "x") - mocksession.report.expect("warning", "*test command found but not*") - - -def test_ignore_outcome_failing_cmd(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - commands=testenv_fail - ignore_outcome=True - """, - ) - - venv = mocksession.getvenv("python") - venv.test() - assert venv.status == "ignored failed command" - mocksession.report.expect("warning", "*command failed but result from testenv is ignored*") - - -def test_ignore_outcome_missing_cmd(newmocksession): - mocksession = newmocksession( - [], - """\ - [testenv] - commands=-thiscommanddoesntexist - """, - ) - - venv = mocksession.getvenv("python") - venv.test() - assert venv.status == 0 - mocksession.report.expect("warning", "*command not found but explicitly ignored*") - - -def test_tox_testenv_create(newmocksession): - log = [] - - class Plugin: - @tox.hookimpl - def tox_testenv_create(self, action, venv): - assert isinstance(action, tox.session.Action) - assert isinstance(venv, VirtualEnv) - log.append(1) - - @tox.hookimpl - def tox_testenv_install_deps(self, action, venv): - assert isinstance(action, tox.session.Action) - assert isinstance(venv, VirtualEnv) - log.append(2) - - mocksession = newmocksession( - [], - """\ - [testenv] - commands=testenv_fail - ignore_outcome=True - """, - plugins=[Plugin()], - ) - - venv = mocksession.getvenv("python") - with mocksession.newaction(venv.name, "getenv") as action: - venv.update(action=action) - assert log == [1, 2] - - -def test_tox_testenv_pre_post(newmocksession): - log = [] - - class Plugin: - @tox.hookimpl - def tox_runtest_pre(self): - log.append("started") - - @tox.hookimpl - def tox_runtest_post(self): - log.append("finished") - - mocksession = newmocksession( - [], - """\ - [testenv] - commands=testenv_fail - """, - plugins=[Plugin()], - ) - - venv = mocksession.getvenv("python") - venv.status = None - assert log == [] - runtestenv(venv, venv.envconfig.config) - assert log == ["started", "finished"] - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_empty_instance(tmpdir): - testfile = tmpdir.join("check_shebang_empty_instance.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # empty instance - testfile.write("") - args = prepend_shebang_interpreter(base_args) - assert args == base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_empty_interpreter(tmpdir): - testfile = tmpdir.join("check_shebang_empty_interpreter.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # empty interpreter - testfile.write("#!") - args = prepend_shebang_interpreter(base_args) - assert args == base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_empty_interpreter_ws(tmpdir): - testfile = tmpdir.join("check_shebang_empty_interpreter_ws.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # empty interpreter (whitespaces) - testfile.write("#! \n") - args = prepend_shebang_interpreter(base_args) - assert args == base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_non_utf8(tmpdir): - testfile = tmpdir.join("check_non_utf8.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - testfile.write_binary(b"#!\x9a\xef\x12\xaf\n") - args = prepend_shebang_interpreter(base_args) - assert args == base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_interpreter_simple(tmpdir): - testfile = tmpdir.join("check_shebang_interpreter_simple.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter (simple) - testfile.write("#!interpreter") - args = prepend_shebang_interpreter(base_args) - assert args == ["interpreter"] + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_interpreter_ws(tmpdir): - testfile = tmpdir.join("check_shebang_interpreter_ws.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter (whitespaces) - testfile.write("#! interpreter \n\n") - args = prepend_shebang_interpreter(base_args) - assert args == ["interpreter"] + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_interpreter_arg(tmpdir): - testfile = tmpdir.join("check_shebang_interpreter_arg.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter with argument - testfile.write("#!interpreter argx\n") - args = prepend_shebang_interpreter(base_args) - assert args == ["interpreter", "argx"] + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_interpreter_args(tmpdir): - testfile = tmpdir.join("check_shebang_interpreter_args.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter with argument (ensure single argument) - testfile.write("#!interpreter argx argx-part2\n") - args = prepend_shebang_interpreter(base_args) - assert args == ["interpreter", "argx argx-part2"] + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_real(tmpdir): - testfile = tmpdir.join("check_shebang_real.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter (real example) - testfile.write("#!/usr/bin/env python\n") - args = prepend_shebang_interpreter(base_args) - assert args == ["/usr/bin/env", "python"] + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_long_example(tmpdir): - testfile = tmpdir.join("check_shebang_long_example.py") - base_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter (long example) - testfile.write( - "#!this-is-an-example-of-a-very-long-interpret-directive-what-should-" - "be-directly-invoked-when-tox-needs-to-invoked-the-provided-script-" - "name-in-the-argument-list", - ) - args = prepend_shebang_interpreter(base_args) - expected = [ - "this-is-an-example-of-a-very-long-interpret-directive-what-should-be-" - "directly-invoked-when-tox-needs-to-invoked-the-provided-script-name-" - "in-the-argument-list", - ] - - assert args == expected + base_args - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no shebang on Windows") -def test_tox_testenv_interpret_shebang_skip_truncated(tmpdir): - testfile = tmpdir.join("check_shebang_truncation.py") - original_args = [str(testfile), "arg1", "arg2", "arg3"] - - # interpreter (too long example) - testfile.write("#!" + ("x" * (MAXINTERP + 1))) - args = prepend_shebang_interpreter(original_args) - - assert args == original_args - - -@pytest.mark.parametrize("download", [True, False, None]) -def test_create_download(mocksession, newconfig, download): - config = newconfig( - [], - """\ - [testenv:env] - {} - """.format( - "download={}".format(download) if download else "", - ), - ) - mocksession.new_config(config) - venv = mocksession.getvenv("env") - with mocksession.newaction(venv.name, "getenv") as action: - tox_testenv_create(action=action, venv=venv) - venv.just_created = True - pcalls = mocksession._pcalls - assert len(pcalls) >= 1 - args = pcalls[0].args - if download is True: - assert "--no-download" not in map(str, args) - else: - assert "--no-download" in map(str, args) - mocksession._clearmocks() - - -def test_path_change(tmpdir, mocksession, newconfig, monkeypatch): - config = newconfig( - [], - """\ - [testenv:python] - setenv = - PATH = {env:PATH}{:}{toxinidir}/bin - """, - ) - pkg = tmpdir.ensure("package.tar.gz") - mocksession._clearmocks() - mocksession.new_config(config) - venv = mocksession.getvenv("python") - installpkg(venv, pkg) - venv.test() - - pcalls = mocksession._pcalls - for x in pcalls: - path = x.env["PATH"] - assert os.environ["PATH"] in path - assert path.endswith(str(venv.envconfig.config.toxinidir) + "/bin") diff --git a/tests/unit/test_z_cmdline.py b/tests/unit/test_z_cmdline.py deleted file mode 100644 index c0549c0ea..000000000 --- a/tests/unit/test_z_cmdline.py +++ /dev/null @@ -1,1153 +0,0 @@ -import json -import os -import re -import shutil -import subprocess -import sys -import tempfile - -if sys.version_info[:2] >= (3, 4): - import pathlib -else: - import pathlib2 as pathlib -import py -import pytest - -import tox -from tox.config import parseconfig -from tox.reporter import Verbosity -from tox.session import Session - -pytest_plugins = "pytester" - - -class TestSession: - def test_log_pcall(self, mocksession): - mocksession.logging_levels(quiet=Verbosity.DEFAULT, verbose=Verbosity.INFO) - mocksession.config.logdir.ensure(dir=1) - assert not mocksession.config.logdir.listdir() - with mocksession.newaction("what", "something") as action: - action.popen(["echo"]) - match = mocksession.report.getnext("logpopen") - log_name = py.path.local(match[1].split(">")[-1].strip()).relto( - mocksession.config.logdir, - ) - assert log_name == "what-0.log" - - def test_summary_status(self, initproj, capfd): - initproj( - "logexample123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:hello] - [testenv:world] - """, - }, - ) - config = parseconfig([]) - session = Session(config) - envs = list(session.venv_dict.values()) - assert len(envs) == 2 - env1, env2 = envs - env1.status = "FAIL XYZ" - assert env1.status - env2.status = 0 - assert not env2.status - session._summary() - out, err = capfd.readouterr() - exp = "{}: FAIL XYZ".format(env1.envconfig.envname) - assert exp in out - exp = "{}: commands succeeded".format(env2.envconfig.envname) - assert exp in out - - def test_getvenv(self, initproj): - initproj( - "logexample123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:hello] - [testenv:world] - """, - }, - ) - config = parseconfig([]) - session = Session(config) - venv1 = session.getvenv("hello") - venv2 = session.getvenv("hello") - assert venv1 is venv2 - venv1 = session.getvenv("world") - venv2 = session.getvenv("world") - assert venv1 is venv2 - with pytest.raises(LookupError): - session.getvenv("qwe") - - -def test_notoxini_help_still_works(initproj, cmd): - initproj("example123-0.5", filedefs={"tests": {"test_hello.py": "def test_hello(): pass"}}) - result = cmd("-h") - assert result.out.startswith("usage: ") - assert any("--help" in line for line in result.outlines), result.outlines - result.assert_success(is_run_test_env=False) - - -def test_notoxini_noerror_in_help(initproj, cmd): - initproj("examplepro", filedefs={}) - result = cmd("-h") - msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" - assert result.err != msg - - -def test_notoxini_help_ini_still_works(initproj, cmd): - initproj("example123-0.5", filedefs={"tests": {"test_hello.py": "def test_hello(): pass"}}) - result = cmd("--help-ini") - assert any("setenv" in line for line in result.outlines), result.outlines - result.assert_success(is_run_test_env=False) - - -def test_notoxini_noerror_in_help_ini(initproj, cmd): - initproj("examplepro", filedefs={}) - result = cmd("--help-ini") - msg = "ERROR: tox config file (either pyproject.toml, tox.ini, setup.cfg) not found\n" - assert result.err != msg - - -def test_unrecognized_arguments_error(initproj, cmd): - initproj( - "examplepro1", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:hello] - [testenv:world] - """, - }, - ) - result1 = cmd("--invalid-argument") - withtoxini = result1.err - initproj("examplepro2", filedefs={}) - result2 = cmd("--invalid-argument") - notoxini = result2.err - assert withtoxini == notoxini - - -def test_envdir_equals_toxini_errors_out(cmd, initproj): - initproj( - "interp123-0.7", - filedefs={ - "tox.ini": """ - [testenv] - envdir={toxinidir} - """, - }, - ) - result = cmd() - assert result.outlines[1] == "ERROR: ConfigError: envdir must not equal toxinidir" - assert re.match( - r"ERROR: venv \'python\' in .* would delete project", - result.outlines[0], - ), result.outlines[0] - result.assert_fail() - - -def test_envdir_would_delete_some_directory(cmd, initproj): - projdir = initproj( - "example-123", - filedefs={ - "tox.ini": """\ - [tox] - - [testenv:venv] - envdir=example - commands= - """, - }, - ) - - result = cmd("-e", "venv") - assert projdir.join("example/__init__.py").exists() - result.assert_fail() - assert "cowardly refusing to delete `envdir`" in result.out - - -def test_recreate(cmd, initproj): - initproj("example-123", filedefs={"tox.ini": ""}) - cmd("-e", "py", "--notest").assert_success() - cmd("-r", "-e", "py", "--notest").assert_success() - - -def test_run_custom_install_command_error(cmd, initproj): - initproj( - "interp123-0.5", - filedefs={ - "tox.ini": """ - [testenv] - install_command=./tox.ini {opts} {packages} - """, - }, - ) - result = cmd() - result.assert_fail() - re.match( - r"ERROR: python: InvocationError for command .* \(exited with code \d+\)", - result.outlines[-1], - ), result.out - - -def test_unknown_interpreter_and_env(cmd, initproj): - initproj( - "interp123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """\ - [testenv:python] - basepython=xyz_unknown_interpreter - [testenv] - changedir=tests - skip_install = true - """, - }, - ) - result = cmd() - result.assert_fail() - assert "ERROR: InterpreterNotFound: xyz_unknown_interpreter" in result.outlines - - result = cmd("-exyz") - result.assert_fail() - assert result.out == "ERROR: unknown environment 'xyz'\n" - - -def test_unknown_interpreter_factor(cmd, initproj): - initproj("py21", filedefs={"tox.ini": "[testenv]\nskip_install=true"}) - result = cmd("-e", "py21") - result.assert_fail() - assert "ERROR: InterpreterNotFound: python2.1" in result.outlines - - -def test_unknown_interpreter(cmd, initproj): - initproj( - "interp123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:python] - basepython=xyz_unknown_interpreter - [testenv] - changedir=tests - """, - }, - ) - result = cmd() - result.assert_fail() - assert any( - "ERROR: InterpreterNotFound: xyz_unknown_interpreter" == line for line in result.outlines - ), result.outlines - - -def test_skip_platform_mismatch(cmd, initproj): - initproj( - "interp123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv] - changedir=tests - platform=x123 - """, - }, - ) - result = cmd() - result.assert_success() - assert any( - "SKIPPED: python: platform mismatch ({!r} does not match 'x123')".format(sys.platform) - == line - for line in result.outlines - ), result.outlines - - -def test_skip_unknown_interpreter(cmd, initproj): - initproj( - "interp123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:python] - basepython=xyz_unknown_interpreter - [testenv] - changedir=tests - """, - }, - ) - result = cmd("--skip-missing-interpreters") - result.assert_success() - msg = "SKIPPED: python: InterpreterNotFound: xyz_unknown_interpreter" - assert any(msg == line for line in result.outlines), result.outlines - - -def test_skip_unknown_interpreter_result_json(cmd, initproj, tmpdir): - report_path = tmpdir.join("toxresult.json") - initproj( - "interp123-0.5", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv:python] - basepython=xyz_unknown_interpreter - [testenv] - changedir=tests - """, - }, - ) - result = cmd("--skip-missing-interpreters", "--result-json", report_path) - result.assert_success() - msg = "SKIPPED: python: InterpreterNotFound: xyz_unknown_interpreter" - assert any(msg == line for line in result.outlines), result.outlines - setup_result_from_json = json.load(report_path)["testenvs"]["python"]["setup"] - for setup_step in setup_result_from_json: - assert "InterpreterNotFound" in setup_step["output"] - assert setup_step["retcode"] == 0 - - -def test_unknown_dep(cmd, initproj): - initproj( - "dep123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [testenv] - deps=qweqwe123 - changedir=tests - """, - }, - ) - result = cmd() - result.assert_fail() - assert result.outlines[-1].startswith("ERROR: python: could not install deps [qweqwe123];") - - -def test_venv_special_chars_issue252(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "tox.ini": """ - [tox] - envlist = special&&1 - [testenv:special&&1] - changedir=tests - """, - }, - ) - result = cmd() - result.assert_success() - pattern = re.compile(r"special&&1 installed: .*pkg123( @ .*-|==)0\.7(\.zip)?.*") - assert any(pattern.match(line) for line in result.outlines), "\n".join(result.outlines) - - -def test_unknown_environment(cmd, initproj): - initproj("env123-0.7", filedefs={"tox.ini": ""}) - result = cmd("-e", "qpwoei") - result.assert_fail() - assert result.out == "ERROR: unknown environment 'qpwoei'\n" - - -def test_unknown_environment_with_envlist(cmd, initproj): - initproj( - "pkg123", - filedefs={ - "tox.ini": """ - [tox] - envlist = py{36,37}-django{20,21} - """, - }, - ) - result = cmd("-e", "py36-djagno21") - result.assert_fail() - assert result.out == "ERROR: unknown environment 'py36-djagno21'\n" - - -def test_minimal_setup_py_empty(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """ - """, - "tox.ini": "", - }, - ) - result = cmd() - result.assert_fail() - assert result.outlines[-1] == "ERROR: setup.py is empty" - - -def test_minimal_setup_py_comment_only(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """\n# some comment - - """, - "tox.ini": "", - }, - ) - result = cmd() - result.assert_fail() - assert result.outlines[-1] == "ERROR: setup.py is empty" - - -def test_minimal_setup_py_non_functional(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """ - import sys - - """, - "tox.ini": "", - }, - ) - result = cmd() - result.assert_fail() - assert any( - re.match(r".*ERROR.*check setup.py.*", line) for line in result.outlines - ), result.outlines - - -def test_sdist_fails(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """ - syntax error - """, - "tox.ini": "", - }, - ) - result = cmd() - result.assert_fail() - assert any( - re.match(r".*FAIL.*could not package project.*", line) for line in result.outlines - ), result.outlines - - -def test_no_setup_py_exits(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [testenv] - commands=python -c "2 + 2" - """, - }, - ) - os.remove("setup.py") - result = cmd() - result.assert_fail() - assert any( - re.match(r".*ERROR.*No pyproject.toml or setup.py file found.*", line) - for line in result.outlines - ), result.outlines - - -def test_no_setup_py_exits_but_pyproject_toml_does(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tox.ini": """ - [testenv] - commands=python -c "2 + 2" - """, - }, - ) - os.remove("setup.py") - pathlib.Path("pyproject.toml").touch() - result = cmd() - result.assert_fail() - assert any( - re.match(r".*ERROR.*pyproject.toml file found.*", line) for line in result.outlines - ), result.outlines - assert any( - re.match(r".*To use a PEP 517 build-backend you are required to*", line) - for line in result.outlines - ), result.outlines - - -def test_package_install_fails(cmd, initproj): - initproj( - "pkg123-0.7", - filedefs={ - "tests": {"test_hello.py": "def test_hello(): pass"}, - "setup.py": """ - from setuptools import setup - setup( - name='pkg123', - description='pkg123 project', - version='0.7', - license='MIT', - platforms=['unix', 'win32'], - packages=['pkg123',], - install_requires=['qweqwe123'], - ) - """, - "tox.ini": "", - }, - ) - result = cmd() - result.assert_fail() - assert result.outlines[-1].startswith("ERROR: python: InvocationError for command ") - - -@pytest.fixture -def example123(initproj): - yield initproj( - "example123-0.5", - filedefs={ - "tests": { - "test_hello.py": """ - def test_hello(pytestconfig): - pass - """, - }, - "tox.ini": """ - [testenv] - changedir=tests - commands= pytest --basetemp={envtmpdir} \ - --junitxml=junit-{envname}.xml - deps=pytest - """, - }, - ) - - -def test_toxuone_env(cmd, example123): - result = cmd() - result.assert_success() - assert re.match( - r".*generated\W+xml\W+file.*junit-python\.xml" r".*\W+1\W+passed.*", - result.out, - re.DOTALL, - ) - result = cmd("-epython") - result.assert_success() - assert re.match( - r".*\W+1\W+passed.*" r"summary.*" r"python:\W+commands\W+succeeded.*", - result.out, - re.DOTALL, - ) - - -def test_different_config_cwd(cmd, example123): - # see that things work with a different CWD - with example123.dirpath().as_cwd(): - result = cmd("-c", "example123/tox.ini") - result.assert_success() - assert re.match( - r".*\W+1\W+passed.*" r"summary.*" r"python:\W+commands\W+succeeded.*", - result.out, - re.DOTALL, - ) - - -def test_result_json(cmd, initproj, example123): - cwd = initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - deps = setuptools - commands_pre = python -c 'print("START")' - commands = python -c 'print("OK")' - - python -c 'print("1"); raise SystemExit(1)' - python -c 'print("1"); raise SystemExit(2)' - python -c 'print("SHOULD NOT HAPPEN")' - commands_post = python -c 'print("END")' - """, - }, - ) - json_path = cwd / "res.json" - result = cmd("--result-json", json_path) - result.assert_fail() - data = json.loads(json_path.read_text(encoding="utf-8")) - - assert data["reportversion"] == "1" - assert data["toxversion"] == tox.__version__ - - for env_data in data["testenvs"].values(): - for command_type in ("setup", "test"): - if command_type not in env_data: - assert False, "missing {}".format(command_type) - for command in env_data[command_type]: - assert isinstance(command["command"], list) - assert command["output"] - assert "retcode" in command - assert isinstance(command["retcode"], int) - # virtualenv, deps install, package install, freeze - assert len(env_data["setup"]) == 4 - # 1 pre + 3 command + 1 post - assert len(env_data["test"]) == 5 - assert isinstance(env_data["installed_packages"], list) - pyinfo = env_data["python"] - assert isinstance(pyinfo["version_info"], list) - assert pyinfo["version"] - assert pyinfo["executable"] - assert "write json report at: {}".format(json_path) == result.outlines[-1] - - -def test_developz(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - """, - }, - ) - result = cmd("-vv", "--develop") - result.assert_success() - assert "sdist-make" not in result.out - - -def test_usedevelop(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - usedevelop=True - """, - }, - ) - result = cmd("-vv") - result.assert_success() - assert "sdist-make" not in result.out - - -def test_usedevelop_mixed(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv:dev] - usedevelop=True - [testenv:nondev] - usedevelop=False - """, - }, - ) - - # running only 'dev' should not do sdist - result = cmd("-vv", "-e", "dev") - result.assert_success() - assert "sdist-make" not in result.out - - # running all envs should do sdist - result = cmd("-vv") - result.assert_success() - assert "sdist-make" in result.out - - -@pytest.mark.parametrize("skipsdist", [False, True]) -@pytest.mark.parametrize("src_root", [".", "src"]) -def test_test_usedevelop(cmd, initproj, src_root, skipsdist): - name = "example123-spameggs" - base = initproj( - (name, "0.5"), - src_root=src_root, - filedefs={ - "tests": { - "test_hello.py": """ - def test_hello(pytestconfig): - pass - """, - }, - "tox.ini": """ - [testenv] - usedevelop=True - changedir=tests - commands= - pytest --basetemp={envtmpdir} --junitxml=junit-{envname}.xml [] - deps=pytest""" - + """ - skipsdist={} - """.format( - skipsdist, - ), - }, - ) - result = cmd("-v") - result.assert_success() - assert re.match( - r".*generated\W+xml\W+file.*junit-python\.xml" r".*\W+1\W+passed.*", - result.out, - re.DOTALL, - ) - assert "sdist-make" not in result.out - result = cmd("-epython") - result.assert_success() - assert "develop-inst-noop" in result.out - assert re.match( - r".*\W+1\W+passed.*" r"summary.*" r"python:\W+commands\W+succeeded.*", - result.out, - re.DOTALL, - ) - - # see that things work with a different CWD - with base.dirpath().as_cwd(): - result = cmd("-c", "{}/tox.ini".format(name)) - result.assert_success() - assert "develop-inst-noop" in result.out - assert re.match( - r".*\W+1\W+passed.*" r"summary.*" r"python:\W+commands\W+succeeded.*", - result.out, - re.DOTALL, - ) - - # see that tests can also fail and retcode is correct - testfile = py.path.local("tests").join("test_hello.py") - assert testfile.check() - testfile.write("def test_fail(): assert 0") - result = cmd() - result.assert_fail() - assert "develop-inst-noop" in result.out - assert re.match( - r".*\W+1\W+failed.*" r"summary.*" r"python:\W+commands\W+failed.*", - result.out, - re.DOTALL, - ) - - # test develop is called if setup.py changes - setup_py = py.path.local("setup.py") - setup_py.write(setup_py.read() + " ") - result = cmd() - result.assert_fail() - assert "develop-inst-nodeps" in result.out - - -def test_warning_emitted(cmd, initproj): - initproj( - "spam-0.0.1", - filedefs={ - "tox.ini": """ - [testenv] - skipsdist=True - usedevelop=True - """, - "setup.py": """ - from setuptools import setup - from warnings import warn - warn("I am a warning") - - setup(name="spam", version="0.0.1") - """, - }, - ) - cmd() - result = cmd() - assert "develop-inst-noop" in result.out - assert "I am a warning" in result.err - - -def _alwayscopy_not_supported(): - # This is due to virtualenv bugs with alwayscopy in some platforms - # see: https://github.com/pypa/virtualenv/issues/565 - supported = True - tmpdir = tempfile.mkdtemp() - try: - with open(os.devnull) as fp: - subprocess.check_call( - [sys.executable, "-m", "virtualenv", "--always-copy", tmpdir], - stdout=fp, - stderr=fp, - ) - except subprocess.CalledProcessError: - supported = False - finally: - shutil.rmtree(tmpdir) - return not supported - - -alwayscopy_not_supported = _alwayscopy_not_supported() - - -@pytest.mark.skipif(alwayscopy_not_supported, reason="Platform doesn't support alwayscopy") -def test_alwayscopy(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - commands={envpython} --version - alwayscopy=True - """, - }, - ) - result = cmd("-vv") - result.assert_success() - assert "virtualenv --always-copy" in result.out - - -def test_alwayscopy_default(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - commands={envpython} --version - """, - }, - ) - result = cmd("-vv") - result.assert_success() - assert "virtualenv --always-copy" not in result.out - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no echo on Windows") -def test_empty_activity_ignored(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - list_dependencies_command=echo - commands={envpython} --version - """, - }, - ) - result = cmd() - result.assert_success() - assert "installed:" not in result.out - - -@pytest.mark.skipif("sys.platform == 'win32'", reason="no echo on Windows") -def test_empty_activity_shown_verbose(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - [testenv] - list_dependencies_command=echo - commands={envpython} --version - allowlist_externals = echo - """, - }, - ) - result = cmd("-v") - result.assert_success() - assert "installed:" in result.out - - -def test_test_piphelp(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """ - # content of: tox.ini - [testenv] - commands=pip -h - """, - }, - ) - result = cmd("-vv") - result.assert_success() - - -def test_notest(initproj, cmd): - initproj( - "example123", - filedefs={ - "tox.ini": """\ - # content of: tox.ini - [testenv:py26] - basepython={} - """.format( - sys.executable, - ), - }, - ) - result = cmd("-v", "--notest") - result.assert_success() - assert re.match(r".*summary.*" r"py26\W+skipped\W+tests.*", result.out, re.DOTALL) - result = cmd("-v", "--notest", "-epy26") - result.assert_success() - assert re.match(r".*py26\W+reusing.*", result.out, re.DOTALL) - - -def test_notest_setup_py_error(initproj, cmd): - initproj( - "example123", - filedefs={ - "setup.py": """\ - from setuptools import setup - setup(name='x', install_requires=['fakefakefakefakefakefake']), - """, - "tox.ini": "", - }, - ) - result = cmd("--notest") - result.assert_fail() - assert re.search("ERROR:.*InvocationError", result.out) - - -@pytest.mark.parametrize("has_config", [True, False]) -def test_devenv(initproj, cmd, has_config): - filedefs = { - "setup.py": """\ - from setuptools import setup - setup(name='x') - """, - } - if has_config: - filedefs[ - "tox.ini" - ] = """\ - [tox] - # envlist is ignored for --devenv - envlist = foo,bar,baz - - [testenv] - # --devenv implies --notest - commands = python -c "exit(1)" - """ - initproj( - "example123", - filedefs=filedefs, - ) - result = cmd("--devenv", "venv") - result.assert_success() - # `--devenv` defaults to the `py` environment and a develop install - assert "py develop-inst:" in result.out - assert re.search("py create:.*venv", result.out) - - -def test_devenv_does_not_allow_multiple_environments(initproj, cmd): - initproj( - "example123", - filedefs={ - "setup.py": """\ - from setuptools import setup - setup(name='x') - """, - "tox.ini": """\ - [tox] - envlist=foo,bar,baz - """, - }, - ) - - result = cmd("--devenv", "venv", "-e", "foo,bar") - result.assert_fail() - assert result.err == "ERROR: --devenv requires only a single -e\n" - - -def test_devenv_does_not_delete_project(initproj, cmd): - initproj( - "example123", - filedefs={ - "setup.py": """\ - from setuptools import setup - setup(name='x') - """, - "tox.ini": """\ - [tox] - envlist=foo,bar,baz - """, - }, - ) - - result = cmd("--devenv", "") - result.assert_fail() - assert "would delete project" in result.out - assert "ERROR: ConfigError: envdir must not equal toxinidir" in result.out - - -def test_PYC(initproj, cmd, monkeypatch): - initproj("example123", filedefs={"tox.ini": ""}) - monkeypatch.setenv("PYTHONDOWNWRITEBYTECODE", "1") - result = cmd("-v", "--notest") - result.assert_success() - assert "create" in result.out - - -def test_env_VIRTUALENV_PYTHON(initproj, cmd, monkeypatch): - initproj("example123", filedefs={"tox.ini": ""}) - monkeypatch.setenv("VIRTUALENV_PYTHON", "/FOO") - result = cmd("-v", "--notest") - result.assert_success() - assert "create" in result.out - - -def test_setup_prints_non_ascii(initproj, cmd): - initproj( - "example123", - filedefs={ - "setup.py": """\ -import sys -getattr(sys.stdout, 'buffer', sys.stdout).write(b'\\xe2\\x98\\x83\\n') - -import setuptools -setuptools.setup(name='example123') -""", - "tox.ini": "", - }, - ) - result = cmd("--notest") - result.assert_success() - assert "create" in result.out - - -def test_envsitepackagesdir(cmd, initproj): - initproj( - "pkg512-0.0.5", - filedefs={ - "tox.ini": """ - [testenv] - commands= - python -c "print(r'X:{envsitepackagesdir}')" - """, - }, - ) - result = cmd() - result.assert_success() - assert re.match(r".*\nX:.*tox.*site-packages.*", result.out, re.DOTALL) - - -def test_envsitepackagesdir_skip_missing_issue280(cmd, initproj): - initproj( - "pkg513-0.0.5", - filedefs={ - "tox.ini": """ - [testenv] - basepython=/usr/bin/qwelkjqwle - commands= - {envsitepackagesdir} - """, - }, - ) - result = cmd("--skip-missing-interpreters") - result.assert_success() - assert re.match(r".*SKIPPED:.*qwelkj.*", result.out, re.DOTALL) - - -@pytest.mark.parametrize("verbosity", ["", "-v", "-vv"]) -def test_verbosity(cmd, initproj, verbosity): - initproj( - "pkgX-0.0.5", - filedefs={ - "tox.ini": """ - [testenv] - """, - }, - ) - result = cmd(verbosity) - result.assert_success() - - needle = "Successfully installed pkgX-0.0.5" - if verbosity == "-vv": - assert any(needle in line for line in result.outlines), result.outlines - else: - assert all(needle not in line for line in result.outlines), result.outlines - - -def test_envtmpdir(initproj, cmd): - initproj( - "foo", - filedefs={ - # This file first checks that envtmpdir is existent and empty. Then it - # creates an empty file in that directory. The tox command is run - # twice below, so this is to test whether the directory is cleared - # before the second run. - "check_empty_envtmpdir.py": """if True: - import os - from sys import argv - envtmpdir = argv[1] - assert os.path.exists(envtmpdir) - assert os.listdir(envtmpdir) == [] - open(os.path.join(envtmpdir, 'test'), 'w').close() - """, - "tox.ini": """ - [testenv] - commands=python check_empty_envtmpdir.py {envtmpdir} - """, - }, - ) - - result = cmd() - result.assert_success() - - result = cmd() - result.assert_success() - - -def test_missing_env_fails(initproj, cmd): - ini = """ - [testenv:foo] - install_command={env:FOO} - commands={env:VAR} - """ - initproj("foo", filedefs={"tox.ini": ini}) - result = cmd() - result.assert_fail() - assert result.out.endswith( - "foo: unresolvable substitution(s):\n" - " commands: 'VAR'\n" - " install_command: 'FOO'\n" - "Environment variables are missing or defined recursively.\n", - ) - - -def test_tox_console_script(initproj): - initproj("help", filedefs={"tox.ini": ""}) - result = subprocess.check_call(["tox", "--help"]) - assert result == 0 - - -def test_tox_quickstart_script(initproj): - initproj("help", filedefs={"tox.ini": ""}) - result = subprocess.check_call(["tox-quickstart", "--help"]) - assert result == 0 - - -def test_tox_cmdline_no_args(monkeypatch, initproj): - initproj("help", filedefs={"tox.ini": ""}) - monkeypatch.setattr(sys, "argv", ["caller_script", "--help"]) - with pytest.raises(SystemExit): - tox.cmdline() - - -def test_tox_cmdline_args(initproj): - initproj("help", filedefs={"tox.ini": ""}) - with pytest.raises(SystemExit): - tox.cmdline(["caller_script", "--help"]) - - -@pytest.mark.parametrize("exit_code", [0, 6]) -def test_exit_code(initproj, cmd, exit_code, mocker): - """Check for correct InvocationError, with exit code, - except for zero exit code""" - import tox.exception - - mocker.spy(tox.exception, "exit_code_str") - tox_ini_content = "[testenv:foo]\ncommands=python -c 'import sys; sys.exit({:d})'".format( - exit_code, - ) - initproj("foo", filedefs={"tox.ini": tox_ini_content}) - cmd() - if exit_code: - # need mocker.spy above - assert tox.exception.exit_code_str.call_count == 1 - (args, kwargs) = tox.exception.exit_code_str.call_args - assert kwargs == {} - (call_error_name, call_command, call_exit_code) = args - assert call_error_name == "InvocationError" - # quotes are removed in result.out - # do not include "python" as it is changed to python.EXE by appveyor - expected_command_arg = " -c 'import sys; sys.exit({:d})'".format(exit_code) - assert expected_command_arg in call_command - assert call_exit_code == exit_code - else: - # need mocker.spy above - assert tox.exception.exit_code_str.call_count == 0 diff --git a/tests/unit/util/test_graph.py b/tests/unit/util/test_graph.py deleted file mode 100644 index 4323bbaec..000000000 --- a/tests/unit/util/test_graph.py +++ /dev/null @@ -1,64 +0,0 @@ -from collections import OrderedDict - -import pytest - -from tox.util.graph import stable_topological_sort - - -def test_topological_order_specified_only(): - graph = OrderedDict() - graph["A"] = "B", "C" - result = stable_topological_sort(graph) - assert result == ["A"] - - -def test_topological_order(): - graph = OrderedDict() - graph["A"] = "B", "C" - graph["B"] = () - graph["C"] = () - result = stable_topological_sort(graph) - assert result == ["B", "C", "A"] - - -def test_topological_order_cycle(): - graph = OrderedDict() - graph["A"] = "B", "C" - graph["B"] = ("A",) - with pytest.raises(ValueError, match="A | B"): - stable_topological_sort(graph) - - -def test_topological_complex(): - graph = OrderedDict() - graph["A"] = "B", "C" - graph["B"] = "C", "D" - graph["C"] = ("D",) - graph["D"] = () - result = stable_topological_sort(graph) - assert result == ["D", "C", "B", "A"] - - -def test_two_sub_graph(): - graph = OrderedDict() - graph["F"] = () - graph["E"] = () - graph["D"] = "E", "F" - graph["A"] = "B", "C" - graph["B"] = () - graph["C"] = () - - result = stable_topological_sort(graph) - assert result == ["F", "E", "D", "B", "C", "A"] - - -def test_two_sub_graph_circle(): - graph = OrderedDict() - graph["F"] = () - graph["E"] = () - graph["D"] = "E", "F" - graph["A"] = "B", "C" - graph["B"] = ("A",) - graph["C"] = () - with pytest.raises(ValueError, match="A | B"): - stable_topological_sort(graph) diff --git a/tests/unit/util/test_spinner.py b/tests/unit/util/test_spinner.py deleted file mode 100644 index 2511d5848..000000000 --- a/tests/unit/util/test_spinner.py +++ /dev/null @@ -1,155 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - -import datetime -import os -import sys -import time - -import pytest -from freezegun import freeze_time - -from tox.util import spinner - - -@freeze_time("2012-01-14") -def test_spinner(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - with spinner.Spinner(refresh_rate=100) as spin: - for _ in range(len(spin.frames)): - spin.stream.write("\n") - spin.render_frame() - spin.stream.write("\n") - out, err = capfd.readouterr() - lines = out.split("\n") - expected = ["\r{}\r{} [0] ".format(spin.CLEAR_LINE, i) for i in spin.frames] + [ - "\r{}\r{} [0] ".format(spin.CLEAR_LINE, spin.frames[0]), - "\r{}".format(spin.CLEAR_LINE), - ] - assert lines == expected - - -@freeze_time("2012-01-14") -def test_spinner_progress(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - with spinner.Spinner() as spin: - for _ in range(len(spin.frames)): - spin.stream.write("\n") - time.sleep(spin.refresh_rate) - - out, err = capfd.readouterr() - assert not err - assert len({i.strip() for i in out.split("[0]")}) > len(spin.frames) / 2 - - -@freeze_time("2012-01-14") -def test_spinner_atty(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "isatty", lambda: True) - with spinner.Spinner(refresh_rate=100) as spin: - spin.stream.write("\n") - out, err = capfd.readouterr() - lines = out.split("\n") - posix = os.name == "posix" - expected = [ - "{}\r{}\r{} [0] ".format("\x1b[?25l" if posix else "", spin.CLEAR_LINE, spin.frames[0]), - "\r\x1b[K{}".format("\x1b[?25h" if posix else ""), - ] - assert lines == expected - - -@freeze_time("2012-01-14") -def test_spinner_report(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - with spinner.Spinner(refresh_rate=100) as spin: - spin.stream.write(os.linesep) - spin.add("ok") - spin.add("fail") - spin.add("skip") - spin.succeed("ok") - spin.fail("fail") - spin.skip("skip") - out, err = capfd.readouterr() - lines = out.split(os.linesep) - del lines[0] - expected = [ - "\r{}✔ OK ok in 0.0 seconds".format(spin.CLEAR_LINE), - "\r{}✖ FAIL fail in 0.0 seconds".format(spin.CLEAR_LINE), - "\r{}⚠ SKIP skip in 0.0 seconds".format(spin.CLEAR_LINE), - "\r{}".format(spin.CLEAR_LINE), - ] - assert lines == expected - assert not err - - -def test_spinner_long_text(capfd, monkeypatch): - monkeypatch.setattr(sys.stdout, "isatty", lambda: False) - with spinner.Spinner(refresh_rate=100) as spin: - spin.stream.write("\n") - spin.add("a" * 60) - spin.add("b" * 60) - spin.render_frame() - spin.stream.write("\n") - out, err = capfd.readouterr() - assert not err - expected = [ - "\r{}\r{} [2] {} | {}...".format(spin.CLEAR_LINE, spin.frames[1], "a" * 60, "b" * 49), - "\r{}".format(spin.CLEAR_LINE), - ] - lines = out.split("\n") - del lines[0] - assert lines == expected - - -def test_spinner_stdout_not_unicode(mocker, capfd): - stdout = mocker.patch("tox.util.spinner.sys.stdout") - stdout.encoding = "ascii" - with spinner.Spinner(refresh_rate=100) as spin: - for _ in range(len(spin.frames)): - spin.render_frame() - out, err = capfd.readouterr() - assert not err - assert not out - written = "".join({i[0][0] for i in stdout.write.call_args_list}) - assert all(f in written for f in spin.frames) - - -@freeze_time("2012-01-14") -def test_spinner_report_not_unicode(mocker, capfd): - stdout = mocker.patch("tox.util.spinner.sys.stdout") - stdout.encoding = "ascii" - # Disable color to simplify parsing output strings - stdout.isatty = lambda: False - with spinner.Spinner(refresh_rate=100) as spin: - spin.stream.write(os.linesep) - spin.add("ok!") - spin.add("fail!") - spin.add("skip!") - spin.succeed("ok!") - spin.fail("fail!") - spin.skip("skip!") - lines = "".join(args[0] for args, _ in stdout.write.call_args_list).split(os.linesep) - del lines[0] - expected = [ - "\r{}[ OK ] ok! in 0.0 seconds".format(spin.CLEAR_LINE), - "\r{}[FAIL] fail! in 0.0 seconds".format(spin.CLEAR_LINE), - "\r{}[SKIP] skip! in 0.0 seconds".format(spin.CLEAR_LINE), - "\r{}".format(spin.CLEAR_LINE), - ] - assert lines == expected - - -@pytest.mark.parametrize( - "seconds, expected", - [ - (0, "0.0 seconds"), - (1.0, "1.0 second"), - (4.0, "4.0 seconds"), - (4.130, "4.13 seconds"), - (4.137, "4.137 seconds"), - (42.12345, "42.123 seconds"), - (61, "1 minute, 1.0 second"), - ], -) -def test_td_human_readable(seconds, expected): - dt = datetime.timedelta(seconds=seconds) - assert spinner.td_human_readable(dt) == expected diff --git a/tests/unit/util/test_util.py b/tests/unit/util/test_util.py deleted file mode 100644 index bdd36ea2e..000000000 --- a/tests/unit/util/test_util.py +++ /dev/null @@ -1,22 +0,0 @@ -import os - -from tox.util import set_os_env_var - - -def test_set_os_env_var_clean_env(monkeypatch): - monkeypatch.delenv("ENV", raising=False) - with set_os_env_var("ENV", "a"): - assert os.environ["ENV"] == "a" - assert "ENV" not in os.environ - - -def test_set_os_env_var_exist_env(monkeypatch): - monkeypatch.setenv("ENV", "b") - with set_os_env_var("ENV", "a"): - assert os.environ["ENV"] == "a" - assert os.environ["ENV"] == "b" - - -def test_set_os_env_var_non_str(): - with set_os_env_var("ENV", 1): - assert os.environ["ENV"] == "1" diff --git a/tests/util/__init__.py b/tests/util/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/util/test_cpu.py b/tests/util/test_cpu.py new file mode 100644 index 000000000..3850ac35e --- /dev/null +++ b/tests/util/test_cpu.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import multiprocessing + +from pytest_mock import MockerFixture + +from tox.util.cpu import auto_detect_cpus + + +def test_auto_detect_cpus() -> None: + num_cpus_actual = multiprocessing.cpu_count() + assert auto_detect_cpus() == num_cpus_actual + + +def test_auto_detect_cpus_returns_one_when_cpu_count_throws(mocker: MockerFixture) -> None: + mocker.patch.object(multiprocessing, "cpu_count", side_effect=NotImplementedError) + assert auto_detect_cpus() == 1 diff --git a/tests/util/test_graph.py b/tests/util/test_graph.py new file mode 100644 index 000000000..b6b60ec29 --- /dev/null +++ b/tests/util/test_graph.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from collections import OrderedDict + +import pytest + +from tox.util.graph import stable_topological_sort + + +def test_topological_order_empty() -> None: + graph: dict[str, set[str]] = OrderedDict() + result = stable_topological_sort(graph) + assert result == [] + + +def test_topological_order_specified_only() -> None: + graph: dict[str, set[str]] = OrderedDict() + graph["A"] = {"B", "C"} + result = stable_topological_sort(graph) + assert result == ["A"] + + +def test_topological_order() -> None: + graph: dict[str, set[str]] = OrderedDict() + graph["A"] = {"B", "C"} + graph["B"] = set() + graph["C"] = set() + result = stable_topological_sort(graph) + assert result == ["B", "C", "A"] + + +def test_topological_order_cycle() -> None: + graph: dict[str, set[str]] = OrderedDict() + graph["A"] = {"B", "C"} + graph["B"] = {"A"} + with pytest.raises(ValueError, match="A | B"): + stable_topological_sort(graph) + + +def test_topological_complex() -> None: + graph: dict[str, set[str]] = OrderedDict() + graph["A"] = {"B", "C"} + graph["B"] = {"C", "D"} + graph["C"] = {"D"} + graph["D"] = set() + result = stable_topological_sort(graph) + assert result == ["D", "C", "B", "A"] + + +def test_two_sub_graph() -> None: + graph: dict[str, set[str]] = OrderedDict() + graph["F"] = set() + graph["E"] = set() + graph["D"] = {"E", "F"} + graph["A"] = {"B", "C"} + graph["B"] = set() + graph["C"] = set() + + result = stable_topological_sort(graph) + assert result == ["F", "E", "D", "B", "C", "A"] + + +def test_two_sub_graph_circle() -> None: + graph: dict[str, set[str]] = OrderedDict() + graph["F"] = set() + graph["E"] = set() + graph["D"] = {"E", "F"} + graph["A"] = {"B", "C"} + graph["B"] = {"A"} + graph["C"] = set() + with pytest.raises(ValueError, match="A | B"): + stable_topological_sort(graph) diff --git a/tests/util/test_path.py b/tests/util/test_path.py new file mode 100644 index 000000000..10665e96f --- /dev/null +++ b/tests/util/test_path.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from pathlib import Path + +from tox.util.path import ensure_empty_dir + + +def test_ensure_empty_dir_file(tmp_path: Path) -> None: + dest = tmp_path / "a" + dest.write_text("") + ensure_empty_dir(dest) + assert dest.is_dir() + assert not list(dest.iterdir()) diff --git a/tests/util/test_spinner.py b/tests/util/test_spinner.py new file mode 100644 index 000000000..f393ffa73 --- /dev/null +++ b/tests/util/test_spinner.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import os +import sys +import time + +import pytest +import time_machine +from colorama import Fore +from pytest_mock import MockerFixture + +from tox.pytest import CaptureFixture, MonkeyPatch +from tox.util import spinner + + +@time_machine.travel("2012-01-14", tick=False) +def test_spinner(capfd: CaptureFixture, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + with spinner.Spinner(refresh_rate=100) as spin: + for _ in range(len(spin.frames)): + spin.stream.write("\n") + spin.render_frame() + spin.stream.write("\n") + out, err = capfd.readouterr() + lines = out.split("\n") + expected = [f"\r{spin.CLEAR_LINE}\r{i} [0]" for i in spin.frames] + [ + f"\r{spin.CLEAR_LINE}\r{spin.frames[0]} [0]", + f"\r{spin.CLEAR_LINE}", + ] + assert lines == expected + + +@time_machine.travel("2012-01-14", tick=False) +def test_spinner_disabled(capfd: CaptureFixture) -> None: + with spinner.Spinner(refresh_rate=100, enabled=False) as spin: + spin.add("x") + for _ in range(len(spin.frames)): + spin.render_frame() + spin.finalize("x", "done", Fore.GREEN) + spin.clear() + out, err = capfd.readouterr() + assert out == f"{Fore.GREEN}x: done in 0 seconds{Fore.RESET}{os.linesep}", out + assert err == "" + + +@time_machine.travel("2012-01-14", tick=False) +def test_spinner_progress(capfd: CaptureFixture, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + with spinner.Spinner() as spin: + for _ in range(len(spin.frames)): # pragma: no branch + spin.stream.write("\n") + time.sleep(spin.refresh_rate) + + out, err = capfd.readouterr() + assert not err + assert len({i.strip() for i in out.split("[0]")}) > len(spin.frames) / 2 + + +@time_machine.travel("2012-01-14", tick=False) +def test_spinner_atty(capfd: CaptureFixture, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + with spinner.Spinner(refresh_rate=100) as spin: + spin.stream.write("\n") + out, err = capfd.readouterr() + lines = out.split("\n") + posix = os.name == "posix" + expected = [ + "{}\r{}\r{} [0]".format("\x1b[?25l" if posix else "", spin.CLEAR_LINE, spin.frames[0]), + "\r\x1b[K{}".format("\x1b[?25h" if posix else ""), + ] + assert lines == expected + + +@time_machine.travel("2012-01-14", tick=False) +def test_spinner_report(capfd: CaptureFixture, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + with spinner.Spinner(refresh_rate=100) as spin: + spin.stream.write(os.linesep) + spin.add("ok") + spin.add("fail") + spin.add("skip") + spin.succeed("ok") + spin.fail("fail") + spin.skip("skip") + out, err = capfd.readouterr() + lines = out.split(os.linesep) + del lines[0] + expected = [ + f"\r{spin.CLEAR_LINE}{Fore.GREEN}ok: OK ✔ in 0 seconds{Fore.RESET}", + f"\r{spin.CLEAR_LINE}{Fore.RED}fail: FAIL ✖ in 0 seconds{Fore.RESET}", + f"\r{spin.CLEAR_LINE}{Fore.YELLOW}skip: SKIP ⚠ in 0 seconds{Fore.RESET}", + f"\r{spin.CLEAR_LINE}", + ] + assert lines == expected + assert not err + + +def test_spinner_long_text(capfd: CaptureFixture, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + with spinner.Spinner(refresh_rate=100) as spin: + spin.stream.write("\n") + spin.add("a" * 60) + spin.add("b" * 60) + spin.render_frame() + spin.stream.write("\n") + out, err = capfd.readouterr() + assert not err + expected = [ + f"\r{spin.CLEAR_LINE}\r{spin.frames[1]} [2] {'a' * 60} |...", + f"\r{spin.CLEAR_LINE}", + ] + lines = out.split("\n") + del lines[0] + assert lines == expected + + +def test_spinner_stdout_not_unicode(capfd: CaptureFixture, mocker: MockerFixture) -> None: + stdout = mocker.patch("tox.util.spinner.sys.stdout") + stdout.encoding = "ascii" + with spinner.Spinner(refresh_rate=100) as spin: + for _ in range(len(spin.frames)): # pragma: no branch + spin.render_frame() + out, err = capfd.readouterr() + assert not err + assert not out + written = "".join({i[0][0] for i in stdout.write.call_args_list}) + assert all(f in written for f in spin.frames) + + +@pytest.mark.parametrize( + ("seconds", "expected"), + [ + (0, "0 seconds"), + (1.0, "1 second"), + (4.0, "4 seconds"), + (4.13, "4.13 seconds"), + (4.137, "4.14 seconds"), + (42.12345, "42.12 seconds"), + (60, "1 minute"), + (61, "1 minute 1 second"), + (120, "2 minutes"), + (40 * 24 * 60 * 60 + 5 * 60, "40 days 5 minutes"), + (40 * 24 * 60 * 60 + 4 * 60 * 60 + 5 * 60 + 1.5, "40 days 4 hours 5 minutes 1.5 seconds"), + ], +) +def test_td_human_readable(seconds: float, expected: str) -> None: + assert spinner.td_human_readable(seconds) == expected diff --git a/tox.ini b/tox.ini index c2557528b..47044c2d3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,122 +1,98 @@ [tox] envlist = - fix_lint + fix py311 py310 py39 py38 py37 - py36 - py35 - py27 - pypy3 - pypy - coverage + cov + type docs - readme + pkg_meta isolated_build = true skip_missing_interpreters = true -minversion = 3.12 +minversion = 3.22 [testenv] -description = run the tests with pytest under {basepython} +description = run the tests with pytest under {envname} passenv = - CURL_CA_BUNDLE - PIP_CACHE_DIR PYTEST_* - REQUESTS_CA_BUNDLE SSL_CERT_FILE setenv = - COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}/.coverage.{envname}} - PIP_DISABLE_PIP_VERSION_CHECK = 1 - {py27,pypy}: PYTHONWARNINGS = ignore:DEPRECATION::pip._internal.cli.base_command + COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}{/}.coverage.{envname}} + COVERAGE_PROCESS_START = {toxinidir}{/}pyproject.toml extras = testing commands = - pytest \ - --cov "{envsitepackagesdir}/tox" \ - --cov-config "{toxinidir}/tox.ini" \ - --junitxml {toxworkdir}/junit.{envname}.xml \ - {posargs:.} - -[testenv:fix_lint] + pytest {tty:--color=yes} {posargs: \ + --junitxml {toxworkdir}{/}junit.{envname}.xml --cov {envsitepackagesdir}{/}tox --cov {toxinidir}{/}tests \ + --cov-config={toxinidir}{/}pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \ + --cov-report html:{envtmpdir}{/}htmlcov \ + --cov-report xml:{toxworkdir}{/}coverage.{envname}.xml \ + -n={env:PYTEST_XDIST_AUTO_NUM_WORKERS:auto} \ + tests --durations 5 --run-integration} + diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {toxworkdir}{/}coverage.{envname}.xml +package = wheel +wheel_build_env = .pkg + +[testenv:fix] description = format the code base to adhere to our styles, and complain about what we cannot do automatically passenv = {[testenv]passenv} - PRE_COMMIT_HOME PROGRAMDATA -basepython = python3.10 skip_install = true deps = pre-commit>=2.20 -extras = - lint commands = - pre-commit run --all-files --show-diff-on-failure {posargs} - python -c 'import pathlib; print("hint: run \{\} install to add checks as pre-commit hook".format(pathlib.Path(r"{envdir}") / "bin" / "pre-commit"))' + pre-commit run --all-files --show-diff-on-failure {tty:--color=always} {posargs} + python -c 'print(r"hint: run {envbindir}{/}pre-commit install to add checks as pre-commit hook")' -[testenv:coverage] -description = [run locally after tests]: combine coverage data and create report; - generates a diff coverage against origin/master (can be changed by setting DIFF_AGAINST env var) -passenv = - {[testenv]passenv} - DIFF_AGAINST +[testenv:py311] setenv = - COVERAGE_FILE = {toxworkdir}/.coverage -skip_install = true + {[testenv]setenv} + AIOHTTP_NO_EXTENSIONS = 1 + +[testenv:type] +description = run type check on code base +setenv = + {tty:MYPY_FORCE_COLOR = 1} deps = - coverage>=6.4.4 - diff-cover>=6.5.1 -parallel_show_output = true + mypy==0.991 + types-cachetools>=5.2.1 + types-chardet>=5.0.4.1 commands = - coverage combine - coverage report -m - coverage xml -o {toxworkdir}/coverage.xml - coverage html -d {toxworkdir}/htmlcov - diff-cover --compare-branch {env:DIFF_AGAINST:origin/master} {toxworkdir}/coverage.xml -depends = py27, py35, py36, py37, py38, py39, py310, py311, pypy, pypy3 + mypy src/tox + mypy tests [testenv:docs] -description = invoke sphinx-build to build the HTML docs +description = build documentation basepython = python3.10 extras = docs commands = - sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" --color -W --keep-going -n -bhtml {posargs} - python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' + sphinx-build -d "{envtmpdir}{/}doctree" docs "{toxworkdir}{/}docs_out" --color -b html {posargs:-b linkcheck -W} + python -c 'print(r"documentation available under file://{toxworkdir}{/}docs_out{/}index.html")' -[testenv:readme] +[testenv:pkg_meta] description = check that the long description is valid -basepython = python3.9 skip_install = true deps = - twine>=4.0.1 -extras = -commands = - pip wheel -w {envtmpdir}/build --no-deps . - twine check {envtmpdir}/build/* - -[testenv:exit_code] -description = commands with several exit codes -basepython = python3.10 -skip_install = true + build[virtualenv]>=0.9 + check-wheel-contents>=0.4 + twine>=4.0.2 commands = - python3.10 -c "import sys; sys.exit(139)" - -[testenv:X] -description = print the positional arguments passed in with echo -commands = - echo {posargs} + python -m build -o {envtmpdir} -s -w . + twine check {envtmpdir}{/}* + check-wheel-contents --no-config {envtmpdir} [testenv:release] description = do a release, required posarg of the version number -passenv = - * -basepython = python3.10 +skip_install = true deps = - gitpython>=3.1.27 - packaging>=21.3 - towncrier>=21.9 + gitpython>=3.1.29 + packaging>=22 + towncrier>=22.8 commands = python {toxinidir}/tasks/release.py --version {posargs} @@ -131,47 +107,3 @@ extras = commands = python -m pip list --format=columns python -c "print(r'{envpython}')" - -[flake8] -max-complexity = 22 -max-line-length = 99 -ignore = E203, W503, C901, E402, B011 - -[pep8] -max-line-length = 99 - -[coverage:run] -branch = true -parallel = true - -[coverage:report] -skip_covered = True -show_missing = True -exclude_lines = - \#\s*pragma: no cover - ^\s*raise AssertionError\b - ^\s*raise NotImplementedError\b - ^\s*return NotImplemented\b - ^\s*raise$ - ^if __name__ == ['"]__main__['"]:$ - -[coverage:paths] -source = src/tox - */.tox/*/lib/python*/site-packages/tox - */.tox/pypy*/site-packages/tox - */.tox\*\Lib\site-packages\tox - */src/tox - *\src\tox - -[pytest] -addopts = -ra --showlocals --no-success-flaky-report -testpaths = tests -xfail_strict = True -markers = - git - network - -[isort] -profile = black -line_length = 99 -known_first_party = tox,tests diff --git a/whitelist.txt b/whitelist.txt new file mode 100644 index 000000000..7630a78c2 --- /dev/null +++ b/whitelist.txt @@ -0,0 +1,179 @@ +0x3 +10ms +1s +2s +5s +abi +addinivalue +addnodes +addoption +anonlabels +argparsing +autoclass +autodoc +autosectionlabel +autouse +binprm +buf +bufsize +cachetools +canonicalize +capfd +caplog +capsys +cfg +changelog +chardet +chdir +codec +colorama +commenters +commonpath +conftest +contnode +copytree +cov +cpus +creq +ctrl +cygwin +deinit +delenv +devenv +devnull +devpi +distinfo +distlib +divmod +doc2path +docname +docutils +e3 +e4 +ebadf +editables +eio +entrypoints +envs +epilog +exe +executables +extlinks +extractall +faulthandler +favicon +filelock +fixup +fromdocname +fromhex +fs +fullmatch +getattribute +getbasetemp +getfqdn +getitem +getmodule +getoption +getresult +getsockname +getsourcelines +groupby +hardlink +hookimpl +hookspec +hookspecs +iexec +inet +insort +instream +intersphinx +isalpha +isatty +isnumeric +isspace +iterdir +levelname +levelno +libs +lightred +linkcheck +list2cmdline +lvl +mktemp +modifyitems +namelist +nitpicky +nok +nonlocal +notset +nox +objtype +ov +pathname +pep517 +platformdirs +pluggy +pos +posargs +posix +prereleases +prj +psutil +purelib +py311 +py38 +py39 +pygments +pypa +pyproject +quickstart +readline +readouterr +recurse +refid +refnode +refspec +reftitle +releaselevel +replacer +repo +reqs +retann +rfind +rpartition +rreq +rst +sdist +setenv +shlex +sigint +sigkill +signum +sigterm +skipif +splitter +statemachine +string2lines +stringify +subparsers +termux +testenv +tmpdir +toml +tomli +tomllib +towncrier +tox +transcoding +trylast +tty +typehints +typeshed +unescaped +usedevelop +usefixtures +vcs +virtualenv +whl +win32 +xdist