├── .dockerignore ├── .github ├── CODEOWNERS ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── config.yml │ └── feature-request.md ├── PULL_REQUEST_TEMPLATE.md ├── config.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── check.yaml │ └── release.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── docs ├── _static │ ├── custom.css │ └── img │ │ ├── tox.png │ │ ├── tox.svg │ │ └── toxfavi.ico ├── changelog.rst ├── changelog │ ├── 3534.feature.rst │ └── template.jinja2 ├── cli_interface.rst ├── conf.py ├── config.rst ├── development.rst ├── faq.rst ├── img │ ├── overview.mermaidjs │ ├── overview_dark.svg │ └── overview_light.svg ├── index.rst ├── installation.rst ├── plugins.rst ├── plugins_api.rst ├── tox_conf.py ├── upgrading.rst └── user_guide.rst ├── ignore-words.txt ├── pyproject.toml ├── src └── tox │ ├── __init__.py │ ├── __main__.py │ ├── config │ ├── __init__.py │ ├── cli │ │ ├── __init__.py │ │ ├── env_var.py │ │ ├── ini.py │ │ ├── parse.py │ │ └── parser.py │ ├── loader │ │ ├── __init__.py │ │ ├── api.py │ │ ├── convert.py │ │ ├── ini │ │ │ ├── __init__.py │ │ │ ├── factor.py │ │ │ └── replace.py │ │ ├── memory.py │ │ ├── replacer.py │ │ ├── section.py │ │ ├── str_convert.py │ │ ├── stringify.py │ │ └── toml │ │ │ ├── __init__.py │ │ │ ├── _api.py │ │ │ ├── _replace.py │ │ │ └── _validate.py │ ├── main.py │ ├── of_type.py │ ├── set_env.py │ ├── sets.py │ ├── source │ │ ├── __init__.py │ │ ├── api.py │ │ ├── discover.py │ │ ├── ini.py │ │ ├── ini_section.py │ │ ├── legacy_toml.py │ │ ├── setup_cfg.py │ │ ├── toml_pyproject.py │ │ ├── toml_tox.py │ │ └── tox_ini.py │ └── types.py │ ├── execute │ ├── __init__.py │ ├── api.py │ ├── local_sub_process │ │ ├── __init__.py │ │ ├── read_via_thread.py │ │ ├── read_via_thread_unix.py │ │ └── read_via_thread_windows.py │ ├── pep517_backend.py │ ├── request.py │ ├── stream.py │ └── util.py │ ├── journal │ ├── __init__.py │ ├── env.py │ └── main.py │ ├── plugin │ ├── __init__.py │ ├── inline.py │ ├── manager.py │ └── spec.py │ ├── provision.py │ ├── py.typed │ ├── pytest.py │ ├── report.py │ ├── run.py │ ├── session │ ├── __init__.py │ ├── cmd │ │ ├── __init__.py │ │ ├── depends.py │ │ ├── devenv.py │ │ ├── exec_.py │ │ ├── legacy.py │ │ ├── list_env.py │ │ ├── quickstart.py │ │ ├── run │ │ │ ├── __init__.py │ │ │ ├── common.py │ │ │ ├── parallel.py │ │ │ ├── sequential.py │ │ │ └── single.py │ │ ├── schema.py │ │ ├── show_config.py │ │ └── version_flag.py │ ├── env_select.py │ └── state.py │ ├── tox.schema.json │ ├── tox_env │ ├── __init__.py │ ├── api.py │ ├── errors.py │ ├── info.py │ ├── installer.py │ ├── package.py │ ├── python │ │ ├── __init__.py │ │ ├── api.py │ │ ├── dependency_groups.py │ │ ├── package.py │ │ ├── pip │ │ │ ├── __init__.py │ │ │ ├── pip_install.py │ │ │ ├── req │ │ │ │ ├── __init__.py │ │ │ │ ├── args.py │ │ │ │ ├── file.py │ │ │ │ └── util.py │ │ │ └── req_file.py │ │ ├── runner.py │ │ └── virtual_env │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ ├── package │ │ │ ├── __init__.py │ │ │ ├── cmd_builder.py │ │ │ ├── pyproject.py │ │ │ └── util.py │ │ │ └── runner.py │ ├── register.py │ ├── runner.py │ └── util.py │ └── util │ ├── __init__.py │ ├── ci.py │ ├── cpu.py │ ├── file_view.py │ ├── graph.py │ ├── path.py │ └── spinner.py ├── tasks └── release.py ├── tests ├── __init__.py ├── config │ ├── __init__.py │ ├── cli │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_cli_env_var.py │ │ ├── test_cli_ini.py │ │ ├── test_parse.py │ │ └── test_parser.py │ ├── conftest.py │ ├── loader │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── ini │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── replace │ │ │ │ ├── __init__.py │ │ │ │ ├── test_replace_env_var.py │ │ │ │ ├── test_replace_os_pathsep.py │ │ │ │ ├── test_replace_os_sep.py │ │ │ │ ├── test_replace_posargs.py │ │ │ │ ├── test_replace_tox_env.py │ │ │ │ └── test_replace_tty.py │ │ │ ├── test_factor.py │ │ │ └── test_ini_loader.py │ │ ├── test_loader.py │ │ ├── test_memory_loader.py │ │ ├── test_replace.py │ │ ├── test_section.py │ │ ├── test_str_convert.py │ │ └── test_toml_loader.py │ ├── source │ │ ├── __init__.py │ │ ├── test_discover.py │ │ ├── test_legacy_toml.py │ │ ├── test_setup_cfg.py │ │ ├── test_source_ini.py │ │ ├── test_toml_pyproject.py │ │ └── test_toml_tox.py │ ├── test_main.py │ ├── test_of_types.py │ ├── test_set_env.py │ ├── test_sets.py │ └── test_types.py ├── conftest.py ├── demo_pkg_inline │ ├── build.py │ └── pyproject.toml ├── demo_pkg_setuptools │ ├── demo_pkg_setuptools │ │ └── __init__.py │ ├── pyproject.toml │ └── setup.cfg ├── execute │ ├── __init__.py │ ├── conftest.py │ ├── local_subprocess │ │ ├── __init__.py │ │ ├── bad_process.py │ │ ├── local_subprocess_sigint.py │ │ ├── test_execute_util.py │ │ ├── test_local_subprocess.py │ │ └── tty_check.py │ ├── test_request.py │ └── test_stream.py ├── journal │ ├── __init__.py │ └── test_main_journal.py ├── plugin │ ├── conftest.py │ ├── test_inline.py │ ├── test_plugin.py │ └── test_plugin_custom_config_set.py ├── pytest_ │ ├── __init__.py │ └── test_init.py ├── session │ ├── __init__.py │ ├── cmd │ │ ├── __init__.py │ │ ├── run │ │ │ ├── __init__.py │ │ │ └── test_common.py │ │ ├── test_depends.py │ │ ├── test_devenv.py │ │ ├── test_exec_.py │ │ ├── test_legacy.py │ │ ├── test_list_envs.py │ │ ├── test_parallel.py │ │ ├── test_quickstart.py │ │ ├── test_schema.py │ │ ├── test_sequential.py │ │ ├── test_show_config.py │ │ └── test_state.py │ ├── test_env_select.py │ └── test_session_common.py ├── test_call_modes.py ├── test_provision.py ├── test_report.py ├── test_run.py ├── test_version.py ├── tox_env │ ├── __init__.py │ ├── python │ │ ├── __init__.py │ │ ├── pip │ │ │ ├── req │ │ │ │ └── test_file.py │ │ │ ├── test_pip_install.py │ │ │ └── test_req_file.py │ │ ├── test-pkg │ │ │ └── pyproject.toml │ │ ├── test_python_api.py │ │ ├── test_python_runner.py │ │ └── virtual_env │ │ │ ├── __init__.py │ │ │ ├── package │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── test_package_cmd_builder.py │ │ │ ├── test_package_pyproject.py │ │ │ └── test_python_package_util.py │ │ │ ├── test_setuptools.py │ │ │ └── test_virtualenv_api.py │ ├── test_api.py │ ├── test_info.py │ ├── test_register.py │ ├── test_tox_env_api.py │ └── test_tox_env_runner.py ├── type_check │ └── add_config_container_factory.py └── util │ ├── __init__.py │ ├── test_ci.py │ ├── test_cpu.py │ ├── test_graph.py │ ├── test_path.py │ └── test_spinner.py └── tox.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | .tox* 2 | .*_cache 3 | *.egg-info 4 | Dockerfile 5 | build 6 | dist 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gaborbernat 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `tox` 2 | 3 | Thank you for your interest in contributing to `tox`! There are many ways to contribute, and we appreciate all of them. 4 | As a reminder, all contributors are expected to follow our [Code of Conduct][coc]. 5 | 6 | [coc]: https://www.pypa.io/en/latest/code-of-conduct/ 7 | 8 | ## Development Documentation 9 | 10 | Our [development documentation](http://tox.readthedocs.org/en/latest/development.html#development) contains details on 11 | how to get started with contributing to `tox`, and details of our development processes. 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/tox 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | ## Issue 10 | 11 | 12 | 13 | ## Environment 14 | 15 | Provide at least: 16 | 17 | - OS: 18 | 19 |
20 | Output of pip list of the host Python, where tox is installed 21 | 22 | ```console 23 | 24 | ``` 25 | 26 |
27 | 28 | ## Output of running tox 29 | 30 |
31 | Output of tox -rvv 32 | 33 | ```console 34 | 35 | ``` 36 | 37 |
38 | 39 | ## Minimal example 40 | 41 | 42 | 43 | ```console 44 | 45 | ``` 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser 2 | blank_issues_enabled: true # default 3 | contact_links: 4 | - name: 🤷💻🤦 Discussions 5 | url: https://github.com/tox-dev/tox/discussions 6 | about: | 7 | Ask typical Q&A here. Please note that we cannot give support about Python packaging in general, questions about structuring projects and so on. 8 | - name: 📝 PyPA Code of Conduct 9 | url: https://www.pypa.io/en/latest/code-of-conduct/ 10 | about: ❤ Be nice to other members of the community. ☮ Behave. 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an enhancement for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | ## What's the problem this feature will solve? 10 | 11 | 12 | 13 | ## Describe the solution you'd like 14 | 15 | 16 | 17 | 18 | 19 | ## Alternative Solutions 20 | 21 | 23 | 24 | ## Additional context 25 | 26 | 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | - [ ] ran the linter to address style issues (`tox -e fix`) 7 | - [ ] wrote descriptive pull request text 8 | - [ ] ensured there are test(s) validating the fix 9 | - [ ] added news fragment in `docs/changelog` folder 10 | - [ ] updated/extended the documentation 11 | -------------------------------------------------------------------------------- /.github/config.yml: -------------------------------------------------------------------------------- 1 | chronographer: 2 | enforce_name: 3 | suffix: .rst 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: check 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: ["main"] 6 | tags-ignore: ["**"] 7 | pull_request: 8 | schedule: 9 | - cron: "0 8 * * *" 10 | 11 | env: 12 | FORCE_COLOR: 1 13 | 14 | concurrency: 15 | group: check-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | test: 20 | name: test ${{ matrix.py }} on ${{ matrix.os }} 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | py: 26 | - "3.14" 27 | - "3.13" 28 | - "3.12" 29 | - "3.11" 30 | - "3.10" 31 | - "3.9" 32 | os: 33 | - ubuntu-latest 34 | - windows-latest 35 | - macos-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | with: 39 | fetch-depth: 0 40 | - name: Install the latest version of uv 41 | uses: astral-sh/setup-uv@v6 42 | with: 43 | enable-cache: true 44 | cache-dependency-glob: "pyproject.toml" 45 | github-token: ${{ secrets.GITHUB_TOKEN }} 46 | - name: Add .local/bin to Windows PATH 47 | if: runner.os == 'Windows' 48 | shell: bash 49 | run: echo "$USERPROFILE/.local/bin" >> $GITHUB_PATH 50 | - name: Install tox@self 51 | run: uv tool install --python-preference only-managed --python ${{ matrix.py }} tox@. 52 | - name: Setup test suite 53 | run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.py }} 54 | - name: Run test suite 55 | run: tox run --skip-pkg-install -e ${{ matrix.py }} 56 | env: 57 | PYTEST_ADDOPTS: "-vv --durations=20" 58 | DIFF_AGAINST: HEAD 59 | PYTEST_XDIST_AUTO_NUM_WORKERS: 0 60 | 61 | check: 62 | name: tox env ${{ matrix.tox_env }} on ${{ matrix.os }} 63 | runs-on: ${{ matrix.os }} 64 | strategy: 65 | fail-fast: false 66 | matrix: 67 | tox_env: 68 | - type 69 | - dev 70 | - docs 71 | - pkg_meta 72 | os: 73 | - ubuntu-latest 74 | - windows-latest 75 | exclude: 76 | - { os: windows-latest, tox_env: docs } 77 | steps: 78 | - uses: actions/checkout@v4 79 | with: 80 | fetch-depth: 0 81 | - name: Install the latest version of uv 82 | uses: astral-sh/setup-uv@v6 83 | with: 84 | enable-cache: true 85 | cache-dependency-glob: "pyproject.toml" 86 | github-token: ${{ secrets.GITHUB_TOKEN }} 87 | - name: Add .local/bin to Windows PATH 88 | if: runner.os == 'Windows' 89 | shell: bash 90 | run: echo "$USERPROFILE/.local/bin" >> $GITHUB_PATH 91 | - name: Install tox@self 92 | run: uv tool install --python-preference only-managed --python 3.13 tox@. 93 | - name: Setup check suite 94 | run: tox r -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }} 95 | - name: Run check for ${{ matrix.tox_env }} 96 | run: tox r --skip-pkg-install -e ${{ matrix.tox_env }} 97 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | on: 3 | push: 4 | tags: ["*"] 5 | 6 | env: 7 | dists-artifact-name: python-package-distributions 8 | FORCE_COLOR: 1 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Install the latest version of uv 18 | uses: astral-sh/setup-uv@v6 19 | with: 20 | enable-cache: true 21 | cache-dependency-glob: "pyproject.toml" 22 | github-token: ${{ secrets.GITHUB_TOKEN }} 23 | - name: Build package 24 | run: uv build --python 3.13 --python-preference only-managed --sdist --wheel . --out-dir dist 25 | - name: Store the distribution packages 26 | uses: actions/upload-artifact@v4 27 | with: 28 | name: ${{ env.dists-artifact-name }} 29 | path: dist/* 30 | 31 | release: 32 | needs: 33 | - build 34 | runs-on: ubuntu-latest 35 | environment: 36 | name: release 37 | url: https://pypi.org/project/tox/${{ github.ref_name }} 38 | permissions: 39 | id-token: write 40 | steps: 41 | - name: Download all the dists 42 | uses: actions/download-artifact@v4 43 | with: 44 | name: ${{ env.dists-artifact-name }} 45 | path: dist/ 46 | - name: Publish to PyPI 47 | uses: pypa/gh-action-pypi-publish@v1.12.4 48 | with: 49 | attestations: true 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.*_cache 2 | /build 3 | /dist 4 | /docs/_draft.rst 5 | /src/tox/version.py 6 | /toxfile.py 7 | /Dockerfile 8 | /.tox 9 | *.py[co] 10 | __pycache__ 11 | *.swp 12 | *.egg-info 13 | /tests/demo_pkg_setuptools/build/lib/demo_pkg_setuptools/__init__.py 14 | /tests/demo_pkg_inline.lock 15 | /tests/demo_pkg_inline/.tox/ 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/python-jsonschema/check-jsonschema 8 | rev: 0.33.0 9 | hooks: 10 | - id: check-github-workflows 11 | args: ["--verbose"] 12 | - repo: https://github.com/codespell-project/codespell 13 | rev: v2.4.1 14 | hooks: 15 | - id: codespell 16 | additional_dependencies: ["tomli>=2.2.1"] 17 | - repo: https://github.com/tox-dev/pyproject-fmt 18 | rev: "v2.6.0" 19 | hooks: 20 | - id: pyproject-fmt 21 | - repo: https://github.com/abravalheri/validate-pyproject 22 | rev: "v0.24.1" 23 | hooks: 24 | - id: validate-pyproject 25 | - repo: https://github.com/astral-sh/ruff-pre-commit 26 | rev: "v0.11.11" 27 | hooks: 28 | - id: ruff-format 29 | - id: ruff 30 | args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] 31 | - repo: https://github.com/asottile/blacken-docs 32 | rev: 1.19.1 33 | hooks: 34 | - id: blacken-docs 35 | additional_dependencies: [black==25.1] 36 | - repo: https://github.com/pre-commit/pygrep-hooks 37 | rev: v1.10.0 38 | hooks: 39 | - id: rst-backticks 40 | - repo: https://github.com/rbubley/mirrors-prettier 41 | rev: "v3.5.3" 42 | hooks: 43 | - id: prettier 44 | - repo: local 45 | hooks: 46 | - id: changelogs-rst 47 | name: changelog filenames 48 | language: fail 49 | entry: "changelog files must be named ####.(feature|bugfix|doc|removal|misc).rst" 50 | exclude: ^docs/changelog/(\d+\.(feature|bugfix|doc|removal|misc).rst|template.jinja2) 51 | files: ^docs/changelog/ 52 | - repo: meta 53 | hooks: 54 | - id: check-hooks-apply 55 | - id: check-useless-excludes 56 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-lts-latest 4 | tools: 5 | python: "3" 6 | commands: 7 | - pip install uv 8 | - uv venv 9 | - uv pip install tox-uv tox@. 10 | - .venv/bin/tox run -e docs -- 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making 6 | participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, 7 | disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, 8 | religion, or sexual identity and orientation. 9 | 10 | ## Our Standards 11 | 12 | Examples of behavior that contributes to creating a positive environment include: 13 | 14 | - Using welcoming and inclusive language 15 | - Being respectful of differing viewpoints and experiences 16 | - Gracefully accepting constructive criticism 17 | - Focusing on what is best for the community 18 | - Showing empathy towards other community members 19 | 20 | Examples of unacceptable behavior by participants include: 21 | 22 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 23 | - Trolling, insulting/derogatory comments, and personal or political attacks 24 | - Public or private harassment 25 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 26 | - Other conduct which could reasonably be considered inappropriate in a professional setting 27 | 28 | ## Our Responsibilities 29 | 30 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take 31 | appropriate and fair corrective action in response to any instances of unacceptable behavior. 32 | 33 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, 34 | issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any 35 | contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 36 | 37 | ## Scope 38 | 39 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the 40 | project or its community. Examples of representing a project or community include using an official project e-mail 41 | address, posting via an official social media account, or acting as an appointed representative at an online or offline 42 | event. Representation of a project may be further defined and clarified by project maintainers. 43 | 44 | ## Enforcement 45 | 46 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team. The 47 | project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the 48 | circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. 49 | Further details of specific enforcement policies may be posted separately. 50 | 51 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent 52 | repercussions as determined by other members of the project's leadership. 53 | 54 | ## Attribution 55 | 56 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at 57 | [https://www.contributor-covenant.org/version/1/4/code-of-conduct.html][version] 58 | 59 | [homepage]: https://www.contributor-covenant.org/ 60 | [version]: https://www.contributor-covenant.org/version/1/4/ 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a 2 | copy of this software and associated documentation files (the 3 | "Software"), to deal in the Software without restriction, including 4 | without limitation the rights to use, copy, modify, merge, publish, 5 | distribute, sublicense, and/or sell copies of the Software, and to 6 | permit persons to whom the Software is furnished to do so, subject to 7 | the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included 10 | in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 13 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 14 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 15 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 16 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 17 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 18 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tox 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/tox)](https://pypi.org/project/tox/) 4 | [![Supported Python 5 | versions](https://img.shields.io/pypi/pyversions/tox.svg)](https://pypi.org/project/tox/) 6 | [![Downloads](https://static.pepy.tech/badge/tox/month)](https://pepy.tech/project/tox) 7 | [![Documentation 8 | status](https://readthedocs.org/projects/tox/badge/?version=latest)](https://tox.readthedocs.io/en/latest/?badge=latest) 9 | [![check](https://github.com/tox-dev/tox/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/tox/actions/workflows/check.yaml) 10 | 11 | `tox` aims to automate and standardize testing in Python. It is part of a larger vision of easing the packaging, testing 12 | and release process of Python software (alongside [pytest](https://docs.pytest.org/en/latest/) and 13 | [devpi](https://www.devpi.net)). 14 | 15 | tox is a generic virtual environment management and test command line tool you can use for: 16 | 17 | - checking your package builds and installs correctly under different environments (such as different Python 18 | implementations, versions or installation dependencies), 19 | - running your tests in each of the environments with the test tool of choice, 20 | - acting as a frontend to continuous integration servers, greatly reducing boilerplate and merging CI and shell-based 21 | testing. 22 | 23 | Please read our [user guide](https://tox.wiki/en/latest/user_guide.html#basic-example) for an example and more detailed 24 | introduction, or watch [this YouTube video](https://www.youtube.com/watch?v=SFqna5ilqig) that presents the problem space 25 | and how tox solves it. 26 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Vulnerabilities 2 | 3 | **⚠️ Please do not file public GitHub issues for security vulnerabilities as they are open for everyone to see! ⚠️** 4 | 5 | We encourage responsible disclosure practices for security vulnerabilities. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | If you believe you've found a security-related bug, fill out a new 10 | vulnerability report via GitHub directly. To do so, follow these instructions: 11 | 12 | 1. Click on the `Security` tab in the project repository. 13 | 2. Click the green `Report a vulnerability` button at the top right corner. 14 | 3. Fill in the form as accurately as you can, including as many details as possible. 15 | 4. Click the green `Submit report` button at the bottom. 16 | 17 | ## Don't have a GitHub account? 18 | 19 | Alternatively, to report a security vulnerability, please use the 20 | [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 21 | 22 | It is currently set up to forward every incoming report to Bernát Gábor. We will try to assess the problem in timely 23 | manner and disclose it in a responsible way. 24 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | blockquote { 2 | border-left: none; 3 | font-style: normal; 4 | margin-left: 1.5rem; 5 | margin-right: 0; 6 | padding: 0; 7 | } 8 | -------------------------------------------------------------------------------- /docs/_static/img/tox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/docs/_static/img/tox.png -------------------------------------------------------------------------------- /docs/_static/img/toxfavi.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/docs/_static/img/toxfavi.ico -------------------------------------------------------------------------------- /docs/changelog/3534.feature.rst: -------------------------------------------------------------------------------- 1 | Add ``free_threaded`` flag to to ``"python"`` entries in json output of ``--result-json``. 2 | -------------------------------------------------------------------------------- /docs/changelog/template.jinja2: -------------------------------------------------------------------------------- 1 | {% set top_underline = underlines[0] %} 2 | {% if versiondata.name %} 3 | v{{ versiondata.version }} ({{ versiondata.date }}) 4 | {{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}} 5 | {% else %} 6 | {{ versiondata.version }} ({{ versiondata.date }}) 7 | {{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} 8 | {% endif %} 9 | 10 | {% for section, _ in sections.items() %} 11 | {% set underline = underlines[1] %} 12 | {% if sections[section] %} 13 | {% for category, val in definitions.items() if category in sections[section]%} 14 | {{ definitions[category]['name'] }} - {{ versiondata.version }} 15 | {{ underline * ((definitions[category]['name'] + versiondata.version)|length + 3)}} 16 | {% if definitions[category]['showcontent'] %} 17 | {% for text, values in sections[section][category].items() %} 18 | - {{ text }} ({{ values|join(', ') }}) 19 | {% endfor %} 20 | 21 | {% else %} 22 | - {{ sections[section][category]['']|join(', ') }} 23 | 24 | {% endif %} 25 | {% if sections[section][category]|length == 0 %} 26 | No significant changes. 27 | 28 | {% else %} 29 | {% endif %} 30 | {% endfor %} 31 | {% else %} 32 | No significant changes. 33 | 34 | 35 | {% endif %} 36 | {% endfor %} 37 | -------------------------------------------------------------------------------- /docs/cli_interface.rst: -------------------------------------------------------------------------------- 1 | .. _cli: 2 | 3 | .. sphinx_argparse_cli:: 4 | :module: tox.config.cli.parse 5 | :func: _get_parser_doc 6 | -------------------------------------------------------------------------------- /docs/img/overview.mermaidjs: -------------------------------------------------------------------------------- 1 | stateDiagram-v2 2 | %%{init:{'state':{'nodeSpacing': 250, 'rankSpacing': 30}}}%% 3 | 4 | [*] --> conf 5 | conf --> tox_env 6 | 7 | state tox_env { 8 | state hdi <> 9 | state hpi <> 10 | state fpi <> 11 | 12 | [*] --> create 13 | create --> hdi : has (new) project dependencies (deps) 14 | hdi --> deps: yes 15 | hdi --> hpi: no, has package 16 | deps --> hpi: has package 17 | hpi --> fpi: yes, built package in this run 18 | hpi --> commands : no 19 | fpi --> install_deps: yes 20 | fpi --> package: no 21 | package --> install_deps 22 | install_deps --> install 23 | install --> commands 24 | commands --> commands: for each entry
in commands* 25 | commands --> [*] : pass outcome to report 26 | } 27 | tox_env --> tox_env :for each tox environment 28 | 29 | tox_env --> report 30 | report --> report :for each tox environment 31 | report --> [*] 32 | 33 | conf: build configuration (CLI + files)
identify environments to run 34 | create: create an isolated tox environment
the other steps are executed within this 35 | deps: install project dependencies (if has deps) 36 | package: build package 37 | install: install package without dependencies 38 | install_deps: install (new) package dependencies 39 | commands: run command 40 | report: report the outcome of the run 41 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | As tool 5 | -------- 6 | 7 | :pypi:`tox` is a CLI tool that needs a Python interpreter (version 3.9 or higher) to run. We recommend either 8 | :pypi:`pipx` or :pypi:`uv` to install tox into an isolated environment. This has the added benefit that later you'll 9 | be able to upgrade tox without affecting other parts of the system. We provide method for ``pip`` too here but we 10 | discourage that path if you can: 11 | 12 | .. tab:: uv 13 | 14 | .. code-block:: bash 15 | 16 | # install uv per https://docs.astral.sh/uv/#getting-started 17 | uv tool install tox 18 | tox --help 19 | 20 | 21 | .. tab:: pipx 22 | 23 | .. code-block:: bash 24 | 25 | python -m pip install pipx-in-pipx --user 26 | pipx install tox 27 | tox --help 28 | 29 | .. tab:: pip 30 | 31 | .. code-block:: bash 32 | 33 | python -m pip install --user tox 34 | python -m tox --help 35 | 36 | You can install it within the global Python interpreter itself (perhaps as a user package via the 37 | ``--user`` flag). Be cautious if you are using a Python installation that is managed by your operating system or 38 | another package manager. ``pip`` might not coordinate with those tools, and may leave your system in an inconsistent 39 | state. Note, if you go down this path you need to ensure pip is new enough per the subsections below 40 | 41 | wheel 42 | ~~~~~ 43 | Installing tox via a wheel (default with pip) requires an installer that can understand the ``python-requires`` tag (see 44 | :pep:`503`), with pip this is version ``9.0.0`` (released in November 2016). Furthermore, in case you're not installing 45 | it via PyPI you need to use a mirror that correctly forwards the ``python-requires`` tag (notably the OpenStack mirrors 46 | don't do this, or older :gh_repo:`devpi/devpi` versions - added with version ``4.7.0``). 47 | 48 | .. _sdist: 49 | 50 | sdist 51 | ~~~~~ 52 | When installing via a source distribution you need an installer that handles the :pep:`517` specification. In case of 53 | ``pip`` this is version ``18.0.0`` or later (released in July 2018). If you cannot upgrade your pip to support this you 54 | need to ensure that the build requirements from :gh:`pyproject.toml ` are 55 | satisfied before triggering the installation. 56 | 57 | via ``setup.py`` 58 | ---------------- 59 | We don't recommend and officially support this method. You should prefer using an installer that supports :pep:`517` 60 | interface, such as pip ``19.0.0`` or later. That being said you might be able to still install a package via this method 61 | if you satisfy build dependencies before calling the installation command (as described under :ref:`sdist`). 62 | 63 | latest unreleased 64 | ----------------- 65 | Installing an unreleased version is discouraged and should be only done for testing purposes. If you do so you'll need 66 | a pip version of at least ``18.0.0`` and use the following command: 67 | 68 | 69 | .. code-block:: bash 70 | 71 | pip install git+https://github.com/tox-dev/tox.git@main 72 | 73 | .. _compatibility-requirements: 74 | 75 | Python and OS Compatibility 76 | --------------------------- 77 | 78 | tox works with the following Python interpreter implementations: 79 | 80 | - `CPython `_ versions 3.9, 3.10, 3.11, 3.12, 3.13 81 | 82 | This means tox works on the latest patch version of each of these minor versions. Previous patch versions are supported 83 | on a best effort approach. 84 | -------------------------------------------------------------------------------- /docs/plugins.rst: -------------------------------------------------------------------------------- 1 | Extending tox 2 | ============= 3 | 4 | Extensions points 5 | ~~~~~~~~~~~~~~~~~ 6 | 7 | .. automodule:: tox.plugin 8 | :members: 9 | :exclude-members: impl 10 | 11 | .. autodata:: tox.plugin.impl 12 | :no-value: 13 | 14 | .. automodule:: tox.plugin.spec 15 | :members: 16 | 17 | A plugin can define its plugin module a: 18 | 19 | .. code-block:: python 20 | 21 | def tox_append_version_info() -> str: 22 | return "magic" 23 | 24 | and this message will be appended to the output of the ``--version`` flag. 25 | 26 | Adoption of a plugin under tox-dev Github organization 27 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 28 | 29 | You're free to host your plugin on your favorite platform, however the core tox development is happening on Github, 30 | under the ``tox-dev`` org organization. We are happy to adopt tox plugins under the ``tox-dev`` organization if: 31 | 32 | - 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 33 | directory), 34 | - it's released on PyPI with at least 100 downloads per month (to ensure it's a plugin used by people). 35 | 36 | What's in for you in this: 37 | 38 | - you get owner rights on the repository under the tox-dev organization, 39 | - exposure of your plugin under the core umbrella, 40 | - backup maintainers from other tox plugin development. 41 | 42 | How to apply: 43 | 44 | - create an issue under the ``tox-dev/tox`` Github repository with the title 45 | :gh:`Adopt plugin \ `, 46 | - wait for the green light by one of our maintainers (see :ref:`current-maintainers`), 47 | - follow the `guidance by Github 48 | `_, 49 | - (optionally) add at least one other people as co-maintainer on PyPI. 50 | 51 | Migration from tox 3 52 | ~~~~~~~~~~~~~~~~~~~~ 53 | This section explains how the plugin interface changed between tox 3 and 4, and provides guidance for plugin developers 54 | on how to migrate. 55 | 56 | ``tox_get_python_executable`` 57 | ----------------------------- 58 | With tox 4 the Python discovery is performed ``tox.tox_env.python.virtual_env.api._get_python`` that delegates the job 59 | to ``virtualenv``. Therefore first `define a new virtualenv discovery mechanism 60 | `_ and then set that by setting the 61 | ``VIRTUALENV_DISCOVERY`` environment variable. 62 | 63 | ``tox_package`` 64 | --------------- 65 | Register new packager types via :func:`tox_register_tox_env `. 66 | 67 | ``tox_addoption`` 68 | ----------------- 69 | Renamed to :func:`tox_add_option `. 70 | -------------------------------------------------------------------------------- /ignore-words.txt: -------------------------------------------------------------------------------- 1 | releas 2 | master 3 | -------------------------------------------------------------------------------- /src/tox/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .run import main 4 | from .version import version as __version__ 5 | 6 | __all__ = ( 7 | "__version__", 8 | "main", 9 | ) 10 | -------------------------------------------------------------------------------- /src/tox/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from tox.run import run 4 | 5 | if __name__ == "__main__": 6 | run() 7 | -------------------------------------------------------------------------------- /src/tox/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/src/tox/config/__init__.py -------------------------------------------------------------------------------- /src/tox/config/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/src/tox/config/cli/__init__.py -------------------------------------------------------------------------------- /src/tox/config/cli/env_var.py: -------------------------------------------------------------------------------- 1 | """Provides configuration values from the environment variables.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import os 7 | from typing import Any, List 8 | 9 | from tox.config.loader.str_convert import StrConvert 10 | 11 | CONVERT = StrConvert() 12 | 13 | 14 | def get_env_var(key: str, of_type: type[Any]) -> tuple[Any, str] | None: 15 | """ 16 | Get the environment variable option. 17 | 18 | :param key: the config key requested 19 | :param of_type: the type we would like to convert it to 20 | :return: 21 | """ 22 | key_upper = key.upper() 23 | for environ_key in (f"TOX_{key_upper}", f"TOX{key_upper}"): 24 | if environ_key in os.environ: 25 | value = os.environ[environ_key] 26 | origin = getattr(of_type, "__origin__", of_type.__class__) 27 | try: 28 | if origin in {list, List}: 29 | entry_type = of_type.__args__[0] 30 | result = [CONVERT.to(raw=v, of_type=entry_type, factory=None) for v in value.split(";")] 31 | else: 32 | result = CONVERT.to(raw=value, of_type=of_type, factory=None) 33 | except Exception as exception: # noqa: BLE001 34 | logging.warning( 35 | "env var %s=%r cannot be transformed to %r because %r", 36 | environ_key, 37 | value, 38 | of_type, 39 | exception, 40 | ) 41 | else: 42 | return result, f"env var {environ_key}" 43 | return None 44 | 45 | 46 | __all__ = ("get_env_var",) 47 | -------------------------------------------------------------------------------- /src/tox/config/cli/ini.py: -------------------------------------------------------------------------------- 1 | """Provides configuration values from tox.ini files.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | import os 7 | from configparser import ConfigParser 8 | from pathlib import Path 9 | from typing import Any, ClassVar 10 | 11 | from platformdirs import user_config_dir 12 | 13 | from tox.config.loader.api import ConfigLoadArgs 14 | from tox.config.loader.ini import IniLoader 15 | from tox.config.source.ini_section import CORE 16 | 17 | DEFAULT_CONFIG_FILE = Path(user_config_dir("tox")) / "config.ini" 18 | 19 | 20 | class IniConfig: 21 | TOX_CONFIG_FILE_ENV_VAR = "TOX_USER_CONFIG_FILE" 22 | STATE: ClassVar[dict[bool | None, str]] = {None: "failed to parse", True: "active", False: "missing"} 23 | 24 | def __init__(self) -> None: 25 | config_file = os.environ.get(self.TOX_CONFIG_FILE_ENV_VAR, None) 26 | self.is_env_var = config_file is not None 27 | self.config_file = Path(config_file if config_file is not None else DEFAULT_CONFIG_FILE) 28 | self._cache: dict[tuple[str, type[Any]], Any] = {} 29 | self.has_config_file: bool | None = self.config_file.exists() 30 | self.ini: IniLoader | None = None 31 | 32 | if self.has_config_file: 33 | self.config_file = self.config_file.absolute() 34 | try: 35 | parser = ConfigParser(interpolation=None) 36 | with self.config_file.open() as file_handler: 37 | parser.read_file(file_handler) 38 | self.has_tox_section = parser.has_section(CORE.key) 39 | if self.has_tox_section: 40 | self.ini = IniLoader(CORE, parser, overrides=[], core_section=CORE) 41 | except Exception as exception: # noqa: BLE001 42 | logging.error("failed to read config file %s because %r", config_file, exception) # noqa: TRY400 43 | self.has_config_file = None 44 | 45 | def get(self, key: str, of_type: type[Any]) -> Any: 46 | cache_key = key, of_type 47 | if cache_key in self._cache: 48 | result = self._cache[cache_key] 49 | else: 50 | try: 51 | if self.ini is None: # pragma: no cover # this can only happen if we don't call __bool__ firsts 52 | result = None 53 | else: 54 | source = "file" 55 | args = ConfigLoadArgs(chain=[key], name=CORE.prefix, env_name=None) 56 | value = self.ini.load(key, of_type=of_type, conf=None, factory=None, args=args) 57 | result = value, source 58 | except KeyError: # just not found 59 | result = None 60 | except Exception as exception: # noqa: BLE001 61 | logging.warning("%s key %s as type %r failed with %r", self.config_file, key, of_type, exception) 62 | result = None 63 | self._cache[cache_key] = result 64 | return result 65 | 66 | def __bool__(self) -> bool: 67 | return bool(self.has_config_file) and bool(self.has_tox_section) 68 | 69 | @property 70 | def epilog(self) -> str: 71 | # text to show within the parsers epilog 72 | return ( 73 | f"{os.linesep}config file {str(self.config_file)!r} {self.STATE[self.has_config_file]} " 74 | f"(change{'d' if self.is_env_var else ''} via env var {self.TOX_CONFIG_FILE_ENV_VAR})" 75 | ) 76 | -------------------------------------------------------------------------------- /src/tox/config/cli/parse.py: -------------------------------------------------------------------------------- 1 | """This module pulls together this package: create and parse CLI arguments for tox.""" 2 | 3 | from __future__ import annotations 4 | 5 | import locale 6 | import os 7 | from contextlib import redirect_stderr 8 | from pathlib import Path 9 | from typing import TYPE_CHECKING, Callable, NamedTuple, Sequence, cast 10 | 11 | from tox.config.source import Source, discover_source 12 | from tox.report import ToxHandler, setup_report 13 | 14 | from .parser import Parsed, ToxParser 15 | 16 | if TYPE_CHECKING: 17 | from tox.session.state import State 18 | 19 | 20 | class Options(NamedTuple): 21 | parsed: Parsed 22 | pos_args: Sequence[str] | None 23 | source: Source 24 | cmd_handlers: dict[str, Callable[[State], int]] 25 | log_handler: ToxHandler 26 | 27 | 28 | def get_options(*args: str) -> Options: 29 | pos_args: tuple[str, ...] | None = None 30 | try: # remove positional arguments passed to parser if specified, they are pulled directly from sys.argv 31 | pos_arg_at = args.index("--") 32 | except ValueError: 33 | pass 34 | else: 35 | pos_args = tuple(args[pos_arg_at + 1 :]) 36 | args = args[:pos_arg_at] 37 | 38 | guess_verbosity, log_handler, source = _get_base(args) 39 | parsed, cmd_handlers = _get_all(args) 40 | if guess_verbosity != parsed.verbosity: 41 | log_handler.update_verbosity(parsed.verbosity) 42 | return Options(parsed, pos_args, source, cmd_handlers, log_handler) 43 | 44 | 45 | def _get_base(args: Sequence[str]) -> tuple[int, ToxHandler, Source]: 46 | """First just load the base options (verbosity+color) to setup the logging framework.""" 47 | tox_parser = ToxParser.base() 48 | parsed = Parsed() 49 | try: 50 | with Path(os.devnull).open( 51 | "w", encoding=locale.getpreferredencoding(do_setlocale=False) 52 | ) as file_handler, redirect_stderr(file_handler): 53 | tox_parser.parse_known_args(args, namespace=parsed) 54 | except SystemExit: 55 | ... # ignore parse errors, such as -va raises ignored explicit argument 'a' 56 | guess_verbosity = parsed.verbosity 57 | handler = setup_report(guess_verbosity, parsed.is_colored) 58 | from tox.plugin.manager import MANAGER # load the plugin system right after we set up report # noqa: PLC0415 59 | 60 | source = discover_source(parsed.config_file, parsed.root_dir) 61 | 62 | MANAGER.load_plugins(source.path) 63 | 64 | return guess_verbosity, handler, source 65 | 66 | 67 | def _get_all(args: Sequence[str]) -> tuple[Parsed, dict[str, Callable[[State], int]]]: 68 | """Parse all the options.""" 69 | tox_parser = _get_parser() 70 | parsed = cast("Parsed", tox_parser.parse_args(args)) 71 | handlers = {k: p for k, (_, p) in tox_parser.handlers.items()} 72 | return parsed, handlers 73 | 74 | 75 | def _get_parser() -> ToxParser: 76 | tox_parser = ToxParser.core() # load the core options 77 | # plus options setup by plugins 78 | from tox.plugin.manager import MANAGER # noqa: PLC0415 79 | 80 | MANAGER.tox_add_option(tox_parser) 81 | tox_parser.fix_defaults() 82 | return tox_parser 83 | 84 | 85 | def _get_parser_doc() -> ToxParser: 86 | # trigger register of tox env types (during normal run we call this later to handle plugins) 87 | from tox.plugin.manager import MANAGER # pragma: no cover # noqa: PLC0415 88 | 89 | MANAGER.load_plugins(Path.cwd()) 90 | 91 | return _get_parser() # pragma: no cover 92 | 93 | 94 | __all__ = ( 95 | "Options", 96 | "get_options", 97 | ) 98 | -------------------------------------------------------------------------------- /src/tox/config/loader/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/src/tox/config/loader/__init__.py -------------------------------------------------------------------------------- /src/tox/config/loader/memory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING, Any, Iterator 5 | 6 | from tox.config.types import Command, EnvList 7 | 8 | from .api import Loader 9 | from .section import Section 10 | from .str_convert import StrConvert 11 | 12 | if TYPE_CHECKING: 13 | from tox.config.main import Config 14 | 15 | 16 | class MemoryLoader(Loader[Any]): 17 | """Loads configuration directly from data in memory.""" 18 | 19 | def __init__(self, **kwargs: Any) -> None: 20 | super().__init__(Section(prefix="", name=str(id(self))), []) 21 | self.raw: dict[str, Any] = {**kwargs} 22 | 23 | def load_raw(self, key: Any, conf: Config | None, env_name: str | None) -> Any: # noqa: ARG002 24 | return self.raw[key] 25 | 26 | def found_keys(self) -> set[str]: 27 | return set(self.raw.keys()) 28 | 29 | @staticmethod 30 | def to_bool(value: Any) -> bool: 31 | return bool(value) 32 | 33 | @staticmethod 34 | def to_str(value: Any) -> str: 35 | return str(value) 36 | 37 | @staticmethod 38 | def to_list(value: Any, of_type: type[Any]) -> Iterator[Any]: # noqa: ARG004 39 | return iter(value) 40 | 41 | @staticmethod 42 | def to_set(value: Any, of_type: type[Any]) -> Iterator[Any]: # noqa: ARG004 43 | return iter(value) 44 | 45 | @staticmethod 46 | def to_dict(value: Any, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[Any, Any]]: # noqa: ARG004 47 | return value.items() # type: ignore[no-any-return] 48 | 49 | @staticmethod 50 | def to_path(value: Any) -> Path: 51 | return Path(value) 52 | 53 | @staticmethod 54 | def to_command(value: Any) -> Command | None: 55 | if isinstance(value, Command): 56 | return value 57 | if isinstance(value, str): 58 | return StrConvert.to_command(value) 59 | raise TypeError(value) 60 | 61 | @staticmethod 62 | def to_env_list(value: Any) -> EnvList: 63 | if isinstance(value, EnvList): 64 | return value 65 | if isinstance(value, str): 66 | return StrConvert.to_env_list(value) 67 | raise TypeError(value) 68 | -------------------------------------------------------------------------------- /src/tox/config/loader/section.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | import sys 7 | 8 | if sys.version_info >= (3, 11): # pragma: no cover (py311+) 9 | from typing import Self 10 | else: # pragma: no cover ( None: 20 | self._prefix = prefix 21 | self._name = name 22 | 23 | @classmethod 24 | def from_key(cls: type[Self], key: str) -> Self: 25 | """ 26 | Create a section from a section key. 27 | 28 | :param key: the section key 29 | :return: the constructed section 30 | """ 31 | sep_at = key.find(cls.SEP) 32 | if sep_at == -1: 33 | prefix, name = None, key 34 | else: 35 | prefix, name = key[:sep_at], key[sep_at + 1 :] 36 | return cls(prefix, name) 37 | 38 | @property 39 | def prefix(self) -> str | None: 40 | """:return: the prefix of the section""" 41 | return self._prefix 42 | 43 | @property 44 | def name(self) -> str: 45 | """:return: the name of the section""" 46 | return self._name 47 | 48 | @property 49 | def key(self) -> str: 50 | """:return: the section key""" 51 | return self.SEP.join(i for i in (self._prefix, self._name) if i is not None) 52 | 53 | def __str__(self) -> str: 54 | return self.key 55 | 56 | def __repr__(self) -> str: 57 | return f"{self.__class__.__name__}(prefix={self._prefix!r}, name={self._name!r})" 58 | 59 | def __eq__(self, other: object) -> bool: 60 | return isinstance(other, self.__class__) and (self._prefix, self._name) == ( 61 | other._prefix, 62 | other.name, 63 | ) 64 | 65 | 66 | __all__ = [ 67 | "Section", 68 | ] 69 | -------------------------------------------------------------------------------- /src/tox/config/loader/stringify.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import Any, Mapping, Sequence, Set 5 | 6 | from tox.config.set_env import SetEnv 7 | from tox.config.types import Command, EnvList 8 | from tox.tox_env.python.pip.req_file import PythonDeps 9 | 10 | 11 | def stringify(value: Any) -> tuple[str, bool]: # noqa: PLR0911 12 | """ 13 | Transform a value into a string representation. 14 | 15 | :param value: the value in question 16 | :return: a tuple, first the value as str, second a flag if the value if a multi-line one 17 | """ 18 | if isinstance(value, str): 19 | return value, False 20 | if isinstance(value, (Path, float, int, bool)): 21 | return str(value), False 22 | if isinstance(value, Mapping): 23 | return "\n".join(f"{stringify(k)[0]}={stringify(v)[0]}" for k, v in value.items()), True 24 | if isinstance(value, Sequence): 25 | return "\n".join(stringify(i)[0] for i in value), True 26 | if isinstance(value, Set): # sort it to make it stable 27 | return "\n".join(sorted(stringify(i)[0] for i in value)), True 28 | if isinstance(value, EnvList): 29 | return "\n".join(e for e in value.envs), True 30 | if isinstance(value, Command): 31 | return value.shell, True 32 | if isinstance(value, SetEnv): 33 | env_var_keys = sorted(value) 34 | return stringify({k: value.load(k) for k in env_var_keys}) 35 | if isinstance(value, PythonDeps): 36 | return stringify(value.lines()) 37 | return str(value), False 38 | 39 | 40 | __all__ = ("stringify",) 41 | -------------------------------------------------------------------------------- /src/tox/config/loader/toml/_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Dict, List, Union 4 | 5 | if TYPE_CHECKING: 6 | import sys 7 | 8 | if sys.version_info >= (3, 10): # pragma: no cover (py310+) 9 | from typing import TypeAlias 10 | else: # pragma: no cover (py310+) 11 | from typing_extensions import TypeAlias 12 | 13 | TomlTypes: TypeAlias = Union[Dict[str, "TomlTypes"], List["TomlTypes"], str, int, float, bool, None] 14 | 15 | __all__ = [ 16 | "TomlTypes", 17 | ] 18 | -------------------------------------------------------------------------------- /src/tox/config/loader/toml/_validate.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from inspect import isclass 4 | from typing import ( 5 | TYPE_CHECKING, 6 | Any, 7 | Dict, 8 | List, 9 | Literal, 10 | TypeVar, 11 | Union, 12 | cast, 13 | ) 14 | 15 | from tox.config.types import Command 16 | 17 | if TYPE_CHECKING: 18 | import sys 19 | 20 | from ._api import TomlTypes 21 | 22 | if sys.version_info >= (3, 11): # pragma: no cover (py311+) 23 | from typing import TypeGuard 24 | else: # pragma: no cover (py311+) 25 | from typing_extensions import TypeGuard 26 | 27 | T = TypeVar("T") 28 | 29 | 30 | def validate(val: TomlTypes, of_type: type[T]) -> TypeGuard[T]: # noqa: C901, PLR0912 31 | casting_to = getattr(of_type, "__origin__", of_type.__class__) 32 | msg = "" 33 | if casting_to in {list, List}: 34 | entry_type = of_type.__args__[0] # type: ignore[attr-defined] 35 | if isinstance(val, list): 36 | for va in val: 37 | validate(va, entry_type) 38 | else: 39 | msg = f"{val!r} is not list" 40 | elif isclass(of_type) and issubclass(of_type, Command): 41 | # first we cast it to list then create commands, so for now validate it as a nested list 42 | validate(val, List[str]) 43 | elif casting_to in {dict, Dict}: 44 | key_type, value_type = of_type.__args__[0], of_type.__args__[1] # type: ignore[attr-defined] 45 | if isinstance(val, dict): 46 | for va in val: 47 | validate(va, key_type) 48 | for va in val.values(): 49 | validate(va, value_type) 50 | else: 51 | msg = f"{val!r} is not dictionary" 52 | elif casting_to == Union: # handle Optional values 53 | args: list[type[Any]] = of_type.__args__ # type: ignore[attr-defined] 54 | for arg in args: 55 | try: 56 | validate(val, arg) 57 | break 58 | except TypeError: 59 | pass 60 | else: 61 | msg = f"{val!r} is not union of {', '.join(a.__name__ for a in args)}" 62 | elif casting_to in {Literal, type(Literal)}: 63 | choice = of_type.__args__ # type: ignore[attr-defined] 64 | if val not in choice: 65 | msg = f"{val!r} is not one of literal {','.join(repr(i) for i in choice)}" 66 | elif not isinstance(val, of_type): 67 | if issubclass(of_type, (bool, str, int)): 68 | fail = not isinstance(val, of_type) 69 | else: 70 | try: # check if it can be converted 71 | of_type(val) # type: ignore[call-arg] 72 | fail = False 73 | except Exception: # noqa: BLE001 74 | fail = True 75 | if fail: 76 | msg = f"{val!r} is not of type {of_type.__name__!r}" 77 | if msg: 78 | raise TypeError(msg) 79 | return cast("T", val) # type: ignore[return-value] # logic too complicated for mypy 80 | 81 | 82 | __all__ = [ 83 | "validate", 84 | ] 85 | -------------------------------------------------------------------------------- /src/tox/config/source/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .api import Source 4 | from .discover import discover_source 5 | 6 | __all__ = ( 7 | "Source", 8 | "discover_source", 9 | ) 10 | -------------------------------------------------------------------------------- /src/tox/config/source/discover.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from itertools import chain 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | 8 | from tox.report import HandledError 9 | 10 | from .legacy_toml import LegacyToml 11 | from .setup_cfg import SetupCfg 12 | from .toml_pyproject import TomlPyProject 13 | from .toml_tox import TomlTox 14 | from .tox_ini import ToxIni 15 | 16 | if TYPE_CHECKING: 17 | from .api import Source 18 | 19 | SOURCE_TYPES: tuple[type[Source], ...] = ( 20 | ToxIni, 21 | SetupCfg, 22 | LegacyToml, 23 | TomlPyProject, 24 | TomlTox, 25 | ) 26 | 27 | 28 | def discover_source(config_file: Path | None, root_dir: Path | None) -> Source: 29 | """ 30 | Discover a source for configuration. 31 | 32 | :param config_file: the file storing the source 33 | :param root_dir: the root directory as set by the user (None means not set) 34 | :return: the source of the config 35 | """ 36 | if config_file is None: 37 | src = _locate_source() 38 | if src is None: 39 | src = _create_default_source(root_dir) 40 | elif config_file.is_dir(): 41 | src = None 42 | for src_type in SOURCE_TYPES: 43 | candidate: Path = config_file / src_type.FILENAME 44 | try: 45 | src = src_type(candidate) 46 | break 47 | except ValueError: 48 | continue 49 | if src is None: 50 | msg = f"could not find any config file in {config_file}" 51 | raise HandledError(msg) 52 | else: 53 | src = _load_exact_source(config_file) 54 | return src 55 | 56 | 57 | def _locate_source() -> Source | None: 58 | folder = Path.cwd() 59 | for base in chain([folder], folder.parents): 60 | for src_type in SOURCE_TYPES: 61 | candidate: Path = base / src_type.FILENAME 62 | try: 63 | return src_type(candidate) 64 | except ValueError: 65 | pass 66 | return None 67 | 68 | 69 | def _load_exact_source(config_file: Path) -> Source: 70 | # if the filename matches to the letter some config file name do not fallback to other source types 71 | exact_match = [s for s in SOURCE_TYPES if config_file.name == s.FILENAME] # pragma: no cover 72 | for src_type in exact_match or SOURCE_TYPES: # pragma: no branch 73 | try: 74 | return src_type(config_file) 75 | except ValueError: # noqa: PERF203 76 | pass 77 | msg = f"could not recognize config file {config_file}" 78 | raise HandledError(msg) 79 | 80 | 81 | def _create_default_source(root_dir: Path | None) -> Source: 82 | if root_dir is None: # if set use that 83 | empty = Path.cwd() 84 | for base in chain([empty], empty.parents): 85 | if (base / "pyproject.toml").exists(): 86 | empty = base 87 | break 88 | else: # if not set use where we find pyproject.toml in the tree or cwd 89 | empty = root_dir 90 | names = " or ".join({i.FILENAME: None for i in SOURCE_TYPES}) 91 | logging.warning("No %s found, assuming empty tox.ini at %s", names, empty) 92 | return ToxIni(empty / "tox.ini", content="") 93 | 94 | 95 | __all__ = ("discover_source",) 96 | -------------------------------------------------------------------------------- /src/tox/config/source/ini_section.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from tox.config.loader.ini.factor import expand_ranges, extend_factors 4 | from tox.config.loader.section import Section 5 | 6 | 7 | class IniSection(Section): 8 | @classmethod 9 | def test_env(cls, name: str) -> IniSection: 10 | return cls(TEST_ENV_PREFIX, name) 11 | 12 | @property 13 | def is_test_env(self) -> bool: 14 | return self.prefix == TEST_ENV_PREFIX 15 | 16 | @property 17 | def names(self) -> list[str]: 18 | return list(extend_factors(expand_ranges(self.name))) 19 | 20 | 21 | TEST_ENV_PREFIX = "testenv" 22 | PKG_ENV_PREFIX = "pkgenv" 23 | CORE = IniSection(None, "tox") 24 | 25 | __all__ = [ 26 | "CORE", 27 | "PKG_ENV_PREFIX", 28 | "TEST_ENV_PREFIX", 29 | "IniSection", 30 | ] 31 | -------------------------------------------------------------------------------- /src/tox/config/source/legacy_toml.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | if sys.version_info >= (3, 11): # pragma: no cover (py311+) 6 | import tomllib 7 | else: # pragma: no cover (py311+) 8 | import tomli as tomllib 9 | 10 | 11 | from typing import TYPE_CHECKING 12 | 13 | from .ini import IniSource 14 | 15 | if TYPE_CHECKING: 16 | from pathlib import Path 17 | 18 | 19 | class LegacyToml(IniSource): 20 | FILENAME = "pyproject.toml" 21 | 22 | def __init__(self, path: Path) -> None: 23 | if path.name != self.FILENAME or not path.exists(): 24 | raise ValueError 25 | with path.open("rb") as file_handler: 26 | toml_content = tomllib.load(file_handler) 27 | try: 28 | content = toml_content["tool"]["tox"]["legacy_tox_ini"] 29 | except KeyError as exc: 30 | raise ValueError(path) from exc 31 | super().__init__(path, content=content) 32 | 33 | 34 | __all__ = ("LegacyToml",) 35 | -------------------------------------------------------------------------------- /src/tox/config/source/setup_cfg.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from .ini import IniSource 6 | from .ini_section import IniSection 7 | 8 | if TYPE_CHECKING: 9 | from pathlib import Path 10 | 11 | 12 | class SetupCfg(IniSource): 13 | """Configuration sourced from a tox.ini file.""" 14 | 15 | CORE_SECTION = IniSection("tox", "tox") 16 | FILENAME = "setup.cfg" 17 | 18 | def __init__(self, path: Path) -> None: 19 | super().__init__(path) 20 | if not self._parser.has_section(self.CORE_SECTION.key): 21 | raise ValueError 22 | 23 | 24 | __all__ = ("SetupCfg",) 25 | -------------------------------------------------------------------------------- /src/tox/config/source/toml_tox.py: -------------------------------------------------------------------------------- 1 | """Load from a tox.toml file.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .toml_pyproject import TomlPyProject, TomlSection 6 | 7 | 8 | class TomlToxSection(TomlSection): 9 | PREFIX = () 10 | 11 | 12 | class TomlTox(TomlPyProject): 13 | """Configuration sourced from a pyproject.toml files.""" 14 | 15 | FILENAME = "tox.toml" 16 | _Section = TomlToxSection 17 | 18 | 19 | __all__ = [ 20 | "TomlTox", 21 | ] 22 | -------------------------------------------------------------------------------- /src/tox/config/source/tox_ini.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .ini import IniSource 4 | 5 | 6 | class ToxIni(IniSource): 7 | """Configuration sourced from a tox.ini file.""" 8 | 9 | FILENAME = "tox.ini" 10 | 11 | 12 | __all__ = ("ToxIni",) 13 | -------------------------------------------------------------------------------- /src/tox/config/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import OrderedDict 4 | from typing import Iterator, Sequence 5 | 6 | from tox.execute.request import shell_cmd 7 | 8 | 9 | class CircularChainError(ValueError): 10 | """circular chain in config""" 11 | 12 | 13 | class Command: # noqa: PLW1641 14 | """A command to execute.""" 15 | 16 | def __init__(self, args: list[str]) -> None: 17 | """ 18 | Create a new command to execute. 19 | 20 | :param args: the command line arguments (first value can be ``-`` to indicate ignore the exit code) 21 | """ 22 | self.ignore_exit_code: bool = args[0] == "-" #: a flag indicating if the exit code should be ignored 23 | self.invert_exit_code: bool = args[0] == "!" #: a flag for flipped exit code (non-zero = success, 0 = error) 24 | self.args: list[str] = ( 25 | args[1:] if self.ignore_exit_code or self.invert_exit_code else args 26 | ) #: the command line arguments 27 | 28 | def __repr__(self) -> str: 29 | args = (["-"] if self.ignore_exit_code else ["!"] if self.invert_exit_code else []) + self.args 30 | return f"{type(self).__name__}(args={args!r})" 31 | 32 | def __eq__(self, other: object) -> bool: 33 | return type(self) == type(other) and (self.args, self.ignore_exit_code, self.invert_exit_code) == ( # noqa: E721 34 | other.args, # type: ignore[attr-defined] 35 | other.ignore_exit_code, # type: ignore[attr-defined] 36 | other.invert_exit_code, # type: ignore[attr-defined] 37 | ) 38 | 39 | def __ne__(self, other: object) -> bool: 40 | return not (self == other) 41 | 42 | @property 43 | def shell(self) -> str: 44 | """:return: a shell representation of the command (platform dependent)""" 45 | return shell_cmd(self.args) 46 | 47 | 48 | class EnvList: # noqa: PLW1641 49 | """A tox environment list.""" 50 | 51 | def __init__(self, envs: Sequence[str]) -> None: 52 | """ 53 | Crate a new tox environment list. 54 | 55 | :param envs: the list of tox environments 56 | """ 57 | self.envs = list(OrderedDict((e, None) for e in envs).keys()) 58 | 59 | def __repr__(self) -> str: 60 | return f"{type(self).__name__}({self.envs!r})" 61 | 62 | def __eq__(self, other: object) -> bool: 63 | return type(self) == type(other) and self.envs == other.envs # type: ignore[attr-defined] # noqa: E721 64 | 65 | def __ne__(self, other: object) -> bool: 66 | return not (self == other) 67 | 68 | def __iter__(self) -> Iterator[str]: 69 | """:return: iterator that goes through the defined env-list""" 70 | return iter(self.envs) 71 | 72 | 73 | __all__ = ( 74 | "Command", 75 | "EnvList", 76 | ) 77 | -------------------------------------------------------------------------------- /src/tox/execute/__init__.py: -------------------------------------------------------------------------------- 1 | """Package that handles execution of commands within tox environments.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .api import Outcome 6 | from .request import ExecuteRequest 7 | 8 | __all__ = ( 9 | "ExecuteRequest", 10 | "Outcome", 11 | ) 12 | -------------------------------------------------------------------------------- /src/tox/execute/local_sub_process/read_via_thread.py: -------------------------------------------------------------------------------- 1 | """A reader that drain a stream via its file no on a background thread.""" 2 | 3 | from __future__ import annotations 4 | 5 | from abc import ABC, abstractmethod 6 | from threading import Event, Thread 7 | from typing import TYPE_CHECKING, Callable 8 | 9 | if TYPE_CHECKING: 10 | import sys 11 | from types import TracebackType 12 | 13 | if sys.version_info >= (3, 11): # pragma: no cover (py311+) 14 | from typing import Self 15 | else: # pragma: no cover ( None: # noqa: FBT001 24 | self.file_no = file_no 25 | self.stop = Event() 26 | self.thread = Thread(target=self._read_stream, name=f"tox-r-{name}-{file_no}") 27 | self.handler = handler 28 | self._on_exit_drain = drain 29 | 30 | def __enter__(self) -> Self: 31 | self.thread.start() 32 | return self 33 | 34 | def __exit__( 35 | self, 36 | exc_type: type[BaseException] | None, 37 | exc_val: BaseException | None, 38 | exc_tb: TracebackType | None, 39 | ) -> None: 40 | self.stop.set() # signal thread to stop 41 | while self.thread.is_alive(): # wait until it stops 42 | self.thread.join(WAIT_GENERAL) 43 | self._drain_stream() # read anything left 44 | 45 | @abstractmethod 46 | def _read_stream(self) -> None: 47 | raise NotImplementedError 48 | 49 | @abstractmethod 50 | def _drain_stream(self) -> None: 51 | raise NotImplementedError 52 | -------------------------------------------------------------------------------- /src/tox/execute/local_sub_process/read_via_thread_unix.py: -------------------------------------------------------------------------------- 1 | """On UNIX we use select.select to ensure we drain in a non-blocking fashion.""" 2 | 3 | from __future__ import annotations 4 | 5 | import errno # pragma: win32 no cover 6 | import os # pragma: win32 no cover 7 | import select # pragma: win32 no cover 8 | from typing import Callable 9 | 10 | from .read_via_thread import ReadViaThread # pragma: win32 no cover 11 | 12 | STOP_EVENT_CHECK_PERIODICITY_IN_MS = 0.01 # pragma: win32 no cover 13 | 14 | 15 | class ReadViaThreadUnix(ReadViaThread): # pragma: win32 no cover 16 | def __init__(self, file_no: int, handler: Callable[[bytes], None], name: str, drain: bool) -> None: # noqa: FBT001 17 | super().__init__(file_no, handler, name, drain) 18 | 19 | def _read_stream(self) -> None: 20 | while not self.stop.is_set(): 21 | # we need to drain the stream, but periodically give chance for the thread to break if the stop event has 22 | # been set (this is so that an interrupt can be handled) 23 | if self._read_available() is None: # pragma: no branch 24 | break # pragma: no cover 25 | 26 | def _drain_stream(self) -> None: 27 | # no block just poll 28 | while True: 29 | if self._read_available(timeout=0) is not True: # pragma: no branch 30 | break # pragma: no cover 31 | 32 | def _read_available(self, timeout: float = STOP_EVENT_CHECK_PERIODICITY_IN_MS) -> bool | None: 33 | try: 34 | ready, __, ___ = select.select([self.file_no], [], [], timeout) 35 | if ready: 36 | data = os.read(self.file_no, 1024) # read up to 1024 characters 37 | # If the end of the file referred to by fd has been reached, an empty bytes object is returned. 38 | if data: 39 | self.handler(data) 40 | return True 41 | except OSError as exception: # pragma: no cover 42 | # Bad file descriptor or Input/output error 43 | if exception.errno in {errno.EBADF, errno.EIO}: 44 | return None 45 | raise 46 | else: 47 | return False 48 | -------------------------------------------------------------------------------- /src/tox/execute/local_sub_process/read_via_thread_windows.py: -------------------------------------------------------------------------------- 1 | """On Windows we use overlapped mechanism, borrowing it from asyncio (but without the event loop).""" 2 | 3 | from __future__ import annotations # pragma: win32 cover 4 | 5 | import _overlapped # type: ignore[import] # pragma: win32 cover # noqa: PLC2701 6 | import logging # pragma: win32 cover 7 | from asyncio.windows_utils import BUFSIZE # type: ignore[attr-defined] # pragma: win32 cover 8 | from time import sleep # pragma: win32 cover 9 | from typing import Callable # pragma: win32 cover 10 | 11 | from .read_via_thread import ReadViaThread # pragma: win32 cover 12 | 13 | # mypy: warn-unused-ignores=false 14 | 15 | 16 | class ReadViaThreadWindows(ReadViaThread): # pragma: win32 cover 17 | def __init__(self, file_no: int, handler: Callable[[bytes], None], name: str, drain: bool) -> None: # noqa: FBT001 18 | super().__init__(file_no, handler, name, drain) 19 | self.closed = False 20 | self._ov: _overlapped.Overlapped | None = None 21 | self._waiting_for_read = False 22 | 23 | def _read_stream(self) -> None: 24 | keep_reading = True 25 | while keep_reading: # try to read at least once 26 | wait = self._read_batch() 27 | if wait is None: 28 | break 29 | if wait is True: 30 | sleep(0.01) # sleep for 10ms if there was no data to read and try again 31 | keep_reading = not self.stop.is_set() 32 | 33 | def _drain_stream(self) -> None: 34 | wait: bool | None = self.closed 35 | while wait is False: 36 | wait = self._read_batch() 37 | 38 | def _read_batch(self) -> bool | None: 39 | """:returns: None means error can no longer read, True wait for result, False try again""" 40 | if self._waiting_for_read is False: 41 | self._ov = _overlapped.Overlapped(0) # can use it only once to read a batch 42 | try: # read up to BUFSIZE at a time 43 | self._ov.ReadFile(self.file_no, BUFSIZE) # type: ignore[attr-defined] 44 | self._waiting_for_read = True 45 | except OSError: 46 | self.closed = True 47 | return None 48 | try: # wait=False to not block and give chance for the stop check 49 | data = self._ov.getresult(False) # type: ignore[union-attr] # noqa: FBT003 50 | except OSError as exception: 51 | # 996 (0x3E4) Overlapped I/O event is not in a signaled state. 52 | # 995 (0x3E3) The I/O operation has been aborted because of either a thread exit or an application request. 53 | win_error = getattr(exception, "winerror", None) 54 | if win_error == 996: # noqa: PLR2004 55 | return True 56 | if win_error != 995: # noqa: PLR2004 57 | logging.error("failed to read %r", exception) # noqa: TRY400 58 | return None 59 | else: 60 | self._ov = None 61 | self._waiting_for_read = False 62 | if data: 63 | self.handler(data) 64 | else: 65 | return None 66 | return False 67 | -------------------------------------------------------------------------------- /src/tox/execute/request.py: -------------------------------------------------------------------------------- 1 | """Module declaring a command execution request.""" 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from enum import Enum 7 | from pathlib import Path 8 | from typing import Sequence 9 | 10 | 11 | class StdinSource(Enum): 12 | OFF = 0 #: input disabled 13 | USER = 1 #: input via the standard input 14 | API = 2 #: input via programmatic access 15 | 16 | @staticmethod 17 | def user_only() -> StdinSource: 18 | """:return: ``USER`` if the standard input is tty type else ``OFF``""" 19 | return StdinSource.USER if sys.stdin.isatty() else StdinSource.OFF 20 | 21 | 22 | class ExecuteRequest: 23 | """Defines a commands execution request.""" 24 | 25 | def __init__( # noqa: PLR0913 26 | self, 27 | cmd: Sequence[str | Path], 28 | cwd: Path, 29 | env: dict[str, str], 30 | stdin: StdinSource, 31 | run_id: str, 32 | allow: list[str] | None = None, 33 | ) -> None: 34 | """ 35 | Create a new execution request. 36 | 37 | :param cmd: the command to run 38 | :param cwd: the current working directory 39 | :param env: the environment variables 40 | :param stdin: the type of standard input allowed 41 | :param run_id: an id to identify this run 42 | """ 43 | if len(cmd) == 0: 44 | msg = "cannot execute an empty command" 45 | raise ValueError(msg) 46 | self.cmd: list[str] = [str(i) for i in cmd] #: the command to run 47 | self.cwd = cwd #: the working directory to use 48 | self.env = env #: the environment variables to use 49 | self.stdin = stdin #: the type of standard input interaction allowed 50 | self.run_id = run_id #: an id to identify this run 51 | if allow is not None and "*" in allow: 52 | allow = None # if we allow everything we can just disable the check 53 | self.allow = allow 54 | 55 | @property 56 | def shell_cmd(self) -> str: 57 | """:return: the command to run as a shell command""" 58 | try: 59 | exe = str(Path(self.cmd[0]).relative_to(self.cwd)) 60 | except ValueError: 61 | exe = self.cmd[0] 62 | cmd = [exe] 63 | cmd.extend(self.cmd[1:]) 64 | return shell_cmd(cmd) 65 | 66 | def __repr__(self) -> str: 67 | return f"{self.__class__.__name__}(cmd={self.cmd!r}, cwd={self.cwd!r}, env=..., stdin={self.stdin!r})" 68 | 69 | 70 | def shell_cmd(cmd: Sequence[str]) -> str: 71 | if sys.platform == "win32": # pragma: win32 cover 72 | from subprocess import list2cmdline # noqa: PLC0415 73 | 74 | return list2cmdline(tuple(str(x) for x in cmd)) 75 | # pragma: win32 no cover 76 | from shlex import quote as shlex_quote # noqa: PLC0415 77 | 78 | return " ".join(shlex_quote(str(x)) for x in cmd) 79 | 80 | 81 | __all__ = ( 82 | "ExecuteRequest", 83 | "StdinSource", 84 | "shell_cmd", 85 | ) 86 | -------------------------------------------------------------------------------- /src/tox/execute/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | 6 | def shebang(exe: str) -> list[str] | None: 7 | """ 8 | :param exe: the executable 9 | :return: the shebang interpreter arguments 10 | """ 11 | # When invoking a command using a shebang line that exceeds the OS shebang limit (e.g. Linux has a limit of 128; 12 | # BINPRM_BUF_SIZE) the invocation will fail. In this case you'd want to replace the shebang invocation with an 13 | # explicit invocation. 14 | # see https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/fs/binfmt_script.c#n34 15 | try: 16 | with Path(exe).open("rb") as file_handler: 17 | marker = file_handler.read(2) 18 | if marker != b"#!": 19 | return None 20 | shebang_line = file_handler.readline() 21 | except OSError: 22 | return None 23 | try: 24 | decoded = shebang_line.decode("UTF-8") 25 | except UnicodeDecodeError: 26 | return None 27 | return [i.strip() for i in decoded.strip().split() if i.strip()] 28 | 29 | 30 | __all__ = [ 31 | "shebang", 32 | ] 33 | -------------------------------------------------------------------------------- /src/tox/journal/__init__.py: -------------------------------------------------------------------------------- 1 | """This module handles collecting and persisting in json format a tox session.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import locale 7 | from pathlib import Path 8 | 9 | from .env import EnvJournal 10 | from .main import Journal 11 | 12 | 13 | def write_journal(path: Path | None, journal: Journal) -> None: 14 | if path is None: 15 | return 16 | with Path(path).open("w", encoding=locale.getpreferredencoding(do_setlocale=False)) as file_handler: 17 | json.dump(journal.content, file_handler, indent=2, ensure_ascii=False) 18 | 19 | 20 | __all__ = ( 21 | "EnvJournal", 22 | "Journal", 23 | "write_journal", 24 | ) 25 | -------------------------------------------------------------------------------- /src/tox/journal/env.py: -------------------------------------------------------------------------------- 1 | """Record information about tox environments.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Any 6 | 7 | if TYPE_CHECKING: 8 | from tox.execute import Outcome 9 | 10 | 11 | class EnvJournal: 12 | """Report the status of a tox environment.""" 13 | 14 | def __init__(self, enabled: bool, name: str) -> None: # noqa: FBT001 15 | self._enabled = enabled 16 | self.name = name 17 | self._content: dict[str, Any] = {} 18 | self._executes: list[tuple[str, Outcome]] = [] 19 | 20 | def __setitem__(self, key: str, value: Any) -> None: 21 | """ 22 | Add a new entry under key into the event journal. 23 | 24 | :param key: the key under what to add the data 25 | :param value: the data to add 26 | """ 27 | self._content[key] = value 28 | 29 | def __bool__(self) -> bool: 30 | """:return: a flag indicating if the event journal is on or not""" 31 | return self._enabled 32 | 33 | def add_execute(self, outcome: Outcome, run_id: str) -> None: 34 | """ 35 | Add a command execution to the journal. 36 | 37 | :param outcome: the execution outcome 38 | :param run_id: the execution id 39 | """ 40 | self._executes.append((run_id, outcome)) 41 | 42 | @property 43 | def content(self) -> dict[str, Any]: 44 | """:return: the env journal content (merges explicit keys and execution commands)""" 45 | tests: list[dict[str, Any]] = [] 46 | setup: list[dict[str, Any]] = [] 47 | for run_id, outcome in self._executes: 48 | one = { 49 | "command": outcome.cmd, 50 | "output": outcome.out, 51 | "err": outcome.err, 52 | "retcode": outcome.exit_code, 53 | "elapsed": outcome.elapsed, 54 | "show_on_standard": outcome.show_on_standard, 55 | "run_id": run_id, 56 | "start": outcome.start, 57 | "end": outcome.end, 58 | } 59 | if run_id.startswith(("commands", "build")): 60 | tests.append(one) 61 | else: 62 | setup.append(one) 63 | if tests: 64 | self["test"] = tests 65 | if setup: 66 | self["setup"] = setup 67 | return self._content 68 | 69 | 70 | __all__ = ("EnvJournal",) 71 | -------------------------------------------------------------------------------- /src/tox/journal/main.py: -------------------------------------------------------------------------------- 1 | """Generate json report of a tox run.""" 2 | 3 | from __future__ import annotations 4 | 5 | import socket 6 | import sys 7 | from typing import Any 8 | 9 | from tox.version import version 10 | 11 | from .env import EnvJournal 12 | 13 | 14 | class Journal: 15 | """The result of a tox session.""" 16 | 17 | def __init__(self, enabled: bool) -> None: # noqa: FBT001 18 | self._enabled = enabled 19 | self._content: dict[str, Any] = {} 20 | self._env: dict[str, EnvJournal] = {} 21 | 22 | if self._enabled: 23 | self._content.update( 24 | { 25 | "reportversion": "1", 26 | "toxversion": version, 27 | "platform": sys.platform, 28 | "host": socket.getfqdn(), 29 | }, 30 | ) 31 | 32 | def get_env_journal(self, name: str) -> EnvJournal: 33 | """Return the env log of an environment (create on first call).""" 34 | if name not in self._env: 35 | env = EnvJournal(self._enabled, name) 36 | self._env[name] = env 37 | return self._env[name] 38 | 39 | @property 40 | def content(self) -> dict[str, Any]: 41 | test_env_journals: dict[str, Any] = {} 42 | for name, value in self._env.items(): 43 | test_env_journals[name] = value.content 44 | if test_env_journals: 45 | self._content["testenvs"] = test_env_journals 46 | return self._content 47 | 48 | def __bool__(self) -> bool: 49 | return self._enabled 50 | 51 | 52 | __all__ = ("Journal",) 53 | -------------------------------------------------------------------------------- /src/tox/plugin/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | tox uses `pluggy `_ to customize the default behavior. It provides an 3 | extension mechanism for plugin management by calling hooks. 4 | 5 | Pluggy discovers a plugin by looking up for entry-points named ``tox``, for example in a pyproject.toml: 6 | 7 | .. code-block:: toml 8 | 9 | [project.entry-points.tox] 10 | your_plugin = "your_plugin.hooks" 11 | 12 | Therefore, to start using a plugin, you solely need to install it in the same environment tox is running in and it will 13 | be discovered via the defined entry-point (in the example above, tox will load ``your_plugin.hooks``). 14 | 15 | A plugin is created by implementing extension points in the form of hooks. For example the following code snippet would 16 | define a new ``--magic`` command line interface flag the user can specify: 17 | 18 | .. code-block:: python 19 | 20 | from tox.config.cli.parser import ToxParser 21 | from tox.plugin import impl 22 | 23 | 24 | @impl 25 | def tox_add_option(parser: ToxParser) -> None: 26 | parser.add_argument("--magic", action="store_true", help="magical flag") 27 | 28 | You can define such hooks either in a package installed alongside tox or within a ``toxfile.py`` found alongside your 29 | tox configuration file (root of your project). 30 | """ 31 | 32 | from __future__ import annotations 33 | 34 | from typing import Any, Callable, TypeVar 35 | 36 | import pluggy 37 | 38 | NAME = "tox" #: the name of the tox hook 39 | 40 | _F = TypeVar("_F", bound=Callable[..., Any]) 41 | impl: Callable[[_F], _F] = pluggy.HookimplMarker(NAME) #: decorator to mark tox plugin hooks 42 | 43 | 44 | __all__ = ( 45 | "NAME", 46 | "impl", 47 | ) 48 | -------------------------------------------------------------------------------- /src/tox/plugin/inline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | import sys 5 | from typing import TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: 8 | from pathlib import Path 9 | from types import ModuleType 10 | 11 | 12 | def load_inline(path: Path) -> ModuleType | None: 13 | # nox uses here the importlib.machinery.SourceFileLoader but I consider this similarly good, and we can keep any 14 | # name for the tox file, its content will always be loaded in this module from a system point of view 15 | for name in ("toxfile", "☣"): 16 | candidate = path.parent / f"{name}.py" 17 | if candidate.exists(): 18 | return _load_plugin(candidate) 19 | return None 20 | 21 | 22 | def _load_plugin(path: Path) -> ModuleType: 23 | in_folder = path.parent 24 | module_name = path.stem 25 | 26 | sys.path.insert(0, str(in_folder)) 27 | try: 28 | if module_name in sys.modules: 29 | del sys.modules[module_name] # pragma: no cover 30 | return importlib.import_module(module_name) 31 | finally: 32 | del sys.path[0] 33 | -------------------------------------------------------------------------------- /src/tox/plugin/spec.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | import pluggy 6 | 7 | from . import NAME 8 | 9 | if TYPE_CHECKING: 10 | from tox.config.cli.parser import ToxParser 11 | from tox.config.sets import ConfigSet, EnvConfigSet 12 | from tox.execute import Outcome 13 | from tox.session.state import State 14 | from tox.tox_env.api import ToxEnv 15 | from tox.tox_env.register import ToxEnvRegister 16 | 17 | _spec = pluggy.HookspecMarker(NAME) 18 | 19 | 20 | @_spec 21 | def tox_register_tox_env(register: ToxEnvRegister) -> None: 22 | """ 23 | Register new tox environment type. You can register: 24 | 25 | - **run environment**: by default this is a local subprocess backed virtualenv Python 26 | - **packaging environment**: by default this is a PEP-517 compliant local subprocess backed virtualenv Python 27 | 28 | :param register: a object that can be used to register new tox environment types 29 | """ 30 | 31 | 32 | @_spec 33 | def tox_add_option(parser: ToxParser) -> None: 34 | """ 35 | Add a command line argument. This is the first hook to be called, right after the logging setup and config source 36 | discovery. 37 | 38 | :param parser: the command line parser 39 | """ 40 | 41 | 42 | @_spec 43 | def tox_add_core_config(core_conf: ConfigSet, state: State) -> None: 44 | """ 45 | Called when the core configuration is built for a tox environment. 46 | 47 | :param core_conf: the core configuration object 48 | :param state: the global tox state object 49 | """ 50 | 51 | 52 | @_spec 53 | def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None: 54 | """ 55 | Called when configuration is built for a tox environment. 56 | 57 | :param env_conf: the core configuration object 58 | :param state: the global tox state object 59 | """ 60 | 61 | 62 | @_spec 63 | def tox_before_run_commands(tox_env: ToxEnv) -> None: 64 | """ 65 | Called before the commands set is executed. 66 | 67 | :param tox_env: the tox environment being executed 68 | """ 69 | 70 | 71 | @_spec 72 | def tox_after_run_commands(tox_env: ToxEnv, exit_code: int, outcomes: list[Outcome]) -> None: 73 | """ 74 | Called after the commands set is executed. 75 | 76 | :param tox_env: the tox environment being executed 77 | :param exit_code: exit code of the command 78 | :param outcomes: outcome of each command execution 79 | """ 80 | 81 | 82 | @_spec 83 | def tox_on_install(tox_env: ToxEnv, arguments: Any, section: str, of_type: str) -> None: 84 | """ 85 | Called before executing an installation command. 86 | 87 | :param tox_env: the tox environment where the command runs in 88 | :param arguments: installation arguments 89 | :param section: section of the installation 90 | :param of_type: type of the installation 91 | """ 92 | 93 | 94 | @_spec 95 | def tox_env_teardown(tox_env: ToxEnv) -> None: 96 | """ 97 | Called after a tox environment has been teared down. 98 | 99 | :param tox_env: the tox environment 100 | """ 101 | 102 | 103 | __all__ = [ 104 | "NAME", 105 | "tox_add_core_config", 106 | "tox_add_env_config", 107 | "tox_add_option", 108 | "tox_after_run_commands", 109 | "tox_before_run_commands", 110 | "tox_env_teardown", 111 | "tox_on_install", 112 | "tox_register_tox_env", 113 | ] 114 | -------------------------------------------------------------------------------- /src/tox/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/src/tox/py.typed -------------------------------------------------------------------------------- /src/tox/run.py: -------------------------------------------------------------------------------- 1 | """Main entry point for tox.""" 2 | 3 | from __future__ import annotations 4 | 5 | import faulthandler 6 | import logging 7 | import os 8 | import sys 9 | import time 10 | from typing import Sequence 11 | 12 | from tox.config.cli.parse import get_options 13 | from tox.report import HandledError, ToxHandler 14 | from tox.session.state import State 15 | 16 | 17 | def run(args: Sequence[str] | None = None) -> None: 18 | try: 19 | with ToxHandler.patch_thread(): 20 | result = main(sys.argv[1:] if args is None else args) 21 | except Exception as exception: 22 | if isinstance(exception, HandledError): 23 | logging.error("%s| %s", type(exception).__name__, str(exception)) # noqa: TRY400 24 | result = -2 25 | else: 26 | raise 27 | except KeyboardInterrupt: 28 | result = -2 29 | finally: 30 | if "_TOX_SHOW_THREAD" in os.environ: # pragma: no cover 31 | import threading # pragma: no cover # noqa: PLC0415 32 | 33 | for thread in threading.enumerate(): # pragma: no cover 34 | print(thread) # pragma: no cover # noqa: T201 35 | raise SystemExit(result) 36 | 37 | 38 | def main(args: Sequence[str]) -> int: 39 | state = setup_state(args) 40 | from tox.provision import provision # noqa: PLC0415 41 | 42 | result = provision(state) 43 | if result is not False: 44 | return result 45 | handler = state._options.cmd_handlers[state.conf.options.command] # noqa: SLF001 46 | return handler(state) 47 | 48 | 49 | def setup_state(args: Sequence[str]) -> State: 50 | """Setup the state object of this run.""" 51 | start = time.monotonic() 52 | # parse CLI arguments 53 | options = get_options(*args) 54 | options.parsed.start = start 55 | if options.parsed.exit_and_dump_after: 56 | faulthandler.dump_traceback_later(timeout=options.parsed.exit_and_dump_after, exit=True) # pragma: no cover 57 | # build tox environment config objects 58 | return State(options, args) 59 | -------------------------------------------------------------------------------- /src/tox/session/__init__.py: -------------------------------------------------------------------------------- 1 | """Package that handles execution of various commands within tox.""" 2 | 3 | from __future__ import annotations 4 | -------------------------------------------------------------------------------- /src/tox/session/cmd/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/src/tox/session/cmd/__init__.py -------------------------------------------------------------------------------- /src/tox/session/cmd/depends.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, cast 4 | 5 | from tox.plugin import impl 6 | from tox.session.cmd.run.common import env_run_create_flags, run_order 7 | 8 | if TYPE_CHECKING: 9 | from tox.config.cli.parser import ToxParser 10 | from tox.session.state import State 11 | from tox.tox_env.runner import RunToxEnv 12 | 13 | 14 | @impl 15 | def tox_add_option(parser: ToxParser) -> None: 16 | our = parser.add_command( 17 | "depends", 18 | ["de"], 19 | "visualize tox environment dependencies", 20 | depends, 21 | ) 22 | env_run_create_flags(our, mode="depends") 23 | 24 | 25 | def depends(state: State) -> int: 26 | to_run_list = list(state.envs.iter(only_active=False)) 27 | order, todo = run_order(state, to_run_list) 28 | print(f"Execution order: {', '.join(order)}") # noqa: T201 29 | 30 | deps: dict[str, list[str]] = {k: [o for o in order if o in v] for k, v in todo.items()} 31 | deps["ALL"] = to_run_list 32 | 33 | def _handle(at: int, env: str) -> None: 34 | print(" " * at, end="") # noqa: T201 35 | print(env, end="") # noqa: T201 36 | if env != "ALL": 37 | run_env = cast("RunToxEnv", state.envs[env]) 38 | packager_list: list[str] = [] 39 | try: 40 | for pkg_env in run_env.package_envs: 41 | packager_list.append(pkg_env.name) # noqa: PERF401 42 | except Exception as exception: # noqa: BLE001 43 | packager_list.append(f"... ({exception})") 44 | names = " | ".join(packager_list) 45 | if names: 46 | print(f" ~ {names}", end="") # noqa: T201 47 | print() # noqa: T201 48 | at += 1 49 | for dep in deps[env]: 50 | _handle(at, dep) 51 | 52 | _handle(0, "ALL") 53 | return 0 54 | -------------------------------------------------------------------------------- /src/tox/session/cmd/devenv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | from tox.config.loader.memory import MemoryLoader 8 | from tox.plugin import impl 9 | from tox.report import HandledError 10 | from tox.session.cmd.run.common import env_run_create_flags 11 | from tox.session.cmd.run.sequential import run_sequential 12 | from tox.session.env_select import CliEnv, register_env_select_flags 13 | 14 | if TYPE_CHECKING: 15 | from tox.config.cli.parser import ToxParser 16 | from tox.session.state import State 17 | 18 | 19 | @impl 20 | def tox_add_option(parser: ToxParser) -> None: 21 | help_msg = "sets up a development environment at ENVDIR based on the tox configuration specified " 22 | our = parser.add_command("devenv", ["d"], help_msg, devenv) 23 | our.add_argument("devenv_path", metavar="path", default=Path("venv"), nargs="?", type=Path) 24 | register_env_select_flags(our, default=CliEnv("py"), multiple=False) 25 | env_run_create_flags(our, mode="devenv") 26 | 27 | 28 | def devenv(state: State) -> int: 29 | opt = state.conf.options 30 | opt.devenv_path = opt.devenv_path.absolute() 31 | opt.skip_missing_interpreters = False # the target python must exist 32 | opt.no_test = False # do not run the test suite 33 | opt.package_only = False 34 | opt.install_pkg = None # no explicit packages to install 35 | opt.skip_pkg_install = False # always install a package in this case 36 | opt.no_test = True # do not run the test phase 37 | loader = MemoryLoader( # these configuration values are loaded from in-memory always (no file conf) 38 | usedevelop=True, # dev environments must be of type dev 39 | env_dir=opt.devenv_path, # move it in source 40 | ) 41 | state.conf.memory_seed_loaders[next(iter(opt.env))].append(loader) 42 | 43 | state.envs.ensure_only_run_env_is_active() 44 | envs = list(state.envs.iter()) 45 | if len(envs) != 1: 46 | msg = f"exactly one target environment allowed in devenv mode but found {', '.join(envs)}" 47 | raise HandledError(msg) 48 | result = run_sequential(state) 49 | if result == 0: 50 | logging.warning("created development environment under %s", opt.devenv_path) 51 | return result 52 | -------------------------------------------------------------------------------- /src/tox/session/cmd/exec_.py: -------------------------------------------------------------------------------- 1 | """Execute a command in a tox environment.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from tox.config.loader.memory import MemoryLoader 8 | from tox.config.types import Command 9 | from tox.plugin import impl 10 | from tox.report import HandledError 11 | from tox.session.cmd.run.common import env_run_create_flags 12 | from tox.session.cmd.run.sequential import run_sequential 13 | from tox.session.env_select import CliEnv, register_env_select_flags 14 | 15 | if TYPE_CHECKING: 16 | from pathlib import Path 17 | 18 | from tox.config.cli.parser import ToxParser 19 | from tox.session.state import State 20 | 21 | 22 | @impl 23 | def tox_add_option(parser: ToxParser) -> None: 24 | our = parser.add_command("exec", ["e"], "execute an arbitrary command within a tox environment", exec_) 25 | our.epilog = "For example: tox exec -e py39 -- python --version" 26 | register_env_select_flags(our, default=CliEnv("py"), multiple=False) 27 | env_run_create_flags(our, mode="exec") 28 | 29 | 30 | def exec_(state: State) -> int: 31 | state.conf.options.skip_pkg_install = True # avoid package install 32 | envs = list(state.envs.iter()) 33 | if len(envs) != 1: 34 | msg = f"exactly one target environment allowed in exec mode but found {', '.join(envs)}" 35 | raise HandledError(msg) 36 | loader = MemoryLoader( # these configuration values are loaded from in-memory always (no file conf) 37 | commands_pre=[], 38 | commands=[], 39 | commands_post=[], 40 | ) 41 | conf = state.envs[envs[0]].conf 42 | conf.loaders.insert(0, loader) 43 | to_path: Path | None = conf["change_dir"] if conf["args_are_paths"] else None 44 | pos_args = state.conf.pos_args(to_path) 45 | if not pos_args: 46 | msg = "You must specify a command as positional arguments, use -- " 47 | raise HandledError(msg) 48 | loader.raw["commands"] = [Command(list(pos_args))] 49 | return run_sequential(state) 50 | -------------------------------------------------------------------------------- /src/tox/session/cmd/list_env.py: -------------------------------------------------------------------------------- 1 | """Print available tox environments.""" 2 | 3 | from __future__ import annotations 4 | 5 | from itertools import chain 6 | from typing import TYPE_CHECKING 7 | 8 | from tox.plugin import impl 9 | from tox.session.env_select import register_env_select_flags 10 | 11 | if TYPE_CHECKING: 12 | from tox.config.cli.parser import ToxParser 13 | from tox.session.state import State 14 | 15 | 16 | @impl 17 | def tox_add_option(parser: ToxParser) -> None: 18 | our = parser.add_command("list", ["l"], "list environments", list_env) 19 | our.add_argument("--no-desc", action="store_true", help="do not show description", dest="list_no_description") 20 | d = register_env_select_flags(our, default=None, group_only=True) 21 | d.add_argument("-d", action="store_true", help="list just default envs", dest="list_default_only") 22 | 23 | 24 | def list_env(state: State) -> int: 25 | option = state.conf.options 26 | has_group_select = bool(option.factors or option.labels) 27 | active_only = has_group_select or option.list_default_only 28 | 29 | active = dict.fromkeys(state.envs.iter()) 30 | inactive = {} if active_only else {env: None for env in state.envs.iter(only_active=False) if env not in active} 31 | 32 | if not has_group_select and not option.list_no_description and active: 33 | print("default environments:") # noqa: T201 34 | max_length = max((len(env) for env in chain(active, inactive)), default=0) 35 | 36 | def report_env(name: str) -> None: 37 | if not option.list_no_description: 38 | tox_env = state.envs[name] 39 | text = tox_env.conf["description"] 40 | if not text.strip(): 41 | text = "[no description]" 42 | text = text.replace("\n", " ") 43 | msg = f"{env.ljust(max_length)} -> {text}".strip() 44 | else: 45 | msg = env 46 | print(msg) # noqa: T201 47 | 48 | for env in active: 49 | report_env(env) 50 | 51 | if not has_group_select and not option.list_default_only and inactive: 52 | if not option.list_no_description: 53 | if active: # pragma: no branch 54 | print() # noqa: T201 55 | print("additional environments:") # noqa: T201 56 | for env in inactive: 57 | report_env(env) 58 | return 0 59 | -------------------------------------------------------------------------------- /src/tox/session/cmd/quickstart.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from pathlib import Path 5 | from textwrap import dedent 6 | from typing import TYPE_CHECKING 7 | 8 | from packaging.version import Version 9 | 10 | from tox.plugin import impl 11 | from tox.version import version as __version__ 12 | 13 | if TYPE_CHECKING: 14 | from tox.config.cli.parser import ToxParser 15 | from tox.session.state import State 16 | 17 | 18 | @impl 19 | def tox_add_option(parser: ToxParser) -> None: 20 | our = parser.add_command( 21 | "quickstart", 22 | ["q"], 23 | "Command line script to quickly create a tox config file for a Python project", 24 | quickstart, 25 | ) 26 | our.add_argument( 27 | "quickstart_root", 28 | metavar="root", 29 | default=Path().absolute(), 30 | nargs="?", 31 | help="folder to create the tox.ini file", 32 | type=Path, 33 | ) 34 | 35 | 36 | def quickstart(state: State) -> int: 37 | root = state.conf.options.quickstart_root.absolute() 38 | tox_ini = root / "tox.ini" 39 | if tox_ini.exists(): 40 | print(f"{tox_ini} already exist, refusing to overwrite") # noqa: T201 41 | return 1 42 | version = str(Version(__version__.split("+")[0])) 43 | text = f""" 44 | [tox] 45 | env_list = 46 | py{"".join(str(i) for i in sys.version_info[0:2])} 47 | minversion = {version} 48 | 49 | [testenv] 50 | description = run the tests with pytest 51 | package = wheel 52 | wheel_build_env = .pkg 53 | deps = 54 | pytest>=6 55 | commands = 56 | pytest {{tty:--color=yes}} {{posargs}} 57 | """ 58 | content = dedent(text).lstrip() 59 | 60 | print(f"tox {__version__} quickstart utility, will create {tox_ini}:") # noqa: T201 61 | print(content, end="") # noqa: T201 62 | 63 | root.mkdir(parents=True, exist_ok=True) 64 | tox_ini.write_text(content) 65 | return 0 66 | -------------------------------------------------------------------------------- /src/tox/session/cmd/run/__init__.py: -------------------------------------------------------------------------------- 1 | """Defines how we execute a tox environment.""" 2 | 3 | from __future__ import annotations 4 | -------------------------------------------------------------------------------- /src/tox/session/cmd/run/parallel.py: -------------------------------------------------------------------------------- 1 | """Run tox environments in parallel.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from argparse import ArgumentParser, ArgumentTypeError 7 | from typing import TYPE_CHECKING 8 | 9 | from tox.plugin import impl 10 | from tox.session.env_select import CliEnv, register_env_select_flags 11 | from tox.util.ci import is_ci 12 | from tox.util.cpu import auto_detect_cpus 13 | 14 | from .common import env_run_create_flags, execute 15 | 16 | if TYPE_CHECKING: 17 | from tox.config.cli.parser import ToxParser 18 | from tox.session.state import State 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | ENV_VAR_KEY = "TOX_PARALLEL_ENV" 23 | OFF_VALUE = 0 24 | DEFAULT_PARALLEL = "auto" 25 | 26 | 27 | @impl 28 | def tox_add_option(parser: ToxParser) -> None: 29 | our = parser.add_command("run-parallel", ["p"], "run environments in parallel", run_parallel) 30 | register_env_select_flags(our, default=CliEnv()) 31 | env_run_create_flags(our, mode="run-parallel") 32 | parallel_flags(our, default_parallel=DEFAULT_PARALLEL, default_spinner=is_ci()) 33 | 34 | 35 | def parse_num_processes(str_value: str) -> int | None: 36 | if str_value == "all": 37 | return None 38 | if str_value == "auto": 39 | return auto_detect_cpus() 40 | try: 41 | value = int(str_value) 42 | except ValueError as exc: 43 | msg = f"value must be a positive number, is {str_value!r}" 44 | raise ArgumentTypeError(msg) from exc 45 | if value < 0: 46 | msg = f"value must be positive, is {value!r}" 47 | raise ArgumentTypeError(msg) 48 | return value 49 | 50 | 51 | def parallel_flags( 52 | our: ArgumentParser, 53 | default_parallel: int | str, 54 | no_args: bool = False, # noqa: FBT001, FBT002 55 | *, 56 | default_spinner: bool = False, 57 | ) -> None: 58 | our.add_argument( 59 | "-p", 60 | "--parallel", 61 | dest="parallel", 62 | help="run tox environments in parallel, the argument controls limit: all," 63 | " auto - cpu count, some positive number, zero is turn off", 64 | action="store", 65 | type=parse_num_processes, 66 | default=default_parallel, 67 | metavar="VAL", 68 | **({"nargs": "?"} if no_args else {}), # type: ignore[arg-type] # type checker can't unroll it 69 | ) 70 | our.add_argument( 71 | "-o", 72 | "--parallel-live", 73 | action="store_true", 74 | dest="parallel_live", 75 | help="connect to stdout while running environments", 76 | ) 77 | our.add_argument( 78 | "--parallel-no-spinner", 79 | action="store_true", 80 | dest="parallel_no_spinner", 81 | default=default_spinner, 82 | help=( 83 | "run tox environments in parallel, but don't show the spinner, implies --parallel. " 84 | "Disabled by default if CI is detected (not in legacy API)." 85 | ), 86 | ) 87 | 88 | 89 | def run_parallel(state: State) -> int: 90 | """Here we'll just start parallel sub-processes.""" 91 | option = state.conf.options 92 | return execute( 93 | state, 94 | max_workers=auto_detect_cpus() if option.parallel == 0 else option.parallel, 95 | has_spinner=option.parallel_no_spinner is False and option.parallel_live is False, 96 | live=option.parallel_live, 97 | ) 98 | -------------------------------------------------------------------------------- /src/tox/session/cmd/run/sequential.py: -------------------------------------------------------------------------------- 1 | """Run tox environments in sequential order.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from tox.plugin import impl 8 | from tox.session.env_select import CliEnv, register_env_select_flags 9 | 10 | from .common import env_run_create_flags, execute 11 | 12 | if TYPE_CHECKING: 13 | from tox.config.cli.parser import ToxParser 14 | from tox.session.state import State 15 | 16 | 17 | @impl 18 | def tox_add_option(parser: ToxParser) -> None: 19 | our = parser.add_command("run", ["r"], "run environments", run_sequential) 20 | register_env_select_flags(our, default=CliEnv()) 21 | env_run_create_flags(our, mode="run") 22 | 23 | 24 | def run_sequential(state: State) -> int: 25 | return execute(state, max_workers=1, has_spinner=False, live=True) 26 | -------------------------------------------------------------------------------- /src/tox/session/cmd/version_flag.py: -------------------------------------------------------------------------------- 1 | """Display the version information about tox.""" 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from argparse import SUPPRESS, Action, ArgumentParser, Namespace 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING, Any, Sequence, cast 9 | 10 | import tox 11 | from tox.plugin import impl 12 | from tox.plugin.manager import MANAGER 13 | from tox.version import version 14 | 15 | if TYPE_CHECKING: 16 | from tox.config.cli.parser import HelpFormatter, ToxParser 17 | 18 | 19 | @impl 20 | def tox_add_option(parser: ToxParser) -> None: 21 | class _V(Action): 22 | def __init__(self, option_strings: Sequence[str], dest: str = SUPPRESS) -> None: 23 | help_msg = "show program's and plugins version number and exit" 24 | super().__init__(option_strings=option_strings, dest=dest, nargs=0, help=help_msg, default=SUPPRESS) 25 | 26 | def __call__( 27 | self, 28 | parser: ArgumentParser, 29 | namespace: Namespace, # noqa: ARG002 30 | values: str | Sequence[Any] | None, # noqa: ARG002 31 | option_string: str | None = None, # noqa: ARG002 32 | ) -> None: 33 | formatter = cast("HelpFormatter", parser._get_formatter()) # noqa: SLF001 34 | formatter.add_raw_text(get_version_info()) 35 | parser._print_message(formatter.format_help(), sys.stdout) # noqa: SLF001 36 | parser.exit() 37 | 38 | parser.add_argument("--version", action=_V) 39 | 40 | 41 | def get_version_info() -> str: 42 | out = [f"{version} from {Path(tox.__file__).absolute()}"] 43 | plugin_info = MANAGER.manager.list_plugin_distinfo() 44 | if plugin_info: 45 | out.append("registered plugins:") 46 | for module, egg_info in plugin_info: 47 | source = getattr(module, "__file__", repr(module)) 48 | info = module.tox_append_version_info() if hasattr(module, "tox_append_version_info") else "" 49 | with_info = f" {info}" if info else "" 50 | out.append(f" {egg_info.project_name}-{egg_info.version} at {source}{with_info}") 51 | return "\n".join(out) 52 | -------------------------------------------------------------------------------- /src/tox/session/state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING, Sequence 5 | 6 | from tox.config.main import Config 7 | from tox.journal import Journal 8 | from tox.plugin import impl 9 | 10 | from .env_select import EnvSelector 11 | 12 | if TYPE_CHECKING: 13 | from tox.config.cli.parse import Options 14 | from tox.config.cli.parser import ToxParser 15 | 16 | 17 | class State: 18 | """Runtime state holder.""" 19 | 20 | def __init__(self, options: Options, args: Sequence[str]) -> None: 21 | self.conf = Config.make(options.parsed, options.pos_args, options.source) 22 | self.conf.core.add_constant( 23 | keys=["on_platform"], 24 | desc="platform we are running on", 25 | value=sys.platform, 26 | ) 27 | self._options = options 28 | self.args = args 29 | self._journal: Journal = Journal(getattr(options.parsed, "result_json", None) is not None) 30 | self._selector: EnvSelector | None = None 31 | 32 | @property 33 | def envs(self) -> EnvSelector: 34 | """:return: provides access to the tox environments""" 35 | if self._selector is None: 36 | self._selector = EnvSelector(self) 37 | return self._selector 38 | 39 | 40 | @impl 41 | def tox_add_option(parser: ToxParser) -> None: 42 | from tox.tox_env.register import REGISTER # noqa: PLC0415 43 | 44 | parser.add_argument( 45 | "--runner", 46 | dest="default_runner", 47 | help="the tox run engine to use when not explicitly stated in tox env configuration", 48 | default=REGISTER.default_env_runner, 49 | choices=list(REGISTER.env_runners), 50 | ) 51 | -------------------------------------------------------------------------------- /src/tox/tox_env/__init__.py: -------------------------------------------------------------------------------- 1 | """Package handling the creation and management of tox environments.""" 2 | 3 | from __future__ import annotations 4 | -------------------------------------------------------------------------------- /src/tox/tox_env/errors.py: -------------------------------------------------------------------------------- 1 | """Defines tox error types.""" 2 | 3 | from __future__ import annotations 4 | 5 | 6 | class Recreate(Exception): # noqa: N818 7 | """Recreate the tox environment.""" 8 | 9 | 10 | class Skip(Exception): # noqa: N818 11 | """Skip this tox environment.""" 12 | 13 | 14 | class Fail(Exception): # noqa: N818 15 | """Failed creating env.""" 16 | -------------------------------------------------------------------------------- /src/tox/tox_env/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | Declare and handle the tox env info file (a file at the root of every tox environment that contains information about 3 | the status of the tox environment - python version of the environment, installed packages, etc.). 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import json 9 | from contextlib import contextmanager 10 | from typing import TYPE_CHECKING, Any, Iterator 11 | 12 | if TYPE_CHECKING: 13 | from pathlib import Path 14 | 15 | 16 | class Info: 17 | """Stores metadata about the tox environment.""" 18 | 19 | def __init__(self, path: Path) -> None: 20 | self._path = path / ".tox-info.json" 21 | try: 22 | value = json.loads(self._path.read_text()) 23 | except (ValueError, OSError): 24 | value = {} 25 | self._content = value 26 | 27 | def __repr__(self) -> str: 28 | return f"{self.__class__.__name__}(path={self._path})" 29 | 30 | @contextmanager 31 | def compare( 32 | self, 33 | value: Any, 34 | section: str, 35 | sub_section: str | None = None, 36 | ) -> Iterator[tuple[bool, Any | None]]: 37 | """ 38 | Compare new information with the existing one and update if differs. 39 | 40 | :param value: the value stored 41 | :param section: the primary key of the information 42 | :param sub_section: the secondary key of the information 43 | :return: a tuple where the first value is if it differs and the second is the old value 44 | """ 45 | old = self._content.get(section) 46 | if sub_section is not None and old is not None: 47 | old = old.get(sub_section) 48 | 49 | if old == value: 50 | yield True, old 51 | else: 52 | yield False, old 53 | # if no exception thrown update 54 | if sub_section is None: 55 | self._content[section] = value 56 | elif self._content.get(section) is None: 57 | self._content[section] = {sub_section: value} 58 | else: 59 | self._content[section][sub_section] = value 60 | self._write() 61 | 62 | def reset(self) -> None: 63 | self._content = {} 64 | 65 | def _write(self) -> None: 66 | self._path.parent.mkdir(parents=True, exist_ok=True) 67 | self._path.write_text(json.dumps(self._content, indent=2)) 68 | 69 | 70 | __all__ = ("Info",) 71 | -------------------------------------------------------------------------------- /src/tox/tox_env/installer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import TYPE_CHECKING, Any, Generic, TypeVar 5 | 6 | if TYPE_CHECKING: 7 | from tox.tox_env.api import ToxEnv 8 | 9 | T = TypeVar("T", bound="ToxEnv") 10 | 11 | 12 | class Installer(ABC, Generic[T]): 13 | def __init__(self, tox_env: T) -> None: 14 | self._env = tox_env 15 | self._register_config() 16 | 17 | @abstractmethod 18 | def _register_config(self) -> None: 19 | """Register configurations for the installer.""" 20 | raise NotImplementedError 21 | 22 | @abstractmethod 23 | def installed(self) -> Any: 24 | """:returns: a list of packages installed (JSON dump-able)""" 25 | raise NotImplementedError 26 | 27 | @abstractmethod 28 | def install(self, arguments: Any, section: str, of_type: str) -> None: 29 | raise NotImplementedError 30 | -------------------------------------------------------------------------------- /src/tox/tox_env/python/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/src/tox/tox_env/python/__init__.py -------------------------------------------------------------------------------- /src/tox/tox_env/python/dependency_groups.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING, TypedDict 5 | 6 | from packaging.requirements import InvalidRequirement, Requirement 7 | from packaging.utils import canonicalize_name 8 | 9 | from tox.tox_env.errors import Fail 10 | 11 | if TYPE_CHECKING: 12 | from pathlib import Path 13 | 14 | 15 | if sys.version_info >= (3, 11): # pragma: no cover (py311+) 16 | import tomllib 17 | else: # pragma: no cover (py311+) 18 | import tomli as tomllib 19 | 20 | _IncludeGroup = TypedDict("_IncludeGroup", {"include-group": str}) 21 | 22 | 23 | def resolve(root: Path, groups: set[str]) -> set[Requirement]: 24 | pyproject_file = root / "pyproject.toml" 25 | if not pyproject_file.exists(): # check if it's static PEP-621 metadata 26 | return set() 27 | with pyproject_file.open("rb") as file_handler: 28 | pyproject = tomllib.load(file_handler) 29 | dependency_groups = pyproject["dependency-groups"] 30 | if not isinstance(dependency_groups, dict): 31 | msg = f"dependency-groups is {type(dependency_groups).__name__} instead of table" 32 | raise Fail(msg) 33 | result: set[Requirement] = set() 34 | for group in groups: 35 | result = result.union(_resolve_dependency_group(dependency_groups, group)) 36 | return result 37 | 38 | 39 | def _resolve_dependency_group( 40 | dependency_groups: dict[str, list[str] | _IncludeGroup], group: str, past_groups: tuple[str, ...] = () 41 | ) -> set[Requirement]: 42 | if group in past_groups: 43 | msg = f"Cyclic dependency group include: {group!r} -> {past_groups!r}" 44 | raise Fail(msg) 45 | if group not in dependency_groups: 46 | msg = f"dependency group {group!r} not found" 47 | raise Fail(msg) 48 | raw_group = dependency_groups[group] 49 | if not isinstance(raw_group, list): 50 | msg = f"dependency group {group!r} is not a list" 51 | raise Fail(msg) 52 | 53 | result = set() 54 | for item in raw_group: 55 | if isinstance(item, str): 56 | # packaging.requirements.Requirement parsing ensures that this is a valid 57 | # PEP 508 Dependency Specifier 58 | # raises InvalidRequirement on failure 59 | try: 60 | result.add(Requirement(item)) 61 | except InvalidRequirement as exc: 62 | msg = f"{item!r} is not valid requirement due to {exc}" 63 | raise Fail(msg) from exc 64 | elif isinstance(item, dict) and tuple(item.keys()) == ("include-group",): 65 | include_group = canonicalize_name(next(iter(item.values()))) 66 | result = result.union(_resolve_dependency_group(dependency_groups, include_group, (*past_groups, group))) 67 | else: 68 | msg = f"invalid dependency group item: {item!r}" 69 | raise Fail(msg) 70 | return result 71 | 72 | 73 | __all__ = [ 74 | "resolve", 75 | ] 76 | -------------------------------------------------------------------------------- /src/tox/tox_env/python/pip/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/src/tox/tox_env/python/pip/__init__.py -------------------------------------------------------------------------------- /src/tox/tox_env/python/pip/req/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Specification is defined within pip itself and documented under: 3 | - https://pip.pypa.io/en/stable/reference/pip_install/#requirements-file-format 4 | - https://github.com/pypa/pip/blob/master/src/pip/_internal/req/constructors.py#L291. 5 | """ 6 | 7 | from __future__ import annotations 8 | -------------------------------------------------------------------------------- /src/tox/tox_env/python/pip/req/util.py: -------------------------------------------------------------------------------- 1 | """Borrowed from the pip code base.""" 2 | 3 | from __future__ import annotations 4 | 5 | from urllib.parse import urlsplit 6 | from urllib.request import url2pathname 7 | 8 | from packaging.utils import canonicalize_name 9 | 10 | VCS = ["ftp", "ssh", "git", "hg", "bzr", "sftp", "svn"] 11 | VALID_SCHEMAS = ["http", "https", "file", *VCS] 12 | 13 | 14 | def is_url(name: str) -> bool: 15 | return get_url_scheme(name) in VALID_SCHEMAS 16 | 17 | 18 | def get_url_scheme(url: str) -> str | None: 19 | if ":" not in url: 20 | return None 21 | return url.split(":", 1)[0].lower() 22 | 23 | 24 | def url_to_path(url: str) -> str: 25 | _, netloc, path, _, _ = urlsplit(url) 26 | if not netloc or netloc == "localhost": # According to RFC 8089, same as empty authority. 27 | netloc = "" 28 | else: 29 | msg = f"non-local file URIs are not supported on this platform: {url!r}" 30 | raise ValueError(msg) 31 | return url2pathname(netloc + path) 32 | 33 | 34 | def handle_binary_option(value: str, target: set[str], other: set[str]) -> None: 35 | new = value.split(",") 36 | while ":all:" in new: 37 | other.clear() 38 | target.clear() 39 | target.add(":all:") 40 | del new[: new.index(":all:") + 1] 41 | if ":none:" not in new: 42 | return 43 | for name in new: 44 | if name == ":none:": 45 | target.clear() 46 | continue 47 | normalized_name = canonicalize_name(name) 48 | other.discard(normalized_name) 49 | target.add(normalized_name) 50 | -------------------------------------------------------------------------------- /src/tox/tox_env/python/virtual_env/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/src/tox/tox_env/python/virtual_env/__init__.py -------------------------------------------------------------------------------- /src/tox/tox_env/python/virtual_env/package/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/src/tox/tox_env/python/virtual_env/package/__init__.py -------------------------------------------------------------------------------- /src/tox/tox_env/python/virtual_env/package/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from copy import deepcopy 4 | from typing import TYPE_CHECKING, Optional, Set, cast 5 | 6 | if TYPE_CHECKING: 7 | from packaging._parser import Op, Variable 8 | from packaging.markers import Marker 9 | from packaging.requirements import Requirement 10 | 11 | 12 | def dependencies_with_extras(deps: list[Requirement], extras: set[str], package_name: str) -> list[Requirement]: 13 | return dependencies_with_extras_from_markers(extract_extra_markers(deps), extras, package_name) 14 | 15 | 16 | def dependencies_with_extras_from_markers( 17 | deps_with_markers: list[tuple[Requirement, set[str | None]]], 18 | extras: set[str], 19 | package_name: str, 20 | ) -> list[Requirement]: 21 | result: list[Requirement] = [] 22 | found: set[str] = set() 23 | todo: set[str | None] = extras | {None} 24 | visited: set[str | None] = set() 25 | while todo: 26 | new_extras: set[str | None] = set() 27 | for req, extra_markers in deps_with_markers: 28 | if todo & extra_markers: 29 | if req.name == package_name: # support for recursive extras 30 | new_extras.update(req.extras or set()) 31 | else: 32 | req_str = str(req) 33 | if req_str not in found: 34 | found.add(req_str) 35 | result.append(req) 36 | visited.update(todo) 37 | todo = new_extras - visited 38 | return result 39 | 40 | 41 | def extract_extra_markers(deps: list[Requirement]) -> list[tuple[Requirement, set[str | None]]]: 42 | """ 43 | Extract extra markers from dependencies. 44 | 45 | :param deps: the dependencies 46 | :return: a list of requirement, extras set 47 | """ 48 | return [_extract_extra_markers(d) for d in deps] 49 | 50 | 51 | def _extract_extra_markers(req: Requirement) -> tuple[Requirement, set[str | None]]: 52 | req = deepcopy(req) 53 | markers: list[str | tuple[Variable, Op, Variable]] = getattr(req.marker, "_markers", []) or [] 54 | new_markers: list[str | tuple[Variable, Op, Variable]] = [] 55 | extra_markers: set[str] = set() # markers that have a key of extra 56 | marker = markers.pop(0) if markers else None 57 | while marker: 58 | extra = _get_extra(marker) 59 | if extra is not None: 60 | extra_markers.add(extra) 61 | if new_markers and new_markers[-1] in {"and", "or"}: 62 | del new_markers[-1] 63 | marker = markers.pop(0) if markers else None 64 | if marker in {"and", "or"}: 65 | marker = markers.pop(0) if markers else None 66 | else: 67 | new_markers.append(marker) 68 | marker = markers.pop(0) if markers else None 69 | if new_markers: 70 | cast("Marker", req.marker)._markers = new_markers # noqa: SLF001 71 | else: 72 | req.marker = None 73 | return req, cast("Set[Optional[str]]", extra_markers) or {None} 74 | 75 | 76 | def _get_extra(_marker: str | tuple[Variable, Op, Variable]) -> str | None: 77 | if ( 78 | isinstance(_marker, tuple) 79 | and len(_marker) == 3 # noqa: PLR2004 80 | and _marker[0].value == "extra" 81 | and _marker[1].value == "==" 82 | ): 83 | return _marker[2].value 84 | return None 85 | -------------------------------------------------------------------------------- /src/tox/tox_env/python/virtual_env/runner.py: -------------------------------------------------------------------------------- 1 | """A tox python environment runner that uses the virtualenv project.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from tox.plugin import impl 8 | from tox.tox_env.python.runner import PythonRun 9 | 10 | from .api import VirtualEnv 11 | 12 | if TYPE_CHECKING: 13 | from pathlib import Path 14 | 15 | from tox.tox_env.register import ToxEnvRegister 16 | 17 | 18 | class VirtualEnvRunner(VirtualEnv, PythonRun): 19 | """local file system python virtual environment via the virtualenv package.""" 20 | 21 | @staticmethod 22 | def id() -> str: 23 | return "virtualenv" 24 | 25 | @property 26 | def _package_tox_env_type(self) -> str: 27 | return "virtualenv-pep-517" 28 | 29 | @property 30 | def _external_pkg_tox_env_type(self) -> str: 31 | return "virtualenv-cmd-builder" 32 | 33 | @property 34 | def default_pkg_type(self) -> str: 35 | tox_root: Path = self.core["tox_root"] 36 | if not (any((tox_root / i).exists() for i in ("pyproject.toml", "setup.py", "setup.cfg"))): 37 | return "skip" 38 | return super().default_pkg_type 39 | 40 | 41 | @impl 42 | def tox_register_tox_env(register: ToxEnvRegister) -> None: 43 | register.add_run_env(VirtualEnvRunner) 44 | -------------------------------------------------------------------------------- /src/tox/tox_env/register.py: -------------------------------------------------------------------------------- 1 | """Manages the tox environment registry.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Iterable 6 | 7 | if TYPE_CHECKING: 8 | from tox.plugin.manager import Plugin 9 | 10 | from .package import PackageToxEnv 11 | from .runner import RunToxEnv 12 | 13 | 14 | class ToxEnvRegister: 15 | """tox environment registry.""" 16 | 17 | def __init__(self) -> None: 18 | self._run_envs: dict[str, type[RunToxEnv]] = {} 19 | self._package_envs: dict[str, type[PackageToxEnv]] = {} 20 | self._default_run_env: str = "" 21 | 22 | def _register_tox_env_types(self, manager: Plugin) -> None: 23 | manager.tox_register_tox_env(register=self) 24 | 25 | def add_run_env(self, of_type: type[RunToxEnv]) -> None: 26 | """ 27 | Define a new run tox environment type. 28 | 29 | :param of_type: the new run environment type 30 | """ 31 | self._run_envs[of_type.id()] = of_type 32 | 33 | def add_package_env(self, of_type: type[PackageToxEnv]) -> None: 34 | """ 35 | Define a new packaging tox environment type. 36 | 37 | :param of_type: the new packaging environment type 38 | """ 39 | self._package_envs[of_type.id()] = of_type 40 | 41 | @property 42 | def env_runners(self) -> Iterable[str]: 43 | """:returns: run environment types currently defined""" 44 | return self._run_envs.keys() 45 | 46 | @property 47 | def default_env_runner(self) -> str: 48 | """:returns: the default run environment type""" 49 | if not self._default_run_env and self._run_envs: 50 | self._default_run_env = next(iter(self._run_envs.keys())) 51 | return self._default_run_env 52 | 53 | @default_env_runner.setter 54 | def default_env_runner(self, value: str) -> None: 55 | """ 56 | Change the default run environment type. 57 | 58 | :param value: the new run environment type by name 59 | """ 60 | if value not in self._run_envs: 61 | msg = "run env must be registered before setting it as default" 62 | raise ValueError(msg) 63 | self._default_run_env = value 64 | 65 | def runner(self, name: str) -> type[RunToxEnv]: 66 | """ 67 | Lookup a run tox environment type by name. 68 | 69 | :param name: the name of the runner type 70 | :return: the type of the runner type 71 | """ 72 | return self._run_envs[name] 73 | 74 | def package(self, name: str) -> type[PackageToxEnv]: 75 | """ 76 | Lookup a packaging tox environment type by name. 77 | 78 | :param name: the name of the packaging type 79 | :return: the type of the packaging type 80 | """ 81 | return self._package_envs[name] 82 | 83 | 84 | REGISTER = ToxEnvRegister() #: the tox register 85 | 86 | __all__ = ( 87 | "REGISTER", 88 | "ToxEnvRegister", 89 | ) 90 | -------------------------------------------------------------------------------- /src/tox/tox_env/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING, cast 5 | 6 | if TYPE_CHECKING: 7 | from tox.config.sets import CoreConfigSet, EnvConfigSet 8 | 9 | 10 | def add_change_dir_conf(config: EnvConfigSet, core: CoreConfigSet) -> None: 11 | def _post_process_change_dir(value: Path) -> Path: 12 | if not value.is_absolute(): 13 | value = (core["tox_root"] / value).resolve() 14 | return value 15 | 16 | config.add_config( 17 | keys=["change_dir", "changedir"], 18 | of_type=Path, 19 | default=lambda conf, name: cast("Path", conf.core["tox_root"]), # noqa: ARG005 20 | desc="change to this working directory when executing the test command", 21 | post_process=_post_process_change_dir, 22 | ) 23 | 24 | 25 | __all__ = [ 26 | "add_change_dir_conf", 27 | ] 28 | -------------------------------------------------------------------------------- /src/tox/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/src/tox/util/__init__.py -------------------------------------------------------------------------------- /src/tox/util/ci.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | _ENV_VARS = { # per https://adamj.eu/tech/2020/03/09/detect-if-your-tests-are-running-on-ci 6 | "CI": None, # generic flag 7 | "TF_BUILD": "true", # Azure Pipelines 8 | "bamboo.buildKey": None, # Bamboo 9 | "BUILDKITE": "true", # Buildkite 10 | "CIRCLECI": "true", # Circle CI 11 | "CIRRUS_CI": "true", # Cirrus CI 12 | "CODEBUILD_BUILD_ID": None, # CodeBuild 13 | "GITHUB_ACTIONS": "true", # GitHub Actions 14 | "GITLAB_CI": None, # GitLab CI 15 | "HEROKU_TEST_RUN_ID": None, # Heroku CI 16 | "BUILD_ID": None, # Hudson 17 | "TEAMCITY_VERSION": None, # TeamCity 18 | "TRAVIS": "true", # Travis CI 19 | } 20 | 21 | 22 | def is_ci() -> bool: 23 | """:return: a flag indicating if running inside a CI env or not""" 24 | for env_key, value in _ENV_VARS.items(): 25 | if env_key in os.environ if value is None else os.environ.get(env_key) == value: 26 | if env_key == "TEAMCITY_VERSION" and os.environ.get(env_key) == "LOCAL": 27 | continue 28 | return True 29 | return False 30 | 31 | 32 | __all__ = [ 33 | "is_ci", 34 | ] 35 | -------------------------------------------------------------------------------- /src/tox/util/cpu.py: -------------------------------------------------------------------------------- 1 | """Helper methods related to the CPU.""" 2 | 3 | from __future__ import annotations 4 | 5 | import multiprocessing 6 | 7 | 8 | def auto_detect_cpus() -> int: 9 | try: 10 | n: int | None = multiprocessing.cpu_count() 11 | except NotImplementedError: 12 | n = None 13 | return n or 1 14 | 15 | 16 | __all__ = ("auto_detect_cpus",) 17 | -------------------------------------------------------------------------------- /src/tox/util/file_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | import shutil 6 | from itertools import chain 7 | from os.path import commonpath 8 | from typing import TYPE_CHECKING 9 | 10 | if TYPE_CHECKING: 11 | from pathlib import Path 12 | 13 | 14 | def create_session_view(package: Path, temp_path: Path) -> Path: 15 | """Allows using the file after you no longer holding a lock to it by moving it into a temp folder.""" 16 | # we'll number the active instances, and use the max value as session folder for a new build 17 | # note we cannot change package names as PEP-491 (wheel binary format) 18 | # is strict about file name structure 19 | 20 | temp_path.mkdir(parents=True, exist_ok=True) 21 | exists = [i.name for i in temp_path.iterdir()] 22 | file_id = max(chain((0,), (int(i) for i in exists if str(i).isnumeric()))) 23 | session_dir = temp_path / str(file_id + 1) 24 | session_dir.mkdir() 25 | session_package = session_dir / package.name 26 | 27 | links = False # if we can do hard links do that, otherwise just copy 28 | if hasattr(os, "link"): 29 | try: 30 | os.link(package, session_package) 31 | links = True 32 | except (OSError, NotImplementedError): 33 | pass 34 | if not links: 35 | shutil.copyfile(package, session_package) 36 | operation = "links" if links else "copied" 37 | common = commonpath((session_package, package)) 38 | rel_session, rel_package = session_package.relative_to(common), package.relative_to(common) 39 | logging.debug("package %s %s to %s (%s)", rel_session, operation, rel_package, common) 40 | return session_package 41 | -------------------------------------------------------------------------------- /src/tox/util/graph.py: -------------------------------------------------------------------------------- 1 | """Helper methods related to graph theory.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections import OrderedDict, defaultdict 6 | 7 | 8 | def stable_topological_sort(graph: dict[str, set[str]]) -> list[str]: # noqa: C901 9 | to_order = set(graph.keys()) # keep a log of what we need to order 10 | 11 | # normalize graph - fill missing nodes (assume no dependency) 12 | for values in list(graph.values()): 13 | for value in values: 14 | if value not in graph: 15 | graph[value] = set() 16 | 17 | inverse_graph = defaultdict(set) 18 | for key, depends in graph.items(): 19 | for depend in depends: 20 | inverse_graph[depend].add(key) 21 | 22 | topology = [] 23 | degree = {k: len(v) for k, v in graph.items()} 24 | ready_to_visit = {n for n, d in degree.items() if not d} 25 | need_to_visit = OrderedDict((i, None) for i in graph) 26 | while need_to_visit: 27 | # to keep stable, pick the first node ready to visit in the original order 28 | for node in need_to_visit: 29 | if node in ready_to_visit: 30 | break 31 | else: 32 | break 33 | del need_to_visit[node] 34 | 35 | topology.append(node) 36 | 37 | # decrease degree for nodes we're going too 38 | for to_node in inverse_graph[node]: 39 | degree[to_node] -= 1 40 | if not degree[to_node]: # if a node has no more incoming node it's ready to visit 41 | ready_to_visit.add(to_node) 42 | 43 | result = [n for n in topology if n in to_order] # filter out missing nodes we extended 44 | 45 | if len(result) < len(to_order): 46 | identify_cycle(graph) 47 | msg = "could not order tox environments and failed to detect circle" # pragma: no cover 48 | raise ValueError(msg) # pragma: no cover 49 | return result 50 | 51 | 52 | def identify_cycle(graph: dict[str, set[str]]) -> None: 53 | path: dict[str, None] = OrderedDict() 54 | visited = set() 55 | 56 | def visit(vertex: str) -> dict[str, None] | None: 57 | if vertex in visited: 58 | return None 59 | visited.add(vertex) 60 | path[vertex] = None 61 | for neighbor in graph.get(vertex, ()): 62 | if neighbor in path or visit(neighbor): 63 | return path 64 | del path[vertex] 65 | return None 66 | 67 | for node in graph: # pragma: no branch # we never get here if the graph is empty 68 | result = visit(node) 69 | if result is not None: 70 | msg = f"{' | '.join(result.keys())}" 71 | raise ValueError(msg) 72 | -------------------------------------------------------------------------------- /src/tox/util/path.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from shutil import rmtree 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from pathlib import Path 8 | 9 | 10 | def ensure_empty_dir(path: Path, except_filename: str | None = None) -> None: 11 | if path.exists(): 12 | if path.is_dir(): 13 | for sub_path in path.iterdir(): 14 | if sub_path.name == except_filename: 15 | continue 16 | if sub_path.is_dir(): 17 | rmtree(sub_path, ignore_errors=True) 18 | else: 19 | sub_path.unlink() 20 | else: 21 | path.unlink() 22 | path.mkdir() 23 | else: 24 | path.mkdir(parents=True) 25 | 26 | 27 | __all__ = [ 28 | "ensure_empty_dir", 29 | ] 30 | -------------------------------------------------------------------------------- /tasks/release.py: -------------------------------------------------------------------------------- 1 | """Handles creating a release PR.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | from subprocess import check_call 7 | 8 | from git import Commit, Head, Remote, Repo, TagReference 9 | from packaging.version import Version 10 | 11 | ROOT_SRC_DIR = Path(__file__).parents[1] 12 | 13 | 14 | def main(version_str: str) -> None: 15 | version = Version(version_str) 16 | repo = Repo(str(ROOT_SRC_DIR)) 17 | 18 | if repo.is_dirty(): 19 | msg = "Current repository is dirty. Please commit any changes and try again." 20 | raise RuntimeError(msg) 21 | upstream, release_branch = create_release_branch(repo, version) 22 | try: 23 | release_commit = release_changelog(repo, version) 24 | tag = tag_release_commit(release_commit, repo, version) 25 | print("push release commit") # noqa: T201 26 | repo.git.push(upstream.name, f"{release_branch}:main", "-f") 27 | print("push release tag") # noqa: T201 28 | repo.git.push(upstream.name, tag, "-f") 29 | finally: 30 | print("checkout main to new release and delete release branch") # noqa: T201 31 | repo.heads.main.checkout() 32 | repo.delete_head(release_branch, force=True) 33 | upstream.fetch() 34 | repo.git.reset("--hard", "upstream/main") 35 | print("All done! ✨ 🍰 ✨") # noqa: T201 36 | 37 | 38 | def create_release_branch(repo: Repo, version: Version) -> tuple[Remote, Head]: 39 | print("create release branch from upstream main") # noqa: T201 40 | upstream = get_upstream(repo) 41 | upstream.fetch() 42 | branch_name = f"release-{version}" 43 | release_branch = repo.create_head(branch_name, upstream.refs.main, force=True) 44 | upstream.push(refspec=f"{branch_name}:{branch_name}", force=True) 45 | release_branch.set_tracking_branch(repo.refs[f"{upstream.name}/{branch_name}"]) 46 | release_branch.checkout() 47 | return upstream, release_branch 48 | 49 | 50 | def get_upstream(repo: Repo) -> Remote: 51 | for remote in repo.remotes: 52 | if any(url.endswith("tox-dev/tox.git") for url in remote.urls): 53 | return remote 54 | msg = "could not find tox-dev/tox.git remote" 55 | raise RuntimeError(msg) 56 | 57 | 58 | def release_changelog(repo: Repo, version: Version) -> Commit: 59 | print("generate release commit") # noqa: T201 60 | check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_SRC_DIR)) # noqa: S607 61 | return repo.index.commit(f"release {version}") 62 | 63 | 64 | def tag_release_commit(release_commit: Commit, repo: Repo, version: Version) -> TagReference: 65 | print("tag release commit") # noqa: T201 66 | existing_tags = [x.name for x in repo.tags] 67 | if version in existing_tags: 68 | print(f"delete existing tag {version}") # noqa: T201 69 | repo.delete_tag(version) 70 | print(f"create tag {version}") # noqa: T201 71 | return repo.create_tag(version, ref=release_commit, force=True) 72 | 73 | 74 | if __name__ == "__main__": 75 | import argparse 76 | 77 | parser = argparse.ArgumentParser(prog="release") 78 | parser.add_argument("--version", required=True) 79 | options = parser.parse_args() 80 | main(options.version) 81 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/__init__.py -------------------------------------------------------------------------------- /tests/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/config/__init__.py -------------------------------------------------------------------------------- /tests/config/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/config/cli/__init__.py -------------------------------------------------------------------------------- /tests/config/cli/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Callable 4 | 5 | import pytest 6 | 7 | from tox.session.cmd.depends import depends 8 | from tox.session.cmd.devenv import devenv 9 | from tox.session.cmd.exec_ import exec_ 10 | from tox.session.cmd.legacy import legacy 11 | from tox.session.cmd.list_env import list_env 12 | from tox.session.cmd.quickstart import quickstart 13 | from tox.session.cmd.run.parallel import run_parallel 14 | from tox.session.cmd.run.sequential import run_sequential 15 | from tox.session.cmd.schema import gen_schema 16 | from tox.session.cmd.show_config import show_config 17 | 18 | if TYPE_CHECKING: 19 | from tox.session.state import State 20 | 21 | 22 | @pytest.fixture 23 | def core_handlers() -> dict[str, Callable[[State], int]]: 24 | return { 25 | "config": show_config, 26 | "c": show_config, 27 | "schema": gen_schema, 28 | "list": list_env, 29 | "l": list_env, 30 | "run": run_sequential, 31 | "r": run_sequential, 32 | "run-parallel": run_parallel, 33 | "p": run_parallel, 34 | "d": devenv, 35 | "devenv": devenv, 36 | "q": quickstart, 37 | "quickstart": quickstart, 38 | "de": depends, 39 | "depends": depends, 40 | "le": legacy, 41 | "legacy": legacy, 42 | "e": exec_, 43 | "exec": exec_, 44 | } 45 | -------------------------------------------------------------------------------- /tests/config/cli/test_parse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | from tox.config.cli.parse import get_options 9 | 10 | if TYPE_CHECKING: 11 | from tox.pytest import CaptureFixture 12 | 13 | 14 | def test_help_does_not_default_cmd(capsys: CaptureFixture) -> None: 15 | with pytest.raises(SystemExit): 16 | get_options("-h") 17 | out, err = capsys.readouterr() 18 | assert not err 19 | assert "--verbose" in out 20 | assert "subcommands:" in out 21 | 22 | 23 | def test_verbosity_guess_miss_match(capsys: CaptureFixture) -> None: 24 | result = get_options("-rv") 25 | assert result.parsed.verbosity == 3 26 | 27 | assert logging.getLogger().level == logging.INFO 28 | 29 | for name in ("distlib.util", "filelock"): 30 | logger = logging.getLogger(name) 31 | assert logger.disabled 32 | logging.error("E") 33 | logging.warning("W") 34 | logging.info("I") 35 | logging.debug("D") 36 | 37 | out, _err = capsys.readouterr() 38 | assert out == "ROOT: E\nROOT: W\nROOT: I\n" 39 | 40 | 41 | @pytest.mark.parametrize("arg", ["-av", "-va"]) 42 | def test_verbosity(arg: str) -> None: 43 | result = get_options(arg) 44 | assert result.parsed.verbosity == 3 45 | -------------------------------------------------------------------------------- /tests/config/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | if TYPE_CHECKING: 8 | from tests.conftest import ToxIniCreator 9 | from tox.config.main import Config 10 | 11 | 12 | @pytest.fixture 13 | def empty_config(tox_ini_conf: ToxIniCreator) -> Config: 14 | return tox_ini_conf("") 15 | -------------------------------------------------------------------------------- /tests/config/loader/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/config/loader/__init__.py -------------------------------------------------------------------------------- /tests/config/loader/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from tox.config.cli.parser import Parsed 8 | from tox.config.loader.api import ConfigLoadArgs 9 | from tox.config.main import Config 10 | from tox.config.source.tox_ini import ToxIni 11 | 12 | if TYPE_CHECKING: 13 | from pathlib import Path 14 | 15 | from typing import Protocol 16 | 17 | 18 | class ReplaceOne(Protocol): 19 | def __call__(self, conf: str, pos_args: list[str] | None = None) -> str: ... 20 | 21 | 22 | @pytest.fixture 23 | def replace_one(tmp_path: Path) -> ReplaceOne: 24 | def example(conf: str, pos_args: list[str] | None = None) -> str: 25 | tox_ini_file = tmp_path / "tox.ini" 26 | tox_ini_file.write_text(f"[testenv:py]\nenv={conf}\n") 27 | tox_ini = ToxIni(tox_ini_file) 28 | 29 | config = Config( 30 | tox_ini, 31 | options=Parsed(override=[]), 32 | root=tmp_path, 33 | pos_args=pos_args, 34 | work_dir=tmp_path, 35 | ) 36 | loader = config.get_env("py").loaders[0] 37 | args = ConfigLoadArgs(chain=[], name="a", env_name="a") 38 | return loader.load(key="env", of_type=str, conf=config, factory=None, args=args) 39 | 40 | return example 41 | -------------------------------------------------------------------------------- /tests/config/loader/ini/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/config/loader/ini/__init__.py -------------------------------------------------------------------------------- /tests/config/loader/ini/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from configparser import ConfigParser 4 | from typing import TYPE_CHECKING, Callable 5 | 6 | import pytest 7 | 8 | if TYPE_CHECKING: 9 | from pathlib import Path 10 | 11 | 12 | @pytest.fixture 13 | def mk_ini_conf(tmp_path: Path) -> Callable[[str], ConfigParser]: 14 | def _func(raw: str) -> ConfigParser: 15 | filename = tmp_path / "demo.ini" 16 | filename.write_bytes(raw.encode("utf-8")) # win32: avoid CR normalization - what you pass is what you get 17 | parser = ConfigParser(interpolation=None) 18 | with filename.open() as file_handler: 19 | parser.read_file(file_handler) 20 | return parser 21 | 22 | return _func 23 | -------------------------------------------------------------------------------- /tests/config/loader/ini/replace/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/config/loader/ini/replace/__init__.py -------------------------------------------------------------------------------- /tests/config/loader/ini/replace/test_replace_os_pathsep.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from tests.config.loader.conftest import ReplaceOne 8 | 9 | 10 | def test_replace_os_pathsep(replace_one: ReplaceOne) -> None: 11 | result = replace_one("{:}") 12 | assert result == os.pathsep 13 | -------------------------------------------------------------------------------- /tests/config/loader/ini/replace/test_replace_os_sep.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | if TYPE_CHECKING: 9 | from tests.config.loader.conftest import ReplaceOne 10 | from tox.pytest import MonkeyPatch 11 | 12 | 13 | def test_replace_os_sep(replace_one: ReplaceOne) -> None: 14 | result = replace_one("{/}") 15 | assert result == os.sep 16 | 17 | 18 | @pytest.mark.parametrize("sep", ["/", "\\"]) 19 | def test_replace_os_sep_before_curly(monkeypatch: MonkeyPatch, replace_one: ReplaceOne, sep: str) -> None: 20 | """Explicit test case for issue #2732 (windows only).""" 21 | monkeypatch.setattr(os, "sep", sep) 22 | monkeypatch.delenv("_", raising=False) 23 | result = replace_one("{/}{env:_:foo}") 24 | assert result == os.sep + "foo" 25 | 26 | 27 | @pytest.mark.parametrize("sep", ["/", "\\"]) 28 | def test_replace_os_sep_sub_exp_regression(monkeypatch: MonkeyPatch, replace_one: ReplaceOne, sep: str) -> None: 29 | monkeypatch.setattr(os, "sep", sep) 30 | monkeypatch.delenv("_", raising=False) 31 | result = replace_one("{env:_:{posargs}{/}.{posargs}}", ["foo"]) 32 | assert result == f"foo{os.sep}.foo" 33 | -------------------------------------------------------------------------------- /tests/config/loader/ini/replace/test_replace_posargs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | if TYPE_CHECKING: 9 | from tests.config.loader.conftest import ReplaceOne 10 | 11 | 12 | @pytest.mark.parametrize("syntax", ["{posargs}", "[]"]) 13 | def test_replace_pos_args_none_sys_argv(syntax: str, replace_one: ReplaceOne) -> None: 14 | result = replace_one(syntax, None) 15 | assert not result 16 | 17 | 18 | @pytest.mark.parametrize("syntax", ["{posargs}", "[]"]) 19 | def test_replace_pos_args_empty_sys_argv(syntax: str, replace_one: ReplaceOne) -> None: 20 | result = replace_one(syntax, []) 21 | assert not result 22 | 23 | 24 | @pytest.mark.parametrize("syntax", ["{posargs}", "[]"]) 25 | def test_replace_pos_args_extra_sys_argv(syntax: str, replace_one: ReplaceOne) -> None: 26 | result = replace_one(syntax, [sys.executable, "magic"]) 27 | assert result == f"{sys.executable} magic" 28 | 29 | 30 | @pytest.mark.parametrize("syntax", ["{posargs}", "[]"]) 31 | def test_replace_pos_args(syntax: str, replace_one: ReplaceOne) -> None: 32 | result = replace_one(syntax, ["ok", "what", " yes "]) 33 | quote = '"' if sys.platform == "win32" else "'" 34 | assert result == f"ok what {quote} yes {quote}" 35 | 36 | 37 | @pytest.mark.parametrize( 38 | ("value", "result"), 39 | [ 40 | ("magic", "magic"), 41 | ("magic:colon", "magic:colon"), 42 | ("magic\n b c", "magic\nb c"), # an unescaped newline keeps the newline 43 | ("magi\\\n c d", "magic d"), # an escaped newline merges the lines 44 | ("\\{a\\}", "{a}"), # escaped curly braces 45 | ], 46 | ) 47 | def test_replace_pos_args_default(replace_one: ReplaceOne, value: str, result: str) -> None: 48 | outcome = replace_one(f"{{posargs:{value}}}", None) 49 | assert result == outcome 50 | 51 | 52 | @pytest.mark.parametrize( 53 | "value", 54 | [ 55 | "\\{posargs}", 56 | "{posargs\\}", 57 | "\\{posargs\\}", 58 | "{\\{posargs}", 59 | "{\\{posargs}{}", 60 | "\\[]", 61 | "[\\]", 62 | "\\[\\]", 63 | ], 64 | ) 65 | def test_replace_pos_args_escaped(replace_one: ReplaceOne, value: str) -> None: 66 | result = replace_one(value, None) 67 | outcome = value.replace("\\", "") 68 | assert result == outcome 69 | 70 | 71 | @pytest.mark.parametrize( 72 | ("value", "result"), 73 | [ 74 | ("[]-{posargs}", "foo-foo"), 75 | ("{posargs}-[]", "foo-foo"), 76 | ], 77 | ) 78 | def test_replace_mixed_brackets_and_braces(replace_one: ReplaceOne, value: str, result: str) -> None: 79 | outcome = replace_one(value, ["foo"]) 80 | assert result == outcome 81 | 82 | 83 | def test_half_escaped_braces(replace_one: ReplaceOne) -> None: 84 | """See https://github.com/tox-dev/tox/issues/1956""" 85 | outcome = replace_one(r"\{posargs} {posargs}", ["foo"]) 86 | assert outcome == "{posargs} foo" 87 | -------------------------------------------------------------------------------- /tests/config/loader/ini/replace/test_replace_tty.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | if TYPE_CHECKING: 9 | from pytest_mock import MockFixture 10 | 11 | from tests.config.loader.conftest import ReplaceOne 12 | 13 | 14 | @pytest.mark.parametrize("is_atty", [True, False]) 15 | def test_replace_env_set(replace_one: ReplaceOne, mocker: MockFixture, is_atty: bool) -> None: 16 | mocker.patch.object(sys.stdout, "isatty", return_value=is_atty) 17 | 18 | result = replace_one("1 {tty} 2") 19 | assert result == "1 2" 20 | 21 | result = replace_one("1 {tty:a} 2") 22 | assert result == f"1 {'a' if is_atty else ''} 2" 23 | 24 | result = replace_one("1 {tty:a:b} 2") 25 | assert result == f"1 {'a' if is_atty else 'b'} 2" 26 | -------------------------------------------------------------------------------- /tests/config/loader/ini/test_ini_loader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Callable 4 | 5 | import pytest 6 | 7 | from tox.config.loader.api import ConfigLoadArgs, Override 8 | from tox.config.loader.ini import IniLoader 9 | from tox.config.source.ini_section import IniSection 10 | 11 | if TYPE_CHECKING: 12 | from configparser import ConfigParser 13 | 14 | 15 | def test_ini_loader_keys(mk_ini_conf: Callable[[str], ConfigParser]) -> None: 16 | core = IniSection(None, "tox") 17 | loader = IniLoader(core, mk_ini_conf("\n[tox]\n\na=b\nc=d\n\n"), [], core_section=core) 18 | assert loader.found_keys() == {"a", "c"} 19 | 20 | 21 | def test_ini_loader_repr(mk_ini_conf: Callable[[str], ConfigParser]) -> None: 22 | core = IniSection(None, "tox") 23 | loader = IniLoader(core, mk_ini_conf("\n[tox]\n\na=b\nc=d\n\n"), [Override("tox.a=1")], core_section=core) 24 | assert repr(loader) == "IniLoader(section=tox, overrides={'a': [Override('tox.a=1')]})" 25 | 26 | 27 | def test_ini_loader_has_section(mk_ini_conf: Callable[[str], ConfigParser]) -> None: 28 | core = IniSection(None, "tox") 29 | loader = IniLoader(core, mk_ini_conf("[magic]\n[tox]\n\na=b\nc=d\n\n"), [], core_section=core) 30 | assert loader.get_section("magic") is not None 31 | 32 | 33 | def test_ini_loader_has_no_section(mk_ini_conf: Callable[[str], ConfigParser]) -> None: 34 | core = IniSection(None, "tox") 35 | loader = IniLoader(core, mk_ini_conf("[tox]\n\na=b\nc=d\n\n"), [], core_section=core) 36 | assert loader.get_section("magic") is None 37 | 38 | 39 | def test_ini_loader_raw(mk_ini_conf: Callable[[str], ConfigParser]) -> None: 40 | core = IniSection(None, "tox") 41 | args = ConfigLoadArgs([], "name", None) 42 | loader = IniLoader(core, mk_ini_conf("[tox]\na=b"), [], core_section=core) 43 | result = loader.load(key="a", of_type=str, conf=None, factory=None, args=args) 44 | assert result == "b" 45 | 46 | 47 | @pytest.mark.parametrize("sep", ["\n", "\r\n"]) 48 | def test_ini_loader_raw_strip_escaped_newline(mk_ini_conf: Callable[[str], ConfigParser], sep: str) -> None: 49 | core = IniSection(None, "tox") 50 | args = ConfigLoadArgs([], "name", None) 51 | loader = IniLoader(core, mk_ini_conf(f"[tox]{sep}a=b\\{sep} c"), [], core_section=core) 52 | result = loader.load(key="a", of_type=str, conf=None, factory=None, args=args) 53 | assert result == "bc" 54 | 55 | 56 | @pytest.mark.parametrize( 57 | ("case", "result"), 58 | [ 59 | ("# a", ""), 60 | ("#", ""), 61 | ("a # w", "a"), 62 | ("a\t# w", "a"), 63 | ("a# w", "a"), 64 | ("a\\# w", "a# w"), 65 | ("#a\n b # w\n w", "b\nw"), 66 | ], 67 | ) 68 | def test_ini_loader_strip_comments(mk_ini_conf: Callable[[str], ConfigParser], case: str, result: str) -> None: 69 | core = IniSection(None, "tox") 70 | args = ConfigLoadArgs([], "name", None) 71 | loader = IniLoader(core, mk_ini_conf(f"[tox]\na={case}"), [], core_section=core) 72 | outcome = loader.load(key="a", of_type=str, conf=None, factory=None, args=args) 73 | assert outcome == result 74 | -------------------------------------------------------------------------------- /tests/config/loader/test_loader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from tox.config.cli.parse import get_options 8 | from tox.config.loader.api import Override 9 | 10 | if TYPE_CHECKING: 11 | from tox.pytest import CaptureFixture 12 | 13 | 14 | @pytest.mark.parametrize("flag", ["-x", "--override"]) 15 | def test_override_incorrect(flag: str, capsys: CaptureFixture) -> None: 16 | with pytest.raises(SystemExit): 17 | get_options(flag, "magic") 18 | out, err = capsys.readouterr() 19 | assert not out 20 | assert "override magic has no = sign in it" in err 21 | 22 | 23 | @pytest.mark.parametrize("flag", ["-x", "--override"]) 24 | def test_override_add(flag: str) -> None: 25 | parsed, _, __, ___, ____ = get_options(flag, "magic=true") 26 | assert len(parsed.override) == 1 27 | value = parsed.override[0] 28 | assert value.key == "magic" 29 | assert value.value == "true" 30 | assert not value.namespace 31 | assert value.append is False 32 | 33 | 34 | @pytest.mark.parametrize("flag", ["-x", "--override"]) 35 | def test_override_append(flag: str) -> None: 36 | parsed, _, __, ___, ____ = get_options(flag, "magic+=true") 37 | assert len(parsed.override) == 1 38 | value = parsed.override[0] 39 | assert value.key == "magic" 40 | assert value.value == "true" 41 | assert not value.namespace 42 | assert value.append is True 43 | 44 | 45 | @pytest.mark.parametrize("flag", ["-x", "--override"]) 46 | def test_override_multiple(flag: str) -> None: 47 | parsed, _, __, ___, ____ = get_options(flag, "magic+=1", flag, "magic+=2") 48 | assert len(parsed.override) == 2 49 | 50 | 51 | def test_override_equals() -> None: 52 | assert Override("a=b") == Override("a=b") 53 | 54 | 55 | def test_override_not_equals() -> None: 56 | assert Override("a=b") != Override("c=d") 57 | 58 | 59 | def test_override_not_equals_different_type() -> None: 60 | assert Override("a=b") != 1 61 | 62 | 63 | def test_override_repr() -> None: 64 | assert repr(Override("b.a=c")) == "Override('b.a=c')" 65 | -------------------------------------------------------------------------------- /tests/config/loader/test_memory_loader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | from typing import Any, Dict, List, Optional, Set 6 | 7 | import pytest 8 | 9 | from tox.config.loader.api import ConfigLoadArgs, Override 10 | from tox.config.loader.memory import MemoryLoader 11 | from tox.config.types import Command, EnvList 12 | 13 | 14 | def test_memory_loader_repr() -> None: 15 | loader = MemoryLoader(a=1) 16 | assert repr(loader) == "MemoryLoader" 17 | 18 | 19 | def test_memory_loader_override() -> None: 20 | loader = MemoryLoader(a=1) 21 | loader.overrides["a"] = [Override("a=2")] 22 | args = ConfigLoadArgs([], "name", None) 23 | loaded = loader.load("a", of_type=int, conf=None, factory=None, args=args) 24 | assert loaded == 2 25 | 26 | 27 | @pytest.mark.parametrize( 28 | ("value", "of_type", "outcome"), 29 | [ 30 | (True, bool, True), 31 | (1, int, 1), 32 | ("magic", str, "magic"), 33 | ({"1"}, Set[str], {"1"}), 34 | ([1], List[int], [1]), 35 | ({1: 2}, Dict[int, int], {1: 2}), 36 | (Path.cwd(), Path, Path.cwd()), 37 | (Command(["a"]), Command, Command(["a"])), 38 | (EnvList("a,b"), EnvList, EnvList("a,b")), 39 | (1, Optional[int], 1), 40 | ("1", Optional[str], "1"), 41 | (0, bool, False), 42 | (1, bool, True), 43 | ("1", int, 1), 44 | (1, str, "1"), 45 | ({1}, Set[str], {"1"}), 46 | ({"1"}, List[int], [1]), 47 | ({"1": "2"}, Dict[int, int], {1: 2}), 48 | (os.getcwd(), Path, Path.cwd()), # noqa: PTH109 49 | ("pip list", Command, Command(["pip", "list"])), 50 | ("a\nb", EnvList, EnvList(["a", "b"])), 51 | ("1", Optional[int], 1), 52 | ], 53 | ) 54 | def test_memory_loader(value: Any, of_type: type[Any], outcome: Any) -> None: 55 | loader = MemoryLoader(a=value, kwargs={}) 56 | args = ConfigLoadArgs([], "name", None) 57 | loaded = loader.load("a", of_type=of_type, conf=None, factory=None, args=args) 58 | assert loaded == outcome 59 | 60 | 61 | @pytest.mark.parametrize( 62 | ("value", "of_type", "exception", "msg"), 63 | [ 64 | ("m", int, ValueError, "invalid literal for int"), 65 | ({"m"}, Set[int], ValueError, "invalid literal for int"), 66 | (["m"], List[int], ValueError, "invalid literal for int"), 67 | ({"m": 1}, Dict[int, int], ValueError, "invalid literal for int"), 68 | ({1: "m"}, Dict[int, int], ValueError, "invalid literal for int"), 69 | (object, Path, TypeError, r"str(, bytes)? or (an )?os\.PathLike object"), 70 | (1, Command, TypeError, "1"), 71 | (1, EnvList, TypeError, "1"), 72 | ], 73 | ) 74 | def test_memory_loader_fails_invalid(value: Any, of_type: type[Any], exception: Exception, msg: str) -> None: 75 | loader = MemoryLoader(a=value, kwargs={}) 76 | args = ConfigLoadArgs([], "name", None) 77 | with pytest.raises(exception, match=msg): # type: ignore[call-overload] 78 | loader.load("a", of_type=of_type, conf=None, factory=None, args=args) 79 | 80 | 81 | def test_memory_found_keys() -> None: 82 | loader = MemoryLoader(a=1, c=2) 83 | assert loader.found_keys() == {"a", "c"} 84 | 85 | 86 | def test_memory_loader_contains() -> None: 87 | loader = MemoryLoader(a=1) 88 | assert "a" in loader 89 | assert "b" not in loader 90 | -------------------------------------------------------------------------------- /tests/config/loader/test_section.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | import pytest 6 | 7 | from tox.config.loader.section import Section 8 | 9 | 10 | @pytest.mark.parametrize( 11 | ("section", "outcome"), 12 | [ 13 | (Section("a", "b"), "a:b"), 14 | (Section(None, "a"), "a"), 15 | ], 16 | ) 17 | def test_section_str(section: Section, outcome: str) -> None: 18 | assert str(section) == outcome 19 | 20 | 21 | @pytest.mark.parametrize( 22 | ("section", "outcome"), 23 | [ 24 | (Section("a", "b"), "Section(prefix='a', name='b')"), 25 | (Section(None, "a"), "Section(prefix=None, name='a')"), 26 | ], 27 | ) 28 | def test_section_repr(section: Section, outcome: str) -> None: 29 | assert repr(section) == outcome 30 | 31 | 32 | def test_section_eq() -> None: 33 | assert Section(None, "a") == Section(None, "a") 34 | 35 | 36 | @pytest.mark.parametrize( 37 | ("section", "other"), 38 | [ 39 | (Section("a", "b"), "a-b"), 40 | (Section(None, "a"), Section("b", "a")), 41 | (Section("a", "b"), Section("a", "c")), 42 | ], 43 | ) 44 | def test_section_not_eq(section: Section, other: Any) -> None: 45 | assert section != other 46 | -------------------------------------------------------------------------------- /tests/config/source/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/config/source/__init__.py -------------------------------------------------------------------------------- /tests/config/source/test_discover.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from pathlib import Path 7 | 8 | from tox.pytest import ToxProjectCreator 9 | 10 | 11 | def out_no_src(path: Path) -> str: 12 | return ( 13 | f"ROOT: No tox.ini or setup.cfg or pyproject.toml or tox.toml found, assuming empty tox.ini at {path}\n" 14 | f"default environments:\npy -> [no description]\n" 15 | ) 16 | 17 | 18 | def test_no_src_cwd(tox_project: ToxProjectCreator) -> None: 19 | project = tox_project({}) 20 | outcome = project.run("l") 21 | outcome.assert_success() 22 | assert outcome.out == out_no_src(project.path) 23 | assert outcome.state.conf.src_path == (project.path / "tox.ini") 24 | 25 | 26 | def test_no_src_has_py_project_toml_above(tox_project: ToxProjectCreator, tmp_path: Path) -> None: 27 | (tmp_path / "pyproject.toml").write_text("") 28 | project = tox_project({}) 29 | outcome = project.run("l") 30 | outcome.assert_success() 31 | assert outcome.out == out_no_src(tmp_path) 32 | assert outcome.state.conf.src_path == (tmp_path / "tox.ini") 33 | 34 | 35 | def test_no_src_root_dir(tox_project: ToxProjectCreator, tmp_path: Path) -> None: 36 | root = tmp_path / "root" 37 | root.mkdir() 38 | project = tox_project({}) 39 | outcome = project.run("l", "--root", str(root)) 40 | outcome.assert_success() 41 | assert outcome.out == out_no_src(root) 42 | assert outcome.state.conf.src_path == (root / "tox.ini") 43 | 44 | 45 | def test_bad_src_content(tox_project: ToxProjectCreator, tmp_path: Path) -> None: 46 | project = tox_project({}) 47 | 48 | outcome = project.run("l", "-c", str(tmp_path / "setup.cfg")) 49 | outcome.assert_failed() 50 | assert outcome.out == f"ROOT: HandledError| could not recognize config file {tmp_path / 'setup.cfg'}\n" 51 | -------------------------------------------------------------------------------- /tests/config/source/test_legacy_toml.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from tox.pytest import ToxProjectCreator 7 | 8 | 9 | def test_conf_in_legacy_toml(tox_project: ToxProjectCreator) -> None: 10 | project = tox_project({"pyproject.toml": '[tool.tox]\nlegacy_tox_ini="""[tox]\nenv_list=\n a\n b\n"""'}) 11 | 12 | outcome = project.run("l") 13 | outcome.assert_success() 14 | assert outcome.out == "default environments:\na -> [no description]\nb -> [no description]\n" 15 | -------------------------------------------------------------------------------- /tests/config/source/test_setup_cfg.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from tox.pytest import ToxProjectCreator 7 | 8 | 9 | def test_conf_in_setup_cfg(tox_project: ToxProjectCreator) -> None: 10 | project = tox_project({"setup.cfg": "[tox:tox]\nenv_list=\n a\n b"}) 11 | 12 | outcome = project.run("l") 13 | outcome.assert_success() 14 | assert outcome.out == "default environments:\na -> [no description]\nb -> [no description]\n" 15 | 16 | 17 | def test_bad_conf_setup_cfg(tox_project: ToxProjectCreator) -> None: 18 | project = tox_project({"setup.cfg": "[tox]\nenv_list=\n a\n b"}) 19 | filename = str(project.path / "setup.cfg") 20 | outcome = project.run("l", "-c", filename) 21 | outcome.assert_failed() 22 | assert outcome.out == f"ROOT: HandledError| could not recognize config file {filename}\n" 23 | -------------------------------------------------------------------------------- /tests/config/source/test_source_ini.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from tox.config.loader.section import Section 6 | from tox.config.sets import ConfigSet 7 | from tox.config.source.ini import IniSource 8 | 9 | if TYPE_CHECKING: 10 | from pathlib import Path 11 | 12 | from tests.conftest import ToxIniCreator 13 | 14 | 15 | def test_source_ini_with_interpolated(tmp_path: Path) -> None: 16 | loader = IniSource(tmp_path, content="[tox]\na = %(c)s").get_loader(Section(None, "tox"), {}) 17 | assert loader is not None 18 | loader.load_raw("a", None, None) 19 | 20 | 21 | def test_source_ini_ignore_non_testenv_sections(tmp_path: Path) -> None: 22 | loader = IniSource(tmp_path, content="[mypy-rest_framework.compat.*]") 23 | res = list(loader.envs({"env_list": []})) # type: ignore[arg-type] 24 | assert not res 25 | 26 | 27 | def test_source_ini_ignore_invalid_factor_filters(tmp_path: Path) -> None: 28 | loader = IniSource(tmp_path, content="[a]\nb= if c: d") 29 | res = list(loader.envs({"env_list": []})) # type: ignore[arg-type] 30 | assert not res 31 | 32 | 33 | def test_source_ini_custom_non_testenv_sections(tox_ini_conf: ToxIniCreator) -> None: 34 | """Validate that a plugin can load section with custom prefix overlapping testenv name.""" 35 | 36 | class CustomConfigSet(ConfigSet): 37 | def register_config(self) -> None: 38 | self.add_config( 39 | keys=["a"], 40 | of_type=str, 41 | default="", 42 | desc="d", 43 | ) 44 | 45 | config = tox_ini_conf("[testenv:foo]\n[custom:foo]\na = b") 46 | known_envs = list(config._src.envs(config.core)) # noqa: SLF001 47 | assert known_envs 48 | custom_section = config.get_section_config( 49 | section=Section("custom", "foo"), 50 | base=[], 51 | of_type=CustomConfigSet, 52 | for_env=None, 53 | ) 54 | assert custom_section["a"] == "b" 55 | -------------------------------------------------------------------------------- /tests/config/test_of_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from tox.config.of_type import ConfigConstantDefinition, ConfigDynamicDefinition 4 | 5 | 6 | def test_config_constant_eq() -> None: 7 | val_1 = ConfigConstantDefinition(("key",), "description", "value") 8 | val_2 = ConfigConstantDefinition(("key",), "description", "value") 9 | assert val_1 == val_2 10 | 11 | 12 | def test_config_dynamic_eq() -> None: 13 | def func(name: str) -> str: 14 | return name # pragma: no cover 15 | 16 | val_1 = ConfigDynamicDefinition(("key",), "description", str, "default", post_process=func) 17 | val_2 = ConfigDynamicDefinition(("key",), "description", str, "default", post_process=func) 18 | assert val_1 == val_2 19 | -------------------------------------------------------------------------------- /tests/config/test_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from tox.config.types import Command, EnvList 4 | 5 | 6 | def tests_command_repr() -> None: 7 | cmd = Command(["python", "-m", "pip", "list"]) 8 | assert repr(cmd) == "Command(args=['python', '-m', 'pip', 'list'])" 9 | assert cmd.invert_exit_code is False 10 | assert cmd.ignore_exit_code is False 11 | 12 | 13 | def tests_command_repr_ignore() -> None: 14 | cmd = Command(["-", "python", "-m", "pip", "list"]) 15 | assert repr(cmd) == "Command(args=['-', 'python', '-m', 'pip', 'list'])" 16 | assert cmd.invert_exit_code is False 17 | assert cmd.ignore_exit_code is True 18 | 19 | 20 | def tests_command_repr_invert() -> None: 21 | cmd = Command(["!", "python", "-m", "pip", "list"]) 22 | assert repr(cmd) == "Command(args=['!', 'python', '-m', 'pip', 'list'])" 23 | assert cmd.invert_exit_code is True 24 | assert cmd.ignore_exit_code is False 25 | 26 | 27 | def tests_command_eq() -> None: 28 | cmd_1 = Command(["python", "-m", "pip", "list"]) 29 | cmd_2 = Command(["python", "-m", "pip", "list"]) 30 | assert cmd_1 == cmd_2 31 | 32 | 33 | def tests_command_ne() -> None: 34 | cmd_1 = Command(["python", "-m", "pip", "list"]) 35 | cmd_2 = Command(["-", "python", "-m", "pip", "list"]) 36 | cmd_3 = Command(["!", "python", "-m", "pip", "list"]) 37 | assert cmd_1 != cmd_2 != cmd_3 38 | 39 | 40 | def tests_env_list_repr() -> None: 41 | env = EnvList(["py39", "py38"]) 42 | assert repr(env) == "EnvList(['py39', 'py38'])" 43 | 44 | 45 | def tests_env_list_eq() -> None: 46 | env_1 = EnvList(["py39", "py38"]) 47 | env_2 = EnvList(["py39", "py38"]) 48 | assert env_1 == env_2 49 | 50 | 51 | def tests_env_list_ne() -> None: 52 | env_1 = EnvList(["py39", "py38"]) 53 | env_2 = EnvList(["py38", "py39"]) 54 | assert env_1 != env_2 55 | -------------------------------------------------------------------------------- /tests/demo_pkg_inline/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "build" 3 | requires = [ 4 | ] 5 | backend-path = [ 6 | ".", 7 | ] 8 | 9 | [tool.black] 10 | line-length = 120 11 | -------------------------------------------------------------------------------- /tests/demo_pkg_setuptools/demo_pkg_setuptools/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def do() -> None: 5 | print("greetings from demo_pkg_setuptools") # noqa: T201 6 | -------------------------------------------------------------------------------- /tests/demo_pkg_setuptools/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = 'setuptools.build_meta' 3 | requires = [ 4 | "setuptools>=63", 5 | ] 6 | -------------------------------------------------------------------------------- /tests/demo_pkg_setuptools/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = demo_pkg_setuptools 3 | version = 1.2.3 4 | 5 | [options] 6 | packages = find: 7 | -------------------------------------------------------------------------------- /tests/execute/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/execute/__init__.py -------------------------------------------------------------------------------- /tests/execute/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture(scope="session") 9 | def os_env() -> dict[str, str]: 10 | return os.environ.copy() 11 | -------------------------------------------------------------------------------- /tests/execute/local_subprocess/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/execute/local_subprocess/__init__.py -------------------------------------------------------------------------------- /tests/execute/local_subprocess/bad_process.py: -------------------------------------------------------------------------------- 1 | """This is a non compliant process that does not listens to signals""" 2 | 3 | # pragma: no cover 4 | from __future__ import annotations 5 | 6 | import os 7 | import signal 8 | import sys 9 | import time 10 | from pathlib import Path 11 | from typing import TYPE_CHECKING 12 | 13 | if TYPE_CHECKING: 14 | from types import FrameType 15 | 16 | out = sys.stdout 17 | 18 | 19 | def handler(signum: int, _: FrameType | None) -> None: 20 | _p(f"how about no signal {signum!r}") 21 | 22 | 23 | def _p(m: str) -> None: 24 | out.write(f"{m}{os.linesep}") 25 | out.flush() # force output flush in case we get killed 26 | 27 | 28 | _p(f"start {__name__} with {sys.argv!r}") 29 | signal.signal(signal.SIGINT, handler) 30 | signal.signal(signal.SIGTERM, handler) 31 | 32 | try: 33 | start_file = Path(sys.argv[1]) 34 | _p(f"create {start_file}") 35 | start_file.write_text("") 36 | _p(f"created {start_file}") 37 | while True: 38 | time.sleep(0.01) 39 | finally: 40 | _p(f"done {__name__}") 41 | -------------------------------------------------------------------------------- /tests/execute/local_subprocess/local_subprocess_sigint.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import locale 4 | import logging 5 | import os 6 | import signal 7 | import sys 8 | from io import TextIOWrapper 9 | from pathlib import Path 10 | from typing import TYPE_CHECKING 11 | from unittest.mock import MagicMock 12 | 13 | from tox.execute.local_sub_process import LocalSubProcessExecutor 14 | from tox.execute.request import ExecuteRequest, StdinSource 15 | from tox.report import NamedBytesIO 16 | 17 | if TYPE_CHECKING: 18 | from types import FrameType 19 | 20 | from tox.execute import Outcome 21 | 22 | logging.basicConfig(level=logging.DEBUG, format="%(relativeCreated)d\t%(levelname).1s\t%(message)s") 23 | bad_process = Path(__file__).parent / "bad_process.py" 24 | 25 | executor = LocalSubProcessExecutor(colored=False) 26 | request = ExecuteRequest( 27 | cmd=[sys.executable, bad_process, sys.argv[1]], 28 | cwd=Path().absolute(), 29 | env=os.environ.copy(), 30 | stdin=StdinSource.API, 31 | run_id="", 32 | ) 33 | out_err = ( 34 | TextIOWrapper(NamedBytesIO("out"), encoding=locale.getpreferredencoding(False)), 35 | TextIOWrapper(NamedBytesIO("err"), encoding=locale.getpreferredencoding(False)), 36 | ) 37 | 38 | 39 | def show_outcome(outcome: Outcome | None) -> None: 40 | if outcome is not None: # pragma: no branch 41 | print(outcome.exit_code) # noqa: T201 42 | print(repr(outcome.out)) # noqa: T201 43 | print(repr(outcome.err)) # noqa: T201 44 | print(outcome.elapsed, end="") # noqa: T201 45 | print("done show outcome", file=sys.stderr) # noqa: T201 46 | 47 | 48 | def handler(s: int, f: FrameType | None) -> None: 49 | logging.info("signal %s at %s", s, f) 50 | global interrupt_done # noqa: PLW0603 51 | if interrupt_done is False: # pragma: no branch 52 | interrupt_done = True 53 | logging.info("interrupt via %s", status) 54 | status.interrupt() 55 | logging.info("interrupt finished via %s", status) 56 | 57 | 58 | interrupt_done = False 59 | signal.signal(signal.SIGINT, handler) 60 | logging.info("PID %d start %r", os.getpid(), request) 61 | tox_env = MagicMock(conf={"suicide_timeout": 0.01, "interrupt_timeout": 0.05, "terminate_timeout": 0.07}) 62 | try: 63 | with executor.call(request, show=False, out_err=out_err, env=tox_env) as status: 64 | logging.info("wait on %r", status) 65 | while status.exit_code is None: 66 | status.wait(timeout=0.01) # use wait here with timeout to not block the main thread 67 | logging.info("wait over on %r", status) 68 | show_outcome(status.outcome) 69 | except Exception as exception: # pragma: no cover 70 | logging.exception(exception) # noqa: TRY401 # pragma: no cover 71 | finally: 72 | logging.info("done") 73 | logging.shutdown() 74 | -------------------------------------------------------------------------------- /tests/execute/local_subprocess/test_execute_util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from tox.execute.util import shebang 6 | 7 | if TYPE_CHECKING: 8 | from pathlib import Path 9 | 10 | 11 | def test_shebang_found(tmp_path: Path) -> None: 12 | script_path = tmp_path / "a" 13 | script_path.write_text("#! /bin/python \t-c\t") 14 | assert shebang(str(script_path)) == ["/bin/python", "-c"] 15 | 16 | 17 | def test_shebang_file_missing(tmp_path: Path) -> None: 18 | script_path = tmp_path / "a" 19 | assert shebang(str(script_path)) is None 20 | 21 | 22 | def test_shebang_no_shebang(tmp_path: Path) -> None: 23 | script_path = tmp_path / "a" 24 | script_path.write_bytes(b"magic") 25 | assert shebang(str(script_path)) is None 26 | 27 | 28 | def test_shebang_non_utf8_file(tmp_path: Path) -> None: 29 | script_path, content = tmp_path / "a", b"#!" + bytearray.fromhex("c0") 30 | script_path.write_bytes(content) 31 | assert shebang(str(script_path)) is None 32 | -------------------------------------------------------------------------------- /tests/execute/local_subprocess/tty_check.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import shutil 5 | import sys 6 | 7 | args = { 8 | "stdout": sys.stdout.isatty(), 9 | "stderr": sys.stderr.isatty(), 10 | "stdin": sys.stdin.isatty(), 11 | "terminal": shutil.get_terminal_size(fallback=(-1, -1)), 12 | } 13 | result = json.dumps(args) 14 | print(result) # noqa: T201 15 | -------------------------------------------------------------------------------- /tests/execute/test_request.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from pathlib import Path 6 | 7 | import pytest 8 | 9 | from tox.execute.request import ExecuteRequest, StdinSource 10 | 11 | 12 | def test_execute_request_raise_on_empty_cmd(os_env: dict[str, str]) -> None: 13 | with pytest.raises(ValueError, match="cannot execute an empty command"): 14 | ExecuteRequest(cmd=[], cwd=Path().absolute(), env=os_env, stdin=StdinSource.OFF, run_id="") 15 | 16 | 17 | def test_request_allow_star_is_none() -> None: 18 | request = ExecuteRequest( 19 | cmd=[sys.executable], 20 | cwd=Path.cwd(), 21 | env={"PATH": os.environ["PATH"]}, 22 | stdin=StdinSource.OFF, 23 | run_id="run-id", 24 | allow=["*", "magic"], 25 | ) 26 | assert request.allow is None 27 | -------------------------------------------------------------------------------- /tests/execute/test_stream.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from colorama import Fore 4 | 5 | from tox.execute.stream import SyncWrite 6 | 7 | 8 | def test_sync_write_repr() -> None: 9 | sync_write = SyncWrite(name="a", target=None, color=Fore.RED) 10 | assert repr(sync_write) == f"SyncWrite(name='a', target=None, color={Fore.RED!r})" 11 | 12 | 13 | def test_sync_write_decode_surrogate() -> None: 14 | sync_write = SyncWrite(name="a", target=None) 15 | sync_write.handler(b"\xed\n") 16 | assert sync_write.text == "\udced\n" 17 | -------------------------------------------------------------------------------- /tests/journal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/journal/__init__.py -------------------------------------------------------------------------------- /tests/journal/test_main_journal.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import socket 4 | import sys 5 | from typing import Any 6 | 7 | import pytest 8 | 9 | from tox import __version__ 10 | from tox.journal.main import Journal 11 | 12 | 13 | @pytest.fixture 14 | def base_info() -> dict[str, Any]: 15 | return { 16 | "reportversion": "1", 17 | "toxversion": __version__, 18 | "platform": sys.platform, 19 | "host": socket.getfqdn(), 20 | } 21 | 22 | 23 | def test_journal_enabled_default(base_info: dict[str, Any]) -> None: 24 | journal = Journal(enabled=True) 25 | assert bool(journal) is True 26 | assert journal.content == base_info 27 | 28 | 29 | def test_journal_disabled_default() -> None: 30 | journal = Journal(enabled=False) 31 | assert bool(journal) is False 32 | assert journal.content == {} 33 | 34 | 35 | def test_env_journal_enabled(base_info: dict[str, Any]) -> None: 36 | journal = Journal(enabled=True) 37 | env = journal.get_env_journal("a") 38 | assert journal.get_env_journal("a") is env 39 | env["demo"] = 1 40 | 41 | assert bool(env) is True 42 | base_info["testenvs"] = {"a": {"demo": 1}} 43 | assert journal.content == base_info 44 | 45 | 46 | def test_env_journal_disabled() -> None: 47 | journal = Journal(enabled=False) 48 | env = journal.get_env_journal("a") 49 | assert bool(env) is False 50 | 51 | env["demo"] = 2 52 | assert journal.content == {"testenvs": {"a": {"demo": 2}}} 53 | -------------------------------------------------------------------------------- /tests/plugin/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import Any, Sequence 5 | 6 | HERE = Path(__file__).parent 7 | 8 | 9 | def pytest_collection_modifyitems(items: Sequence[Any]) -> None: 10 | """automatically apply plugin test to all the test in this suite""" 11 | root = str(HERE) 12 | for item in items: 13 | if item.module.__file__.startswith(root): 14 | item.add_marker("plugin_test") 15 | -------------------------------------------------------------------------------- /tests/plugin/test_inline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from tox.config.cli.parser import ToxParser 7 | from tox.pytest import ToxProjectCreator 8 | 9 | 10 | def test_inline_tox_py(tox_project: ToxProjectCreator) -> None: 11 | def plugin() -> None: # pragma: no cover # the code is copied to a python file 12 | import logging # noqa: PLC0415 13 | 14 | from tox.plugin import impl # noqa: PLC0415 15 | 16 | @impl 17 | def tox_add_option(parser: ToxParser) -> None: 18 | logging.warning("Add magic") 19 | parser.add_argument("--magic", action="store_true") 20 | 21 | project = tox_project({"toxfile.py": plugin}) 22 | result = project.run("-h") 23 | result.assert_success() 24 | assert "--magic" in result.out 25 | -------------------------------------------------------------------------------- /tests/pytest_/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/pytest_/__init__.py -------------------------------------------------------------------------------- /tests/session/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/session/__init__.py -------------------------------------------------------------------------------- /tests/session/cmd/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/session/cmd/__init__.py -------------------------------------------------------------------------------- /tests/session/cmd/run/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/session/cmd/run/__init__.py -------------------------------------------------------------------------------- /tests/session/cmd/run/test_common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import re 5 | from argparse import ArgumentError, ArgumentParser, Namespace 6 | from typing import TYPE_CHECKING 7 | 8 | import pytest 9 | 10 | from tox.session.cmd.run.common import InstallPackageAction, SkipMissingInterpreterAction 11 | 12 | if TYPE_CHECKING: 13 | from pathlib import Path 14 | 15 | from tox.pytest import ToxProjectCreator 16 | 17 | 18 | @pytest.mark.parametrize("values", ["config", None, "true", "false"]) 19 | def test_skip_missing_interpreter_action_ok(values: str | None) -> None: 20 | args_namespace = Namespace() 21 | SkipMissingInterpreterAction(option_strings=["-i"], dest="into")(ArgumentParser(), args_namespace, values) 22 | expected = "true" if values is None else values 23 | assert args_namespace.into == expected 24 | 25 | 26 | def test_skip_missing_interpreter_action_nok() -> None: 27 | argument_parser = ArgumentParser() 28 | with pytest.raises(ArgumentError, match=r"value must be 'config', 'true', or 'false' \(got 'bad value'\)"): 29 | SkipMissingInterpreterAction(option_strings=["-i"], dest="into")(argument_parser, Namespace(), "bad value") 30 | 31 | 32 | def test_install_pkg_ok(tmp_path: Path) -> None: 33 | argument_parser = ArgumentParser() 34 | path = tmp_path / "a" 35 | path.write_text("") 36 | namespace = Namespace() 37 | InstallPackageAction(option_strings=["--install-pkg"], dest="into")(argument_parser, namespace, str(path)) 38 | assert namespace.into == path 39 | 40 | 41 | def test_install_pkg_does_not_exist(tmp_path: Path) -> None: 42 | argument_parser = ArgumentParser() 43 | path = str(tmp_path / "a") 44 | with pytest.raises(ArgumentError, match=re.escape(f"argument --install-pkg: {path} does not exist")): 45 | InstallPackageAction(option_strings=["--install-pkg"], dest="into")(argument_parser, Namespace(), path) 46 | 47 | 48 | def test_install_pkg_not_file(tmp_path: Path) -> None: 49 | argument_parser = ArgumentParser() 50 | path = str(tmp_path) 51 | with pytest.raises(ArgumentError, match=re.escape(f"argument --install-pkg: {path} is not a file")): 52 | InstallPackageAction(option_strings=["--install-pkg"], dest="into")(argument_parser, Namespace(), path) 53 | 54 | 55 | def test_install_pkg_empty() -> None: 56 | argument_parser = ArgumentParser() 57 | with pytest.raises(ArgumentError, match=re.escape("argument --install-pkg: cannot be empty")): 58 | InstallPackageAction(option_strings=["--install-pkg"], dest="into")(argument_parser, Namespace(), "") 59 | 60 | 61 | def test_empty_report(tox_project: ToxProjectCreator) -> None: 62 | proj = tox_project({"tox.ini": ""}) 63 | outcome = proj.run("exec", "-qq", "--", "python", "-c", "print('foo')") 64 | 65 | outcome.assert_success() 66 | outcome.assert_out_err(f"foo{os.linesep}", "") 67 | -------------------------------------------------------------------------------- /tests/session/cmd/test_depends.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from textwrap import dedent 5 | from typing import TYPE_CHECKING, Callable 6 | 7 | if TYPE_CHECKING: 8 | from tox.pytest import ToxProjectCreator 9 | 10 | 11 | def test_depends_wheel(tox_project: ToxProjectCreator, patch_prev_py: Callable[[bool], tuple[str, str]]) -> None: 12 | prev_ver, impl = patch_prev_py(True) # has previous python 13 | ver = sys.version_info[0:2] 14 | py = f"py{''.join(str(i) for i in ver)}" 15 | prev_py = f"py{prev_ver}" 16 | ini = f""" 17 | [tox] 18 | env_list = py,{py},{prev_py},py31,cov2,cov 19 | [testenv] 20 | package = wheel 21 | [testenv:cov] 22 | depends = py,{py},{prev_py},py31 23 | skip_install = true 24 | [testenv:cov2] 25 | depends = cov 26 | skip_install = true 27 | """ 28 | project = tox_project({"tox.ini": ini, "pyproject.toml": ""}) 29 | outcome = project.run("de") 30 | outcome.assert_success() 31 | 32 | expected = f""" 33 | Execution order: py, {py}, {prev_py}, py31, cov, cov2 34 | ALL 35 | py ~ .pkg 36 | {py} ~ .pkg 37 | {prev_py} ~ .pkg | .pkg-{impl}{prev_ver} 38 | py31 ~ .pkg 39 | cov2 40 | cov 41 | py ~ .pkg 42 | {py} ~ .pkg 43 | {prev_py} ~ .pkg | .pkg-{impl}{prev_ver} 44 | py31 ~ .pkg 45 | cov 46 | py ~ .pkg 47 | {py} ~ .pkg 48 | {prev_py} ~ .pkg | .pkg-{impl}{prev_ver} 49 | py31 ~ .pkg 50 | """ 51 | assert outcome.out == dedent(expected).lstrip() 52 | 53 | 54 | def test_depends_sdist(tox_project: ToxProjectCreator, patch_prev_py: Callable[[bool], tuple[str, str]]) -> None: 55 | prev_ver, _impl = patch_prev_py(True) # has previous python 56 | ver = sys.version_info[0:2] 57 | py = f"py{''.join(str(i) for i in ver)}" 58 | prev_py = f"py{prev_ver}" 59 | ini = f""" 60 | [tox] 61 | env_list = py,{py},{prev_py},py31,cov2,cov 62 | [testenv] 63 | package = sdist 64 | [testenv:cov] 65 | depends = py,{py},{prev_py},py31 66 | skip_install = true 67 | [testenv:cov2] 68 | depends = cov 69 | skip_install = true 70 | """ 71 | project = tox_project({"tox.ini": ini, "pyproject.toml": ""}) 72 | outcome = project.run("de") 73 | outcome.assert_success() 74 | 75 | expected = f""" 76 | Execution order: py, {py}, {prev_py}, py31, cov, cov2 77 | ALL 78 | py ~ .pkg 79 | {py} ~ .pkg 80 | {prev_py} ~ .pkg 81 | py31 ~ .pkg 82 | cov2 83 | cov 84 | py ~ .pkg 85 | {py} ~ .pkg 86 | {prev_py} ~ .pkg 87 | py31 ~ .pkg 88 | cov 89 | py ~ .pkg 90 | {py} ~ .pkg 91 | {prev_py} ~ .pkg 92 | py31 ~ .pkg 93 | """ 94 | assert outcome.out == dedent(expected).lstrip() 95 | 96 | 97 | def test_depends_help(tox_project: ToxProjectCreator) -> None: 98 | outcome = tox_project({"tox.ini": ""}).run("de", "-h") 99 | outcome.assert_success() 100 | -------------------------------------------------------------------------------- /tests/session/cmd/test_devenv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | if TYPE_CHECKING: 8 | from tox.pytest import ToxProjectCreator 9 | 10 | 11 | def test_devenv_fail_multiple_target(tox_project: ToxProjectCreator) -> None: 12 | outcome = tox_project({"tox.ini": "[tox]\nenv_list=a,b"}).run("d", "-e", "a,b") 13 | outcome.assert_failed() 14 | msg = "ROOT: HandledError| exactly one target environment allowed in devenv mode but found a, b\n" 15 | outcome.assert_out_err(msg, "") 16 | 17 | 18 | @pytest.mark.integration 19 | def test_devenv_ok(tox_project: ToxProjectCreator, enable_pip_pypi_access: str | None) -> None: # noqa: ARG001 20 | content = { 21 | "setup.py": "from setuptools import setup\nsetup(name='demo', version='1.0')", 22 | "tox.ini": "[tox]\nenv_list = py\n[testenv]\nusedevelop = True", 23 | } 24 | project = tox_project(content) 25 | outcome = project.run("d", "-e", "py") 26 | 27 | outcome.assert_success() 28 | assert (project.path / "venv").exists() 29 | assert f"created development environment under {project.path / 'venv'}" in outcome.out 30 | 31 | 32 | def test_devenv_help(tox_project: ToxProjectCreator) -> None: 33 | outcome = tox_project({"tox.ini": ""}).run("d", "-h") 34 | outcome.assert_success() 35 | -------------------------------------------------------------------------------- /tests/session/cmd/test_exec_.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | if TYPE_CHECKING: 9 | from tox.pytest import ToxProjectCreator 10 | 11 | 12 | @pytest.mark.parametrize("trail", [[], ["--"]], ids=["no_posargs", "empty_posargs"]) 13 | def test_exec_fail_no_posargs(tox_project: ToxProjectCreator, trail: list[str]) -> None: 14 | outcome = tox_project({"tox.ini": ""}).run("e", "-e", "py39", *trail) 15 | outcome.assert_failed() 16 | msg = "ROOT: HandledError| You must specify a command as positional arguments, use -- \n" 17 | outcome.assert_out_err(msg, "") 18 | 19 | 20 | def test_exec_fail_multiple_target(tox_project: ToxProjectCreator) -> None: 21 | outcome = tox_project({"tox.ini": ""}).run("e", "-e", "py39,py38", "--", "py") 22 | outcome.assert_failed() 23 | msg = "ROOT: HandledError| exactly one target environment allowed in exec mode but found py39, py38\n" 24 | outcome.assert_out_err(msg, "") 25 | 26 | 27 | @pytest.mark.parametrize("exit_code", [1, 0]) 28 | def test_exec(tox_project: ToxProjectCreator, exit_code: int) -> None: 29 | prj = tox_project({"tox.ini": "[testenv]\npackage=skip"}) 30 | py_cmd = f"import sys; print(sys.version); raise SystemExit({exit_code})" 31 | outcome = prj.run("e", "-e", "py", "--", "python", "-c", py_cmd) 32 | if exit_code: 33 | outcome.assert_failed() 34 | else: 35 | outcome.assert_success() 36 | assert sys.version in outcome.out 37 | 38 | 39 | def test_exec_help(tox_project: ToxProjectCreator) -> None: 40 | outcome = tox_project({"tox.ini": ""}).run("e", "-h") 41 | outcome.assert_success() 42 | -------------------------------------------------------------------------------- /tests/session/cmd/test_list_envs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | if TYPE_CHECKING: 8 | from tox.pytest import ToxProject, ToxProjectCreator 9 | 10 | 11 | @pytest.fixture 12 | def project(tox_project: ToxProjectCreator) -> ToxProject: 13 | ini = """ 14 | [tox] 15 | env_list=py32,py31,py 16 | [testenv] 17 | package = wheel 18 | wheel_build_env = pkg 19 | description = with {basepython} 20 | deps = pypy: 21 | [testenv:py] 22 | basepython=py32,py31 23 | [testenv:fix] 24 | description = fix it 25 | [testenv:pkg] 26 | """ 27 | return tox_project({"tox.ini": ini}) 28 | 29 | 30 | def test_list_env(project: ToxProject) -> None: 31 | outcome = project.run("l") 32 | 33 | outcome.assert_success() 34 | expected = """ 35 | default environments: 36 | py32 -> with py32 37 | py31 -> with py31 38 | py -> with py32 py31 39 | 40 | additional environments: 41 | fix -> fix it 42 | pypy -> with pypy 43 | """ 44 | outcome.assert_out_err(expected, "") 45 | 46 | 47 | def test_list_env_default(project: ToxProject) -> None: 48 | outcome = project.run("l", "-d") 49 | 50 | outcome.assert_success() 51 | expected = """ 52 | default environments: 53 | py32 -> with py32 54 | py31 -> with py31 55 | py -> with py32 py31 56 | """ 57 | outcome.assert_out_err(expected, "") 58 | 59 | 60 | def test_list_env_quiet(project: ToxProject) -> None: 61 | outcome = project.run("l", "--no-desc") 62 | 63 | outcome.assert_success() 64 | expected = """ 65 | py32 66 | py31 67 | py 68 | fix 69 | pypy 70 | """ 71 | outcome.assert_out_err(expected, "") 72 | 73 | 74 | def test_list_env_quiet_default(project: ToxProject) -> None: 75 | outcome = project.run("l", "--no-desc", "-d") 76 | 77 | outcome.assert_success() 78 | expected = """ 79 | py32 80 | py31 81 | py 82 | """ 83 | outcome.assert_out_err(expected, "") 84 | 85 | 86 | def test_list_env_package_env_before_run(tox_project: ToxProjectCreator) -> None: 87 | ini = """ 88 | [testenv:pkg] 89 | [testenv:run] 90 | package = wheel 91 | wheel_build_env = pkg 92 | """ 93 | project = tox_project({"tox.ini": ini}) 94 | outcome = project.run("l") 95 | 96 | outcome.assert_success() 97 | expected = """ 98 | default environments: 99 | py -> [no description] 100 | 101 | additional environments: 102 | run -> [no description] 103 | """ 104 | outcome.assert_out_err(expected, "") 105 | 106 | 107 | def test_list_env_package_self(tox_project: ToxProjectCreator) -> None: 108 | ini = """ 109 | [tox] 110 | env_list = pkg 111 | [testenv:pkg] 112 | package = wheel 113 | wheel_build_env = pkg 114 | """ 115 | project = tox_project({"tox.ini": ini}) 116 | outcome = project.run("l") 117 | 118 | outcome.assert_failed() 119 | assert outcome.out.splitlines() == ["ROOT: HandledError| pkg cannot self-package"] 120 | 121 | 122 | def test_list_envs_help(tox_project: ToxProjectCreator) -> None: 123 | outcome = tox_project({"tox.ini": ""}).run("l", "-h") 124 | outcome.assert_success() 125 | -------------------------------------------------------------------------------- /tests/session/cmd/test_quickstart.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from textwrap import dedent 5 | from typing import TYPE_CHECKING 6 | 7 | from packaging.version import Version 8 | 9 | from tox.version import version as __version__ 10 | 11 | if TYPE_CHECKING: 12 | from tox.pytest import ToxProjectCreator 13 | 14 | 15 | def test_quickstart_ok(tox_project: ToxProjectCreator) -> None: 16 | project = tox_project({}) 17 | tox_ini = project.path / "demo" / "tox.ini" 18 | assert not tox_ini.exists() 19 | 20 | outcome = project.run("q", str(tox_ini.parent)) 21 | outcome.assert_success() 22 | 23 | assert tox_ini.exists() 24 | found = tox_ini.read_text() 25 | 26 | version = str(Version(__version__.split("+")[0])) 27 | text = f""" 28 | [tox] 29 | env_list = 30 | py{"".join(str(i) for i in sys.version_info[0:2])} 31 | minversion = {version} 32 | 33 | [testenv] 34 | description = run the tests with pytest 35 | package = wheel 36 | wheel_build_env = .pkg 37 | deps = 38 | pytest>=6 39 | commands = 40 | pytest {{tty:--color=yes}} {{posargs}} 41 | """ 42 | content = dedent(text).lstrip() 43 | assert found == content 44 | 45 | 46 | def test_quickstart_refuse(tox_project: ToxProjectCreator) -> None: 47 | project = tox_project({"tox.ini": ""}) 48 | outcome = project.run("q", str(project.path)) 49 | outcome.assert_failed(code=1) 50 | assert "tox.ini already exist, refusing to overwrite" in outcome.out 51 | 52 | 53 | def test_quickstart_help(tox_project: ToxProjectCreator) -> None: 54 | outcome = tox_project({"tox.ini": ""}).run("q", "-h") 55 | outcome.assert_success() 56 | 57 | 58 | def test_quickstart_no_args(tox_project: ToxProjectCreator) -> None: 59 | outcome = tox_project({}).run("q") 60 | outcome.assert_success() 61 | -------------------------------------------------------------------------------- /tests/session/cmd/test_schema.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from pathlib import Path 8 | 9 | from tox.pytest import MonkeyPatch, ToxProjectCreator 10 | 11 | 12 | def test_show_schema_empty_dir(tox_project: ToxProjectCreator, monkeypatch: MonkeyPatch, tmp_path: Path) -> None: 13 | monkeypatch.chdir(tmp_path) 14 | 15 | project = tox_project({}) 16 | result = project.run("-qq", "schema") 17 | schema = json.loads(result.out) 18 | assert "properties" in schema 19 | assert "tox_root" in schema["properties"] 20 | -------------------------------------------------------------------------------- /tests/session/cmd/test_state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from tox.pytest import ToxProjectCreator 7 | 8 | 9 | def test_env_already_packaging(tox_project: ToxProjectCreator) -> None: 10 | proj = tox_project( 11 | { 12 | "tox.ini": "[testenv]\npackage=wheel", 13 | "pyproject.toml": '[build-system]\nrequires=[]\nbuild-backend="build"', 14 | }, 15 | ) 16 | result = proj.run("r", "-e", "py,.pkg") 17 | result.assert_failed(code=-2) 18 | assert "cannot run packaging environment(s) .pkg" in result.out, result.out 19 | 20 | 21 | def test_env_run_cannot_be_packaging_too(tox_project: ToxProjectCreator) -> None: 22 | proj = tox_project({"tox.ini": "[testenv]\npackage=wheel\npackage_env=py", "pyproject.toml": ""}) 23 | result = proj.run("r", "-e", "py") 24 | result.assert_failed(code=-2) 25 | assert " py cannot self-package" in result.out, result.out 26 | -------------------------------------------------------------------------------- /tests/session/test_session_common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from tox.session.env_select import CliEnv 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ("val", "exp"), 10 | [ 11 | (CliEnv(["a", "b"]), "CliEnv('a,b')"), 12 | (CliEnv(["ALL", "b"]), "CliEnv('ALL')"), 13 | (CliEnv([]), "CliEnv()"), 14 | (CliEnv(), "CliEnv()"), 15 | ], 16 | ) 17 | def test_cli_env_repr(val: CliEnv, exp: str) -> None: 18 | assert repr(val) == exp 19 | 20 | 21 | @pytest.mark.parametrize( 22 | ("val", "exp"), 23 | [ 24 | (CliEnv(["a", "b"]), "a,b"), 25 | (CliEnv(["ALL", "b"]), "ALL"), 26 | (CliEnv([]), ""), 27 | (CliEnv(), ""), 28 | ], 29 | ) 30 | def test_cli_env_str(val: CliEnv, exp: str) -> None: 31 | assert str(val) == exp 32 | -------------------------------------------------------------------------------- /tests/test_call_modes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | import sys 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | from tox.pytest import ToxProject 10 | 11 | 12 | def test_call_as_module(empty_project: ToxProject) -> None: # noqa: ARG001 13 | subprocess.check_output([sys.executable, "-m", "tox", "-h"]) 14 | 15 | 16 | def test_call_as_exe(empty_project: ToxProject) -> None: # noqa: ARG001 17 | subprocess.check_output([str(Path(sys.executable).parent / "tox"), "-h"]) 18 | -------------------------------------------------------------------------------- /tests/test_report.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | from colorama import Style, deinit 9 | 10 | from tox.report import setup_report 11 | 12 | if TYPE_CHECKING: 13 | from pytest_mock import MockerFixture 14 | 15 | from tox.pytest import CaptureFixture 16 | 17 | 18 | @pytest.mark.parametrize("color", [True, False], ids=["on", "off"]) 19 | @pytest.mark.parametrize("verbosity", range(7)) 20 | def test_setup_report(mocker: MockerFixture, capsys: CaptureFixture, verbosity: int, color: bool) -> None: 21 | color_init = mocker.patch("tox.report.init") 22 | 23 | setup_report(verbosity=verbosity, is_colored=color) 24 | try: 25 | logging.critical("critical") 26 | logging.error("error") 27 | # special warning line that should be auto-colored 28 | logging.warning("%s%s> %s", "warning", "foo", "bar") 29 | logging.info("info") 30 | logging.debug("debug") 31 | logging.log(logging.NOTSET, "not-set") # this should not be logged 32 | disabled = "distlib.util", "filelock" 33 | for name in disabled: 34 | logger = logging.getLogger(name) 35 | logger.warning("%s-warn", name) 36 | logger.info("%s-info", name) 37 | logger.debug("%s-debug", name) 38 | logger.log(logging.NOTSET, "%s-notset", name) 39 | finally: 40 | deinit() 41 | 42 | assert color_init.call_count == (1 if color else 0) 43 | 44 | msg_count = min(verbosity + 1, 5) 45 | is_debug_or_more = verbosity >= 4 46 | if is_debug_or_more: 47 | msg_count += 1 # we log at debug level setting up the logger 48 | 49 | out, err = capsys.readouterr() 50 | assert not err 51 | assert out 52 | assert "filelock" not in out 53 | assert "distlib.util" not in out 54 | lines = out.splitlines() 55 | assert len(lines) == msg_count, out 56 | 57 | if is_debug_or_more and lines: # assert we start with relative created, contain path 58 | line = lines[0] 59 | int(line.split(" ")[1]) # first element is an int number 60 | assert f"[tox{os.sep}report.py" in line # relative file location 61 | 62 | if color: 63 | assert f"{Style.RESET_ALL}" in out 64 | # check that our Warning line using special format was colored 65 | expected_warning_text = "W\x1b[0m\x1b[36m warning\x1b[22mfoo\x1b[2m>\x1b[0m bar\x1b[0m\x1b[2m" 66 | else: 67 | assert f"{Style.RESET_ALL}" not in out 68 | expected_warning_text = "warningfoo> bar" 69 | if verbosity >= 4: # where warnings are logged 70 | assert expected_warning_text in lines[3] 71 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from types import SimpleNamespace 4 | from typing import TYPE_CHECKING 5 | 6 | from tox import __version__ 7 | from tox.plugin.manager import MANAGER 8 | 9 | if TYPE_CHECKING: 10 | from pytest_mock import MockFixture 11 | 12 | from tox.pytest import ToxProjectCreator 13 | 14 | 15 | def test_version() -> None: 16 | assert __version__ 17 | 18 | 19 | def test_version_without_plugin(tox_project: ToxProjectCreator) -> None: 20 | outcome = tox_project({"tox.ini": ""}).run("--version") 21 | outcome.assert_success() 22 | assert __version__ in outcome.out 23 | assert "plugin" not in outcome.out 24 | 25 | 26 | def test_version_with_plugin(tox_project: ToxProjectCreator, mocker: MockFixture) -> None: 27 | dist = [ 28 | ( 29 | mocker.create_autospec("types.ModuleType", __file__="B-path", tox_append_version_info=lambda: "magic"), 30 | SimpleNamespace(project_name="B", version="1.0"), 31 | ), 32 | ( 33 | mocker.create_autospec("types.ModuleType", __file__="A-path"), 34 | SimpleNamespace(project_name="A", version="2.0"), 35 | ), 36 | ] 37 | mocker.patch.object(MANAGER.manager, "list_plugin_distinfo", return_value=dist) 38 | 39 | outcome = tox_project({"tox.ini": ""}).run("--version") 40 | 41 | outcome.assert_success() 42 | assert not outcome.err 43 | lines = outcome.out.splitlines() 44 | assert lines[0].startswith(__version__) 45 | 46 | assert lines[1:] == [ 47 | "registered plugins:", 48 | " B-1.0 at B-path magic", 49 | " A-2.0 at A-path", 50 | ] 51 | -------------------------------------------------------------------------------- /tests/tox_env/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/tox_env/__init__.py -------------------------------------------------------------------------------- /tests/tox_env/python/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/tox_env/python/__init__.py -------------------------------------------------------------------------------- /tests/tox_env/python/pip/test_req_file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from argparse import Namespace 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | from tox.tox_env.python.pip.req_file import PythonDeps 9 | 10 | if TYPE_CHECKING: 11 | from pathlib import Path 12 | 13 | 14 | @pytest.mark.parametrize("legacy_flag", ["-r", "-c"]) 15 | def test_legacy_requirement_file(tmp_path: Path, legacy_flag: str) -> None: 16 | python_deps = PythonDeps(f"{legacy_flag}a.txt", tmp_path) 17 | (tmp_path / "a.txt").write_text("b") 18 | assert python_deps.as_root_args == [legacy_flag, "a.txt"] 19 | assert vars(python_deps.options) == {} 20 | assert [str(i) for i in python_deps.requirements] == ["b" if legacy_flag == "-r" else "-c b"] 21 | 22 | 23 | def test_deps_with_hash(tmp_path: Path) -> None: 24 | """deps with --hash should raise an exception.""" 25 | python_deps = PythonDeps( 26 | raw="foo==1 --hash sha256:97a702083b0d906517b79672d8501eee470d60ae55df0fa9d4cfba56c7f65a82", 27 | root=tmp_path, 28 | ) 29 | with pytest.raises(ValueError, match="Cannot use --hash in deps list"): 30 | _ = python_deps.requirements 31 | 32 | 33 | def test_deps_with_requirements_with_hash(tmp_path: Path) -> None: 34 | """deps can point to a requirements file that has --hash.""" 35 | exp_hash = "sha256:97a702083b0d906517b79672d8501eee470d60ae55df0fa9d4cfba56c7f65a82" 36 | requirements = tmp_path / "requirements.txt" 37 | requirements.write_text(f"foo==1 --hash {exp_hash}") 38 | python_deps = PythonDeps(raw="-r requirements.txt", root=tmp_path) 39 | assert len(python_deps.requirements) == 1 40 | parsed_req = python_deps.requirements[0] 41 | assert str(parsed_req.requirement) == "foo==1" 42 | assert parsed_req.options == {"hash": [exp_hash]} 43 | assert parsed_req.from_file == str(requirements) 44 | 45 | 46 | def test_deps_with_no_deps(tmp_path: Path) -> None: 47 | (tmp_path / "r.txt").write_text("urrlib3") 48 | python_deps = PythonDeps(raw="-rr.txt\n--no-deps", root=tmp_path) 49 | 50 | assert len(python_deps.requirements) == 1 51 | parsed_req = python_deps.requirements[0] 52 | assert str(parsed_req.requirement) == "urrlib3" 53 | 54 | assert python_deps.options.no_deps is True 55 | assert python_deps.as_root_args == ["-r", "r.txt", "--no-deps"] 56 | 57 | 58 | def test_req_with_no_deps(tmp_path: Path) -> None: 59 | (tmp_path / "r.txt").write_text("--no-deps") 60 | python_deps = PythonDeps(raw="-rr.txt", root=tmp_path) 61 | with pytest.raises(ValueError, match="unrecognized arguments: --no-deps"): 62 | python_deps.requirements # noqa: B018 63 | 64 | 65 | def test_opt_only_req_file(tmp_path: Path) -> None: 66 | (tmp_path / "r.txt").write_text("--use-feature fast-deps") 67 | python_deps = PythonDeps(raw="-rr.txt", root=tmp_path) 68 | assert not python_deps.requirements 69 | assert python_deps.options == Namespace(features_enabled=["fast-deps"]) 70 | 71 | 72 | def test_req_iadd(tmp_path: Path) -> None: 73 | a = PythonDeps(raw="foo", root=tmp_path) 74 | b = PythonDeps(raw="bar", root=tmp_path) 75 | a += b 76 | assert a.lines() == ["foo", "bar"] 77 | -------------------------------------------------------------------------------- /tests/tox_env/python/test-pkg/pyproject.toml: -------------------------------------------------------------------------------- 1 | # empty 2 | -------------------------------------------------------------------------------- /tests/tox_env/python/virtual_env/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/tox_env/python/virtual_env/__init__.py -------------------------------------------------------------------------------- /tests/tox_env/python/virtual_env/package/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/tox_env/python/virtual_env/package/__init__.py -------------------------------------------------------------------------------- /tests/tox_env/python/virtual_env/package/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from textwrap import dedent 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | 9 | if TYPE_CHECKING: 10 | from pathlib import Path 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def pkg_with_extras_project(tmp_path_factory: pytest.TempPathFactory) -> Path: 15 | py_ver = ".".join(str(i) for i in sys.version_info[0:2]) 16 | setup_cfg = f""" 17 | [metadata] 18 | name = demo 19 | version = 1.0.0 20 | [options] 21 | packages = find: 22 | install_requires = 23 | platformdirs>=2.1 24 | colorama>=0.4.3 25 | 26 | [options.extras_require] 27 | testing = 28 | covdefaults>=1.2; python_version == '2.7' or python_version == '{py_ver}' 29 | pytest>=5.4.1; python_version == '{py_ver}' 30 | docs = 31 | sphinx>=3 32 | sphinx-rtd-theme>=0.4.3,<1 33 | format = 34 | black>=3 35 | flake8 36 | """ 37 | tmp_path = tmp_path_factory.mktemp("prj") 38 | (tmp_path / "setup.cfg").write_text(dedent(setup_cfg)) 39 | (tmp_path / "setup.py").write_text("from setuptools import setup; setup()") 40 | toml = '[build-system]\nrequires=["setuptools", "wheel"]\nbuild-backend = "setuptools.build_meta"' 41 | (tmp_path / "pyproject.toml").write_text(toml) 42 | return tmp_path 43 | -------------------------------------------------------------------------------- /tests/tox_env/python/virtual_env/test_setuptools.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from typing import TYPE_CHECKING, cast 6 | 7 | import pytest 8 | 9 | from tox.tox_env.python.package import WheelPackage 10 | from tox.tox_env.python.virtual_env.package.pyproject import Pep517VirtualEnvPackager 11 | 12 | if TYPE_CHECKING: 13 | from pathlib import Path 14 | 15 | from tox.pytest import ToxProjectCreator 16 | from tox.tox_env.runner import RunToxEnv 17 | 18 | 19 | @pytest.mark.integration 20 | def test_setuptools_package( 21 | tox_project: ToxProjectCreator, 22 | demo_pkg_setuptools: Path, 23 | enable_pip_pypi_access: str | None, # noqa: ARG001 24 | ) -> None: 25 | tox_ini = """ 26 | [testenv] 27 | package = wheel 28 | commands_pre = python -c 'import sys; print("start", sys.executable)' 29 | commands = python -c 'from demo_pkg_setuptools import do; do()' 30 | commands_post = python -c 'import sys; print("end", sys.executable)' 31 | """ 32 | project = tox_project({"tox.ini": tox_ini}, base=demo_pkg_setuptools) 33 | 34 | outcome = project.run("r", "-e", "py") 35 | 36 | outcome.assert_success() 37 | assert f"\ngreetings from demo_pkg_setuptools{os.linesep}" in outcome.out 38 | tox_env = cast("RunToxEnv", outcome.state.envs["py"]) 39 | 40 | (package_env,) = list(tox_env.package_envs) 41 | assert isinstance(package_env, Pep517VirtualEnvPackager) 42 | packages = package_env.perform_packaging(tox_env.conf) 43 | assert len(packages) == 1 44 | package = packages[0] 45 | assert isinstance(package, WheelPackage) 46 | assert str(package) == str(package.path) 47 | assert package.path.name == f"demo_pkg_setuptools-1.2.3-py{sys.version_info.major}-none-any.whl" 48 | 49 | result = outcome.out.split("\n") 50 | py_messages = [i for i in result if "py: " in i] 51 | assert len(py_messages) == 5, "\n".join(py_messages) # 1 install wheel + 3 command + 1 final report 52 | 53 | package_messages = [i for i in result if ".pkg: " in i] 54 | # 1 optional hooks + 1 install requires + 1 build meta + 1 build isolated 55 | assert len(package_messages) == 4, "\n".join(package_messages) 56 | -------------------------------------------------------------------------------- /tests/tox_env/test_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from pathlib import Path 7 | 8 | from tox.pytest import ToxProjectCreator 9 | 10 | 11 | def test_ensure_temp_dir_exists(tox_project: ToxProjectCreator) -> None: 12 | ini = "[testenv]\ncommands=python -c 'import os; os.path.exists(r\"{temp_dir}\")'" 13 | project = tox_project({"tox.ini": ini}) 14 | result = project.run() 15 | result.assert_success() 16 | 17 | 18 | def test_dont_cleanup_temp_dir(tox_project: ToxProjectCreator, tmp_path: Path) -> None: 19 | (tmp_path / "foo" / "bar").mkdir(parents=True) 20 | project = tox_project({"tox.ini": "[tox]\ntemp_dir=foo"}) 21 | result = project.run() 22 | result.assert_success() 23 | assert (tmp_path / "foo" / "bar").exists() 24 | 25 | 26 | def test_setenv_section_substitution(tox_project: ToxProjectCreator) -> None: 27 | ini = """[variables] 28 | var = VAR = val 29 | [testenv] 30 | setenv = {[variables]var} 31 | commands = python -c 'import os; os.environ["VAR"]'""" 32 | project = tox_project({"tox.ini": ini}) 33 | result = project.run() 34 | result.assert_success() 35 | -------------------------------------------------------------------------------- /tests/tox_env/test_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | from tox.tox_env.info import Info 6 | 7 | 8 | def test_info_repr() -> None: 9 | at_loc = Path().absolute() 10 | info_object = Info(at_loc) 11 | assert repr(info_object) == f"Info(path={at_loc / '.tox-info.json'})" 12 | -------------------------------------------------------------------------------- /tests/tox_env/test_register.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from tox.tox_env.python.virtual_env.runner import VirtualEnvRunner 6 | from tox.tox_env.register import ToxEnvRegister 7 | 8 | 9 | def test_register_set_new_default_no_register() -> None: 10 | register = ToxEnvRegister() 11 | with pytest.raises(ValueError, match="run env must be registered before setting it as default"): 12 | register.default_env_runner = "new-env" 13 | 14 | 15 | def test_register_set_new_default_with_register() -> None: 16 | class B(VirtualEnvRunner): 17 | @staticmethod 18 | def id() -> str: 19 | return "B" 20 | 21 | register = ToxEnvRegister() 22 | register.add_run_env(VirtualEnvRunner) 23 | assert register.default_env_runner == VirtualEnvRunner.id() 24 | register.add_run_env(B) 25 | assert register.default_env_runner == VirtualEnvRunner.id() 26 | register.default_env_runner = B.id() 27 | assert register.default_env_runner == "B" 28 | -------------------------------------------------------------------------------- /tests/tox_env/test_tox_env_runner.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from pathlib import Path 7 | 8 | from tox.pytest import ToxProjectCreator 9 | 10 | 11 | def test_package_only( 12 | tox_project: ToxProjectCreator, 13 | demo_pkg_inline: Path, 14 | ) -> None: 15 | ini = "[testenv]\ncommands = python -c 'print('foo')'" 16 | proj = tox_project( 17 | {"tox.ini": ini, "pyproject.toml": (demo_pkg_inline / "pyproject.toml").read_text()}, 18 | base=demo_pkg_inline, 19 | ) 20 | execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None) 21 | result = proj.run("r", "--sdistonly") 22 | result.assert_success() 23 | 24 | expected_calls = [ 25 | (".pkg", "_optional_hooks"), 26 | (".pkg", "get_requires_for_build_sdist"), 27 | (".pkg", "get_requires_for_build_wheel"), 28 | (".pkg", "build_wheel"), 29 | (".pkg", "build_sdist"), 30 | (".pkg", "_exit"), 31 | ] 32 | found_calls = [(i[0][0].conf.name, i[0][3].run_id) for i in execute_calls.call_args_list] 33 | assert found_calls == expected_calls 34 | -------------------------------------------------------------------------------- /tests/type_check/add_config_container_factory.py: -------------------------------------------------------------------------------- 1 | """check that factory for a container works""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import List 6 | 7 | from tox.config.sets import ConfigSet 8 | 9 | 10 | class EnvDockerConfigSet(ConfigSet): 11 | def register_config(self) -> None: 12 | def factory(container_name: object) -> str: 13 | raise NotImplementedError 14 | 15 | self.add_config( 16 | keys=["k"], 17 | of_type=List[str], 18 | default=[], 19 | desc="desc", 20 | factory=factory, 21 | ) 22 | -------------------------------------------------------------------------------- /tests/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tox-dev/tox/3ad56f04fcf923649e1ce2eb40bb32778a6659c3/tests/util/__init__.py -------------------------------------------------------------------------------- /tests/util/test_ci.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import operator 4 | 5 | import pytest 6 | 7 | from tox.util.ci import _ENV_VARS, is_ci # noqa: PLC2701 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "env_var", 12 | { 13 | "CI": None, # generic flag 14 | "TF_BUILD": "true", # Azure Pipelines 15 | "bamboo.buildKey": None, # Bamboo 16 | "BUILDKITE": "true", # Buildkite 17 | "CIRCLECI": "true", # Circle CI 18 | "CIRRUS_CI": "true", # Cirrus CI 19 | "CODEBUILD_BUILD_ID": None, # CodeBuild 20 | "GITHUB_ACTIONS": "true", # GitHub Actions 21 | "GITLAB_CI": None, # GitLab CI 22 | "HEROKU_TEST_RUN_ID": None, # Heroku CI 23 | "BUILD_ID": None, # Hudson 24 | "TEAMCITY_VERSION": None, # TeamCity 25 | "TRAVIS": "true", # Travis CI 26 | }.items(), 27 | ids=operator.itemgetter(0), 28 | ) 29 | def test_is_ci(env_var: tuple[str, str | None], monkeypatch: pytest.MonkeyPatch) -> None: 30 | for var in _ENV_VARS: 31 | monkeypatch.delenv(var, raising=False) 32 | monkeypatch.setenv(env_var[0], env_var[1] or "") 33 | assert is_ci() 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "env_var", 38 | { 39 | "TF_BUILD": "", # Azure Pipelines 40 | "BUILDKITE": "", # Buildkite 41 | "CIRCLECI": "", # Circle CI 42 | "CIRRUS_CI": "", # Cirrus CI 43 | "GITHUB_ACTIONS": "", # GitHub Actions 44 | "TRAVIS": "", # Travis CI 45 | }.items(), 46 | ids=operator.itemgetter(0), 47 | ) 48 | def test_is_ci_bad_set(env_var: tuple[str, str], monkeypatch: pytest.MonkeyPatch) -> None: 49 | for var in _ENV_VARS: 50 | monkeypatch.delenv(var, raising=False) 51 | monkeypatch.setenv(env_var[0], env_var[1]) 52 | assert not is_ci() 53 | 54 | 55 | def test_is_ci_not(monkeypatch: pytest.MonkeyPatch) -> None: 56 | for var in _ENV_VARS: 57 | monkeypatch.delenv(var, raising=False) 58 | assert not is_ci() 59 | 60 | 61 | def test_is_ci_not_teamcity_local(monkeypatch: pytest.MonkeyPatch) -> None: 62 | # pycharm sets this 63 | for var in _ENV_VARS: 64 | monkeypatch.delenv(var, raising=False) 65 | 66 | monkeypatch.setenv("TEAMCITY_VERSION", "LOCAL") 67 | assert not is_ci() 68 | -------------------------------------------------------------------------------- /tests/util/test_cpu.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import multiprocessing 4 | from typing import TYPE_CHECKING 5 | 6 | from tox.util.cpu import auto_detect_cpus 7 | 8 | if TYPE_CHECKING: 9 | from pytest_mock import MockerFixture 10 | 11 | 12 | def test_auto_detect_cpus() -> None: 13 | num_cpus_actual = multiprocessing.cpu_count() 14 | assert auto_detect_cpus() == num_cpus_actual 15 | 16 | 17 | def test_auto_detect_cpus_returns_one_when_cpu_count_throws(mocker: MockerFixture) -> None: 18 | mocker.patch.object(multiprocessing, "cpu_count", side_effect=NotImplementedError) 19 | assert auto_detect_cpus() == 1 20 | -------------------------------------------------------------------------------- /tests/util/test_graph.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import OrderedDict 4 | 5 | import pytest 6 | 7 | from tox.util.graph import stable_topological_sort 8 | 9 | 10 | def test_topological_order_empty() -> None: 11 | graph: dict[str, set[str]] = OrderedDict() 12 | result = stable_topological_sort(graph) 13 | assert result == [] 14 | 15 | 16 | def test_topological_order_specified_only() -> None: 17 | graph: dict[str, set[str]] = OrderedDict() 18 | graph["A"] = {"B", "C"} 19 | result = stable_topological_sort(graph) 20 | assert result == ["A"] 21 | 22 | 23 | def test_topological_order() -> None: 24 | graph: dict[str, set[str]] = OrderedDict() 25 | graph["A"] = {"B", "C"} 26 | graph["B"] = set() 27 | graph["C"] = set() 28 | result = stable_topological_sort(graph) 29 | assert result == ["B", "C", "A"] 30 | 31 | 32 | def test_topological_order_cycle() -> None: 33 | graph: dict[str, set[str]] = OrderedDict() 34 | graph["A"] = {"B", "C"} 35 | graph["B"] = {"A"} 36 | with pytest.raises(ValueError, match=r"^A \| B$"): 37 | stable_topological_sort(graph) 38 | 39 | 40 | def test_topological_complex() -> None: 41 | graph: dict[str, set[str]] = OrderedDict() 42 | graph["A"] = {"B", "C"} 43 | graph["B"] = {"C", "D"} 44 | graph["C"] = {"D"} 45 | graph["D"] = set() 46 | result = stable_topological_sort(graph) 47 | assert result == ["D", "C", "B", "A"] 48 | 49 | 50 | def test_two_sub_graph() -> None: 51 | graph: dict[str, set[str]] = OrderedDict() 52 | graph["F"] = set() 53 | graph["E"] = set() 54 | graph["D"] = {"E", "F"} 55 | graph["A"] = {"B", "C"} 56 | graph["B"] = set() 57 | graph["C"] = set() 58 | 59 | result = stable_topological_sort(graph) 60 | assert result == ["F", "E", "D", "B", "C", "A"] 61 | 62 | 63 | def test_two_sub_graph_circle() -> None: 64 | graph: dict[str, set[str]] = OrderedDict() 65 | graph["F"] = set() 66 | graph["E"] = set() 67 | graph["D"] = {"E", "F"} 68 | graph["A"] = {"B", "C"} 69 | graph["B"] = {"A"} 70 | graph["C"] = set() 71 | with pytest.raises(ValueError, match=r"^A \| B$"): 72 | stable_topological_sort(graph) 73 | -------------------------------------------------------------------------------- /tests/util/test_path.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from tox.util.path import ensure_empty_dir 6 | 7 | if TYPE_CHECKING: 8 | from pathlib import Path 9 | 10 | 11 | def test_ensure_empty_dir_file(tmp_path: Path) -> None: 12 | dest = tmp_path / "a" 13 | dest.write_text("") 14 | ensure_empty_dir(dest) 15 | assert dest.is_dir() 16 | assert not list(dest.iterdir()) 17 | --------------------------------------------------------------------------------