diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 2d64afd70..000000000 --- a/.coveragerc +++ /dev/null @@ -1,29 +0,0 @@ -[coverage:report] -show_missing = True -exclude_lines = - \#\s*pragma: no cover - ^\s*raise AssertionError\b - ^\s*raise NotImplementedError\b - ^\s*raise$ - ^if __name__ == ['"]__main__['"]:$ -omit = - # site.py is ran before the coverage can be enabled, no way to measure coverage on this - src/virtualenv/create/via_global_ref/builtin/python2/site.py - src/virtualenv/create/via_global_ref/_virtualenv.py - src/virtualenv/activation/python/activate_this.py - src/virtualenv/seed/wheels/embed/pip-*.whl/* - -[coverage:paths] -source = - src - .tox/*/lib/python*/site-packages - .tox/pypy*/site-packages - .tox\*\Lib\site-packages\ - */src - *\src - -[coverage:run] -branch = false -parallel = true -source = - ${_COVERAGE_SRC} diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6c2928227..22b635337 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,11 +1,11 @@ -# Contributing to ``virtualenv`` +# Contributing to `virtualenv` -Thank you for your interest in contributing to virtualenv! There are many ways to contribute, and we appreciate all of them. -As a reminder, all contributors are expected to follow the [PSF Code of Conduct][coc]. +Thank you for your interest in contributing to virtualenv! There are many ways to contribute, and we appreciate all of +them. As a reminder, all contributors are expected to follow the [PSF Code of Conduct][coc]. [coc]: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md ## Development Documentation Our [development documentation](https://virtualenv.pypa.io/en/latest/development.html#development) contains details on -how to get started with contributing to ``virtualenv``, and details of our development processes. +how to get started with contributing to `virtualenv`, and details of our development processes. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..91b483e4e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +tidelift: "pypi/virtualenv" diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 8b576e1ee..0b3a25014 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,12 +1,13 @@ **Issue** -Describe what's the expected behaviour and what you're observing. +Describe what's the expected behavior and what you're observing. **Environment** Provide at least: + - OS: -- ``pip list`` of the host python where ``virtualenv`` is installed: +- `pip list` of the host python where `virtualenv` is installed: ```console diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 3a8b35e89..258ad844a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,21 +1,21 @@ --- name: Bug report about: Create a report to help us improve -title: '' +title: "" labels: bug -assignees: '' - +assignees: "" --- **Issue** -Describe what's the expected behaviour and what you're observing. +Describe what's the expected behavior and what you're observing. **Environment** Provide at least: + - OS: -- ``pip list`` of the host python where ``virtualenv`` is installed: +- `pip list` of the host python where `virtualenv` is installed: ```console diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8143f9d5c..1cc9cf633 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,13 +1,13 @@ # Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser -blank_issues_enabled: true # default +blank_issues_enabled: true # default contact_links: -- name: '💬 pypa/virtualenv @ Discord' - url: https://discord.gg/pypa - about: Chat with the devs -- name: 🤷💻🤦 Discourse - url: https://discuss.python.org/c/packaging - about: | - Please ask typical Q&A here: general ideas for Python packaging, questions about structuring projects and so on -- name: 📝 PSF Code of Conduct - url: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md - about: ❤ Be nice to other members of the community. ☮ Behave. + - name: "💬 pypa/virtualenv @ Discord" + url: https://discord.gg/pypa + about: Chat with the devs + - name: 🤷💻🤦 Discourse + url: https://discuss.python.org/c/packaging + about: | + Please ask typical Q&A here: general ideas for Python packaging, questions about structuring projects and so on + - name: 📝 PSF Code of Conduct + url: https://github.com/pypa/.github/blob/main/CODE_OF_CONDUCT.md + about: ❤ Be nice to other members of the community. ☮ Behave. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index ef66da0cd..8714ceb6e 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -1,22 +1,26 @@ --- name: Feature request about: Suggest an enhancement for this project -title: '' +title: "" labels: enhancement -assignees: '' - +assignees: "" --- **What's the problem this feature will solve?** + **Describe the solution you'd like** + **Alternative Solutions** - + + **Additional context** + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d4a1cd8c2..ae6ea93a0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,7 @@ -### Thanks for contributing, make sure you address all the checklists (for details on how see -[development documentation](https://virtualenv.pypa.io/en/latest/development.html#development))! +### Thanks for contributing, make sure you address all the checklists (for details on how see [development documentation](https://virtualenv.pypa.io/en/latest/development.html#development)) -- [ ] ran the linter to address style issues (``tox -e fix_lint``) +- [ ] ran the linter to address style issues (`tox -e fix`) - [ ] wrote descriptive pull request text - [ ] ensured there are test(s) validating the fix -- [ ] added news fragment in ``docs/changelog`` folder +- [ ] added news fragment in `docs/changelog` folder - [ ] updated/extended the documentation diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000..90836425e --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| --------- | ------------------ | +| 20.15.1 + | :white_check_mark: | +| < 20.15.1 | :x: | + +## Reporting a Vulnerability + +To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift +will coordinate the fix and disclosure. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..123014908 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..9d1e0987b --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - dependabot + - pre-commit-ci diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 000000000..cb3ea8491 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,149 @@ +name: check +on: + workflow_dispatch: + push: + branches: ["main"] + tags-ignore: ["**"] + pull_request: + schedule: + - cron: "0 8 * * *" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: test ${{ matrix.py }} - ${{ matrix.os }} + if: github.event_name != 'schedule' || github.repository_owner == 'pypa' + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + py: + - "3.13" + - "3.12" + - "3.11" + - "3.10" + - "3.9" + - "3.8" + - pypy-3.10 + - pypy-3.9 + - pypy-3.8 + os: + - ubuntu-latest + - macos-latest + - windows-latest + include: + - { os: macos-latest, py: "brew@3.11" } + - { os: macos-latest, py: "brew@3.10" } + - { os: macos-latest, py: "brew@3.9" } + exclude: + - { os: windows-latest, py: "pypy-3.10" } + - { os: windows-latest, py: "pypy-3.9" } + - { os: windows-latest, py: "pypy-3.8" } + steps: + - uses: taiki-e/install-action@cargo-binstall + - name: Install OS dependencies + run: | + set -x + for i in 1 2 3; do + echo "try $i" && \ + ${{ runner.os == 'Linux' && 'sudo apt-get update -y && sudo apt-get install snapd fish csh -y' || true }} && \ + ${{ runner.os == 'Linux' && 'cargo binstall -y nu' || true }} && \ + ${{ runner.os == 'macOS' && 'brew install fish tcsh nushell' || true }} && \ + ${{ runner.os == 'Windows' && 'choco install nushell' || true }} && \ + exit 0 || true; + sleep 1 + done + exit 1 + shell: bash + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Add .local/bin to PATH Windows + if: runner.os == 'Windows' + shell: bash + run: echo "$USERPROFILE/.local/bin" >> $GITHUB_PATH + - name: Add .local/bin to PATH macos-13 + if: matrix.os == 'macos-13' + shell: bash + run: echo ~/.local/bin >> $GITHUB_PATH + - name: Install tox + if: matrix.py == '3.13' + run: uv tool install --python-preference only-managed --python 3.12 tox --with tox-uv + - name: Install tox + if: matrix.py != '3.13' + run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv + - name: Setup brew python for test ${{ matrix.py }} + if: startsWith(matrix.py,'brew@') + run: | + set -e + PY=$(echo '${{ matrix.py }}' | cut -c 6-) + brew cleanup && brew upgrade python@$PY || brew install python@$PY + echo "/usr/local/opt/python@$PY/libexec/bin" >>"${GITHUB_PATH}" + shell: bash + - name: Setup python for test ${{ matrix.py }} + if: "!( startsWith(matrix.py,'brew@') || endsWith(matrix.py, '-dev') )" + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.py }} + allow-prereleases: true + - name: Pick environment to run + run: python tasks/pick_tox_env.py ${{ matrix.py }} + - name: Setup test suite + run: tox run -vv --notest --skip-missing-interpreters false + - name: Run test suite + run: tox run --skip-pkg-install + timeout-minutes: 20 + env: + PYTEST_ADDOPTS: "-vv --durations=20" + CI_RUN: "yes" + DIFF_AGAINST: HEAD + + check: + name: ${{ matrix.tox_env }} - ${{ matrix.os }} + if: github.event_name != 'schedule' || github.repository_owner == 'pypa' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + tox_env: + - dev + - docs + - readme + - upgrade + - zipapp + exclude: + - { os: windows-latest, tox_env: readme } + - { os: windows-latest, tox_env: docs } + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Add .local/bin to Windows PATH + if: runner.os == 'Windows' + shell: bash + run: echo "$USERPROFILE/.local/bin" >> $GITHUB_PATH + - name: Install tox + run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv + - name: Setup check suite + run: tox r -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }} + - name: Run check for ${{ matrix.tox_env }} + run: tox r --skip-pkg-install -e ${{ matrix.tox_env }} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index fc8ee1069..000000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,202 +0,0 @@ -name: check -on: - push: - pull_request: - schedule: - - cron: "0 8 * * *" - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: pre-commit/action@v2.0.3 - - test: - name: test ${{ matrix.py }} - ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - py: - - "3.10" - - "3.9" - - "3.8" - - "3.7" - - "3.6" - - "3.5" - - pypy-3.6-v7.3.3 - - pypy-3.7-v7.3.7 - - pypy-3.8-v7.3.7 - - "2.7" - - pypy-2.7 - os: - - ubuntu-20.04 - - macos-10.15 - - windows-2022 - include: - - { os: macos-10.15, py: brew@py3 } - steps: - - name: Install OS dependencies - run: | - for i in 1 2 3; do - echo "try $i" && \ - ${{ runner.os == 'Linux' && 'sudo apt-get update -y && sudo apt-get install snapd fish csh -y' || true }} && \ - ${{ runner.os == 'Linux' && 'sudo apt-get install curl wget -y' || true }} && \ - ${{ runner.os == 'Linux' && 'nushell_url=$(curl -s https://api.github.com/repos/nushell/nushell/releases/latest | grep "browser_" | cut -d\" -f4 | grep .tar.gz)' || true }} && \ - ${{ runner.os == 'Linux' && 'wget -O nushell.tar.gz $nushell_url' || true }} && \ - ${{ runner.os == 'Linux' && 'tar -zxf nushell.tar.gz --one-top-level=nushell --strip-components=2' || true }} && \ - ${{ runner.os == 'Linux' && 'sudo cp nushell/nu /usr/bin' || true }} && \ - ${{ runner.os == 'Windows' && 'choco install nushell' || true }} && \ - ${{ runner.os == 'macOS' && 'brew update && brew install fish tcsh nushell' || true }} && \ - exit 0 || true; - done - exit 1 - shell: bash - - name: Setup python for tox - uses: actions/setup-python@v2 - with: - python-version: "3.10" - - name: Install tox - run: python -m pip install tox - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Use local virtualenv for tox - run: python -m pip install . - - name: Install Python 2 for cross test - uses: actions/setup-python@v2 - with: - python-version: "2.7" - - name: Setup python for test ${{ matrix.py }} - if: "!( startsWith(matrix.py,'brew@py') || endsWith(matrix.py, '-dev') )" - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.py }} - - name: Setup brew python for test ${{ matrix.py }} - if: startsWith(matrix.py,'brew@py') - run: | - import subprocess; import codecs; import os - subprocess.check_call(["bash", "-c", "brew upgrade python@3 || brew install python@3"]) - with codecs.open(os.environ["GITHUB_PATH"], "a", "utf-8") as file_handler: - file_handler.write("/usr/local/opt/python@3") - shell: python - - name: Pick environment to run - run: | - import platform; import os; import sys; import codecs - cpy = platform.python_implementation() == "CPython" - base =("{}{}{}" if cpy else "{}{}").format("py" if cpy else "pypy", *sys.version_info[0:2]) - env = "TOXENV={}\n".format(base) - print("Picked:\n{}for{}".format(env, sys.version)) - with codecs.open(os.environ["GITHUB_ENV"], "a", "utf-8") as file_handler: - file_handler.write(env) - shell: python - - name: Setup test suite - run: tox -vv --notest - - name: Run test suite - run: tox --skip-pkg-install - env: - PYTEST_ADDOPTS: "-vv --durations=20" - CI_RUN: "yes" - DIFF_AGAINST: HEAD - - name: Rename coverage report file - run: import os; import sys; os.rename(".tox/.coverage.{}".format(os.environ['TOXENV']), ".tox/.coverage.{}-{}".format(os.environ['TOXENV'], sys.platform)) - shell: python - - name: Upload coverage data - uses: actions/upload-artifact@v2 - with: - name: coverage-data - path: ".tox/.coverage.*" - - coverage: - name: Combine coverage - runs-on: ubuntu-latest - needs: test - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - uses: actions/setup-python@v2 - with: - python-version: "3.10" - - name: Install tox - run: python -m pip install tox - - name: Setup coverage tool - run: tox -e coverage --notest - - name: Install package builder - run: python -m pip install build - - name: Build package - run: pyproject-build --wheel . - - name: Download coverage data - uses: actions/download-artifact@v2 - with: - name: coverage-data - path: .tox - - name: Combine and report coverage - run: tox -e coverage - - name: Upload HTML report - uses: actions/upload-artifact@v2 - with: - name: html-report - path: .tox/htmlcov - - check: - name: ${{ matrix.tox_env }} - ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: - - ubuntu-20.04 - - windows-2022 - tox_env: - - dev - - docs - - readme - - upgrade - - zipapp - exclude: - - { os: windows-2022, tox_env: readme } - - { os: windows-2022, tox_env: docs } - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Setup Python "3.10" - uses: actions/setup-python@v2 - with: - python-version: "3.10" - - name: Install tox - run: python -m pip install tox - - name: Run check for ${{ matrix.tox_env }} - run: python -m tox -e ${{ matrix.tox_env }} - env: - UPGRADE_ADVISORY: "yes" - - publish: - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - needs: [check, coverage, lint] - runs-on: ubuntu-20.04 - steps: - - name: Setup python to build package - uses: actions/setup-python@v2 - with: - python-version: "3.10" - - name: Install https://pypi.org/project/build - run: python -m pip install build - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Build sdist and wheel - run: python -m build -s -w . -o dist - - name: Publish to PyPi - uses: pypa/gh-action-pypi-publish@master - with: - skip_existing: true - user: __token__ - password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 000000000..7c2e522d3 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,48 @@ +name: Release to PyPI +on: + push: + tags: ["*"] + +env: + dists-artifact-name: python-package-distributions + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build package + run: uv build --python 3.13 --python-preference only-managed --sdist --wheel . --out-dir dist + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: ${{ env.dists-artifact-name }} + path: dist/* + + release: + needs: + - build + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/project/virtualenv/${{ github.ref_name }} + permissions: + id-token: write + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: ${{ env.dists-artifact-name }} + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@v1.12.2 + with: + attestations: true diff --git a/.gitignore b/.gitignore index f8aceb2f6..b799ed285 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,6 @@ dist /pip-wheel-metadata /src/virtualenv/version.py /src/virtualenv/out -venv* .python-version *wheel-store* diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 000000000..33a1615f7 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,10 @@ +MD013: + code_blocks: false + headers: false + line_length: 120 + tables: false + +MD046: + style: fenced +no-emphasis-as-header: false +first-line-h1: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3bd5836d6..bc0373e30 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,50 +1,42 @@ repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 - hooks: - - id: check-ast - - id: check-builtin-literals - - id: check-docstring-first - - id: check-merge-conflict - - id: check-yaml - - id: check-toml - - id: debug-statements - - id: end-of-file-fixer - - id: trailing-whitespace -- repo: https://github.com/asottile/pyupgrade - rev: v2.32.1 - hooks: - - id: pyupgrade -- repo: https://github.com/PyCQA/isort - rev: 5.10.1 - hooks: - - id: isort -- repo: https://github.com/psf/black - rev: 22.3.0 - hooks: - - id: black - args: [--safe] -- repo: https://github.com/asottile/blacken-docs - rev: v1.12.1 - hooks: - - id: blacken-docs - additional_dependencies: [black==21.12b0] -- repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.9.0 - hooks: - - id: rst-backticks -- repo: https://github.com/tox-dev/tox-ini-fmt - rev: "0.5.2" - hooks: - - id: tox-ini-fmt - args: ["-p", "fix_lint"] -- repo: https://github.com/asottile/setup-cfg-fmt - rev: v1.20.1 - hooks: - - id: setup-cfg-fmt - args: [--min-py3-version, "3.5", "--max-py-version", "3.10"] -- repo: https://github.com/PyCQA/flake8 - rev: "4.0.1" - hooks: - - id: flake8 - additional_dependencies: ["flake8-bugbear == 21.11.29"] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.29.4 + hooks: + - id: check-github-workflows + args: ["--verbose"] + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + args: ["--write-changes"] + - repo: https://github.com/tox-dev/tox-ini-fmt + rev: "1.4.1" + hooks: + - id: tox-ini-fmt + args: ["-p", "fix"] + - repo: https://github.com/tox-dev/pyproject-fmt + rev: "v2.5.0" + hooks: + - id: pyproject-fmt + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.8.0" + hooks: + - id: ruff-format + - id: ruff + args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] + - repo: https://github.com/rbubley/mirrors-prettier + rev: "v3.3.3" + hooks: + - id: prettier + additional_dependencies: + - prettier@3.3.3 + - "@prettier/plugin-xml@3.4.1" + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes diff --git a/.readthedocs.yml b/.readthedocs.yml index 247a57577..ab3011302 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,18 +1,15 @@ version: 2 build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: - python: "3.10" -formats: - - htmlzip - - epub - - pdf + python: "3" python: - install: - - method: pip - path: . - extra_requirements: ["docs"] + install: + - method: pip + path: . + extra_requirements: + - docs sphinx: builder: html configuration: docs/conf.py - fail_on_warning: false + fail_on_warning: true diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index b8959cf00..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,11 +0,0 @@ -# setuptools-scm by default adds all SCM tracked files, we prune the following maintenance related ones (sdist only) -exclude .gitattributes -exclude .gitignore -exclude .github/* - -exclude azure-pipelines.yml -exclude readthedocs.yml -exclude MANIFEST.in - -exclude tasks/release.py -exclude tasks/upgrade_wheels.py diff --git a/README.md b/README.md index 024718331..87f45e077 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,9 @@ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/virtualenv?style=flat-square)](https://pypi.org/project/virtualenv) [![Documentation](https://readthedocs.org/projects/virtualenv/badge/?version=latest&style=flat-square)](http://virtualenv.pypa.io) [![Discord](https://img.shields.io/discord/803025117553754132)](https://discord.gg/pypa) -[![PyPI - Downloads](https://img.shields.io/pypi/dm/virtualenv?style=flat-square)](https://pypistats.org/packages/virtualenv) +[![Downloads](https://static.pepy.tech/badge/virtualenv/month)](https://pepy.tech/project/virtualenv) [![PyPI - License](https://img.shields.io/pypi/l/virtualenv?style=flat-square)](https://opensource.org/licenses/MIT) -[![Build Status](https://github.com/pypa/virtualenv/workflows/check/badge.svg?branch=main&event=push)](https://github.com/pypa/virtualenv/actions?query=workflow%3Acheck) -[![Code style: -black](https://img.shields.io/badge/code%20style-black-000000.svg?style=flat-square)](https://github.com/psf/black) +[![check](https://github.com/pypa/virtualenv/actions/workflows/check.yaml/badge.svg)](https://github.com/pypa/virtualenv/actions/workflows/check.yaml) A tool for creating isolated `virtual` python environments. diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 7efa026c7..689337387 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,64 +1,65 @@ .wy-nav-content { - padding: 1em; + padding: 1em; } #virtualenv img { - margin-bottom: 6px; + margin-bottom: 6px; } /* Allow table content to wrap around */ -.wy-table-responsive table th, .wy-table-responsive table td { - /* !important because RTD has conflicting stylesheets */ - white-space: normal !important; - padding: 8px 6px !important; +.wy-table-responsive table th, +.wy-table-responsive table td { + /* !important because RTD has conflicting stylesheets */ + white-space: normal !important; + padding: 8px 6px !important; } .wy-table-responsive table { - width: 100%; - margin-left: 0 !important; + width: 100%; + margin-left: 0 !important; } .rst-content table.docutils td ol { - margin-bottom: 0; + margin-bottom: 0; } .rst-content table.docutils td ul { - margin-bottom: 0; + margin-bottom: 0; } .rst-content table.docutils td p { - margin-bottom: 0; + margin-bottom: 0; } div[class*="highlight-"] { - margin-bottom: 12px; + margin-bottom: 12px; } /* Tweak whitespace on the release history page */ #release-history p { - margin-bottom: 0; - margin-top: 0; + margin-bottom: 0; + margin-top: 0; } #release-history h3 { - margin-bottom: 6px; + margin-bottom: 6px; } #release-history ul { - margin-bottom: 12px; + margin-bottom: 12px; } #release-history ul ul { - margin-bottom: 0; - margin-top: 0; + margin-bottom: 0; + margin-top: 0; } #release-history h2 { - margin-bottom: 12px; + margin-bottom: 12px; } /* Reduce whitespace on the inline-code snippets and add softer corners */ .rst-content code { - padding: 2px 3px; - border-radius: 3px; + padding: 2px 3px; + border-radius: 3px; } diff --git a/docs/changelog.rst b/docs/changelog.rst index 91c1ece19..ae4131c45 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,490 @@ Release History .. towncrier release notes start +v20.28.0 (2024-11-25) +--------------------- + +Features - 20.28.0 +~~~~~~~~~~~~~~~~~~ +- Write CACHEDIR.TAG file on creation - by "user:`neilramsay`. (:issue:`2803`) + +v20.27.2 (2024-11-25) +--------------------- + +Bugfixes - 20.27.2 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded wheels: + + * setuptools to ``75.3.0`` from ``75.2.0`` (:issue:`2798`) +- Upgrade embedded wheels: + + * wheel to ``0.45.0`` from ``0.44.0`` + * setuptools to ``75.5.0`` (:issue:`2800`) +- no longer forcibly echo off during windows batch activation (:issue:`2801`) +- Upgrade embedded wheels: + + * setuptools to ``75.6.0`` from ``75.5.0`` + * wheel to ``0.45.1`` from ``0.45.0`` (:issue:`2804`) + +v20.27.1 (2024-10-28) +--------------------- + +Bugfixes - 20.27.1 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded wheels: + + * pip to ``24.3.1`` from ``24.2`` (:issue:`2789`) + +v20.27.0 (2024-10-17) +--------------------- + +Features - 20.27.0 +~~~~~~~~~~~~~~~~~~ +- Drop 3.7 support as the CI environments no longer allow it running - by :user:`gaborbernat`. (:issue:`2758`) + +Bugfixes - 20.27.0 +~~~~~~~~~~~~~~~~~~ +- When a ``$PATH`` entry cannot be checked for existence, skip it instead of terminating - by :user:`hroncok`. (:issue:`2782`) +- Upgrade embedded wheels: + + * setuptools to ``75.2.0`` from ``75.1.0`` + * Removed pip of ``24.0`` + * Removed setuptools of ``68.0.0`` + * Removed wheel of ``0.42.0`` + + - by :user:`gaborbernat`. (:issue:`2783`) +- Fix zipapp is broken on Windows post distlib ``0.3.9`` - by :user:`gaborbernat`. (:issue:`2784`) + +v20.26.6 (2024-09-27) +--------------------- + +Bugfixes - 20.26.6 +~~~~~~~~~~~~~~~~~~ +- Properly quote string placeholders in activation script templates to mitigate + potential command injection - by :user:`y5c4l3`. (:issue:`2768`) + +v20.26.5 (2024-09-17) +--------------------- + +Bugfixes - 20.26.5 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded wheels: setuptools to ``75.1.0`` from ``74.1.2`` - by :user:`gaborbernat`. (:issue:`2765`) + +v20.26.4 (2024-09-07) +--------------------- + +Bugfixes - 20.26.4 +~~~~~~~~~~~~~~~~~~ +- no longer create `()` output in console during activation of a virtualenv by .bat file. (:issue:`2728`) +- Upgrade embedded wheels: + + * wheel to ``0.44.0`` from ``0.43.0`` + * pip to ``24.2`` from ``24.1`` + * setuptools to ``74.1.2`` from ``70.1.0`` (:issue:`2760`) + +v20.26.3 (2024-06-21) +--------------------- + +Bugfixes - 20.26.3 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded wheels: + + * setuptools to ``70.1.0`` from ``69.5.1`` + * pip to ``24.1`` from ``24.0`` (:issue:`2741`) + +v20.26.2 (2024-05-13) +--------------------- + +Bugfixes - 20.26.2 +~~~~~~~~~~~~~~~~~~ +- ``virtualenv.pyz`` no longer fails when zipapp path contains a symlink - by :user:`HandSonic` and :user:`petamas`. (:issue:`1949`) +- Fix bad return code from activate.sh if hashing is disabled - by :user:'fenkes-ibm'. (:issue:`2717`) + +v20.26.1 (2024-04-29) +--------------------- + +Bugfixes - 20.26.1 +~~~~~~~~~~~~~~~~~~ +- fix PATH-based Python discovery on Windows - by :user:`ofek`. (:issue:`2712`) + +v20.26.0 (2024-04-23) +--------------------- + +Bugfixes - 20.26.0 +~~~~~~~~~~~~~~~~~~ +- allow builtin discovery to discover specific interpreters (e.g. ``python3.12``) given an unspecific spec (e.g. ``python3``) - by :user:`flying-sheep`. (:issue:`2709`) + +v20.25.3 (2024-04-17) +--------------------- + +Bugfixes - 20.25.3 +~~~~~~~~~~~~~~~~~~ +- Python 3.13.0a6 renamed pathmod to parser. (:issue:`2702`) + +v20.25.2 (2024-04-16) +--------------------- + +Bugfixes - 20.25.2 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded wheels: + + - setuptools of ``69.1.0`` to ``69.5.1`` + - wheel of ``0.42.0`` to ``0.43.0`` (:issue:`2699`) + +v20.25.1 (2024-02-21) +--------------------- + +Bugfixes - 20.25.1 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded wheels: + + * setuptools to ``69.0.3`` from ``69.0.2`` + * pip to ``23.3.2`` from ``23.3.1`` (:issue:`2681`) +- Upgrade embedded wheels: + + - pip ``23.3.2`` to ``24.0``, + - setuptools ``69.0.3`` to ``69.1.0``. (:issue:`2691`) + +Misc - 20.25.1 +~~~~~~~~~~~~~~ +- :issue:`2688` + +v20.25.0 (2023-12-01) +--------------------- + +Features - 20.25.0 +~~~~~~~~~~~~~~~~~~ +- The tests now pass on the CI with Python 3.13.0a2 - by :user:`hroncok`. (:issue:`2673`) + +Bugfixes - 20.25.0 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded wheels: + + * wheel to ``0.41.3`` from ``0.41.2`` (:issue:`2665`) +- Upgrade embedded wheels: + + * wheel to ``0.42.0`` from ``0.41.3`` + * setuptools to ``69.0.2`` from ``68.2.2`` (:issue:`2669`) + +v20.24.6 (2023-10-23) +--------------------- + +Bugfixes - 20.24.6 +~~~~~~~~~~~~~~~~~~ +- Use get_hookimpls method instead of the private attribute in tests. (:issue:`2649`) +- Upgrade embedded wheels: + + * setuptools to ``68.2.2`` from ``68.2.0`` + * pip to ``23.3.1`` from ``23.2.1`` (:issue:`2656`) + + +v20.24.5 (2023-09-08) +--------------------- + +Bugfixes - 20.24.5 +~~~~~~~~~~~~~~~~~~ +- Declare PyPy 3.10 support - by :user:`cclauss`. (:issue:`2638`) +- Brew on macOS no longer allows copy builds - disallow choosing this by :user:`gaborbernat`. (:issue:`2640`) +- Upgrade embedded wheels: + + * setuptools to ``68.2.0`` from ``68.1.2`` (:issue:`2642`) + + +v20.24.4 (2023-08-30) +--------------------- + +Bugfixes - 20.24.4 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded wheels: + + * setuptools to ``68.1.2`` from ``68.1.0`` on ``3.8+`` + * wheel to ``0.41.2`` from ``0.41.1`` on ``3.7+`` (:issue:`2628`) + + +v20.24.3 (2023-08-11) +--------------------- + +Bugfixes - 20.24.3 +~~~~~~~~~~~~~~~~~~ +- Fixed ResourceWarning on exit caused by periodic update subprocess (:issue:`2472`) +- Upgrade embedded wheels: + + * wheel to ``0.41.1`` from ``0.41.0`` (:issue:`2622`) + +Misc - 20.24.3 +~~~~~~~~~~~~~~ +- :issue:`2610` + + +v20.24.2 (2023-07-24) +--------------------- + +Bugfixes - 20.24.2 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded wheels: + + * pip to ``23.2.1`` from ``23.2`` + * wheel to ``0.41.0`` from ``0.40.0`` (:issue:`2614`) + + +v20.24.1 (2023-07-19) +--------------------- + +Bugfixes - 20.24.1 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded wheels: + + * pip to ``23.2`` from ``23.1.2`` - by :user:`arielkirkwood` (:issue:`2611`) + + +v20.24.0 (2023-07-14) +--------------------- + +Features - 20.24.0 +~~~~~~~~~~~~~~~~~~ +- Export the prompt prefix as ``VIRTUAL_ENV_PROMPT`` when activating a virtual + environment - by :user:`jimporter`. (:issue:`2194`) + +Bugfixes - 20.24.0 +~~~~~~~~~~~~~~~~~~ +- Fix test suite - by :user:`gaborbernat`. (:issue:`2592`) +- Upgrade embedded wheels: + + * setuptools to ``68.0.0`` from ``67.8.0`` (:issue:`2607`) + + +v20.23.1 (2023-06-16) +--------------------- + +Bugfixes - 20.23.1 +~~~~~~~~~~~~~~~~~~ +- update and simplify nushell activation script, fixes an issue on Windows resulting in consecutive command not found - by :user:`melMass`. (:issue:`2572`) +- Upgrade embedded wheels: + + * setuptools to ``67.8.0`` from ``67.7.2`` (:issue:`2588`) + + +v20.23.0 (2023-04-27) +--------------------- + +Features - 20.23.0 +~~~~~~~~~~~~~~~~~~ +- Do not install ``wheel`` and ``setuptools`` seed packages for Python 3.12+. To restore the old behavior use: + + - for ``wheel`` use ``VIRTUALENV_WHEEL=bundle`` environment variable or ``--wheel=bundle`` CLI flag, + - for ``setuptools`` use ``VIRTUALENV_SETUPTOOLS=bundle`` environment variable or ``--setuptools=bundle`` CLI flag. + + By :user:`chrysle`. (:issue:`2487`) +- 3.12 support - by :user:`gaborbernat`. (:issue:`2558`) + +Bugfixes - 20.23.0 +~~~~~~~~~~~~~~~~~~ +- Prevent ``PermissionError`` when using venv creator on systems that deliver files without user write + permission - by :user:`kulikjak`. (:issue:`2543`) +- Upgrade setuptools to ``67.7.2`` from ``67.6.1`` and pip to ``23.1.2`` from ``23.1`` - by :user:`szleb`. (:issue:`2560`) + + +v20.22.0 (2023-04-19) +--------------------- + +Features - 20.22.0 +~~~~~~~~~~~~~~~~~~ +- Drop support for creating Python <=3.6 (including 2) interpreters. Removed pip of ``20.3.4``, ``21.3.1``; wheel of + ``0.37.1``; setuptools of ``59.6.0``, ``44.1.1``, ``50.3.2``- by :user:`gaborbernat`. (:issue:`2548`) + + +v20.21.1 (2023-04-19) +--------------------- + +Bugfixes - 20.21.1 +~~~~~~~~~~~~~~~~~~ +- Add ``tox.ini`` to sdist - by :user:`mtelka`. (:issue:`2511`) +- Move the use of 'let' in nushell to ensure compatibility with future releases of nushell, where 'let' no longer + assumes that its initializer is a full expressions. (:issue:`2527`) +- The nushell command 'str collect' has been superseded by the 'str join' command. The activate.nu script has + been updated to reflect this change. (:issue:`2532`) +- Upgrade embedded wheels: + + * wheel to ``0.40.0`` from ``0.38.4`` + * setuptools to ``67.6.1`` from ``67.4.0`` + * pip to ``23.1`` from ``23.0.1`` (:issue:`2546`) + + +v20.21.0 (2023-03-12) +--------------------- + +Features - 20.21.0 +~~~~~~~~~~~~~~~~~~ +- Make closure syntax explicitly starts with {||. (:issue:`2512`) + +Bugfixes - 20.21.0 +~~~~~~~~~~~~~~~~~~ +- Add ``print`` command to nushell print_prompt to ensure compatibility with future release of nushell, + where intermediate commands no longer print their result to stdout. (:issue:`2514`) +- Do not assume the default encoding. (:issue:`2515`) +- Make ``ReentrantFileLock`` thread-safe and, + thereby, fix race condition in ``virtualenv.cli_run`` - by :user:`radoering`. (:issue:`2516`) + + +v20.20.0 (2023-02-28) +--------------------- + +Features - 20.20.0 +~~~~~~~~~~~~~~~~~~ +- Change environment variable existence check in Nushell activation script to not use deprecated command. (:issue:`2506`) + +Bugfixes - 20.20.0 +~~~~~~~~~~~~~~~~~~ +- Discover CPython implementations distributed on Windows by any organization - by :user:`faph`. (:issue:`2504`) +- Upgrade embedded setuptools to ``67.4.0`` from ``67.1.0`` and pip to ``23.0.1`` from ``23.0`` - by :user:`gaborbernat`. (:issue:`2510`) + + +v20.19.0 (2023-02-07) +--------------------- + +Features - 20.19.0 +~~~~~~~~~~~~~~~~~~ +- Allow platformdirs version ``3`` - by :user:`cdce8p`. (:issue:`2499`) + + +v20.18.0 (2023-02-06) +--------------------- + +Features - 20.18.0 +~~~~~~~~~~~~~~~~~~ +- Drop ``3.6`` runtime support (can still create ``2.7+``) - by :user:`gaborbernat`. (:issue:`2489`) + +Bugfixes - 20.18.0 +~~~~~~~~~~~~~~~~~~ +- Fix broken prompt in Nushell when activating virtual environment - by :user:`kubouc`. (:issue:`2481`) +- Bump embedded pip to ``23.0`` and setuptools to ``67.1`` - by :user:`gaborbernat`. (:issue:`2489`) + + +v20.17.1 (2022-12-05) +--------------------- + +Bugfixes - 20.17.1 +~~~~~~~~~~~~~~~~~~ +- A ``py`` or ``python`` spec means any Python rather than ``CPython`` - by :user:`gaborbernat`. (`#2460 `_) +- Make ``activate.nu`` respect ``VIRTUAL_ENV_DISABLE_PROMPT`` and not set the prompt if requested - by :user:`m-lima`. (`#2461 `_) + + +v20.17.0 (2022-11-27) +--------------------- + +Features - 20.17.0 +~~~~~~~~~~~~~~~~~~ +- Change Nushell activation script to be a module meant to be activated as an overlay. (`#2422 `_) +- Update operator used in Nushell activation script to be compatible with future versions. (`#2450 `_) + +Bugfixes - 20.17.0 +~~~~~~~~~~~~~~~~~~ +- Do not use deprecated API from ``importlib.resources`` on Python 3.10 or later - by :user:`gaborbernat`. (`#2448 `_) +- Upgrade embedded setuptools to ``65.6.3`` from ``65.5.1`` - by :user:`gaborbernat`. (`#2451 `_) + + +v20.16.7 (2022-11-12) +--------------------- + +Bugfixes - 20.16.7 +~~~~~~~~~~~~~~~~~~ +- Use parent directory of python executable for pyvenv.cfg "home" value per PEP 405 - by :user:`vfazio`. (`#2440 `_) +- In POSIX virtual environments, try alternate binary names if ``sys._base_executable`` does not exist - by :user:`vfazio`. (`#2442 `_) +- Upgrade embedded wheel to ``0.38.4`` and pip to ``22.3.1`` from ``22.3`` and setuptools to ``65.5.1`` from + ``65.5.0`` - by :user:`gaborbernat`. (`#2443 `_) + + +v20.16.6 (2022-10-25) +--------------------- + +Features - 20.16.6 +~~~~~~~~~~~~~~~~~~ +- Drop unneeded shims for PyPy3 directory structure (`#2426 `_) + +Bugfixes - 20.16.6 +~~~~~~~~~~~~~~~~~~ +- Fix selected scheme on debian derivatives for python 3.10 when ``python3-distutils`` is not installed or the ``venv`` scheme is not available - by :user:`asottile`. (`#2350 `_) +- Allow the test suite to pass even with the original C shell (rather than ``tcsh``) - by :user:`kulikjak`. (`#2418 `_) +- Fix fallback handling of downloading wheels for bundled packages - by :user:`schaap`. (`#2429 `_) +- Upgrade embedded setuptools to ``65.5.0`` from ``65.3.0`` and pip to ``22.3`` from ``22.2.2`` - by :user:`gaborbernat`. (`#2434 `_) + + +v20.16.5 (2022-09-07) +--------------------- + +Bugfixes - 20.16.5 +~~~~~~~~~~~~~~~~~~ +- Do not turn echo off for subsequent commands in batch activators + (``activate.bat`` and ``deactivate.bat``) - by :user:`pawelszramowski`. (`#2411 `_) + + +v20.16.4 (2022-08-29) +--------------------- + +Bugfixes - 20.16.4 +~~~~~~~~~~~~~~~~~~ +- Bump embed setuptools to ``65.3`` - by :user:`gaborbernat`. (`#2405 `_) + + +v20.16.3 (2022-08-04) +--------------------- + +Bugfixes - 20.16.3 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded pip to ``22.2.2`` from ``22.2.1`` and setuptools to ``63.4.1`` from ``63.2.0`` - by :user:`gaborbernat`. (`#2395 `_) + + +v20.16.2 (2022-07-27) +--------------------- + +Bugfixes - 20.16.2 +~~~~~~~~~~~~~~~~~~ +- Bump embedded pip from ``22.2`` to ``22.2.1`` - by :user:`gaborbernat`. (`#2391 `_) + + +v20.16.1 (2022-07-26) +--------------------- + +Features - 20.16.1 +~~~~~~~~~~~~~~~~~~ +- Update Nushell activation scripts to version 0.67 - by :user:`kubouch`. (`#2386 `_) + + +v20.16.0 (2022-07-25) +--------------------- + +Features - 20.16.0 +~~~~~~~~~~~~~~~~~~ +- Drop support for running under Python 2 (still can generate Python 2 environments) - by :user:`gaborbernat`. (`#2382 `_) +- Upgrade embedded pip to ``22.2`` from ``22.1.2`` and setuptools to ``63.2.0`` from ``62.6.0`` - + by :user:`gaborbernat`. (`#2383 `_) + + +v20.15.1 (2022-06-28) +--------------------- + +Bugfixes - 20.15.1 +~~~~~~~~~~~~~~~~~~ +- Fix the incorrect operation when ``setuptools`` plugins output something into ``stdout``. (`#2335 `_) +- CPython3Windows creator ignores missing ``DLLs`` dir. (`#2368 `_) + + +v20.15.0 (2022-06-25) +--------------------- + +Features - 20.15.0 +~~~~~~~~~~~~~~~~~~ +- Support for Windows embeddable Python package: includes ``python.zip`` in the creator sources + - by :user:`reksarka`. (`#1774 `_) + +Bugfixes - 20.15.0 +~~~~~~~~~~~~~~~~~~ +- Upgrade embedded setuptools to ``62.3.3`` from ``62.6.0`` and pip to ``22.1.2`` from ``22.0.4`` + - by :user:`gaborbernat`. (`#2348 `_) +- Use ``shlex.quote`` instead of deprecated ``pipes.quote`` in Python 3 - by :user:`frenzymadness`. (`#2351 `_) +- Fix Windows PyPy 3.6 - by :user:`reksarka`. (`#2363 `_) + + v20.14.1 (2022-04-11) --------------------- @@ -31,7 +515,7 @@ Bugfixes - 20.14.0 v20.13.4 (2022-03-18) --------------------- -Bugfixes - 20.14.0 +Bugfixes - 20.13.4 ~~~~~~~~~~~~~~~~~~ - Improve performance of python startup inside created virtualenvs - by :user:`asottile`. (`#2317 `_) - Upgrade embedded setuptools to ``60.10.0`` from ``60.9.3`` - by :user:`gaborbernat`. (`#2320 `_) @@ -59,7 +543,7 @@ v20.13.1 (2022-02-05) Bugfixes - 20.13.1 ~~~~~~~~~~~~~~~~~~ - fix "execv() arg 2 must contain only strings" error on M1 MacOS (`#2282 `_) -- Ugrade embedded setuptools to ``60.5.0`` from ``60.2.0`` - by :user:`asottile`. (`#2289 `_) +- Upgrade embedded setuptools to ``60.5.0`` from ``60.2.0`` - by :user:`asottile`. (`#2289 `_) - Upgrade embedded pip to ``22.0.3`` and setuptools to ``60.6.0`` - by :user:`gaborbernat` and :user:`asottile`. (`#2294 `_) @@ -103,7 +587,7 @@ Features - 20.12.0 Bugfixes - 20.12.0 ~~~~~~~~~~~~~~~~~~ - Fix ``--download`` option - by :user:`mayeut`. (`#2120 `_) -- Ugrade embedded setuptools to ``60.2.0`` from ``60.1.1`` - by :user:`gaborbernat`. (`#2263 `_) +- Upgrade embedded setuptools to ``60.2.0`` from ``60.1.1`` - by :user:`gaborbernat`. (`#2263 `_) v20.11.2 (2021-12-29) @@ -130,7 +614,7 @@ Features - 20.11.0 - Avoid deprecation warning from py-filelock argument - by :user:`ofek`. (`#2237 `_) - Upgrade embedded setuptools to ``61.1.0`` from ``58.3.0`` - by :user:`gaborbernat`. (`#2240 `_) - Drop the runtime dependency of ``backports.entry-points-selectable`` - by :user:`hroncok`. (`#2246 `_) -- Fish: PATH variables should not be quoted when being set - by :user:`hroncok`. (`#2248 `_) +- Fish: PATH variables should not be quoted when being set - by :user:`d3dave`. (`#2248 `_) v20.10.0 (2021-11-01) @@ -144,7 +628,7 @@ Features - 20.10.0 A similar technique `was proposed to Python, for the venv module `_ - by ``hroncok`` (`#2208 `_) - The activated virtualenv prompt is now always wrapped in parentheses. This affects venvs created with the ``--prompt`` attribute, and matches virtualenv's - behaviour on par with venv. (`#2224 `_) + behavior on par with venv. (`#2224 `_) Bugfixes - 20.10.0 ~~~~~~~~~~~~~~~~~~ @@ -496,7 +980,7 @@ Bugfixes - 20.0.26 - Improve periodic update handling: - better logging output while running and enable logging on background process call ( - ``_VIRTUALENV_PERIODIC_UPDATE_INLINE`` may be used to debug behaviour inline) + ``_VIRTUALENV_PERIODIC_UPDATE_INLINE`` may be used to debug behavior inline) - fallback to unverified context when querying the PyPi for release date, - stop downloading wheels once we reach the embedded version, @@ -856,7 +1340,7 @@ v20.0.2 (2020-02-11) Features - 20.0.2 ~~~~~~~~~~~~~~~~~ - Print out a one line message about the created virtual environment when no :option:`verbose` is set, this can now be - silenced to get back the original behaviour via the :option:`quiet` flag - by :user:`pradyunsg`. (`#1557 `_) + silenced to get back the original behavior via the :option:`quiet` flag - by :user:`pradyunsg`. (`#1557 `_) - Allow virtualenv's app data cache to be overridden by ``VIRTUALENV_OVERRIDE_APP_DATA`` - by :user:`asottile`. (`#1559 `_) - Passing in the virtual environment name/path is now required (no longer defaults to ``venv``) - by :user:`gaborbernat`. (`#1568 `_) - Add a CLI flag :option:`with-traceback` that allows displaying the stacktrace of the virtualenv when a failure occurs @@ -967,6 +1451,7 @@ v20.0.0b1 (2020-01-28) * First public release of the rewrite. Everything is brand new and just added. * ``--download`` defaults to ``False`` +* No longer replaces builtin ``site`` module with `custom version baked within virtualenv code itself `_. A simple shim module is used to fix up things on Python 2 only. .. warning:: diff --git a/docs/cli_interface.rst b/docs/cli_interface.rst index 71260292c..1bbbe234b 100644 --- a/docs/cli_interface.rst +++ b/docs/cli_interface.rst @@ -10,7 +10,7 @@ CLI flags It modifies the environment variables in a shell to create an isolated Python environment, so you'll need to have a shell to run it. You can type in ``virtualenv`` (name of the application) followed by flags that control its -behaviour. All options have sensible defaults, and there's one required argument: then name/path of the virtual +behavior. All options have sensible defaults, and there's one required argument: the name/path of the virtual environment to create. The default values for the command line options can be overridden via the :ref:`conf_file` or :ref:`env_vars`. Environment variables takes priority over the configuration file values (``--help`` will show if a default comes from the environment variable as the help message will end in this case @@ -32,9 +32,10 @@ Defaults Configuration file ^^^^^^^^^^^^^^^^^^ -virtualenv looks for a standard ini configuration file. The exact location depends on the operating system you're using, -as determined by :pypi:`platformdirs` application configuration definition. The configuration file location is printed as at -the end of the output when ``--help`` is passed. +Unless ``VIRTUALENV_CONFIG_FILE`` is set, virtualenv looks for a standard ``virtualenv.ini`` configuration file. +The exact location depends on the operating system you're using, as determined by :pypi:`platformdirs` application +configuration definition. It can be overridden by setting the ``VIRTUALENV_CONFIG_FILE`` environment variable. +The configuration file location is printed as at the end of the output when ``--help`` is passed. The keys of the settings are derived from the command line option (left strip the ``-`` characters, and replace ``-`` with ``_``). Where multiple flags are available first found wins (where order is as it shows up under the ``--help``). diff --git a/docs/conf.py b/docs/conf.py index b981af1d2..f02c8a7cb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,17 +1,17 @@ +from __future__ import annotations + import subprocess import sys -from datetime import date, datetime +from datetime import datetime, timezone from pathlib import Path -import sphinx_rtd_theme - from virtualenv.version import __version__ company = "PyPA" name = "virtualenv" version = ".".join(__version__.split(".")[:2]) release = __version__ -copyright = f"2007-{date.today().year}, {company}, PyPA" +copyright = f"2007-{datetime.now(tz=timezone.utc).year}, {company}, PyPA" # noqa: A001 extensions = [ "sphinx.ext.autodoc", @@ -30,60 +30,43 @@ project = name today_fmt = "%B %d, %Y" -html_theme = "sphinx_rtd_theme" -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] -html_theme_options = { - "canonical_url": "https://virtualenv.pypa.io/", - "logo_only": False, - "display_version": True, - "prev_next_buttons_location": "bottom", - "collapse_navigation": False, - "sticky_navigation": True, - "navigation_depth": 6, - "includehidden": True, -} -html_static_path = ["_static"] -html_last_updated_fmt = datetime.now().isoformat() -htmlhelp_basename = "Pastedoc" +html_theme = "furo" +html_title, html_last_updated_fmt = project, datetime.now(tz=timezone.utc).isoformat() +pygments_style, pygments_dark_style = "sphinx", "monokai" +html_static_path, html_css_files = ["_static"], ["custom.css"] + autoclass_content = "both" # Include __init__ in class documentation autodoc_member_order = "bysource" autosectionlabel_prefix_document = True extlinks = { - "issue": ("https://github.com/pypa/virtualenv/issues/%s", "#"), - "pull": ("https://github.com/pypa/virtualenv/pull/%s", "PR #"), - "user": ("https://github.com/%s", "@"), - "pypi": ("https://pypi.org/project/%s", ""), + "issue": ("https://github.com/pypa/virtualenv/issues/%s", "#%s"), + "pull": ("https://github.com/pypa/virtualenv/pull/%s", "PR #%s"), + "user": ("https://github.com/%s", "@%s"), + "pypi": ("https://pypi.org/project/%s", "%s"), } -def generate_draft_news(): - root = Path(__file__).parents[1] - new = subprocess.check_output( - [sys.executable, "-m", "towncrier", "--draft", "--version", "NEXT"], - cwd=root, - universal_newlines=True, - ) - dest = root / "docs" / "_draft.rst" - dest.write_text("" if "No significant changes" in new else new) - - -generate_draft_news() - - def setup(app): + here = Path(__file__).parent + root, exe = here.parent, Path(sys.executable) + towncrier = exe.with_name(f"towncrier{exe.suffix}") + cmd = [str(towncrier), "build", "--draft", "--version", "NEXT"] + new = subprocess.check_output(cmd, cwd=root, text=True, stderr=subprocess.DEVNULL, encoding="UTF-8") + (root / "docs" / "_draft.rst").write_text("" if "No significant changes" in new else new, encoding="UTF-8") + # the CLI arguments are dynamically generated doc_tree = Path(app.doctreedir) cli_interface_doctree = doc_tree / "cli_interface.doctree" if cli_interface_doctree.exists(): cli_interface_doctree.unlink() - HERE = Path(__file__).parent - if str(HERE) not in sys.path: - sys.path.append(str(HERE)) + here = Path(__file__).parent + if str(here) not in sys.path: + sys.path.append(str(here)) # noinspection PyUnresolvedReferences - from render_cli import CliTable, literal_data + from render_cli import CliTable, literal_data # noqa: PLC0415 app.add_css_file("custom.css") app.add_directive(CliTable.name, CliTable) diff --git a/docs/development.rst b/docs/development.rst index 36d8cbac3..ef5b75e61 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -83,7 +83,7 @@ run: .. code-block:: console - tox -e fix_lint + tox -e fix .. note:: diff --git a/docs/extend.rst b/docs/extend.rst index afd4e80b2..6dc107e3b 100644 --- a/docs/extend.rst +++ b/docs/extend.rst @@ -7,17 +7,10 @@ Extend functionality - package it as a python library, - install it alongside the virtual environment. -.. warning:: - - The public API of some of these components is still to be finalized, consider the current interface a beta one - until we get some feedback on how well we planned ahead. We expect to do this by end of Q3 2020. Consider the class - interface explained below as initial draft proposal. We reserve the right to change the API until then, however such - changes will be communicated in a timely fashion, and you'll have time to migrate. Thank you for your understanding. - Python discovery ---------------- -The python discovery mechanism is a component that needs to answer the following answer: based on some type of user +The python discovery mechanism is a component that needs to answer the following question: based on some type of user input give me a Python interpreter on the machine that matches that. The builtin interpreter tries to discover an installed Python interpreter (based on PEP-515 and ``PATH`` discovery) on the users machine where the user input is a python specification. An alternative such discovery mechanism for example would be to use the popular @@ -44,7 +37,7 @@ Creators are what actually perform the creation of a virtual environment. The bu all achieve this by referencing a global install; but would be just as valid for a creator to install a brand new entire python under the target path; or one could add additional creators that can create virtual environments for other python implementations, such as IronPython. They must be registered under and entry point with key -``virtualenv.discovery`` , and the class must implement :class:`virtualenv.create.creator.Creator`: +``virtualenv.create`` , and the class must implement :class:`virtualenv.create.creator.Creator`: .. code-block:: ini @@ -80,7 +73,7 @@ under and entry point with key ``virtualenv.seed`` , and the class must implemen Activation scripts ------------------ If you want add an activator for a new shell you can do this by implementing a new activator. They must be registered -under and entry point with key ``virtualenv.activate`` , and the class must implement +under an entry point with key ``virtualenv.activate`` , and the class must implement :class:`virtualenv.activation.activator.Activator`: .. code-block:: ini diff --git a/docs/index.rst b/docs/index.rst index 424a8ae27..e31741978 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,4 +1,4 @@ -Virtualenv +virtualenv ========== .. image:: https://img.shields.io/pypi/v/virtualenv?style=flat-square diff --git a/docs/installation.rst b/docs/installation.rst index 19a37dfe8..c48db8f07 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -4,7 +4,7 @@ Installation via pipx -------- -:pypi:`virtualenv` is a CLI tool that needs a Python interpreter to run. If you already have a ``Python 3.5+`` +:pypi:`virtualenv` is a CLI tool that needs a Python interpreter to run. If you already have a ``Python 3.7+`` interpreter the best is to use :pypi:`pipx` to install virtualenv into an isolated environment. This has the added benefit that later you'll be able to upgrade virtualenv without affecting other parts of the system. @@ -58,18 +58,14 @@ with a python interpreter: The root level zipapp is always the current latest release. To get the last supported zipapp against a given python minor release use the link ``https://bootstrap.pypa.io/virtualenv/x.y/virtualenv.pyz``, e.g. for the last virtualenv -supporting Python 2.7 use -`https://bootstrap.pypa.io/virtualenv/2.7/virtualenv.pyz `_. +supporting Python 3.11 use +`https://bootstrap.pypa.io/virtualenv/3.11/virtualenv.pyz `_. If you are looking for past version of virtualenv.pyz they are available here: -https://github.com/pypa/get-virtualenv/blob//public//virtualenv.pyz?raw=true -via ``setup.py`` ----------------- -We don't recommend and officially support this method. One should prefer using an installer that supports -`PEP-517 `_ interface, such as pip ``19.0.0`` or later. That being said you -might be able to still install a package via this method if you satisfy build dependencies before calling the install -command (as described under :ref:`sdist`). +.. code-block:: console + + https://github.com/pypa/get-virtualenv/blob//public//virtualenv.pyz?raw=true latest unreleased ----------------- @@ -88,8 +84,8 @@ Python and OS Compatibility virtualenv works with the following Python interpreter implementations: -- `CPython `_ versions 2.7, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10 -- `PyPy `_ 2.7 and 3.5+. +- `CPython `_: ``3.12 >= python_version >= 3.7`` +- `PyPy `_: ``3.10 >= python_version >= 3.7`` This means virtualenv works on the latest patch version of each of these minor versions. Previous patch versions are supported on a best effort approach. @@ -99,6 +95,12 @@ Therefore we cannot say universally that we support all platforms, but rather sp of ones not specified here the support is unknown, though likely will work. If you find some cases please open a feature request on our issue tracker. +Note: + +- as of ``20.27.0`` -- ``2024-10-17`` -- we no longer support running under Python ``<=3.7``, +- as of ``20.18.0`` -- ``2023-02-06`` -- we no longer support running under Python ``<=3.6``, +- as of ``20.22.0`` -- ``2023-04-19`` -- we no longer support creating environments for Python ``<=3.6``. + Linux ~~~~~ - installations from `python.org `_ @@ -112,19 +114,11 @@ macOS ~~~~~ In case of macOS we support: -- installations from `python.org `_ -- python versions installed via `brew `_ (both older python2.7 and python3) -- Python 3 part of XCode (Python framework - ``/Library/Frameworks/Python3.framework/``) -- Python 2 part of the OS (``/System/Library/Frameworks/Python.framework/Versions/``) +- installations from `python.org `_, +- python versions installed via `brew `_, +- Python 3 part of XCode (Python framework - ``/Library/Frameworks/Python3.framework/``). Windows ~~~~~~~ - Installations from `python.org `_ -- Windows Store Python - note only `version 3.7+ `_ - -Packaging variants -~~~~~~~~~~~~~~~~~~ -- Normal variant (file structure as comes from `python.org `_). -- We support CPython 2 system installations that do not contain the python files for the standard library if the - respective compiled files are present (e.g. only ``os.pyc``, not ``os.py``). This can be used by custom systems may - want to maximize available storage or obfuscate source code by removing ``.py`` files. +- Windows Store Python - note only `version 3.8+ `_ diff --git a/docs/render_cli.py b/docs/render_cli.py index f905206af..ad77fc6a2 100644 --- a/docs/render_cli.py +++ b/docs/render_cli.py @@ -1,6 +1,8 @@ +from __future__ import annotations + from argparse import SUPPRESS -from collections import namedtuple from contextlib import contextmanager +from typing import Any, ClassVar, NamedTuple from docutils import nodes as n from docutils.parsers.rst.directives import unchanged_required @@ -9,9 +11,17 @@ from virtualenv.run.plugin.base import ComponentBuilder -TableRow = namedtuple("TableRow", ["names", "default", "choices", "help"]) -TextAsDefault = namedtuple("TextAsDefault", ["text"]) +class TableRow(NamedTuple): + names: list[str] + default: str + choices: set[str] + help: str + + +class TextAsDefault(NamedTuple): + text: str + CUSTOM = { "discovery": ComponentBuilder.entry_points_for("virtualenv.discovery"), @@ -22,8 +32,8 @@ class CliTable(SphinxDirective): - name = "table_cli" - option_spec = dict(module=unchanged_required, func=unchanged_required) + name: ClassVar[str] = "table_cli" + option_spec: ClassVar[str, Any] = {"module": unchanged_required, "func": unchanged_required} def run(self): module_name, attr_name = self.options["module"], self.options["func"] @@ -31,11 +41,9 @@ def run(self): core_result = parse_parser(parser_creator()) core_result["action_groups"] = [i for i in core_result["action_groups"] if i["title"] not in CUSTOM] - content = [] - for i in core_result["action_groups"]: - content.append(self._build_table(i["options"], i["title"], i["description"])) + content = [self._build_table(i["options"], i["title"], i["description"]) for i in core_result["action_groups"]] for key, name_to_class in CUSTOM.items(): - section = n.section("", ids=["section-{}".format(key)]) + section = n.section("", ids=[f"section-{key}"]) title = n.title("", key) section += title self.state.document.note_implicit_target(title) @@ -44,21 +52,21 @@ def run(self): for name, class_n in name_to_class.items(): with self._run_parser(class_n, key, name): - cmd = ["--{}".format(key), name] + cmd = [f"--{key}", name] parser_result = parse_parser(parser_creator(cmd)) opt_group = next(i["options"] for i in parser_result["action_groups"] if i["title"] == key) results[name] = opt_group - core_names = set.intersection(*list({tuple(i["name"]) for i in v} for v in results.values())) + core_names = set.intersection(*[{tuple(i["name"]) for i in v} for v in results.values()]) if core_names: rows = [i for i in next(iter(results.values())) if tuple(i["name"]) in core_names] content.append( - self._build_table(rows, title="core", description="options shared across all {}".format(key)), + self._build_table(rows, title="core", description=f"options shared across all {key}"), ) for name, group in results.items(): rows = [i for i in group if tuple(i["name"]) not in core_names] if rows: content.append( - self._build_table(rows, title=name, description="options specific to {} {}".format(key, name)), + self._build_table(rows, title=name, description=f"options specific to {key} {name}"), ) return content @@ -74,14 +82,16 @@ def a(*args, **kwargs): prev(*args, **kwargs) if key == "activators": return True - elif key == "creator": + if key == "creator": if name == "venv": - from virtualenv.create.via_global_ref.venv import ViaGlobalRefMeta + from virtualenv.create.via_global_ref.venv import ViaGlobalRefMeta # noqa: PLC0415 meta = ViaGlobalRefMeta() meta.symlink_error = None return meta - from virtualenv.create.via_global_ref.builtin.via_global_self_do import BuiltinViaGlobalRefMeta + from virtualenv.create.via_global_ref.builtin.via_global_self_do import ( # noqa: PLC0415 + BuiltinViaGlobalRefMeta, + ) meta = BuiltinViaGlobalRefMeta() meta.symlink_error = None @@ -107,7 +117,7 @@ def _build_table(self, options, title, description): options_group += body return table - plugins = { + plugins: ClassVar[dict[str, str]] = { "creator": "virtualenv.create", "seed": "virtualenv.seed", "activators": "virtualenv.activate", @@ -120,11 +130,16 @@ def build_rows(options): for option in options: names = option["name"] default = option["default"] - if default is not None: - if isinstance(default, str) and default and default[0] == default[-1] and default[0] == '"': - default = default[1:-1] - if default == SUPPRESS: - default = None + if ( + default is not None + and isinstance(default, str) + and default + and default[0] == default[-1] + and default[0] == '"' + ): + default = default[1:-1] + if default == SUPPRESS: + default = None choices = option.get("choices") key = names[0].strip("-") if key in CliTable.plugins: @@ -170,19 +185,8 @@ def _get_targeted_names(self, row): @staticmethod def _get_help_text(row): name = row.names[0] - if name in ("--creator",): - content = row.help[: row.help.index("(") - 1] - else: - content = row.help - if name in ("--setuptools", "--pip", "--wheel"): - text = row.help - at = text.index(" bundle ") - help_body = n.paragraph("") - help_body += n.Text(text[: at + 1]) - help_body += n.literal(text="bundle") - help_body += n.Text(text[at + 7 :]) - else: - help_body = n.paragraph("", "", n.Text(content)) + content = row.help[: row.help.index("(") - 1] if name == "--creator" else row.help + help_body = n.paragraph("", "", n.Text(content)) if row.choices is not None: help_body += n.Text("; choice of: ") first = True @@ -209,11 +213,10 @@ def _get_default(row): default_body += n.literal(text="builtin") default_body += n.Text(" if exist, else ") default_body += n.literal(text="venv") + elif default is None: + default_body = n.paragraph("", text="") else: - if default is None: - default_body = n.paragraph("", text="") - else: - default_body = n.literal(text=default if isinstance(default, str) else str(default)) + default_body = n.literal(text=default if isinstance(default, str) else str(default)) return default_body def register_target_option(self, target) -> None: @@ -223,9 +226,9 @@ def register_target_option(self, target) -> None: domain.add_program_option(None, key, self.env.docname, key) -def literal_data(rawtext, app, type, slug, options): +def literal_data(rawtext, app, of_type, slug, options): # noqa: ARG001 """Create a link to a BitBucket resource.""" - of_class = type.split(".") + of_class = of_type.split(".") data = getattr(__import__(".".join(of_class[:-1]), fromlist=[of_class[-1]]), of_class[-1]) return [n.literal("", text=",".join(data))], [] diff --git a/docs/user_guide.rst b/docs/user_guide.rst index bb8ae48f0..54169e062 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -1,6 +1,26 @@ User Guide ========== +Quick start +----------- +Create the environment (creates a folder in your current directory) + .. code-block:: console + + virtualenv env_name +In Linux or Mac, activate the new python environment + .. code-block:: console + + source env_name/bin/activate +Or in Windows + .. code-block:: console + + .\env_name\Scripts\activate +Confirm that the env is successfully selected + .. code-block:: console + + which python3 + + Introduction ------------ @@ -11,7 +31,7 @@ Virtualenv has one basic command: virtualenv venv This will create a python virtual environment of the same version as virtualenv, installed into the subdirectory -``venv``. The command line tool has quite a few of flags that modify the tool's behaviour, for a +``venv``. The command line tool has quite a few of flags that modify the tool's behavior, for a full list make sure to check out :ref:`cli_flags`. The tool works in two phases: @@ -118,8 +138,9 @@ at the moment has two types of virtual environments: Seeders ------- These will install for you some seed packages (one or more of: :pypi:`pip`, :pypi:`setuptools`, :pypi:`wheel`) that -enables you to install additional python packages into the created virtual environment (by invoking pip). There are two -main seed mechanism available: +enables you to install additional python packages into the created virtual environment (by invoking pip). Installing +:pypi:`setuptools` and :pypi:`wheel` is disabled by default on Python 3.12+ environments. There are two +main seed mechanisms available: - ``pip`` - this method uses the bundled pip with virtualenv to install the seed packages (note, a new child process needs to be created to do this, which can be expensive especially on Windows). @@ -161,7 +182,7 @@ package. These wheels may be acquired from multiple locations as follows: not start using a new embedded versions half way through. - The automatic behaviour might be disabled via the :option:`no-periodic-update` configuration flag/option. To acquire + The automatic behavior might be disabled via the :option:`no-periodic-update` configuration flag/option. To acquire the release date of a package virtualenv will perform the following: - lookup ``https://pypi.org/pypi//json`` (primary truth source), @@ -205,28 +226,33 @@ system python's pip before activation, once you do the activation this should re Note, though that all we do is change priority; so, if your virtual environments ``bin``/``Scripts`` folder does not contain some executable, this will still resolve to the same executable it would have resolved before the activation. -For a list of shells we provide activators see :option:`activators`. The location of these is right alongside the python -executables ( usually ``Scripts`` folder on Windows, ``bin`` on POSIX), and are named as ``activate`` (and some -extension that's specific per activator; no extension is bash). You can invoke them, usually by source-ing (the source -command might vary by shell - e.g. bash is ``.``): +For a list of shells we provide activators see :option:`activators`. The location of these is right alongside the Python +executables: usually ``Scripts`` folder on Windows, ``bin`` on POSIX. They are called ``activate``, plus an +extension that's specific per activator, with no extension for Bash. You can invoke them, usually by source-ing them. +The source command might vary by shell - e.g. on Bash it’s ``source`` (or ``.``): .. code-block:: console - source bin/activate + source venv/bin/activate + +The activate script prepends the virtual environment’s binary folder onto the ``PATH`` environment variable. It’s +really just convenience for doing so, since you could do the same yourself. -This is all it does; it's purely a convenience of prepending the virtual environment's binary folder onto the ``PATH`` -environment variable. Note you don't have to activate a virtual environment to use it. In this case though you would -need to type out the path to the executables, rather than relying on your shell to resolve them to your virtual -environment. +Note that you don't have to activate a virtual environment to use it. You can instead use the full paths to its +executables, rather than relying on your shell to resolve them to your virtual environment. -The ``activate`` script will also modify your shell prompt to indicate which environment is currently active. The script -also provisions a ``deactivate`` command that will allow you to undo the operation: +Activator scripts also modify your shell prompt to indicate which environment is currently active, by prepending the +environment name (or the name specified by ``--prompt`` when initially creating the environment) in brackets, like +``(venv)``. You can disable this behavior by setting the environment variable ``VIRTUAL_ENV_DISABLE_PROMPT`` to any +value. You can also get the environment name via the environment variable ``VIRTUAL_ENV_PROMPT`` if you want to +customize your prompt, for example. + +The scripts also provision a ``deactivate`` command that will allow you to undo the operation: .. code-block:: console deactivate - .. note:: If using Powershell, the ``activate`` script is subject to the diff --git a/pyproject.toml b/pyproject.toml index c2b3b9ac4..851b90bc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,203 @@ [build-system] +build-backend = "hatchling.build" requires = [ - "setuptools >= 41.0.0", - "wheel >= 0.30.0", - "setuptools_scm >= 2", + "hatch-vcs>=0.3", + "hatchling>=1.17.1", ] -build-backend = 'setuptools.build_meta' -[tool.black] +[project] +name = "virtualenv" +description = "Virtual Python Environment builder" +readme = "README.md" +keywords = [ + "environments", + "isolated", + "virtual", +] +license = "MIT" +maintainers = [ + { name = "Bernat Gabor", email = "gaborjbernat@gmail.com" }, +] +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", +] +dynamic = [ + "version", +] +dependencies = [ + "distlib>=0.3.7,<1", + "filelock>=3.12.2,<4", + "importlib-metadata>=6.6; python_version<'3.8'", + "platformdirs>=3.9.1,<5", +] +optional-dependencies.docs = [ + "furo>=2023.7.26", + "proselint>=0.13", + "sphinx>=7.1.2,!=7.3", + "sphinx-argparse>=0.4", + "sphinxcontrib-towncrier>=0.2.1a0", + "towncrier>=23.6", +] +optional-dependencies.test = [ + "covdefaults>=2.3", + "coverage>=7.2.7", + "coverage-enable-subprocess>=1", + "flaky>=3.7", + "packaging>=23.1", + "pytest>=7.4", + "pytest-env>=0.8.2", + "pytest-freezer>=0.4.8; platform_python_implementation=='PyPy' or (platform_python_implementation=='CPython' and sys_platform=='win32' and python_version>='3.13')", + "pytest-mock>=3.11.1", + "pytest-randomly>=3.12", + "pytest-timeout>=2.1", + "setuptools>=68", + "time-machine>=2.10; platform_python_implementation=='CPython'", +] +urls.Documentation = "https://virtualenv.pypa.io" +urls.Homepage = "https://github.com/pypa/virtualenv" +urls.Source = "https://github.com/pypa/virtualenv" +urls.Tracker = "https://github.com/pypa/virtualenv/issues" +scripts.virtualenv = "virtualenv.__main__:run_with_catch" +entry-points."virtualenv.activate".bash = "virtualenv.activation.bash:BashActivator" +entry-points."virtualenv.activate".batch = "virtualenv.activation.batch:BatchActivator" +entry-points."virtualenv.activate".cshell = "virtualenv.activation.cshell:CShellActivator" +entry-points."virtualenv.activate".fish = "virtualenv.activation.fish:FishActivator" +entry-points."virtualenv.activate".nushell = "virtualenv.activation.nushell:NushellActivator" +entry-points."virtualenv.activate".powershell = "virtualenv.activation.powershell:PowerShellActivator" +entry-points."virtualenv.activate".python = "virtualenv.activation.python:PythonActivator" +entry-points."virtualenv.create".cpython3-mac-brew = "virtualenv.create.via_global_ref.builtin.cpython.mac_os:CPython3macOsBrew" +entry-points."virtualenv.create".cpython3-mac-framework = "virtualenv.create.via_global_ref.builtin.cpython.mac_os:CPython3macOsFramework" +entry-points."virtualenv.create".cpython3-posix = "virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Posix" +entry-points."virtualenv.create".cpython3-win = "virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Windows" +entry-points."virtualenv.create".pypy3-posix = "virtualenv.create.via_global_ref.builtin.pypy.pypy3:PyPy3Posix" +entry-points."virtualenv.create".pypy3-win = "virtualenv.create.via_global_ref.builtin.pypy.pypy3:Pypy3Windows" +entry-points."virtualenv.create".venv = "virtualenv.create.via_global_ref.venv:Venv" +entry-points."virtualenv.discovery".builtin = "virtualenv.discovery.builtin:Builtin" +entry-points."virtualenv.seed".app-data = "virtualenv.seed.embed.via_app_data.via_app_data:FromAppData" +entry-points."virtualenv.seed".pip = "virtualenv.seed.embed.pip_invoke:PipInvoke" + +[tool.hatch] +build.hooks.vcs.version-file = "src/virtualenv/version.py" +build.targets.sdist.include = [ + "/src", + "/tests", + "/tasks", + "/tox.ini", +] +version.source = "vcs" + +[tool.ruff] line-length = 120 +format.preview = true +format.docstring-code-line-length = 100 +format.docstring-code-format = true +lint.select = [ + "ALL", +] +lint.ignore = [ + "ANN", # no type checking added yet + "COM812", # conflict with formatter + "CPY", # No copyright header + "D10", # no docstrings + "D40", # no imperative mode for docstrings + "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible + "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible + "DOC", # no restructuredtext support + "INP001", # ignore implicit namespace packages + "ISC001", # conflict with formatter + "PLR0914", # Too many local variables + "PLR0917", # Too many positional arguments + "PLR6301", # Method could be a function, class method, or static method + "PLW1510", # no need for check for subprocess + "PTH", # no pathlib, <=39 has problems on Windows with absolute/resolve, can revisit once we no longer need 39 + "S104", # Possible binding to all interfaces + "S404", # Using subprocess is alright + "S603", # subprocess calls are fine +] +lint.per-file-ignores."src/virtualenv/activation/python/activate_this.py" = [ + "F821", # ignore undefined template string placeholders +] +lint.per-file-ignores."tests/**/*.py" = [ + "D", # don't care about documentation in tests + "FBT", # don't care about booleans as positional arguments in tests + "INP001", # no implicit namespace + "PLC2701", # Private import + "PLR2004", # Magic value used in comparison, consider replacing with a constant variable + "S101", # asserts allowed in tests + "S603", # `subprocess` call: check for execution of untrusted input +] +lint.isort = { known-first-party = [ + "virtualenv", +], required-imports = [ + "from __future__ import annotations", +] } +lint.preview = true + +[tool.codespell] +builtin = "clear,usage,en-GB_to_en-US" +count = true + +[tool.pyproject-fmt] +max_supported_python = "3.13" + +[tool.pytest.ini_options] +markers = [ + "slow", +] +timeout = 600 +addopts = "--showlocals --no-success-flaky-report" +env = [ + "PYTHONIOENCODING=utf-8", +] + +[tool.coverage] +html.show_contexts = true +html.skip_covered = false +report.omit = [ + # site.py is run before the coverage can be enabled, no way to measure coverage on this + "**/src/virtualenv/create/via_global_ref/builtin/python2/site.py", + "**/src/virtualenv/create/via_global_ref/_virtualenv.py", + "**/src/virtualenv/activation/python/activate_this.py", + "**/src/virtualenv/seed/wheels/embed/pip-*.whl/pip/**", +] +paths.source = [ + "src", + "**/site-packages", +] +report.fail_under = 76 +run.source = [ + "${_COVERAGE_SRC}", + "tests", +] +run.dynamic_context = "test_function" +run.parallel = true +run.plugins = [ + "covdefaults", +] +run.relative_files = true [tool.towncrier] -package = "virtualenv" -package_dir = "" # we purposfully do not set this as src, forcing import from site-package that has version.py +name = "tox" filename = "docs/changelog.rst" directory = "docs/changelog" title_format = false -issue_format = "`#{issue} `_" +issue_format = ":issue:`{issue}`" template = "docs/changelog/template.jinja2" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index ccd2f8d25..000000000 --- a/setup.cfg +++ /dev/null @@ -1,129 +0,0 @@ -[metadata] -name = virtualenv -description = Virtual Python Environment builder -long_description = file: README.md -long_description_content_type = text/markdown -url = https://virtualenv.pypa.io/ -author = Bernat Gabor -author_email = gaborjbernat@gmail.com -maintainer = Bernat Gabor -maintainer_email = gaborjbernat@gmail.com -license = MIT -license_file = LICENSE -platforms = any -classifiers = - Development Status :: 5 - Production/Stable - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Operating System :: MacOS :: MacOS X - Operating System :: Microsoft :: Windows - Operating System :: POSIX - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.7 - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: Implementation :: CPython - Programming Language :: Python :: Implementation :: PyPy - Topic :: Software Development :: Libraries - Topic :: Software Development :: Testing - Topic :: Utilities -keywords = virtual, environments, isolated -project_urls = - Source=https://github.com/pypa/virtualenv - Tracker=https://github.com/pypa/virtualenv/issues - -[options] -packages = find: -install_requires = - distlib>=0.3.1,<1 - filelock>=3.2,<4 - platformdirs>=2,<3 - six>=1.9.0,<2 # keep it >=1.9.0 as it may cause problems on LTS platforms - importlib-metadata>=0.12;python_version<"3.8" - importlib-resources>=1.0;python_version<"3.7" - pathlib2>=2.3.3,<3;python_version < '3.4' and sys.platform != 'win32' -python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* -package_dir = - =src -zip_safe = True - -[options.packages.find] -where = src - -[options.entry_points] -console_scripts = - virtualenv=virtualenv.__main__:run_with_catch -virtualenv.activate = - bash = virtualenv.activation.bash:BashActivator - cshell = virtualenv.activation.cshell:CShellActivator - batch = virtualenv.activation.batch:BatchActivator - fish = virtualenv.activation.fish:FishActivator - powershell = virtualenv.activation.powershell:PowerShellActivator - python = virtualenv.activation.python:PythonActivator - nushell = virtualenv.activation.nushell:NushellActivator -virtualenv.create = - venv = virtualenv.create.via_global_ref.venv:Venv - cpython3-posix = virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Posix - cpython3-win = virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Windows - cpython2-posix = virtualenv.create.via_global_ref.builtin.cpython.cpython2:CPython2Posix - cpython2-mac-framework = virtualenv.create.via_global_ref.builtin.cpython.mac_os:CPython2macOsFramework - cpython2-mac-arm-framework = virtualenv.create.via_global_ref.builtin.cpython.mac_os:CPython2macOsArmFramework - cpython3-mac-framework = virtualenv.create.via_global_ref.builtin.cpython.mac_os:CPython3macOsFramework - cpython2-win = virtualenv.create.via_global_ref.builtin.cpython.cpython2:CPython2Windows - pypy2-posix = virtualenv.create.via_global_ref.builtin.pypy.pypy2:PyPy2Posix - pypy2-win = virtualenv.create.via_global_ref.builtin.pypy.pypy2:Pypy2Windows - pypy3-posix = virtualenv.create.via_global_ref.builtin.pypy.pypy3:PyPy3Posix - pypy3-win = virtualenv.create.via_global_ref.builtin.pypy.pypy3:Pypy3Windows -virtualenv.discovery = - builtin = virtualenv.discovery.builtin:Builtin -virtualenv.seed = - pip = virtualenv.seed.embed.pip_invoke:PipInvoke - app-data = virtualenv.seed.embed.via_app_data.via_app_data:FromAppData - -[options.extras_require] -docs = - proselint>=0.10.2 - sphinx>=3 - sphinx-argparse>=0.2.5 - sphinx-rtd-theme>=0.4.3 - towncrier>=21.3 -testing = - coverage>=4 - coverage-enable-subprocess>=1 - flaky>=3 - pytest>=4 - pytest-env>=0.6.2 - pytest-freezegun>=0.4.1 - pytest-mock>=2 - pytest-randomly>=1 - pytest-timeout>=1 - packaging>=20.0;python_version>"3.4" - -[options.package_data] -virtualenv.activation.bash = *.sh -virtualenv.activation.batch = *.bat -virtualenv.activation.cshell = *.csh -virtualenv.activation.fish = *.fish -virtualenv.activation.nushell = *.nu -virtualenv.activation.powershell = *.ps1 -virtualenv.seed.wheels.embed = *.whl - -[sdist] -formats = gztar - -[bdist_wheel] -universal = true - -[tool:pytest] -markers = - slow -junit_family = xunit2 -addopts = --tb=auto -ra --showlocals --no-success-flaky-report -env = - PYTHONWARNINGS=ignore:DEPRECATION::pip._internal.cli.base_command - PYTHONIOENCODING=utf-8 diff --git a/setup.py b/setup.py deleted file mode 100644 index cddd1d3e9..000000000 --- a/setup.py +++ /dev/null @@ -1,12 +0,0 @@ -from setuptools import __version__, setup - -if int(__version__.split(".")[0]) < 41: - raise RuntimeError("setuptools >= 41 required to build") - -setup( - use_scm_version={ - "write_to": "src/virtualenv/version.py", - "write_to_template": 'from __future__ import unicode_literals\n\n__version__ = "{version}"\n', - }, - setup_requires=["setuptools_scm >= 2"], -) diff --git a/src/virtualenv/__init__.py b/src/virtualenv/__init__.py index 5f74e3ef2..cc11e7f3e 100644 --- a/src/virtualenv/__init__.py +++ b/src/virtualenv/__init__.py @@ -1,10 +1,10 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from .run import cli_run, session_via_cli from .version import __version__ -__all__ = ( +__all__ = [ "__version__", "cli_run", "session_via_cli", -) +] diff --git a/src/virtualenv/__main__.py b/src/virtualenv/__main__.py index 3b06fd747..f914b31b7 100644 --- a/src/virtualenv/__main__.py +++ b/src/virtualenv/__main__.py @@ -1,63 +1,56 @@ -from __future__ import absolute_import, print_function, unicode_literals +from __future__ import annotations import logging import os import sys -from datetime import datetime +from timeit import default_timer + +LOGGER = logging.getLogger(__name__) def run(args=None, options=None, env=None): env = os.environ if env is None else env - start = datetime.now() - from virtualenv.run import cli_run - from virtualenv.util.error import ProcessCallFailed + start = default_timer() + from virtualenv.run import cli_run # noqa: PLC0415 + from virtualenv.util.error import ProcessCallFailedError # noqa: PLC0415 if args is None: args = sys.argv[1:] try: session = cli_run(args, options, env) - logging.warning(LogSession(session, start)) - except ProcessCallFailed as exception: - print("subprocess call failed for {} with code {}".format(exception.cmd, exception.code)) - print(exception.out, file=sys.stdout, end="") - print(exception.err, file=sys.stderr, end="") - raise SystemExit(exception.code) + LOGGER.warning(LogSession(session, start)) + except ProcessCallFailedError as exception: + print(f"subprocess call failed for {exception.cmd} with code {exception.code}") # noqa: T201 + print(exception.out, file=sys.stdout, end="") # noqa: T201 + print(exception.err, file=sys.stderr, end="") # noqa: T201 + raise SystemExit(exception.code) # noqa: B904 -class LogSession(object): - def __init__(self, session, start): +class LogSession: + def __init__(self, session, start) -> None: self.session = session self.start = start - def __str__(self): - from virtualenv.util.six import ensure_text - + def __str__(self) -> str: spec = self.session.creator.interpreter.spec - elapsed = (datetime.now() - self.start).total_seconds() * 1000 + elapsed = (default_timer() - self.start) * 1000 lines = [ - "created virtual environment {} in {:.0f}ms".format(spec, elapsed), - " creator {}".format(ensure_text(str(self.session.creator))), + f"created virtual environment {spec} in {elapsed:.0f}ms", + f" creator {self.session.creator!s}", ] if self.session.seeder.enabled: - lines += ( - " seeder {}".format(ensure_text(str(self.session.seeder))), - " added seed packages: {}".format( - ", ".join( - sorted( - "==".join(i.stem.split("-")) - for i in self.session.creator.purelib.iterdir() - if i.suffix == ".dist-info" - ), - ), - ), - ) + lines.append(f" seeder {self.session.seeder!s}") + path = self.session.creator.purelib.iterdir() + packages = sorted("==".join(i.stem.split("-")) for i in path if i.suffix == ".dist-info") + lines.append(f" added seed packages: {', '.join(packages)}") + if self.session.activators: - lines.append(" activators {}".format(",".join(i.__class__.__name__ for i in self.session.activators))) + lines.append(f" activators {','.join(i.__class__.__name__ for i in self.session.activators)}") return "\n".join(lines) def run_with_catch(args=None, env=None): - from virtualenv.config.cli.parser import VirtualEnvOptions + from virtualenv.config.cli.parser import VirtualEnvOptions # noqa: PLC0415 env = os.environ if env is None else env options = VirtualEnvOptions() @@ -67,13 +60,13 @@ def run_with_catch(args=None, env=None): try: if getattr(options, "with_traceback", False): raise - else: - if not (isinstance(exception, SystemExit) and exception.code == 0): - logging.error("%s: %s", type(exception).__name__, exception) - code = exception.code if isinstance(exception, SystemExit) else 1 - sys.exit(code) + if not (isinstance(exception, SystemExit) and exception.code == 0): + LOGGER.error("%s: %s", type(exception).__name__, exception) # noqa: TRY400 + code = exception.code if isinstance(exception, SystemExit) else 1 + sys.exit(code) finally: - logging.shutdown() # force flush of log messages before the trace is printed + for handler in LOGGER.handlers: # force flush of log messages before the trace is printed + handler.flush() if __name__ == "__main__": # pragma: no cov diff --git a/src/virtualenv/activation/__init__.py b/src/virtualenv/activation/__init__.py index e9296d86e..5aa4a9d76 100644 --- a/src/virtualenv/activation/__init__.py +++ b/src/virtualenv/activation/__init__.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from .bash import BashActivator from .batch import BatchActivator @@ -10,10 +10,10 @@ __all__ = [ "BashActivator", - "PowerShellActivator", - "CShellActivator", - "PythonActivator", "BatchActivator", + "CShellActivator", "FishActivator", "NushellActivator", + "PowerShellActivator", + "PythonActivator", ] diff --git a/src/virtualenv/activation/activator.py b/src/virtualenv/activation/activator.py index 80d7e47fd..dd404b47c 100644 --- a/src/virtualenv/activation/activator.py +++ b/src/virtualenv/activation/activator.py @@ -1,32 +1,31 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod -from six import add_metaclass +class Activator(ABC): + """Generates activate script for the virtual environment.""" -@add_metaclass(ABCMeta) -class Activator(object): - """Generates an activate script for the virtual environment""" - - def __init__(self, options): - """Create a new activator generator. + def __init__(self, options) -> None: + """ + Create a new activator generator. :param options: the parsed options as defined within :meth:`add_parser_arguments` """ self.flag_prompt = os.path.basename(os.getcwd()) if options.prompt == "." else options.prompt @classmethod - def supports(cls, interpreter): - """Check if the activation script is supported in the given interpreter. + def supports(cls, interpreter): # noqa: ARG003 + """ + Check if the activation script is supported in the given interpreter. :param interpreter: the interpreter we need to support :return: ``True`` if supported, ``False`` otherwise """ return True - @classmethod + @classmethod # noqa: B027 def add_parser_arguments(cls, parser, interpreter): """ Add CLI arguments for this activation script. @@ -37,9 +36,15 @@ def add_parser_arguments(cls, parser, interpreter): @abstractmethod def generate(self, creator): - """Generate the activate script for the given creator. + """ + Generate activate script for the given creator. :param creator: the creator (based of :class:`virtualenv.create.creator.Creator`) we used to create this \ virtual environment """ raise NotImplementedError + + +__all__ = [ + "Activator", +] diff --git a/src/virtualenv/activation/bash/__init__.py b/src/virtualenv/activation/bash/__init__.py index 22c90c382..5e095ddf0 100644 --- a/src/virtualenv/activation/bash/__init__.py +++ b/src/virtualenv/activation/bash/__init__.py @@ -1,13 +1,18 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from virtualenv.util.path import Path +from pathlib import Path -from ..via_template import ViaTemplateActivator +from virtualenv.activation.via_template import ViaTemplateActivator class BashActivator(ViaTemplateActivator): def templates(self): - yield Path("activate.sh") + yield "activate.sh" def as_name(self, template): - return template.stem + return Path(template).stem + + +__all__ = [ + "BashActivator", +] diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh index fb40db63a..d3cf34784 100644 --- a/src/virtualenv/activation/bash/activate.sh +++ b/src/virtualenv/activation/bash/activate.sh @@ -35,6 +35,7 @@ deactivate () { fi unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT if [ ! "${1-}" = "nondestructive" ] ; then # Self destruct! unset -f deactivate @@ -44,16 +45,23 @@ deactivate () { # unset irrelevant variables deactivate nondestructive -VIRTUAL_ENV='__VIRTUAL_ENV__' +VIRTUAL_ENV=__VIRTUAL_ENV__ if ([ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ]) && $(command -v cygpath &> /dev/null) ; then VIRTUAL_ENV=$(cygpath -u "$VIRTUAL_ENV") fi export VIRTUAL_ENV _OLD_VIRTUAL_PATH="$PATH" -PATH="$VIRTUAL_ENV/__BIN_NAME__:$PATH" +PATH="$VIRTUAL_ENV/"__BIN_NAME__":$PATH" export PATH +if [ "x"__VIRTUAL_PROMPT__ != x ] ; then + VIRTUAL_ENV_PROMPT=__VIRTUAL_PROMPT__ +else + VIRTUAL_ENV_PROMPT=$(basename "$VIRTUAL_ENV") +fi +export VIRTUAL_ENV_PROMPT + # unset PYTHONHOME if set if ! [ -z "${PYTHONHOME+_}" ] ; then _OLD_VIRTUAL_PYTHONHOME="$PYTHONHOME" @@ -62,11 +70,7 @@ fi if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT-}" ] ; then _OLD_VIRTUAL_PS1="${PS1-}" - if [ "x__VIRTUAL_PROMPT__" != x ] ; then - PS1="(__VIRTUAL_PROMPT__) ${PS1-}" - else - PS1="(`basename \"$VIRTUAL_ENV\"`) ${PS1-}" - fi + PS1="(${VIRTUAL_ENV_PROMPT}) ${PS1-}" export PS1 fi @@ -80,4 +84,4 @@ pydoc () { # The hash command must be called to get it to forget past # commands. Without forgetting past commands the $PATH changes # we made may not be respected -hash -r 2>/dev/null +hash -r 2>/dev/null || true diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 4149712d8..3d74ba835 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -1,10 +1,8 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os -from virtualenv.util.path import Path - -from ..via_template import ViaTemplateActivator +from virtualenv.activation.via_template import ViaTemplateActivator class BatchActivator(ViaTemplateActivator): @@ -13,11 +11,20 @@ def supports(cls, interpreter): return interpreter.os == "nt" def templates(self): - yield Path("activate.bat") - yield Path("deactivate.bat") - yield Path("pydoc.bat") + yield "activate.bat" + yield "deactivate.bat" + yield "pydoc.bat" + + @staticmethod + def quote(string): + return string def instantiate_template(self, replacements, template, creator): # ensure the text has all newlines as \r\n - required by batch - base = super(BatchActivator, self).instantiate_template(replacements, template, creator) + base = super().instantiate_template(replacements, template, creator) return base.replace(os.linesep, "\n").replace("\n", os.linesep) + + +__all__ = [ + "BatchActivator", +] diff --git a/src/virtualenv/activation/batch/activate.bat b/src/virtualenv/activation/batch/activate.bat index bf774b282..36b0a8bd7 100644 --- a/src/virtualenv/activation/batch/activate.bat +++ b/src/virtualenv/activation/batch/activate.bat @@ -1,39 +1,50 @@ -@echo off +@REM This file is UTF-8 encoded, so we need to update the current code page while executing it +@for /f "tokens=2 delims=:." %%a in ('"%SystemRoot%\System32\chcp.com"') do @set _OLD_CODEPAGE=%%a -set "VIRTUAL_ENV=__VIRTUAL_ENV__" +@if defined _OLD_CODEPAGE ( + "%SystemRoot%\System32\chcp.com" 65001 > nul +) + +@set "VIRTUAL_ENV=__VIRTUAL_ENV__" + +@set "VIRTUAL_ENV_PROMPT=__VIRTUAL_PROMPT__" +@if NOT DEFINED VIRTUAL_ENV_PROMPT ( + @for %%d in ("%VIRTUAL_ENV%") do @set "VIRTUAL_ENV_PROMPT=%%~nxd" +) -if defined _OLD_VIRTUAL_PROMPT ( - set "PROMPT=%_OLD_VIRTUAL_PROMPT%" +@if defined _OLD_VIRTUAL_PROMPT ( + @set "PROMPT=%_OLD_VIRTUAL_PROMPT%" ) else ( - if not defined PROMPT ( - set "PROMPT=$P$G" + @if not defined PROMPT ( + @set "PROMPT=$P$G" ) - if not defined VIRTUAL_ENV_DISABLE_PROMPT ( - set "_OLD_VIRTUAL_PROMPT=%PROMPT%" + @if not defined VIRTUAL_ENV_DISABLE_PROMPT ( + @set "_OLD_VIRTUAL_PROMPT=%PROMPT%" ) ) -if not defined VIRTUAL_ENV_DISABLE_PROMPT ( - if "__VIRTUAL_PROMPT__" NEQ "" ( - set "PROMPT=(__VIRTUAL_PROMPT__) %PROMPT%" - ) else ( - for %%d in ("%VIRTUAL_ENV%") do set "PROMPT=(%%~nxd) %PROMPT%" - ) +@if not defined VIRTUAL_ENV_DISABLE_PROMPT ( + @set "PROMPT=(%VIRTUAL_ENV_PROMPT%) %PROMPT%" ) -REM Don't use () to avoid problems with them in %PATH% -if defined _OLD_VIRTUAL_PYTHONHOME goto ENDIFVHOME - set "_OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME%" +@REM Don't use () to avoid problems with them in %PATH% +@if defined _OLD_VIRTUAL_PYTHONHOME @goto ENDIFVHOME + @set "_OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME%" :ENDIFVHOME -set PYTHONHOME= +@set PYTHONHOME= -REM if defined _OLD_VIRTUAL_PATH ( -if not defined _OLD_VIRTUAL_PATH goto ENDIFVPATH1 - set "PATH=%_OLD_VIRTUAL_PATH%" +@REM if defined _OLD_VIRTUAL_PATH ( +@if not defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH1 + @set "PATH=%_OLD_VIRTUAL_PATH%" :ENDIFVPATH1 -REM ) else ( -if defined _OLD_VIRTUAL_PATH goto ENDIFVPATH2 - set "_OLD_VIRTUAL_PATH=%PATH%" +@REM ) else ( +@if defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH2 + @set "_OLD_VIRTUAL_PATH=%PATH%" :ENDIFVPATH2 -set "PATH=%VIRTUAL_ENV%\__BIN_NAME__;%PATH%" +@set "PATH=%VIRTUAL_ENV%\__BIN_NAME__;%PATH%" + +@if defined _OLD_CODEPAGE ( + "%SystemRoot%\System32\chcp.com" %_OLD_CODEPAGE% > nul + @set _OLD_CODEPAGE= +) diff --git a/src/virtualenv/activation/batch/deactivate.bat b/src/virtualenv/activation/batch/deactivate.bat index 7bbc56882..8939c6c0d 100644 --- a/src/virtualenv/activation/batch/deactivate.bat +++ b/src/virtualenv/activation/batch/deactivate.bat @@ -1,19 +1,18 @@ -@echo off +@set VIRTUAL_ENV= +@set VIRTUAL_ENV_PROMPT= -set VIRTUAL_ENV= - -REM Don't use () to avoid problems with them in %PATH% -if not defined _OLD_VIRTUAL_PROMPT goto ENDIFVPROMPT - set "PROMPT=%_OLD_VIRTUAL_PROMPT%" - set _OLD_VIRTUAL_PROMPT= +@REM Don't use () to avoid problems with them in %PATH% +@if not defined _OLD_VIRTUAL_PROMPT @goto ENDIFVPROMPT + @set "PROMPT=%_OLD_VIRTUAL_PROMPT%" + @set _OLD_VIRTUAL_PROMPT= :ENDIFVPROMPT -if not defined _OLD_VIRTUAL_PYTHONHOME goto ENDIFVHOME - set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%" - set _OLD_VIRTUAL_PYTHONHOME= +@if not defined _OLD_VIRTUAL_PYTHONHOME @goto ENDIFVHOME + @set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%" + @set _OLD_VIRTUAL_PYTHONHOME= :ENDIFVHOME -if not defined _OLD_VIRTUAL_PATH goto ENDIFVPATH - set "PATH=%_OLD_VIRTUAL_PATH%" - set _OLD_VIRTUAL_PATH= +@if not defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH + @set "PATH=%_OLD_VIRTUAL_PATH%" + @set _OLD_VIRTUAL_PATH= :ENDIFVPATH diff --git a/src/virtualenv/activation/cshell/__init__.py b/src/virtualenv/activation/cshell/__init__.py index b25c602a5..7001f999a 100644 --- a/src/virtualenv/activation/cshell/__init__.py +++ b/src/virtualenv/activation/cshell/__init__.py @@ -1,8 +1,6 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from virtualenv.util.path import Path - -from ..via_template import ViaTemplateActivator +from virtualenv.activation.via_template import ViaTemplateActivator class CShellActivator(ViaTemplateActivator): @@ -11,4 +9,9 @@ def supports(cls, interpreter): return interpreter.os != "nt" def templates(self): - yield Path("activate.csh") + yield "activate.csh" + + +__all__ = [ + "CShellActivator", +] diff --git a/src/virtualenv/activation/cshell/activate.csh b/src/virtualenv/activation/cshell/activate.csh index 837dcda85..24de5508b 100644 --- a/src/virtualenv/activation/cshell/activate.csh +++ b/src/virtualenv/activation/cshell/activate.csh @@ -5,22 +5,22 @@ set newline='\ ' -alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH:q" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT:q" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; test "\!:*" != "nondestructive" && unalias deactivate && unalias pydoc' +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH:q" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT:q" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate && unalias pydoc' # Unset irrelevant variables. deactivate nondestructive -setenv VIRTUAL_ENV '__VIRTUAL_ENV__' +setenv VIRTUAL_ENV __VIRTUAL_ENV__ set _OLD_VIRTUAL_PATH="$PATH:q" -setenv PATH "$VIRTUAL_ENV:q/__BIN_NAME__:$PATH:q" +setenv PATH "$VIRTUAL_ENV:q/"__BIN_NAME__":$PATH:q" -if ('__VIRTUAL_PROMPT__' != "") then - set env_name = '(__VIRTUAL_PROMPT__) ' +if (__VIRTUAL_PROMPT__ != "") then + setenv VIRTUAL_ENV_PROMPT __VIRTUAL_PROMPT__ else - set env_name = '('"$VIRTUAL_ENV:t:q"') ' + setenv VIRTUAL_ENV_PROMPT "$VIRTUAL_ENV:t:q" endif if ( $?VIRTUAL_ENV_DISABLE_PROMPT ) then @@ -42,7 +42,7 @@ if ( $do_prompt == "1" ) then if ( "$prompt:q" =~ *"$newline:q"* ) then : else - set prompt = "$env_name:q$prompt:q" + set prompt = '('"$VIRTUAL_ENV_PROMPT:q"') '"$prompt:q" endif endif endif diff --git a/src/virtualenv/activation/fish/__init__.py b/src/virtualenv/activation/fish/__init__.py index 8d0e19c2c..57f790f47 100644 --- a/src/virtualenv/activation/fish/__init__.py +++ b/src/virtualenv/activation/fish/__init__.py @@ -1,10 +1,13 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from virtualenv.util.path import Path - -from ..via_template import ViaTemplateActivator +from virtualenv.activation.via_template import ViaTemplateActivator class FishActivator(ViaTemplateActivator): def templates(self): - yield Path("activate.fish") + yield "activate.fish" + + +__all__ = [ + "FishActivator", +] diff --git a/src/virtualenv/activation/fish/activate.fish b/src/virtualenv/activation/fish/activate.fish index 62f631ead..f3cd1f2ab 100644 --- a/src/virtualenv/activation/fish/activate.fish +++ b/src/virtualenv/activation/fish/activate.fish @@ -44,6 +44,7 @@ function deactivate -d 'Exit virtualenv mode and return to the normal environmen end set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT if test "$argv[1]" != 'nondestructive' # Self-destruct! @@ -57,15 +58,23 @@ end # Unset irrelevant variables. deactivate nondestructive -set -gx VIRTUAL_ENV '__VIRTUAL_ENV__' +set -gx VIRTUAL_ENV __VIRTUAL_ENV__ # https://github.com/fish-shell/fish-shell/issues/436 altered PATH handling if test (echo $FISH_VERSION | head -c 1) -lt 3 - set -gx _OLD_VIRTUAL_PATH (_bashify_path $PATH) + set -gx _OLD_VIRTUAL_PATH (_bashify_path $PATH) else set -gx _OLD_VIRTUAL_PATH $PATH end -set -gx PATH "$VIRTUAL_ENV"'/__BIN_NAME__' $PATH +set -gx PATH "$VIRTUAL_ENV"'/'__BIN_NAME__ $PATH + +# Prompt override provided? +# If not, just use the environment name. +if test -n __VIRTUAL_PROMPT__ + set -gx VIRTUAL_ENV_PROMPT __VIRTUAL_PROMPT__ +else + set -gx VIRTUAL_ENV_PROMPT (basename "$VIRTUAL_ENV") +end # Unset `$PYTHONHOME` if set. if set -q PYTHONHOME @@ -85,13 +94,7 @@ if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" # Run the user's prompt first; it might depend on (pipe)status. set -l prompt (_old_fish_prompt) - # Prompt override provided? - # If not, just prepend the environment name. - if test -n '__VIRTUAL_PROMPT__' - printf '(%s) ' '__VIRTUAL_PROMPT__' - else - printf '(%s) ' (basename "$VIRTUAL_ENV") - end + printf '(%s) ' $VIRTUAL_ENV_PROMPT string join -- \n $prompt # handle multi-line prompts end diff --git a/src/virtualenv/activation/nushell/__init__.py b/src/virtualenv/activation/nushell/__init__.py index 994c1fb6b..ef7a79a9c 100644 --- a/src/virtualenv/activation/nushell/__init__.py +++ b/src/virtualenv/activation/nushell/__init__.py @@ -1,28 +1,40 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -import os - -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_text - -from ..via_template import ViaTemplateActivator +from virtualenv.activation.via_template import ViaTemplateActivator class NushellActivator(ViaTemplateActivator): def templates(self): - yield Path("activate.nu") - yield Path("deactivate.nu") + yield "activate.nu" + + @staticmethod + def quote(string): + """ + Nushell supports raw strings like: r###'this is a string'###. - def replacements(self, creator, dest_folder): - # Due to nushell scoping, it isn't easy to create a function that will - # deactivate the environment. For that reason a __DEACTIVATE_PATH__ - # replacement pointing to the deactivate.nu file is created + This method finds the maximum continuous sharps in the string and then + quote it with an extra sharp. + """ + max_sharps = 0 + current_sharps = 0 + for char in string: + if char == "#": + current_sharps += 1 + max_sharps = max(current_sharps, max_sharps) + else: + current_sharps = 0 + wrapping = "#" * (max_sharps + 1) + return f"r{wrapping}'{string}'{wrapping}" + def replacements(self, creator, dest_folder): # noqa: ARG002 return { "__VIRTUAL_PROMPT__": "" if self.flag_prompt is None else self.flag_prompt, - "__VIRTUAL_ENV__": ensure_text(str(creator.dest)), + "__VIRTUAL_ENV__": str(creator.dest), "__VIRTUAL_NAME__": creator.env_name, - "__BIN_NAME__": ensure_text(str(creator.bin_dir.relative_to(creator.dest))), - "__PATH_SEP__": ensure_text(os.pathsep), - "__DEACTIVATE_PATH__": ensure_text(str(Path(dest_folder) / "deactivate.nu")), + "__BIN_NAME__": str(creator.bin_dir.relative_to(creator.dest)), } + + +__all__ = [ + "NushellActivator", +] diff --git a/src/virtualenv/activation/nushell/activate.nu b/src/virtualenv/activation/nushell/activate.nu index ffeff7d45..4da1c8c07 100644 --- a/src/virtualenv/activation/nushell/activate.nu +++ b/src/virtualenv/activation/nushell/activate.nu @@ -1,92 +1,96 @@ -# This command prepares the required environment variables -def-env activate-virtualenv [] { +# virtualenv activation module +# Activate with `overlay use activate.nu` +# Deactivate with `deactivate`, as usual +# +# To customize the overlay name, you can call `overlay use activate.nu as foo`, +# but then simply `deactivate` won't work because it is just an alias to hide +# the "activate" overlay. You'd need to call `overlay hide foo` manually. + +export-env { def is-string [x] { ($x | describe) == 'string' } - def has-env [name: string] { - $name in (env).name + def has-env [...names] { + $names | each {|n| + $n in $env + } | all {|i| $i == true} } - let is-windows = ((sys).host.name | str downcase) == 'windows' - let virtual-env = '__VIRTUAL_ENV__' - let bin = '__BIN_NAME__' - let path-sep = '__PATH_SEP__' - let path-name = if $is-windows { - if (has-env 'Path') { - 'Path' + # Emulates a `test -z`, but better as it handles e.g 'false' + def is-env-true [name: string] { + if (has-env $name) { + # Try to parse 'true', '0', '1', and fail if not convertible + let parsed = (do -i { $env | get $name | into bool }) + if ($parsed | describe) == 'bool' { + $parsed } else { - 'PATH' + not ($env | get -i $name | is-empty) } - } else { - 'PATH' + } else { + false + } } - let old-path = ( - if $is-windows { - if (has-env 'Path') { - $env.Path - } else { - $env.PATH - } - } else { - $env.PATH - } | if (is-string $in) { - # if Path/PATH is a string, make it a list - $in | split row $path-sep | path expand + let virtual_env = __VIRTUAL_ENV__ + let bin = __BIN_NAME__ + + let is_windows = ($nu.os-info.family) == 'windows' + let path_name = (if (has-env 'Path') { + 'Path' } else { - $in + 'PATH' } ) - let venv-path = ([$virtual-env $bin] | path join) - let new-path = ($old-path | prepend $venv-path | str collect $path-sep) + let venv_path = ([$virtual_env $bin] | path join) + let new_path = ($env | get $path_name | prepend $venv_path) - # Creating the new prompt for the session - let virtual-prompt = if ('__VIRTUAL_PROMPT__' == '') { - $'(char lparen)($virtual-env | path basename)(char rparen) ' + # If there is no default prompt, then use the env name instead + let virtual_env_prompt = (if (__VIRTUAL_PROMPT__ | is-empty) { + ($virtual_env | path basename) } else { - '(__VIRTUAL_PROMPT__) ' - } + __VIRTUAL_PROMPT__ + }) - # Back up the old prompt builder - let old-prompt-command = if (has-env 'VIRTUAL_ENV') && (has-env '_OLD_PROMPT_COMMAND') { - $env._OLD_PROMPT_COMMAND - } else { - if (has-env 'PROMPT_COMMAND') { - $env.PROMPT_COMMAND - } else { - '' - } + let new_env = { + $path_name : $new_path + VIRTUAL_ENV : $virtual_env + VIRTUAL_ENV_PROMPT : $virtual_env_prompt } - # If there is no default prompt, then only the env is printed in the prompt - let new-prompt = if (has-env 'PROMPT_COMMAND') { - if ($old-prompt-command | describe) == 'block' { - { $'($virtual-prompt)(do $old-prompt-command)' } - } else { - { $'($virtual-prompt)($old-prompt-command)' } - } + let new_env = (if (is-env-true 'VIRTUAL_ENV_DISABLE_PROMPT') { + $new_env } else { - { $'($virtual-prompt)' } - } + # Creating the new prompt for the session + let virtual_prefix = $'(char lparen)($virtual_env_prompt)(char rparen) ' - # Environment variables that will be batched loaded to the virtual env - let new-env = { - $path-name : $new-path - VIRTUAL_ENV : $virtual-env - _OLD_VIRTUAL_PATH : ($old-path | str collect $path-sep) - _OLD_PROMPT_COMMAND : $old-prompt-command - PROMPT_COMMAND : $new-prompt - VIRTUAL_PROMPT : $virtual-prompt - } + # Back up the old prompt builder + let old_prompt_command = (if (has-env 'PROMPT_COMMAND') { + $env.PROMPT_COMMAND + } else { + '' + }) - # Activate the environment variables - load-env $new-env -} + let new_prompt = (if (has-env 'PROMPT_COMMAND') { + if 'closure' in ($old_prompt_command | describe) { + {|| $'($virtual_prefix)(do $old_prompt_command)' } + } else { + {|| $'($virtual_prefix)($old_prompt_command)' } + } + } else { + {|| $'($virtual_prefix)' } + }) -# Activate the virtualenv -activate-virtualenv + $new_env | merge { + PROMPT_COMMAND : $new_prompt + VIRTUAL_PREFIX : $virtual_prefix + } + }) + + # Environment variables that will be loaded as the virtual env + load-env $new_env +} -alias pydoc = python -m pydoc -alias deactivate = source '__DEACTIVATE_PATH__' +export alias pydoc = python -m pydoc +export alias deactivate = overlay hide activate diff --git a/src/virtualenv/activation/nushell/deactivate.nu b/src/virtualenv/activation/nushell/deactivate.nu deleted file mode 100644 index 904f7d0e8..000000000 --- a/src/virtualenv/activation/nushell/deactivate.nu +++ /dev/null @@ -1,32 +0,0 @@ -def-env deactivate-virtualenv [] { - def has-env [name: string] { - $name in (env).name - } - - let is-windows = ((sys).host.name | str downcase) == 'windows' - - let path-name = if $is-windows { - if (has-env 'Path') { - 'Path' - } else { - 'PATH' - } - } else { - 'PATH' - } - - load-env { $path-name : $env._OLD_VIRTUAL_PATH } - - let-env PROMPT_COMMAND = $env._OLD_PROMPT_COMMAND - - # Hiding the environment variables that were created when activating the env - hide _OLD_VIRTUAL_PATH - hide _OLD_PROMPT_COMMAND - hide VIRTUAL_ENV - hide VIRTUAL_PROMPT -} - -deactivate-virtualenv - -hide pydoc -hide deactivate diff --git a/src/virtualenv/activation/powershell/__init__.py b/src/virtualenv/activation/powershell/__init__.py index 4fadc63bc..8489656cc 100644 --- a/src/virtualenv/activation/powershell/__init__.py +++ b/src/virtualenv/activation/powershell/__init__.py @@ -1,10 +1,25 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from virtualenv.util.path import Path - -from ..via_template import ViaTemplateActivator +from virtualenv.activation.via_template import ViaTemplateActivator class PowerShellActivator(ViaTemplateActivator): def templates(self): - yield Path("activate.ps1") + yield "activate.ps1" + + @staticmethod + def quote(string): + """ + This should satisfy PowerShell quoting rules [1], unless the quoted + string is passed directly to Windows native commands [2]. + + [1]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules + [2]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parsing#passing-arguments-that-contain-quote-characters + """ # noqa: D205 + string = string.replace("'", "''") + return f"'{string}'" + + +__all__ = [ + "PowerShellActivator", +] diff --git a/src/virtualenv/activation/powershell/activate.ps1 b/src/virtualenv/activation/powershell/activate.ps1 index d5243475e..bd30e2eed 100644 --- a/src/virtualenv/activation/powershell/activate.ps1 +++ b/src/virtualenv/activation/powershell/activate.ps1 @@ -16,6 +16,10 @@ function global:deactivate([switch] $NonDestructive) { Remove-Item env:VIRTUAL_ENV -ErrorAction SilentlyContinue } + if ($env:VIRTUAL_ENV_PROMPT) { + Remove-Item env:VIRTUAL_ENV_PROMPT -ErrorAction SilentlyContinue + } + if (!$NonDestructive) { # Self destruct! Remove-Item function:deactivate @@ -33,28 +37,25 @@ deactivate -nondestructive $VIRTUAL_ENV = $BASE_DIR $env:VIRTUAL_ENV = $VIRTUAL_ENV +if (__VIRTUAL_PROMPT__ -ne "") { + $env:VIRTUAL_ENV_PROMPT = __VIRTUAL_PROMPT__ +} +else { + $env:VIRTUAL_ENV_PROMPT = $( Split-Path $env:VIRTUAL_ENV -Leaf ) +} + New-Variable -Scope global -Name _OLD_VIRTUAL_PATH -Value $env:PATH -$env:PATH = "$env:VIRTUAL_ENV/__BIN_NAME____PATH_SEP__" + $env:PATH +$env:PATH = "$env:VIRTUAL_ENV/" + __BIN_NAME__ + __PATH_SEP__ + $env:PATH if (!$env:VIRTUAL_ENV_DISABLE_PROMPT) { function global:_old_virtual_prompt { "" } $function:_old_virtual_prompt = $function:prompt - if ("__VIRTUAL_PROMPT__" -ne "") { - function global:prompt { - # Add the custom prefix to the existing prompt - $previous_prompt_value = & $function:_old_virtual_prompt - ("(__VIRTUAL_PROMPT__) " + $previous_prompt_value) - } - } - else { - function global:prompt { - # Add a prefix to the current prompt, but don't discard it. - $previous_prompt_value = & $function:_old_virtual_prompt - $new_prompt_value = "($( Split-Path $env:VIRTUAL_ENV -Leaf )) " - ($new_prompt_value + $previous_prompt_value) - } + function global:prompt { + # Add the custom prefix to the existing prompt + $previous_prompt_value = & $function:_old_virtual_prompt + ("(" + $env:VIRTUAL_ENV_PROMPT + ") " + $previous_prompt_value) } } diff --git a/src/virtualenv/activation/python/__init__.py b/src/virtualenv/activation/python/__init__.py index 9e579124d..e900f7ec9 100644 --- a/src/virtualenv/activation/python/__init__.py +++ b/src/virtualenv/activation/python/__init__.py @@ -1,35 +1,32 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os -import sys from collections import OrderedDict -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_text - -from ..via_template import ViaTemplateActivator +from virtualenv.activation.via_template import ViaTemplateActivator class PythonActivator(ViaTemplateActivator): def templates(self): - yield Path("activate_this.py") + yield "activate_this.py" + + @staticmethod + def quote(string): + return repr(string) def replacements(self, creator, dest_folder): - replacements = super(PythonActivator, self).replacements(creator, dest_folder) + replacements = super().replacements(creator, dest_folder) lib_folders = OrderedDict((os.path.relpath(str(i), str(dest_folder)), None) for i in creator.libs) - win_py2 = creator.interpreter.platform == "win32" and creator.interpreter.version_info.major == 2 + lib_folders = os.pathsep.join(lib_folders.keys()) replacements.update( { - "__LIB_FOLDERS__": ensure_text(os.pathsep.join(lib_folders.keys())), - "__DECODE_PATH__": ("yes" if win_py2 else ""), + "__LIB_FOLDERS__": lib_folders, + "__DECODE_PATH__": "", }, ) return replacements - @staticmethod - def _repr_unicode(creator, value): - py2 = creator.interpreter.version_info.major == 2 - if py2: # on Python 2 we need to encode this into explicit utf-8, py3 supports unicode literals - start = 2 if sys.version_info[0] == 3 else 1 - value = ensure_text(repr(value.encode("utf-8"))[start:-1]) - return value + +__all__ = [ + "PythonActivator", +] diff --git a/src/virtualenv/activation/python/activate_this.py b/src/virtualenv/activation/python/activate_this.py index 29debe3e7..9cc816fab 100644 --- a/src/virtualenv/activation/python/activate_this.py +++ b/src/virtualenv/activation/python/activate_this.py @@ -1,31 +1,37 @@ -# -*- coding: utf-8 -*- -"""Activate virtualenv for current interpreter: +""" +Activate virtualenv for current interpreter: -Use exec(open(this_file).read(), {'__file__': this_file}). +import runpy +runpy.run_path(this_file) This can be used when you must use an existing Python interpreter, not the virtualenv bin/python. -""" +""" # noqa: D415 + +from __future__ import annotations + import os import site import sys try: abs_file = os.path.abspath(__file__) -except NameError: - raise AssertionError("You must use exec(open(this_file).read(), {'__file__': this_file}))") +except NameError as exc: + msg = "You must use import runpy; runpy.run_path(this_file)" + raise AssertionError(msg) from exc bin_dir = os.path.dirname(abs_file) -base = bin_dir[: -len("__BIN_NAME__") - 1] # strip away the bin part from the __file__, plus the path separator +base = bin_dir[: -len(__BIN_NAME__) - 1] # strip away the bin part from the __file__, plus the path separator # prepend bin to PATH (this file is inside the bin directory) -os.environ["PATH"] = os.pathsep.join([bin_dir] + os.environ.get("PATH", "").split(os.pathsep)) +os.environ["PATH"] = os.pathsep.join([bin_dir, *os.environ.get("PATH", "").split(os.pathsep)]) os.environ["VIRTUAL_ENV"] = base # virtual env is right above bin directory +os.environ["VIRTUAL_ENV_PROMPT"] = __VIRTUAL_PROMPT__ or os.path.basename(base) # add the virtual environments libraries to the host python import mechanism prev_length = len(sys.path) -for lib in "__LIB_FOLDERS__".split(os.pathsep): +for lib in __LIB_FOLDERS__.split(os.pathsep): path = os.path.realpath(os.path.join(bin_dir, lib)) - site.addsitedir(path.decode("utf-8") if "__DECODE_PATH__" else path) + site.addsitedir(path.decode("utf-8") if __DECODE_PATH__ else path) sys.path[:] = sys.path[prev_length:] + sys.path[0:prev_length] sys.real_prefix = sys.prefix diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activation/via_template.py index 14f097973..6fa4474d2 100644 --- a/src/virtualenv/activation/via_template.py +++ b/src/virtualenv/activation/via_template.py @@ -1,27 +1,37 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os +import shlex import sys -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod -from six import add_metaclass +from .activator import Activator -from virtualenv.util.six import ensure_text +if sys.version_info >= (3, 10): + from importlib.resources import files -from .activator import Activator + def read_binary(module_name: str, filename: str) -> bytes: + return (files(module_name) / filename).read_bytes() -if sys.version_info >= (3, 7): - from importlib.resources import read_binary else: - from importlib_resources import read_binary + from importlib.resources import read_binary -@add_metaclass(ABCMeta) -class ViaTemplateActivator(Activator): +class ViaTemplateActivator(Activator, ABC): @abstractmethod def templates(self): raise NotImplementedError + @staticmethod + def quote(string): + """ + Quote strings in the activation script. + + :param string: the string to quote + :return: quoted string that works in the activation script + """ + return shlex.quote(string) + def generate(self, creator): dest_folder = creator.bin_dir replacements = self.replacements(creator, dest_folder) @@ -30,13 +40,13 @@ def generate(self, creator): creator.pyenv_cfg["prompt"] = self.flag_prompt return generated - def replacements(self, creator, dest_folder): + def replacements(self, creator, dest_folder): # noqa: ARG002 return { "__VIRTUAL_PROMPT__": "" if self.flag_prompt is None else self.flag_prompt, - "__VIRTUAL_ENV__": ensure_text(str(creator.dest)), + "__VIRTUAL_ENV__": str(creator.dest), "__VIRTUAL_NAME__": creator.env_name, - "__BIN_NAME__": ensure_text(str(creator.bin_dir.relative_to(creator.dest))), - "__PATH_SEP__": ensure_text(os.pathsep), + "__BIN_NAME__": str(creator.bin_dir.relative_to(creator.dest)), + "__PATH_SEP__": os.pathsep, } def _generate(self, replacements, templates, to_folder, creator): @@ -44,24 +54,34 @@ def _generate(self, replacements, templates, to_folder, creator): for template in templates: text = self.instantiate_template(replacements, template, creator) dest = to_folder / self.as_name(template) + # remove the file if it already exists - this prevents permission + # errors when the dest is not writable + if dest.exists(): + dest.unlink() + # Powershell assumes Windows 1252 encoding when reading files without BOM + encoding = "utf-8-sig" if str(template).endswith(".ps1") else "utf-8" # use write_bytes to avoid platform specific line normalization (\n -> \r\n) - dest.write_bytes(text.encode("utf-8")) + dest.write_bytes(text.encode(encoding)) generated.append(dest) return generated def as_name(self, template): - return template.name + return template def instantiate_template(self, replacements, template, creator): # read content as binary to avoid platform specific line normalization (\n -> \r\n) - binary = read_binary(self.__module__, str(template)) + binary = read_binary(self.__module__, template) text = binary.decode("utf-8", errors="strict") for key, value in replacements.items(): - value = self._repr_unicode(creator, value) - text = text.replace(key, value) + value_uni = self._repr_unicode(creator, value) + text = text.replace(key, self.quote(value_uni)) return text @staticmethod - def _repr_unicode(creator, value): - # by default we just let it be unicode - return value + def _repr_unicode(creator, value): # noqa: ARG004 + return value # by default, we just let it be unicode + + +__all__ = [ + "ViaTemplateActivator", +] diff --git a/src/virtualenv/app_data/__init__.py b/src/virtualenv/app_data/__init__.py index e56e63d53..d7f148023 100644 --- a/src/virtualenv/app_data/__init__.py +++ b/src/virtualenv/app_data/__init__.py @@ -1,7 +1,6 @@ -""" -Application data stored by virtualenv. -""" -from __future__ import absolute_import, unicode_literals +"""Application data stored by virtualenv.""" + +from __future__ import annotations import logging import os @@ -13,40 +12,41 @@ from .via_disk_folder import AppDataDiskFolder from .via_tempdir import TempAppData +LOGGER = logging.getLogger(__name__) + def _default_app_data_dir(env): - key = str("VIRTUALENV_OVERRIDE_APP_DATA") + key = "VIRTUALENV_OVERRIDE_APP_DATA" if key in env: return env[key] - else: - return user_data_dir(appname="virtualenv", appauthor="pypa") + return user_data_dir(appname="virtualenv", appauthor="pypa") def make_app_data(folder, **kwargs): - read_only = kwargs.pop("read_only") + is_read_only = kwargs.pop("read_only") env = kwargs.pop("env") if kwargs: # py3+ kwonly - raise TypeError("unexpected keywords: {}") + msg = "unexpected keywords: {}" + raise TypeError(msg) if folder is None: folder = _default_app_data_dir(env) folder = os.path.abspath(folder) - if read_only: + if is_read_only: return ReadOnlyAppData(folder) if not os.path.isdir(folder): try: os.makedirs(folder) - logging.debug("created app data folder %s", folder) + LOGGER.debug("created app data folder %s", folder) except OSError as exception: - logging.info("could not create app data folder %s due to %r", folder, exception) + LOGGER.info("could not create app data folder %s due to %r", folder, exception) if os.access(folder, os.W_OK): return AppDataDiskFolder(folder) - else: - logging.debug("app data folder %s has no write access", folder) - return TempAppData() + LOGGER.debug("app data folder %s has no write access", folder) + return TempAppData() __all__ = ( diff --git a/src/virtualenv/app_data/base.py b/src/virtualenv/app_data/base.py index 4ea54d9f6..2077deebd 100644 --- a/src/virtualenv/app_data/base.py +++ b/src/virtualenv/app_data/base.py @@ -1,27 +1,23 @@ -""" -Application data stored by virtualenv. -""" -from __future__ import absolute_import, unicode_literals +"""Application data stored by virtualenv.""" -from abc import ABCMeta, abstractmethod -from contextlib import contextmanager +from __future__ import annotations -import six +from abc import ABC, abstractmethod +from contextlib import contextmanager from virtualenv.info import IS_ZIPAPP -@six.add_metaclass(ABCMeta) -class AppData(object): - """Abstract storage interface for the virtualenv application""" +class AppData(ABC): + """Abstract storage interface for the virtualenv application.""" @abstractmethod def close(self): - """called before virtualenv exits""" + """Called before virtualenv exits.""" @abstractmethod def reset(self): - """called when the user passes in the reset app data""" + """Called when the user passes in the reset app data.""" @abstractmethod def py_info(self, path): @@ -53,7 +49,7 @@ def wheel_image(self, for_py_version, name): @contextmanager def ensure_extracted(self, path, to_folder=None): - """Some paths might be within the zipapp, unzip these to a path on the disk""" + """Some paths might be within the zipapp, unzip these to a path on the disk.""" if IS_ZIPAPP: with self.extract(path, to_folder) as result: yield result @@ -71,8 +67,7 @@ def locked(self, path): raise NotImplementedError -@six.add_metaclass(ABCMeta) -class ContentStore(object): +class ContentStore(ABC): @abstractmethod def exists(self): raise NotImplementedError @@ -93,3 +88,9 @@ def remove(self): @contextmanager def locked(self): pass + + +__all__ = [ + "AppData", + "ContentStore", +] diff --git a/src/virtualenv/app_data/na.py b/src/virtualenv/app_data/na.py index d5897871f..921e83a81 100644 --- a/src/virtualenv/app_data/na.py +++ b/src/virtualenv/app_data/na.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from contextlib import contextmanager @@ -6,45 +6,45 @@ class AppDataDisabled(AppData): - """No application cache available (most likely as we don't have write permissions)""" + """No application cache available (most likely as we don't have write permissions).""" transient = True can_update = False - def __init__(self): + def __init__(self) -> None: pass error = RuntimeError("no app data folder available, probably no write access to the folder") def close(self): - """do nothing""" + """Do nothing.""" def reset(self): - """do nothing""" + """Do nothing.""" - def py_info(self, path): + def py_info(self, path): # noqa: ARG002 return ContentStoreNA() - def embed_update_log(self, distribution, for_py_version): + def embed_update_log(self, distribution, for_py_version): # noqa: ARG002 return ContentStoreNA() - def extract(self, path, to_folder): + def extract(self, path, to_folder): # noqa: ARG002 raise self.error @contextmanager - def locked(self, path): - """do nothing""" + def locked(self, path): # noqa: ARG002 + """Do nothing.""" yield @property def house(self): raise self.error - def wheel_image(self, for_py_version, name): + def wheel_image(self, for_py_version, name): # noqa: ARG002 raise self.error def py_info_clear(self): - """ """ + """Nothing to clear.""" class ContentStoreNA(ContentStore): @@ -52,15 +52,21 @@ def exists(self): return False def read(self): - """ """ - return None + """Nothing to read.""" + return def write(self, content): - """ """ + """Nothing to write.""" def remove(self): - """ """ + """Nothing to remove.""" @contextmanager def locked(self): yield + + +__all__ = [ + "AppDataDisabled", + "ContentStoreNA", +] diff --git a/src/virtualenv/app_data/read_only.py b/src/virtualenv/app_data/read_only.py index 858978cd0..952dbad5a 100644 --- a/src/virtualenv/app_data/read_only.py +++ b/src/virtualenv/app_data/read_only.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os.path from virtualenv.util.lock import NoOpFileLock @@ -8,15 +10,18 @@ class ReadOnlyAppData(AppDataDiskFolder): can_update = False - def __init__(self, folder): # type: (str) -> None + def __init__(self, folder: str) -> None: if not os.path.isdir(folder): - raise RuntimeError("read-only app data directory {} does not exist".format(folder)) + msg = f"read-only app data directory {folder} does not exist" + raise RuntimeError(msg) + super().__init__(folder) self.lock = NoOpFileLock(folder) - def reset(self): # type: () -> None - raise RuntimeError("read-only app data does not support reset") + def reset(self) -> None: + msg = "read-only app data does not support reset" + raise RuntimeError(msg) - def py_info_clear(self): # type: () -> None + def py_info_clear(self) -> None: raise NotImplementedError def py_info(self, path): @@ -27,8 +32,11 @@ def embed_update_log(self, distribution, for_py_version): class _PyInfoStoreDiskReadOnly(PyInfoStoreDisk): - def write(self, content): - raise RuntimeError("read-only app data python info cannot be updated") + def write(self, content): # noqa: ARG002 + msg = "read-only app data python info cannot be updated" + raise RuntimeError(msg) -__all__ = ("ReadOnlyAppData",) +__all__ = [ + "ReadOnlyAppData", +] diff --git a/src/virtualenv/app_data/via_disk_folder.py b/src/virtualenv/app_data/via_disk_folder.py index 3f6afd55f..98cf2886b 100644 --- a/src/virtualenv/app_data/via_disk_folder.py +++ b/src/virtualenv/app_data/via_disk_folder.py @@ -1,12 +1,11 @@ -# -*- coding: utf-8 -*- """ A rough layout of the current storage goes as: virtualenv-app-data ├── py - -│   └── *.json/lock +│ └── *.json/lock ├── wheel -│   ├── house +│ ├── house │ │ └── *.whl │ └── -> 3.9 │ ├── img- @@ -21,49 +20,47 @@ ├── py_info.py ├── debug.py └── _virtualenv.py -""" -from __future__ import absolute_import, unicode_literals +""" # noqa: D415 + +from __future__ import annotations import json import logging -from abc import ABCMeta -from contextlib import contextmanager +from abc import ABC +from contextlib import contextmanager, suppress from hashlib import sha256 -import six - from virtualenv.util.lock import ReentrantFileLock from virtualenv.util.path import safe_delete -from virtualenv.util.six import ensure_text from virtualenv.util.zipapp import extract from virtualenv.version import __version__ from .base import AppData, ContentStore +LOGGER = logging.getLogger(__name__) + class AppDataDiskFolder(AppData): - """ - Store the application data on the disk within a folder layout. - """ + """Store the application data on the disk within a folder layout.""" transient = False can_update = True - def __init__(self, folder): + def __init__(self, folder) -> None: self.lock = ReentrantFileLock(folder) - def __repr__(self): - return "{}({})".format(type(self).__name__, self.lock.path) + def __repr__(self) -> str: + return f"{type(self).__name__}({self.lock.path})" - def __str__(self): + def __str__(self) -> str: return str(self.lock.path) def reset(self): - logging.debug("reset app data folder %s", self.lock.path) + LOGGER.debug("reset app data folder %s", self.lock.path) safe_delete(self.lock.path) def close(self): - """do nothing""" + """Do nothing.""" @contextmanager def locked(self, path): @@ -73,10 +70,7 @@ def locked(self, path): @contextmanager def extract(self, path, to_folder): - if to_folder is not None: - root = ReentrantFileLock(to_folder()) - else: - root = self.lock / "unzip" / __version__ + root = ReentrantFileLock(to_folder()) if to_folder is not None else self.lock / "unzip" / __version__ with root.lock_for_key(path.name): dest = root.path / path.name if not dest.exists(): @@ -91,7 +85,7 @@ def py_info(self, path): return PyInfoStoreDisk(self.py_info_at, path) def py_info_clear(self): - """ """ + """clear py info.""" py_info_folder = self.py_info_at with py_info_folder: for filename in py_info_folder.path.iterdir(): @@ -113,17 +107,16 @@ def wheel_image(self, for_py_version, name): return self.lock.path / "wheel" / for_py_version / "image" / "1" / name -@six.add_metaclass(ABCMeta) -class JSONStoreDisk(ContentStore): - def __init__(self, in_folder, key, msg, msg_args): +class JSONStoreDisk(ContentStore, ABC): + def __init__(self, in_folder, key, msg, msg_args) -> None: self.in_folder = in_folder self.key = key self.msg = msg - self.msg_args = msg_args + (self.file,) + self.msg_args = (*msg_args, self.file) @property def file(self): - return self.in_folder.path / "{}.json".format(self.key) + return self.in_folder.path / f"{self.key}.json" def exists(self): return self.file.exists() @@ -131,23 +124,22 @@ def exists(self): def read(self): data, bad_format = None, False try: - data = json.loads(self.file.read_text()) - logging.debug("got {} from %s".format(self.msg), *self.msg_args) - return data + data = json.loads(self.file.read_text(encoding="utf-8")) except ValueError: bad_format = True - except Exception: # noqa + except Exception: # noqa: BLE001, S110 pass + else: + LOGGER.debug("got %s from %s", self.msg, self.msg_args) + return data if bad_format: - try: + with suppress(OSError): # reading and writing on the same file may cause race on multiple processes self.remove() - except OSError: # reading and writing on the same file may cause race on multiple processes - pass return None def remove(self): self.file.unlink() - logging.debug("removed {} at %s".format(self.msg), *self.msg_args) + LOGGER.debug("removed %s at %s", self.msg, self.msg_args) @contextmanager def locked(self): @@ -157,21 +149,28 @@ def locked(self): def write(self, content): folder = self.file.parent folder.mkdir(parents=True, exist_ok=True) - self.file.write_text(ensure_text(json.dumps(content, sort_keys=True, indent=2))) - logging.debug("wrote {} at %s".format(self.msg), *self.msg_args) + self.file.write_text(json.dumps(content, sort_keys=True, indent=2), encoding="utf-8") + LOGGER.debug("wrote %s at %s", self.msg, self.msg_args) class PyInfoStoreDisk(JSONStoreDisk): - def __init__(self, in_folder, path): - key = sha256(str(path).encode("utf-8") if six.PY3 else str(path)).hexdigest() - super(PyInfoStoreDisk, self).__init__(in_folder, key, "python info of %s", (path,)) + def __init__(self, in_folder, path) -> None: + key = sha256(str(path).encode("utf-8")).hexdigest() + super().__init__(in_folder, key, "python info of %s", (path,)) class EmbedDistributionUpdateStoreDisk(JSONStoreDisk): - def __init__(self, in_folder, distribution): - super(EmbedDistributionUpdateStoreDisk, self).__init__( + def __init__(self, in_folder, distribution) -> None: + super().__init__( in_folder, distribution, "embed update of distribution %s", (distribution,), ) + + +__all__ = [ + "AppDataDiskFolder", + "JSONStoreDisk", + "PyInfoStoreDisk", +] diff --git a/src/virtualenv/app_data/via_tempdir.py b/src/virtualenv/app_data/via_tempdir.py index 112a3fe6b..884a570ce 100644 --- a/src/virtualenv/app_data/via_tempdir.py +++ b/src/virtualenv/app_data/via_tempdir.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging from tempfile import mkdtemp @@ -7,21 +7,28 @@ from .via_disk_folder import AppDataDiskFolder +LOGGER = logging.getLogger(__name__) + class TempAppData(AppDataDiskFolder): transient = True can_update = False - def __init__(self): - super(TempAppData, self).__init__(folder=mkdtemp()) - logging.debug("created temporary app data folder %s", self.lock.path) + def __init__(self) -> None: + super().__init__(folder=mkdtemp()) + LOGGER.debug("created temporary app data folder %s", self.lock.path) def reset(self): - """this is a temporary folder, is already empty to start with""" + """This is a temporary folder, is already empty to start with.""" def close(self): - logging.debug("remove temporary app data folder %s", self.lock.path) + LOGGER.debug("remove temporary app data folder %s", self.lock.path) safe_delete(self.lock.path) def embed_update_log(self, distribution, for_py_version): raise NotImplementedError + + +__all__ = [ + "TempAppData", +] diff --git a/src/virtualenv/config/__init__.py b/src/virtualenv/config/__init__.py index 01e6d4f49..e69de29bb 100644 --- a/src/virtualenv/config/__init__.py +++ b/src/virtualenv/config/__init__.py @@ -1 +0,0 @@ -from __future__ import absolute_import, unicode_literals diff --git a/src/virtualenv/config/cli/__init__.py b/src/virtualenv/config/cli/__init__.py index 01e6d4f49..e69de29bb 100644 --- a/src/virtualenv/config/cli/__init__.py +++ b/src/virtualenv/config/cli/__init__.py @@ -1 +0,0 @@ -from __future__ import absolute_import, unicode_literals diff --git a/src/virtualenv/config/cli/parser.py b/src/virtualenv/config/cli/parser.py index c8e2f551f..a1770f2ac 100644 --- a/src/virtualenv/config/cli/parser.py +++ b/src/virtualenv/config/cli/parser.py @@ -1,18 +1,17 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations # noqa: A005 import os from argparse import SUPPRESS, ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace from collections import OrderedDict from virtualenv.config.convert import get_type - -from ..env_var import get_env_var -from ..ini import IniConfig +from virtualenv.config.env_var import get_env_var +from virtualenv.config.ini import IniConfig class VirtualEnvOptions(Namespace): - def __init__(self, **kwargs): - super(VirtualEnvOptions, self).__init__(**kwargs) + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) self._src = None self._sources = {} @@ -22,10 +21,10 @@ def set_src(self, key, value, src): src = "env var" self._sources[key] = src - def __setattr__(self, key, value): + def __setattr__(self, key, value) -> None: if getattr(self, "_src", None) is not None: self._sources[key] = self._src - super(VirtualEnvOptions, self).__setattr__(key, value) + super().__setattr__(key, value) def get_source(self, key): return self._sources.get(key) @@ -36,19 +35,14 @@ def verbosity(self): return None return max(self.verbose - self.quiet, 0) - def __repr__(self): - return "{}({})".format( - type(self).__name__, - ", ".join("{}={}".format(k, v) for k, v in vars(self).items() if not k.startswith("_")), - ) + def __repr__(self) -> str: + return f"{type(self).__name__}({', '.join(f'{k}={v}' for k, v in vars(self).items() if not k.startswith('_'))})" class VirtualEnvConfigParser(ArgumentParser): - """ - Custom option parser which updates its defaults by checking the configuration files and environmental variables - """ + """Custom option parser which updates its defaults by checking the configuration files and environmental vars.""" - def __init__(self, options=None, env=None, *args, **kwargs): + def __init__(self, options=None, env=None, *args, **kwargs) -> None: env = os.environ if env is None else env self.file_config = IniConfig(env) self.epilog_list = [] @@ -57,10 +51,11 @@ def __init__(self, options=None, env=None, *args, **kwargs): kwargs["add_help"] = False kwargs["formatter_class"] = HelpFormatter kwargs["prog"] = "virtualenv" - super(VirtualEnvConfigParser, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._fixed = set() if options is not None and not isinstance(options, VirtualEnvOptions): - raise TypeError("options must be of type VirtualEnvOptions") + msg = "options must be of type VirtualEnvOptions" + raise TypeError(msg) self.options = VirtualEnvOptions() if options is None else options self._interpreter = None self._app_data = None @@ -100,25 +95,32 @@ def parse_known_args(self, args=None, namespace=None): if namespace is None: namespace = self.options elif namespace is not self.options: - raise ValueError("can only pass in parser.options") + msg = "can only pass in parser.options" + raise ValueError(msg) self._fix_defaults() - self.options._src = "cli" + self.options._src = "cli" # noqa: SLF001 try: namespace.env = self.env - return super(VirtualEnvConfigParser, self).parse_known_args(args, namespace=namespace) + return super().parse_known_args(args, namespace=namespace) finally: - self.options._src = None + self.options._src = None # noqa: SLF001 class HelpFormatter(ArgumentDefaultsHelpFormatter): - def __init__(self, prog): - super(HelpFormatter, self).__init__(prog, max_help_position=32, width=240) + def __init__(self, prog) -> None: + super().__init__(prog, max_help_position=32, width=240) def _get_help_string(self, action): - # noinspection PyProtectedMember - text = super(HelpFormatter, self)._get_help_string(action) + text = super()._get_help_string(action) if hasattr(action, "default_source"): default = " (default: %(default)s)" if text.endswith(default): - text = "{} (default: %(default)s -> from %(default_source)s)".format(text[: -len(default)]) + text = f"{text[: -len(default)]} (default: %(default)s -> from %(default_source)s)" return text + + +__all__ = [ + "HelpFormatter", + "VirtualEnvConfigParser", + "VirtualEnvOptions", +] diff --git a/src/virtualenv/config/convert.py b/src/virtualenv/config/convert.py index df408663d..ef7581dbd 100644 --- a/src/virtualenv/config/convert.py +++ b/src/virtualenv/config/convert.py @@ -1,23 +1,26 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import os +from typing import ClassVar +LOGGER = logging.getLogger(__name__) -class TypeData(object): - def __init__(self, default_type, as_type): + +class TypeData: + def __init__(self, default_type, as_type) -> None: self.default_type = default_type self.as_type = as_type - def __repr__(self): - return "{}(base={}, as={})".format(self.__class__.__name__, self.default_type, self.as_type) + def __repr__(self) -> str: + return f"{self.__class__.__name__}(base={self.default_type}, as={self.as_type})" def convert(self, value): return self.default_type(value) class BoolType(TypeData): - BOOLEAN_STATES = { + BOOLEAN_STATES: ClassVar[dict[str, bool]] = { "1": True, "yes": True, "true": True, @@ -30,7 +33,8 @@ class BoolType(TypeData): def convert(self, value): if value.lower() not in self.BOOLEAN_STATES: - raise ValueError("Not a boolean: %s" % value) + msg = f"Not a boolean: {value}" + raise ValueError(msg) return self.BOOLEAN_STATES[value.lower()] @@ -43,19 +47,19 @@ def convert(self, value): class ListType(TypeData): def _validate(self): - """ """ + """no op.""" - def convert(self, value, flatten=True): + def convert(self, value, flatten=True): # noqa: ARG002, FBT002 values = self.split_values(value) result = [] - for value in values: - sub_values = value.split(os.pathsep) + for a_value in values: + sub_values = a_value.split(os.pathsep) result.extend(sub_values) - converted = [self.as_type(i) for i in result] - return converted + return [self.as_type(i) for i in result] def split_values(self, value): - """Split the provided value into a list. + """ + Split the provided value into a list. First this is done by newlines. If there were no newlines in the text, then we next try to split by comma. @@ -75,11 +79,11 @@ def split_values(self, value): def convert(value, as_type, source): - """Convert the value as a given type where the value comes from the given source""" + """Convert the value as a given type where the value comes from the given source.""" try: return as_type.convert(value) except Exception as exception: - logging.warning("%s failed to convert %r as %r because %r", source, value, as_type, exception) + LOGGER.warning("%s failed to convert %r as %r because %r", source, value, as_type, exception) raise @@ -92,7 +96,7 @@ def get_type(action): return _CONVERT.get(default_type, TypeData)(default_type, as_type) -__all__ = ( +__all__ = [ "convert", "get_type", -) +] diff --git a/src/virtualenv/config/env_var.py b/src/virtualenv/config/env_var.py index 8f6211cae..e12723471 100644 --- a/src/virtualenv/config/env_var.py +++ b/src/virtualenv/config/env_var.py @@ -1,28 +1,30 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from virtualenv.util.six import ensure_str, ensure_text +from contextlib import suppress from .convert import convert def get_env_var(key, as_type, env): - """Get the environment variable option. + """ + Get the environment variable option. :param key: the config key requested :param as_type: the type we would like to convert it to :param env: environment variables to use :return: """ - environ_key = ensure_str("VIRTUALENV_{}".format(key.upper())) + environ_key = f"VIRTUALENV_{key.upper()}" if env.get(environ_key): value = env[environ_key] - # noinspection PyBroadException - try: - source = "env var {}".format(ensure_text(environ_key)) + + with suppress(Exception): # note the converter already logs a warning when failures happen + source = f"env var {environ_key}" as_type = convert(value, as_type, source) return as_type, source - except Exception: # note the converter already logs a warning when failures happen - pass + return None -__all__ = ("get_env_var",) +__all__ = [ + "get_env_var", +] diff --git a/src/virtualenv/config/ini.py b/src/virtualenv/config/ini.py index 0d945ee25..ed0a1b930 100644 --- a/src/virtualenv/config/ini.py +++ b/src/virtualenv/config/ini.py @@ -1,33 +1,32 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import os +from configparser import ConfigParser +from pathlib import Path +from typing import ClassVar from platformdirs import user_config_dir -from virtualenv.info import PY3 -from virtualenv.util import ConfigParser -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_str - from .convert import convert +LOGGER = logging.getLogger(__name__) + -class IniConfig(object): - VIRTUALENV_CONFIG_FILE_ENV_VAR = ensure_str("VIRTUALENV_CONFIG_FILE") - STATE = {None: "failed to parse", True: "active", False: "missing"} +class IniConfig: + VIRTUALENV_CONFIG_FILE_ENV_VAR: ClassVar[str] = "VIRTUALENV_CONFIG_FILE" + STATE: ClassVar[dict[bool | None, str]] = {None: "failed to parse", True: "active", False: "missing"} section = "virtualenv" - def __init__(self, env=None): + def __init__(self, env=None) -> None: env = os.environ if env is None else env config_file = env.get(self.VIRTUALENV_CONFIG_FILE_ENV_VAR, None) self.is_env_var = config_file is not None - config_file = ( - Path(config_file) - if config_file is not None - else Path(user_config_dir(appname="virtualenv", appauthor="pypa")) / "virtualenv.ini" - ) + if config_file is None: + config_file = Path(user_config_dir(appname="virtualenv", appauthor="pypa")) / "virtualenv.ini" + else: + config_file = Path(config_file) self.config_file = config_file self._cache = {} @@ -40,45 +39,39 @@ def __init__(self, env=None): else: if self.has_config_file: self.config_file = self.config_file.resolve() - self.config_parser = ConfigParser.ConfigParser() + self.config_parser = ConfigParser() try: self._load() self.has_virtualenv_section = self.config_parser.has_section(self.section) - except Exception as exc: + except Exception as exc: # noqa: BLE001 exception = exc if exception is not None: - logging.error("failed to read config file %s because %r", config_file, exception) + LOGGER.error("failed to read config file %s because %r", config_file, exception) def _load(self): - with self.config_file.open("rt") as file_handler: - reader = getattr(self.config_parser, "read_file" if PY3 else "readfp") - reader(file_handler) + with self.config_file.open("rt", encoding="utf-8") as file_handler: + return self.config_parser.read_file(file_handler) def get(self, key, as_type): cache_key = key, as_type if cache_key in self._cache: return self._cache[cache_key] - # noinspection PyBroadException try: source = "file" raw_value = self.config_parser.get(self.section, key.lower()) value = convert(raw_value, as_type, source) result = value, source - except Exception: + except Exception: # noqa: BLE001 result = None self._cache[cache_key] = result return result - def __bool__(self): + def __bool__(self) -> bool: return bool(self.has_config_file) and bool(self.has_virtualenv_section) @property def epilog(self): - msg = "{}config file {} {} (change{} via env var {})" - return msg.format( - "\n", - self.config_file, - self.STATE[self.has_config_file], - "d" if self.is_env_var else "", - self.VIRTUALENV_CONFIG_FILE_ENV_VAR, + return ( + f"\nconfig file {self.config_file} {self.STATE[self.has_config_file]} " + f"(change{'d' if self.is_env_var else ''} via env var {self.VIRTUALENV_CONFIG_FILE_ENV_VAR})" ) diff --git a/src/virtualenv/create/__init__.py b/src/virtualenv/create/__init__.py index 01e6d4f49..e69de29bb 100644 --- a/src/virtualenv/create/__init__.py +++ b/src/virtualenv/create/__init__.py @@ -1 +0,0 @@ -from __future__ import absolute_import, unicode_literals diff --git a/src/virtualenv/create/creator.py b/src/virtualenv/create/creator.py index 6363f8b7e..1e577ada5 100644 --- a/src/virtualenv/create/creator.py +++ b/src/virtualenv/create/creator.py @@ -1,21 +1,18 @@ -from __future__ import absolute_import, print_function, unicode_literals +from __future__ import annotations import json import logging import os import sys -from abc import ABCMeta, abstractmethod +import textwrap +from abc import ABC, abstractmethod from argparse import ArgumentTypeError from ast import literal_eval from collections import OrderedDict -from textwrap import dedent - -from six import add_metaclass +from pathlib import Path from virtualenv.discovery.cached_py_info import LogCmd -from virtualenv.info import WIN_CPYTHON_2 -from virtualenv.util.path import Path, safe_delete -from virtualenv.util.six import ensure_str, ensure_text +from virtualenv.util.path import safe_delete from virtualenv.util.subprocess import run_cmd from virtualenv.version import __version__ @@ -23,19 +20,20 @@ HERE = Path(os.path.abspath(__file__)).parent DEBUG_SCRIPT = HERE / "debug.py" +LOGGER = logging.getLogger(__name__) -class CreatorMeta(object): - def __init__(self): +class CreatorMeta: + def __init__(self) -> None: self.error = None -@add_metaclass(ABCMeta) -class Creator(object): - """A class that given a python Interpreter creates a virtual environment""" +class Creator(ABC): + """A class that given a python Interpreter creates a virtual environment.""" - def __init__(self, options, interpreter): - """Construct a new virtual environment creator. + def __init__(self, options, interpreter) -> None: + """ + Construct a new virtual environment creator. :param options: the CLI option as parsed from :meth:`add_parser_arguments` :param interpreter: the interpreter to create virtual environment from @@ -49,22 +47,20 @@ def __init__(self, options, interpreter): self.app_data = options.app_data self.env = options.env - def __repr__(self): - return ensure_str(self.__unicode__()) - - def __unicode__(self): - return "{}({})".format(self.__class__.__name__, ", ".join("{}={}".format(k, v) for k, v in self._args())) + def __repr__(self) -> str: + return f"{self.__class__.__name__}({', '.join(f'{k}={v}' for k, v in self._args())})" def _args(self): return [ - ("dest", ensure_text(str(self.dest))), + ("dest", str(self.dest)), ("clear", self.clear), ("no_vcs_ignore", self.no_vcs_ignore), ] @classmethod - def can_create(cls, interpreter): - """Determine if we can create a virtual environment. + def can_create(cls, interpreter): # noqa: ARG003 + """ + Determine if we can create a virtual environment. :param interpreter: the interpreter in question :return: ``None`` if we can't create, any other object otherwise that will be forwarded to \ @@ -73,8 +69,9 @@ def can_create(cls, interpreter): return True @classmethod - def add_parser_arguments(cls, parser, interpreter, meta, app_data): - """Add CLI arguments for the creator. + def add_parser_arguments(cls, parser, interpreter, meta, app_data): # noqa: ARG003 + """ + Add CLI arguments for the creator. :param parser: the CLI parser :param app_data: the application data folder @@ -107,57 +104,49 @@ def create(self): raise NotImplementedError @classmethod - def validate_dest(cls, raw_value): - """No path separator in the path, valid chars and must be write-able""" + def validate_dest(cls, raw_value): # noqa: C901 + """No path separator in the path, valid chars and must be write-able.""" def non_write_able(dest, value): common = Path(*os.path.commonprefix([value.parts, dest.parts])) - raise ArgumentTypeError( - "the destination {} is not write-able at {}".format(dest.relative_to(common), common), - ) + msg = f"the destination {dest.relative_to(common)} is not write-able at {common}" + raise ArgumentTypeError(msg) # the file system must be able to encode # note in newer CPython this is always utf-8 https://www.python.org/dev/peps/pep-0529/ encoding = sys.getfilesystemencoding() refused = OrderedDict() kwargs = {"errors": "ignore"} if encoding != "mbcs" else {} - for char in ensure_text(raw_value): + for char in str(raw_value): try: trip = char.encode(encoding, **kwargs).decode(encoding) if trip == char: continue - raise ValueError(trip) + raise ValueError(trip) # noqa: TRY301 except ValueError: refused[char] = None if refused: - raise ArgumentTypeError( - "the file system codec ({}) cannot handle characters {!r} within {!r}".format( - encoding, - "".join(refused.keys()), - raw_value, - ), - ) + bad = "".join(refused.keys()) + msg = f"the file system codec ({encoding}) cannot handle characters {bad!r} within {raw_value!r}" + raise ArgumentTypeError(msg) if os.pathsep in raw_value: - raise ArgumentTypeError( - "destination {!r} must not contain the path separator ({}) as this would break " - "the activation scripts".format(raw_value, os.pathsep), + msg = ( + f"destination {raw_value!r} must not contain the path separator ({os.pathsep})" + f" as this would break the activation scripts" ) + raise ArgumentTypeError(msg) value = Path(raw_value) if value.exists() and value.is_file(): - raise ArgumentTypeError("the destination {} already exists and is a file".format(value)) - if (3, 3) <= sys.version_info <= (3, 6): - # pre 3.6 resolve is always strict, aka must exists, sidestep by using os.path operation - dest = Path(os.path.realpath(raw_value)) - else: - dest = Path(os.path.abspath(str(value))).resolve() # on Windows absolute does not imply resolve so use both + msg = f"the destination {value} already exists and is a file" + raise ArgumentTypeError(msg) + dest = Path(os.path.abspath(str(value))).resolve() # on Windows absolute does not imply resolve so use both value = dest while dest: if dest.exists(): - if os.access(ensure_text(str(dest)), os.W_OK): + if os.access(str(dest), os.W_OK): break - else: - non_write_able(dest, value) + non_write_able(dest, value) base, _ = dest.parent, dest.name if base == dest: non_write_able(dest, value) # pragma: no cover @@ -166,16 +155,29 @@ def non_write_able(dest, value): def run(self): if self.dest.exists() and self.clear: - logging.debug("delete %s", self.dest) + LOGGER.debug("delete %s", self.dest) safe_delete(self.dest) self.create() + self.add_cachedir_tag() self.set_pyenv_cfg() if not self.no_vcs_ignore: self.setup_ignore_vcs() + def add_cachedir_tag(self): + """Generate a file indicating that this is not meant to be backed up.""" + cachedir_tag_file = self.dest / "CACHEDIR.TAG" + if not cachedir_tag_file.exists(): + cachedir_tag_text = textwrap.dedent(""" + Signature: 8a477f597d28d172789f06886806bc55 + # This file is a cache directory tag created by Python virtualenv. + # For information about cache directory tags, see: + # https://bford.info/cachedir/ + """).strip() + cachedir_tag_file.write_text(cachedir_tag_text, encoding="utf-8") + def set_pyenv_cfg(self): self.pyenv_cfg.content = OrderedDict() - self.pyenv_cfg["home"] = self.interpreter.system_exec_prefix + self.pyenv_cfg["home"] = os.path.dirname(os.path.abspath(self.interpreter.system_executable)) self.pyenv_cfg["implementation"] = self.interpreter.implementation self.pyenv_cfg["version_info"] = ".".join(str(i) for i in self.interpreter.version_info) self.pyenv_cfg["virtualenv"] = __version__ @@ -185,14 +187,7 @@ def setup_ignore_vcs(self): # mark this folder to be ignored by VCS, handle https://www.python.org/dev/peps/pep-0610/#registered-vcs git_ignore = self.dest / ".gitignore" if not git_ignore.exists(): - git_ignore.write_text( - dedent( - """ - # created by virtualenv automatically - * - """, - ).lstrip(), - ) + git_ignore.write_text("# created by virtualenv automatically\n*\n", encoding="utf-8") # Mercurial - does not support the .hgignore file inside a subdirectory directly, but only if included via the # subinclude directive from root, at which point on might as well ignore the directory itself, see # https://www.selenic.com/mercurial/hgignore.5.html for more details @@ -201,39 +196,46 @@ def setup_ignore_vcs(self): @property def debug(self): - """ - :return: debug information about the virtual environment (only valid after :meth:`create` has run) - """ + """:return: debug information about the virtual environment (only valid after :meth:`create` has run)""" if self._debug is None and self.exe is not None: self._debug = get_env_debug_info(self.exe, self.debug_script(), self.app_data, self.env) return self._debug - # noinspection PyMethodMayBeStatic - def debug_script(self): + @staticmethod + def debug_script(): return DEBUG_SCRIPT def get_env_debug_info(env_exe, debug_script, app_data, env): env = env.copy() - env.pop(str("PYTHONPATH"), None) + env.pop("PYTHONPATH", None) - with app_data.ensure_extracted(debug_script) as debug_script: - cmd = [str(env_exe), str(debug_script)] - if WIN_CPYTHON_2: - cmd = [ensure_text(i) for i in cmd] - logging.debug(str("debug via %r"), LogCmd(cmd)) + with app_data.ensure_extracted(debug_script) as debug_script_extracted: + cmd = [str(env_exe), str(debug_script_extracted)] + LOGGER.debug("debug via %r", LogCmd(cmd)) code, out, err = run_cmd(cmd) - # noinspection PyBroadException try: if code != 0: - result = literal_eval(out) + if out: + result = literal_eval(out) + else: + if code == 2 and "file" in err: # noqa: PLR2004 + # Re-raise FileNotFoundError from `run_cmd()` + raise OSError(err) # noqa: TRY301 + raise Exception(err) # noqa: TRY002, TRY301 else: result = json.loads(out) if err: result["err"] = err - except Exception as exception: + except Exception as exception: # noqa: BLE001 return {"out": out, "err": err, "returncode": code, "exception": repr(exception)} if "sys" in result and "path" in result["sys"]: del result["sys"]["path"][0] return result + + +__all__ = [ + "Creator", + "CreatorMeta", +] diff --git a/src/virtualenv/create/debug.py b/src/virtualenv/create/debug.py index 0cdaa4941..8a4845e98 100644 --- a/src/virtualenv/create/debug.py +++ b/src/virtualenv/create/debug.py @@ -1,18 +1,16 @@ -"""Inspect a target Python interpreter virtual environment wise""" -import sys # built-in +"""Inspect a target Python interpreter virtual environment wise.""" + +from __future__ import annotations -PYPY2_WIN = hasattr(sys, "pypy_version_info") and sys.platform != "win32" and sys.version_info[0] == 2 +import sys # built-in def encode_path(value): if value is None: return None if not isinstance(value, (str, bytes)): - if isinstance(value, type): - value = repr(value) - else: - value = repr(type(value)) - if isinstance(value, bytes) and not PYPY2_WIN: + value = repr(value) if isinstance(value, type) else repr(type(value)) + if isinstance(value, bytes): value = value.decode(sys.getfilesystemencoding()) return value @@ -22,13 +20,13 @@ def encode_list_path(value): def run(): - """print debug data about the virtual environment""" + """Print debug data about the virtual environment.""" try: - from collections import OrderedDict + from collections import OrderedDict # noqa: PLC0415 except ImportError: # pragma: no cover # this is possible if the standard library cannot be accessed - # noinspection PyPep8Naming - OrderedDict = dict # pragma: no cover + + OrderedDict = dict # pragma: no cover # noqa: N806 result = OrderedDict([("sys", OrderedDict())]) path_keys = ( "executable", @@ -43,17 +41,14 @@ def run(): ) for key in path_keys: value = getattr(sys, key, None) - if isinstance(value, list): - value = encode_list_path(value) - else: - value = encode_path(value) + value = encode_list_path(value) if isinstance(value, list) else encode_path(value) result["sys"][key] = value result["sys"]["fs_encoding"] = sys.getfilesystemencoding() result["sys"]["io_encoding"] = getattr(sys.stdout, "encoding", None) result["version"] = sys.version try: - import sysconfig + import sysconfig # noqa: PLC0415 # https://bugs.python.org/issue22199 makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None)) @@ -61,29 +56,26 @@ def run(): except ImportError: pass - import os # landmark + import os # landmark # noqa: PLC0415 result["os"] = repr(os) try: - # noinspection PyUnresolvedReferences - import site # site + import site # site # noqa: PLC0415 result["site"] = repr(site) except ImportError as exception: # pragma: no cover result["site"] = repr(exception) # pragma: no cover try: - # noinspection PyUnresolvedReferences - import datetime # site + import datetime # site # noqa: PLC0415 result["datetime"] = repr(datetime) except ImportError as exception: # pragma: no cover result["datetime"] = repr(exception) # pragma: no cover try: - # noinspection PyUnresolvedReferences - import math # site + import math # site # noqa: PLC0415 result["math"] = repr(math) except ImportError as exception: # pragma: no cover @@ -91,7 +83,7 @@ def run(): # try to print out, this will validate if other core modules are available (json in this case) try: - import json + import json # noqa: PLC0415 result["json"] = repr(json) except ImportError as exception: @@ -103,7 +95,7 @@ def run(): except (ValueError, TypeError) as exception: # pragma: no cover sys.stderr.write(repr(exception)) sys.stdout.write(repr(result)) # pragma: no cover - raise SystemExit(1) # pragma: no cover + raise SystemExit(1) # noqa: B904 # pragma: no cover if __name__ == "__main__": diff --git a/src/virtualenv/create/describe.py b/src/virtualenv/create/describe.py index 6f05ff1e2..1ee250cbc 100644 --- a/src/virtualenv/create/describe.py +++ b/src/virtualenv/create/describe.py @@ -1,22 +1,18 @@ -from __future__ import absolute_import, print_function, unicode_literals +from __future__ import annotations -from abc import ABCMeta +from abc import ABC from collections import OrderedDict - -from six import add_metaclass +from pathlib import Path from virtualenv.info import IS_WIN -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_text -@add_metaclass(ABCMeta) -class Describe(object): - """Given a host interpreter tell us information about what the created interpreter might look like""" +class Describe: + """Given a host interpreter tell us information about what the created interpreter might look like.""" suffix = ".exe" if IS_WIN else "" - def __init__(self, dest, interpreter): + def __init__(self, dest, interpreter) -> None: self.interpreter = interpreter self.dest = dest self._stdlib = None @@ -59,59 +55,56 @@ def stdlib_platform(self): @property def _config_vars(self): if self._conf_vars is None: - self._conf_vars = self._calc_config_vars(ensure_text(str(self.dest))) + self._conf_vars = self._calc_config_vars(self.dest) return self._conf_vars def _calc_config_vars(self, to): - return { - k: (to if v.startswith(self.interpreter.prefix) else v) for k, v in self.interpreter.sysconfig_vars.items() - } + sys_vars = self.interpreter.sysconfig_vars + return {k: (to if v is not None and v.startswith(self.interpreter.prefix) else v) for k, v in sys_vars.items()} @classmethod - def can_describe(cls, interpreter): - """Knows means it knows how the output will look""" + def can_describe(cls, interpreter): # noqa: ARG003 + """Knows means it knows how the output will look.""" return True @property def env_name(self): - return ensure_text(self.dest.parts[-1]) + return self.dest.parts[-1] @property def exe(self): - return self.bin_dir / "{}{}".format(self.exe_stem(), self.suffix) + return self.bin_dir / f"{self.exe_stem()}{self.suffix}" @classmethod def exe_stem(cls): - """executable name without suffix - there seems to be no standard way to get this without creating it""" + """Executable name without suffix - there seems to be no standard way to get this without creating it.""" raise NotImplementedError def script(self, name): - return self.script_dir / "{}{}".format(name, self.suffix) + return self.script_dir / f"{name}{self.suffix}" -@add_metaclass(ABCMeta) -class Python2Supports(Describe): +class Python3Supports(Describe, ABC): @classmethod def can_describe(cls, interpreter): - return interpreter.version_info.major == 2 and super(Python2Supports, cls).can_describe(interpreter) + return interpreter.version_info.major == 3 and super().can_describe(interpreter) # noqa: PLR2004 -@add_metaclass(ABCMeta) -class Python3Supports(Describe): +class PosixSupports(Describe, ABC): @classmethod def can_describe(cls, interpreter): - return interpreter.version_info.major == 3 and super(Python3Supports, cls).can_describe(interpreter) + return interpreter.os == "posix" and super().can_describe(interpreter) -@add_metaclass(ABCMeta) -class PosixSupports(Describe): +class WindowsSupports(Describe, ABC): @classmethod def can_describe(cls, interpreter): - return interpreter.os == "posix" and super(PosixSupports, cls).can_describe(interpreter) + return interpreter.os == "nt" and super().can_describe(interpreter) -@add_metaclass(ABCMeta) -class WindowsSupports(Describe): - @classmethod - def can_describe(cls, interpreter): - return interpreter.os == "nt" and super(WindowsSupports, cls).can_describe(interpreter) +__all__ = [ + "Describe", + "PosixSupports", + "Python3Supports", + "WindowsSupports", +] diff --git a/src/virtualenv/create/pyenv_cfg.py b/src/virtualenv/create/pyenv_cfg.py index 1a8d82440..1d1beacff 100644 --- a/src/virtualenv/create/pyenv_cfg.py +++ b/src/virtualenv/create/pyenv_cfg.py @@ -1,13 +1,14 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging +import os from collections import OrderedDict -from virtualenv.util.six import ensure_text +LOGGER = logging.getLogger(__name__) -class PyEnvCfg(object): - def __init__(self, content, path): +class PyEnvCfg: + def __init__(self, content, path) -> None: self.content = content self.path = path @@ -31,11 +32,12 @@ def _read_values(path): return content def write(self): - logging.debug("write %s", ensure_text(str(self.path))) + LOGGER.debug("write %s", self.path) text = "" for key, value in self.content.items(): - line = "{} = {}".format(key, value) - logging.debug("\t%s", line) + normalized_value = os.path.realpath(value) if value and os.path.exists(value) else value + line = f"{key} = {normalized_value}" + LOGGER.debug("\t%s", line) text += line text += "\n" self.path.write_text(text, encoding="utf-8") @@ -44,18 +46,23 @@ def refresh(self): self.content = self._read_values(self.path) return self.content - def __setitem__(self, key, value): + def __setitem__(self, key, value) -> None: self.content[key] = value def __getitem__(self, key): return self.content[key] - def __contains__(self, item): + def __contains__(self, item) -> bool: return item in self.content def update(self, other): self.content.update(other) return self - def __repr__(self): - return "{}(path={})".format(self.__class__.__name__, self.path) + def __repr__(self) -> str: + return f"{self.__class__.__name__}(path={self.path})" + + +__all__ = [ + "PyEnvCfg", +] diff --git a/src/virtualenv/create/via_global_ref/_virtualenv.py b/src/virtualenv/create/via_global_ref/_virtualenv.py index 6c51ca0d9..b61db3079 100644 --- a/src/virtualenv/create/via_global_ref/_virtualenv.py +++ b/src/virtualenv/create/via_global_ref/_virtualenv.py @@ -1,5 +1,6 @@ -"""Patches that are applied at runtime to the virtual environment""" -# -*- coding: utf-8 -*- +"""Patches that are applied at runtime to the virtual environment.""" + +from __future__ import annotations import os import sys @@ -10,10 +11,10 @@ def patch_dist(dist): """ Distutils allows user to configure some arguments via a configuration file: - https://docs.python.org/3/install/index.html#distutils-configuration-files + https://docs.python.org/3/install/index.html#distutils-configuration-files. Some of this arguments though don't make sense in context of the virtual environment files, let's fix them up. - """ + """ # noqa: D205 # we cannot allow some install config as that would get packages installed outside of the virtual environment old_parse_config_files = dist.Distribution.parse_config_files @@ -24,7 +25,7 @@ def parse_config_files(self, *args, **kwargs): if "prefix" in install: # the prefix governs where to install the libraries install["prefix"] = VIRTUALENV_PATCH_FILE, os.path.abspath(sys.prefix) for base in ("purelib", "platlib", "headers", "scripts", "data"): - key = "install_{}".format(base) + key = f"install_{base}" if key in install: # do not allow global configs to hijack venv paths install.pop(key, None) return result @@ -35,96 +36,68 @@ def parse_config_files(self, *args, **kwargs): # Import hook that patches some modules to ignore configuration values that break package installation in case # of virtual environments. _DISTUTILS_PATCH = "distutils.dist", "setuptools.dist" -if sys.version_info > (3, 4): - # https://docs.python.org/3/library/importlib.html#setting-up-an-importer - - class _Finder: - """A meta path finder that allows patching the imported distutils modules""" - - fullname = None - - # lock[0] is threading.Lock(), but initialized lazily to avoid importing threading very early at startup, - # because there are gevent-based applications that need to be first to import threading by themselves. - # See https://github.com/pypa/virtualenv/issues/1895 for details. - lock = [] - - def find_spec(self, fullname, path, target=None): - if fullname in _DISTUTILS_PATCH and self.fullname is None: - # initialize lock[0] lazily - if len(self.lock) == 0: - import threading - - lock = threading.Lock() - # there is possibility that two threads T1 and T2 are simultaneously running into find_spec, - # observing .lock as empty, and further going into hereby initialization. However due to the GIL, - # list.append() operation is atomic and this way only one of the threads will "win" to put the lock - # - that every thread will use - into .lock[0]. - # https://docs.python.org/3/faq/library.html#what-kinds-of-global-value-mutation-are-thread-safe - self.lock.append(lock) - - from functools import partial - from importlib.util import find_spec - - with self.lock[0]: - self.fullname = fullname - try: - spec = find_spec(fullname, path) - if spec is not None: - # https://www.python.org/dev/peps/pep-0451/#how-loading-will-work - is_new_api = hasattr(spec.loader, "exec_module") - func_name = "exec_module" if is_new_api else "load_module" - old = getattr(spec.loader, func_name) - func = self.exec_module if is_new_api else self.load_module - if old is not func: - try: - setattr(spec.loader, func_name, partial(func, old)) - except AttributeError: - pass # C-Extension loaders are r/o such as zipimporter with None: + super().__init__() self.copy_error = None self.symlink_error = None if not fs_supports_symlink(): @@ -30,10 +28,9 @@ def can_symlink(self): return not self.symlink_error -@add_metaclass(ABCMeta) -class ViaGlobalRefApi(Creator): - def __init__(self, options, interpreter): - super(ViaGlobalRefApi, self).__init__(options, interpreter) +class ViaGlobalRefApi(Creator, ABC): + def __init__(self, options, interpreter) -> None: + super().__init__(options, interpreter) self.symlinks = self._should_symlink(options) self.enable_system_site_package = options.system_site @@ -56,7 +53,7 @@ def _should_symlink(options): @classmethod def add_parser_arguments(cls, parser, interpreter, meta, app_data): - super(ViaGlobalRefApi, cls).add_parser_arguments(parser, interpreter, meta, app_data) + super().add_parser_arguments(parser, interpreter, meta, app_data) parser.add_argument( "--system-site-packages", default=False, @@ -64,9 +61,10 @@ def add_parser_arguments(cls, parser, interpreter, meta, app_data): dest="system_site", help="give the virtual environment access to the system site-packages dir", ) - group = parser.add_mutually_exclusive_group() if not meta.can_symlink and not meta.can_copy: - raise RuntimeError("neither symlink or copy method supported") + msg = "neither symlink or copy method supported" + raise RuntimeError(msg) + group = parser.add_mutually_exclusive_group() if meta.can_symlink: group.add_argument( "--symlinks", @@ -92,21 +90,27 @@ def install_patch(self): text = self.env_patch_text() if text: pth = self.purelib / "_virtualenv.pth" - logging.debug("create virtualenv import hook file %s", ensure_text(str(pth))) - pth.write_text("import _virtualenv") + LOGGER.debug("create virtualenv import hook file %s", pth) + pth.write_text("import _virtualenv", encoding="utf-8") dest_path = self.purelib / "_virtualenv.py" - logging.debug("create %s", ensure_text(str(dest_path))) - dest_path.write_text(text) + LOGGER.debug("create %s", dest_path) + dest_path.write_text(text, encoding="utf-8") def env_patch_text(self): - """Patch the distutils package to not be derailed by its configuration files""" + """Patch the distutils package to not be derailed by its configuration files.""" with self.app_data.ensure_extracted(Path(__file__).parent / "_virtualenv.py") as resolved_path: - text = resolved_path.read_text() + text = resolved_path.read_text(encoding="utf-8") return text.replace('"__SCRIPT_DIR__"', repr(os.path.relpath(str(self.script_dir), str(self.purelib)))) def _args(self): - return super(ViaGlobalRefApi, self)._args() + [("global", self.enable_system_site_package)] + return [*super()._args(), ("global", self.enable_system_site_package)] def set_pyenv_cfg(self): - super(ViaGlobalRefApi, self).set_pyenv_cfg() + super().set_pyenv_cfg() self.pyenv_cfg["include-system-site-packages"] = "true" if self.enable_system_site_package else "false" + + +__all__ = [ + "ViaGlobalRefApi", + "ViaGlobalRefMeta", +] diff --git a/src/virtualenv/create/via_global_ref/builtin/builtin_way.py b/src/virtualenv/create/via_global_ref/builtin/builtin_way.py index 279ee8095..791b1d93d 100644 --- a/src/virtualenv/create/via_global_ref/builtin/builtin_way.py +++ b/src/virtualenv/create/via_global_ref/builtin/builtin_way.py @@ -1,17 +1,19 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from abc import ABCMeta - -from six import add_metaclass +from abc import ABC from virtualenv.create.creator import Creator from virtualenv.create.describe import Describe -@add_metaclass(ABCMeta) -class VirtualenvBuiltin(Creator, Describe): - """A creator that does operations itself without delegation, if we can create it we can also describe it""" +class VirtualenvBuiltin(Creator, Describe, ABC): + """A creator that does operations itself without delegation, if we can create it we can also describe it.""" - def __init__(self, options, interpreter): + def __init__(self, options, interpreter) -> None: Creator.__init__(self, options, interpreter) Describe.__init__(self, self.dest, interpreter) + + +__all__ = [ + "VirtualenvBuiltin", +] diff --git a/src/virtualenv/create/via_global_ref/builtin/cpython/__init__.py b/src/virtualenv/create/via_global_ref/builtin/cpython/__init__.py index 01e6d4f49..e69de29bb 100644 --- a/src/virtualenv/create/via_global_ref/builtin/cpython/__init__.py +++ b/src/virtualenv/create/via_global_ref/builtin/cpython/__init__.py @@ -1 +0,0 @@ -from __future__ import absolute_import, unicode_literals diff --git a/src/virtualenv/create/via_global_ref/builtin/cpython/common.py b/src/virtualenv/create/via_global_ref/builtin/cpython/common.py index c93f9f31e..7c2a04a32 100644 --- a/src/virtualenv/create/via_global_ref/builtin/cpython/common.py +++ b/src/virtualenv/create/via_global_ref/builtin/cpython/common.py @@ -1,45 +1,37 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from abc import ABCMeta +import re +from abc import ABC from collections import OrderedDict - -from six import add_metaclass +from pathlib import Path from virtualenv.create.describe import PosixSupports, WindowsSupports from virtualenv.create.via_global_ref.builtin.ref import RefMust, RefWhen -from virtualenv.util.path import Path - -from ..via_global_self_do import ViaGlobalRefVirtualenvBuiltin +from virtualenv.create.via_global_ref.builtin.via_global_self_do import ViaGlobalRefVirtualenvBuiltin -@add_metaclass(ABCMeta) -class CPython(ViaGlobalRefVirtualenvBuiltin): +class CPython(ViaGlobalRefVirtualenvBuiltin, ABC): @classmethod def can_describe(cls, interpreter): - return interpreter.implementation == "CPython" and super(CPython, cls).can_describe(interpreter) + return interpreter.implementation == "CPython" and super().can_describe(interpreter) @classmethod def exe_stem(cls): return "python" -@add_metaclass(ABCMeta) -class CPythonPosix(CPython, PosixSupports): - """Create a CPython virtual environment on POSIX platforms""" +class CPythonPosix(CPython, PosixSupports, ABC): + """Create a CPython virtual environment on POSIX platforms.""" @classmethod def _executables(cls, interpreter): host_exe = Path(interpreter.system_executable) major, minor = interpreter.version_info.major, interpreter.version_info.minor - targets = OrderedDict( - (i, None) for i in ["python", "python{}".format(major), "python{}.{}".format(major, minor), host_exe.name] - ) - must = RefMust.COPY if interpreter.version_info.major == 2 else RefMust.NA - yield host_exe, list(targets.keys()), must, RefWhen.ANY + targets = OrderedDict((i, None) for i in ["python", f"python{major}", f"python{major}.{minor}", host_exe.name]) + yield host_exe, list(targets.keys()), RefMust.NA, RefWhen.ANY -@add_metaclass(ABCMeta) -class CPythonWindows(CPython, WindowsSupports): +class CPythonWindows(CPython, WindowsSupports, ABC): @classmethod def _executables(cls, interpreter): # symlink of the python executables does not work reliably, copy always instead @@ -59,7 +51,23 @@ def host_python(cls, interpreter): def is_mac_os_framework(interpreter): if interpreter.platform == "darwin": - framework_var = interpreter.sysconfig_vars.get("PYTHONFRAMEWORK") - value = "Python3" if interpreter.version_info.major == 3 else "Python" - return framework_var == value + return interpreter.sysconfig_vars.get("PYTHONFRAMEWORK") == "Python3" return False + + +def is_macos_brew(interpreter): + return interpreter.platform == "darwin" and _BREW.fullmatch(interpreter.system_prefix) is not None + + +_BREW = re.compile( + r"/(usr/local|opt/homebrew)/(opt/python@3\.\d{1,2}|Cellar/python@3\.\d{1,2}/3\.\d{1,2}\.\d{1,2})/Frameworks/" + r"Python\.framework/Versions/3\.\d{1,2}", +) + +__all__ = [ + "CPython", + "CPythonPosix", + "CPythonWindows", + "is_mac_os_framework", + "is_macos_brew", +] diff --git a/src/virtualenv/create/via_global_ref/builtin/cpython/cpython2.py b/src/virtualenv/create/via_global_ref/builtin/cpython/cpython2.py deleted file mode 100644 index dc822bcb9..000000000 --- a/src/virtualenv/create/via_global_ref/builtin/cpython/cpython2.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import abc -import logging - -from six import add_metaclass - -from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest -from virtualenv.util.path import Path - -from ..python2.python2 import Python2 -from .common import CPython, CPythonPosix, CPythonWindows, is_mac_os_framework - - -@add_metaclass(abc.ABCMeta) -class CPython2(CPython, Python2): - """Create a CPython version 2 virtual environment""" - - @classmethod - def sources(cls, interpreter): - for src in super(CPython2, cls).sources(interpreter): - yield src - # include folder needed on Python 2 as we don't have pyenv.cfg - host_include_marker = cls.host_include_marker(interpreter) - if host_include_marker.exists(): - yield PathRefToDest(host_include_marker.parent, dest=lambda self, _: self.include) - - @classmethod - def needs_stdlib_py_module(cls): - return False - - @classmethod - def host_include_marker(cls, interpreter): - return Path(interpreter.system_include) / "Python.h" - - @property - def include(self): - # the pattern include the distribution name too at the end, remove that via the parent call - return (self.dest / self.interpreter.install_path("headers")).parent - - @classmethod - def modules(cls): - return [ - "os", # landmark to set sys.prefix - ] - - def ensure_directories(self): - dirs = super(CPython2, self).ensure_directories() - host_include_marker = self.host_include_marker(self.interpreter) - if host_include_marker.exists(): - dirs.add(self.include.parent) - else: - logging.debug("no include folders as can't find include marker %s", host_include_marker) - return dirs - - -@add_metaclass(abc.ABCMeta) -class CPython2PosixBase(CPython2, CPythonPosix): - """common to macOs framework builds and other posix CPython2""" - - @classmethod - def sources(cls, interpreter): - for src in super(CPython2PosixBase, cls).sources(interpreter): - yield src - - # check if the makefile exists and if so make it available under the virtual environment - make_file = Path(interpreter.sysconfig["makefile_filename"]) - if make_file.exists() and str(make_file).startswith(interpreter.prefix): - under_prefix = make_file.relative_to(Path(interpreter.prefix)) - yield PathRefToDest(make_file, dest=lambda self, s: self.dest / under_prefix) - - -class CPython2Posix(CPython2PosixBase): - """CPython 2 on POSIX (excluding macOs framework builds)""" - - @classmethod - def can_describe(cls, interpreter): - return is_mac_os_framework(interpreter) is False and super(CPython2Posix, cls).can_describe(interpreter) - - @classmethod - def sources(cls, interpreter): - for src in super(CPython2Posix, cls).sources(interpreter): - yield src - # landmark for exec_prefix - exec_marker_file, to_path, _ = cls.from_stdlib(cls.mappings(interpreter), "lib-dynload") - yield PathRefToDest(exec_marker_file, dest=to_path) - - -class CPython2Windows(CPython2, CPythonWindows): - """CPython 2 on Windows""" - - @classmethod - def sources(cls, interpreter): - for src in super(CPython2Windows, cls).sources(interpreter): - yield src - py27_dll = Path(interpreter.system_executable).parent / "python27.dll" - if py27_dll.exists(): # this might be global in the Windows folder in which case it's alright to be missing - yield PathRefToDest(py27_dll, dest=cls.to_bin) - - libs = Path(interpreter.system_prefix) / "libs" - if libs.exists(): - yield PathRefToDest(libs, dest=lambda self, s: self.dest / s.name) diff --git a/src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py b/src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py index fcd92b82f..daa474103 100644 --- a/src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py +++ b/src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py @@ -1,30 +1,34 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import abc +import fnmatch +from itertools import chain +from operator import methodcaller as method +from pathlib import Path from textwrap import dedent -from six import add_metaclass - from virtualenv.create.describe import Python3Supports from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest from virtualenv.create.via_global_ref.store import is_store_python -from virtualenv.util.path import Path -from .common import CPython, CPythonPosix, CPythonWindows, is_mac_os_framework +from .common import CPython, CPythonPosix, CPythonWindows, is_mac_os_framework, is_macos_brew -@add_metaclass(abc.ABCMeta) -class CPython3(CPython, Python3Supports): - """ """ +class CPython3(CPython, Python3Supports, abc.ABC): + """CPython 3 or later.""" class CPython3Posix(CPythonPosix, CPython3): @classmethod def can_describe(cls, interpreter): - return is_mac_os_framework(interpreter) is False and super(CPython3Posix, cls).can_describe(interpreter) + return ( + is_mac_os_framework(interpreter) is False + and is_macos_brew(interpreter) is False + and super().can_describe(interpreter) + ) def env_patch_text(self): - text = super(CPython3Posix, self).env_patch_text() + text = super().env_patch_text() if self.pyvenv_launch_patch_active(self.interpreter): text += dedent( """ @@ -43,25 +47,33 @@ def pyvenv_launch_patch_active(cls, interpreter): class CPython3Windows(CPythonWindows, CPython3): - """ """ + """CPython 3 on Windows.""" @classmethod def setup_meta(cls, interpreter): if is_store_python(interpreter): # store python is not supported here return None - return super(CPython3Windows, cls).setup_meta(interpreter) + return super().setup_meta(interpreter) @classmethod def sources(cls, interpreter): - for src in super(CPython3Windows, cls).sources(interpreter): - yield src - if not cls.has_shim(interpreter): - for src in cls.include_dll_and_pyd(interpreter): - yield src + if cls.has_shim(interpreter): + refs = cls.executables(interpreter) + else: + refs = chain( + cls.executables(interpreter), + cls.dll_and_pyd(interpreter), + cls.python_zip(interpreter), + ) + yield from refs + + @classmethod + def executables(cls, interpreter): + return super().sources(interpreter) @classmethod def has_shim(cls, interpreter): - return interpreter.version_info.minor >= 7 and cls.shim(interpreter) is not None + return interpreter.version_info.minor >= 7 and cls.shim(interpreter) is not None # noqa: PLR2004 @classmethod def shim(cls, interpreter): @@ -76,16 +88,48 @@ def host_python(cls, interpreter): # starting with CPython 3.7 Windows ships with a venvlauncher.exe that avoids the need for dll/pyd copies # it also means the wrapper must be copied to avoid bugs such as https://bugs.python.org/issue42013 return cls.shim(interpreter) - return super(CPython3Windows, cls).host_python(interpreter) + return super().host_python(interpreter) @classmethod - def include_dll_and_pyd(cls, interpreter): + def dll_and_pyd(cls, interpreter): + folders = [Path(interpreter.system_executable).parent] + + # May be missing on some Python hosts. + # See https://github.com/pypa/virtualenv/issues/2368 dll_folder = Path(interpreter.system_prefix) / "DLLs" - host_exe_folder = Path(interpreter.system_executable).parent - for folder in [host_exe_folder, dll_folder]: + if dll_folder.is_dir(): + folders.append(dll_folder) + + for folder in folders: for file in folder.iterdir(): - if file.suffix in (".pyd", ".dll"): - yield PathRefToDest(file, dest=cls.to_dll_and_pyd) + if file.suffix in {".pyd", ".dll"}: + yield PathRefToDest(file, cls.to_bin) - def to_dll_and_pyd(self, src): - return self.bin_dir / src.name + @classmethod + def python_zip(cls, interpreter): + """ + "python{VERSION}.zip" contains compiled *.pyc std lib packages, where + "VERSION" is `py_version_nodot` var from the `sysconfig` module. + :see: https://docs.python.org/3/using/windows.html#the-embeddable-package + :see: `discovery.py_info.PythonInfo` class (interpreter). + :see: `python -m sysconfig` output. + + :note: The embeddable Python distribution for Windows includes + "python{VERSION}.zip" and "python{VERSION}._pth" files. User can + move/rename *zip* file and edit `sys.path` by editing *_pth* file. + Here the `pattern` is used only for the default *zip* file name! + """ # noqa: D205 + pattern = f"*python{interpreter.version_nodot}.zip" + matches = fnmatch.filter(interpreter.path, pattern) + matched_paths = map(Path, matches) + existing_paths = filter(method("exists"), matched_paths) + path = next(existing_paths, None) + if path is not None: + yield PathRefToDest(path, cls.to_bin) + + +__all__ = [ + "CPython3", + "CPython3Posix", + "CPython3Windows", +] diff --git a/src/virtualenv/create/via_global_ref/builtin/cpython/mac_os.py b/src/virtualenv/create/via_global_ref/builtin/cpython/mac_os.py index d64f0d99b..0ddbf9a33 100644 --- a/src/virtualenv/create/via_global_ref/builtin/cpython/mac_os.py +++ b/src/virtualenv/create/via_global_ref/builtin/cpython/mac_os.py @@ -1,48 +1,50 @@ -# -*- coding: utf-8 -*- -"""The Apple Framework builds require their own customization""" +"""The Apple Framework builds require their own customization.""" + +from __future__ import annotations + import logging import os import struct import subprocess -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod +from pathlib import Path from textwrap import dedent -from six import add_metaclass, text_type - -from virtualenv.create.via_global_ref.builtin.ref import ExePathRefToDest, PathRefToDest, RefMust -from virtualenv.info import IS_MAC_ARM64 -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_text +from virtualenv.create.via_global_ref.builtin.ref import ( + ExePathRefToDest, + PathRefToDest, + RefMust, +) +from virtualenv.create.via_global_ref.builtin.via_global_self_do import BuiltinViaGlobalRefMeta -from .common import CPython, CPythonPosix, is_mac_os_framework -from .cpython2 import CPython2PosixBase +from .common import CPython, CPythonPosix, is_mac_os_framework, is_macos_brew from .cpython3 import CPython3 +LOGGER = logging.getLogger(__name__) -@add_metaclass(ABCMeta) -class CPythonmacOsFramework(CPython): + +class CPythonmacOsFramework(CPython, ABC): @classmethod def can_describe(cls, interpreter): - return is_mac_os_framework(interpreter) and super(CPythonmacOsFramework, cls).can_describe(interpreter) + return is_mac_os_framework(interpreter) and super().can_describe(interpreter) def create(self): - super(CPythonmacOsFramework, self).create() + super().create() # change the install_name of the copied python executables target = self.desired_mach_o_image_path() current = self.current_mach_o_image_path() for src in self._sources: - if isinstance(src, ExePathRefToDest): - if src.must == RefMust.COPY or not self.symlinks: - exes = [self.bin_dir / src.base] - if not self.symlinks: - exes.extend(self.bin_dir / a for a in src.aliases) - for exe in exes: - fix_mach_o(str(exe), current, target, self.interpreter.max_size) + if isinstance(src, ExePathRefToDest) and (src.must == RefMust.COPY or not self.symlinks): + exes = [self.bin_dir / src.base] + if not self.symlinks: + exes.extend(self.bin_dir / a for a in src.aliases) + for exe in exes: + fix_mach_o(str(exe), current, target, self.interpreter.max_size) @classmethod def _executables(cls, interpreter): - for _, targets, must, when in super(CPythonmacOsFramework, cls)._executables(interpreter): + for _, targets, must, when in super()._executables(interpreter): # Make sure we use the embedded interpreter inside the framework, even if sys.executable points to the # stub executable in ${sys.prefix}/bin. # See http://groups.google.com/group/python-virtualenv/browse_thread/thread/17cab2f85da75951 @@ -58,92 +60,6 @@ def desired_mach_o_image_path(self): raise NotImplementedError -class CPython2macOsFramework(CPythonmacOsFramework, CPython2PosixBase): - @classmethod - def can_create(cls, interpreter): - if not IS_MAC_ARM64 and super(CPython2macOsFramework, cls).can_describe(interpreter): - return super(CPython2macOsFramework, cls).can_create(interpreter) - return False - - def current_mach_o_image_path(self): - return os.path.join(self.interpreter.prefix, "Python") - - def desired_mach_o_image_path(self): - return "@executable_path/../Python" - - @classmethod - def sources(cls, interpreter): - for src in super(CPython2macOsFramework, cls).sources(interpreter): - yield src - # landmark for exec_prefix - exec_marker_file, to_path, _ = cls.from_stdlib(cls.mappings(interpreter), "lib-dynload") - yield PathRefToDest(exec_marker_file, dest=to_path) - - # add a copy of the host python image - exe = Path(interpreter.prefix) / "Python" - yield PathRefToDest(exe, dest=lambda self, _: self.dest / "Python", must=RefMust.COPY) - - # add a symlink to the Resources dir - resources = Path(interpreter.prefix) / "Resources" - yield PathRefToDest(resources, dest=lambda self, _: self.dest / "Resources") - - @property - def reload_code(self): - result = super(CPython2macOsFramework, self).reload_code - result = dedent( - """ - # the bundled site.py always adds the global site package if we're on python framework build, escape this - import sysconfig - config = sysconfig.get_config_vars() - before = config["PYTHONFRAMEWORK"] - try: - config["PYTHONFRAMEWORK"] = "" - {} - finally: - config["PYTHONFRAMEWORK"] = before - """.format( - result, - ), - ) - return result - - -class CPython2macOsArmFramework(CPython2macOsFramework, CPythonmacOsFramework, CPython2PosixBase): - @classmethod - def can_create(cls, interpreter): - if IS_MAC_ARM64 and super(CPythonmacOsFramework, cls).can_describe(interpreter): - return super(CPythonmacOsFramework, cls).can_create(interpreter) - return False - - def create(self): - super(CPython2macOsFramework, self).create() - self.fix_signature() - - def fix_signature(self): - """ - On Apple M1 machines (arm64 chips), rewriting the python executable invalidates its signature. - In python2 this results in a unusable python exe which just dies. - As a temporary workaround we can codesign the python exe during the creation process. - """ - exe = self.exe - try: - logging.debug("Changing signature of copied python exe %s", exe) - bak_dir = exe.parent / "bk" - # Reset the signing on Darwin since the exe has been modified. - # Note codesign fails on the original exe, it needs to be copied and moved back. - bak_dir.mkdir(parents=True, exist_ok=True) - subprocess.check_call(["cp", text_type(exe), text_type(bak_dir)]) - subprocess.check_call(["mv", text_type(bak_dir / exe.name), text_type(exe)]) - bak_dir.rmdir() - metadata = "--preserve-metadata=identifier,entitlements,flags,runtime" - cmd = ["codesign", "-s", "-", metadata, "-f", text_type(exe)] - logging.debug("Changing Signature: %s", cmd) - subprocess.check_call(cmd) - except Exception: - logging.fatal("Could not change MacOS code signing on copied python exe at %s", exe) - raise - - class CPython3macOsFramework(CPythonmacOsFramework, CPython3, CPythonPosix): def current_mach_o_image_path(self): return "@executable_path/../../../../Python3" @@ -153,8 +69,7 @@ def desired_mach_o_image_path(self): @classmethod def sources(cls, interpreter): - for src in super(CPython3macOsFramework, cls).sources(interpreter): - yield src + yield from super().sources(interpreter) # add a symlink to the host python image exe = Path(interpreter.prefix) / "Python3" @@ -162,27 +77,24 @@ def sources(cls, interpreter): @property def reload_code(self): - result = super(CPython3macOsFramework, self).reload_code - result = dedent( - """ + result = super().reload_code + return dedent( + f""" # the bundled site.py always adds the global site package if we're on python framework build, escape this import sys before = sys._framework try: sys._framework = None - {} + {result} finally: sys._framework = before - """.format( - result, - ), + """, ) - return result def fix_mach_o(exe, current, new, max_size): """ - https://en.wikipedia.org/wiki/Mach-O + https://en.wikipedia.org/wiki/Mach-O. Mach-O, short for Mach object file format, is a file format for executables, object code, shared libraries, dynamically-loaded code, and core dumps. A replacement for the a.out format, Mach-O offers more extensibility and @@ -205,50 +117,50 @@ def fix_mach_o(exe, current, new, max_size): unneeded bits of information, however Mac OS X 10.5 and earlier cannot read this new Link Edit table format. """ try: - logging.debug("change Mach-O for %s from %s to %s", ensure_text(exe), current, ensure_text(new)) + LOGGER.debug("change Mach-O for %s from %s to %s", exe, current, new) _builtin_change_mach_o(max_size)(exe, current, new) - except Exception as e: - logging.warning("Could not call _builtin_change_mac_o: %s. " "Trying to call install_name_tool instead.", e) + except Exception as e: # noqa: BLE001 + LOGGER.warning("Could not call _builtin_change_mac_o: %s. Trying to call install_name_tool instead.", e) try: cmd = ["install_name_tool", "-change", current, new, exe] subprocess.check_call(cmd) except Exception: - logging.fatal("Could not call install_name_tool -- you must " "have Apple's development tools installed") + logging.fatal("Could not call install_name_tool -- you must have Apple's development tools installed") raise -def _builtin_change_mach_o(maxint): - MH_MAGIC = 0xFEEDFACE - MH_CIGAM = 0xCEFAEDFE - MH_MAGIC_64 = 0xFEEDFACF - MH_CIGAM_64 = 0xCFFAEDFE - FAT_MAGIC = 0xCAFEBABE - BIG_ENDIAN = ">" - LITTLE_ENDIAN = "<" - LC_LOAD_DYLIB = 0xC +def _builtin_change_mach_o(maxint): # noqa: C901 + MH_MAGIC = 0xFEEDFACE # noqa: N806 + MH_CIGAM = 0xCEFAEDFE # noqa: N806 + MH_MAGIC_64 = 0xFEEDFACF # noqa: N806 + MH_CIGAM_64 = 0xCFFAEDFE # noqa: N806 + FAT_MAGIC = 0xCAFEBABE # noqa: N806 + BIG_ENDIAN = ">" # noqa: N806 + LITTLE_ENDIAN = "<" # noqa: N806 + LC_LOAD_DYLIB = 0xC # noqa: N806 - class FileView(object): + class FileView: """A proxy for file-like objects that exposes a given view of a file. Modified from macholib.""" - def __init__(self, file_obj, start=0, size=maxint): + def __init__(self, file_obj, start=0, size=maxint) -> None: if isinstance(file_obj, FileView): - self._file_obj = file_obj._file_obj + self._file_obj = file_obj._file_obj # noqa: SLF001 else: self._file_obj = file_obj self._start = start self._end = start + size self._pos = 0 - def __repr__(self): - return "".format(self._start, self._end, self._file_obj) + def __repr__(self) -> str: + return f"" def tell(self): return self._pos def _checkwindow(self, seek_to, op): if not (self._start <= seek_to <= self._end): - msg = "{} to offset {:d} is outside window [{:d}, {:d}]".format(op, seek_to, self._start, self._end) - raise IOError(msg) + msg = f"{op} to offset {seek_to:d} is outside window [{self._start:d}, {self._end:d}]" + raise OSError(msg) def seek(self, offset, whence=0): seek_to = offset @@ -259,7 +171,8 @@ def seek(self, offset, whence=0): elif whence == os.SEEK_END: seek_to += self._end else: - raise IOError("Invalid whence argument to seek: {!r}".format(whence)) + msg = f"Invalid whence argument to seek: {whence!r}" + raise OSError(msg) self._checkwindow(seek_to, "seek") self._file_obj.seek(seek_to) self._pos = seek_to - self._start @@ -273,7 +186,7 @@ def write(self, content): self._pos += len(content) def read(self, size=maxint): - assert size >= 0 + assert size >= 0 # noqa: S101 here = self._start + self._pos self._checkwindow(here, "read") size = min(size, self._end - here) @@ -289,15 +202,17 @@ def read_data(file, endian, num=1): return res[0] return res - def mach_o_change(at_path, what, value): - """Replace a given name (what) in any LC_LOAD_DYLIB command found in the given binary with a new name (value), - provided it's shorter.""" + def mach_o_change(at_path, what, value): # noqa: C901 + """ + Replace a given name (what) in any LC_LOAD_DYLIB command found in the given binary with a new name (value), + provided it's shorter. + """ # noqa: D205 def do_macho(file, bits, endian): # Read Mach-O header (the magic number is assumed read by the caller) - cpu_type, cpu_sub_type, file_type, n_commands, size_of_commands, flags = read_data(file, endian, 6) + _cpu_type, _cpu_sub_type, _file_type, n_commands, _size_of_commands, _flags = read_data(file, endian, 6) # 64-bits header has one more field. - if bits == 64: + if bits == 64: # noqa: PLR2004 read_data(file, endian) # The header is followed by n commands for _ in range(n_commands): @@ -328,7 +243,7 @@ def do_file(file, offset=0, size=maxint): n_fat_arch = read_data(file, BIG_ENDIAN) for _ in range(n_fat_arch): # Read arch header - cpu_type, cpu_sub_type, offset, size, align = read_data(file, BIG_ENDIAN, 5) + _cpu_type, _cpu_sub_type, offset, size, _align = read_data(file, BIG_ENDIAN, 5) do_file(file, offset, size) elif magic == MH_MAGIC: do_macho(file, 32, BIG_ENDIAN) @@ -339,9 +254,28 @@ def do_file(file, offset=0, size=maxint): elif magic == MH_CIGAM_64: do_macho(file, 64, LITTLE_ENDIAN) - assert len(what) >= len(value) + assert len(what) >= len(value) # noqa: S101 with open(at_path, "r+b") as f: do_file(f) return mach_o_change + + +class CPython3macOsBrew(CPython3, CPythonPosix): + @classmethod + def can_describe(cls, interpreter): + return is_macos_brew(interpreter) and super().can_describe(interpreter) + + @classmethod + def setup_meta(cls, interpreter): # noqa: ARG003 + meta = BuiltinViaGlobalRefMeta() + meta.copy_error = "Brew disables copy creation: https://github.com/Homebrew/homebrew-core/issues/138159" + return meta + + +__all__ = [ + "CPython3macOsBrew", + "CPython3macOsFramework", + "CPythonmacOsFramework", +] diff --git a/src/virtualenv/create/via_global_ref/builtin/pypy/common.py b/src/virtualenv/create/via_global_ref/builtin/pypy/common.py index b0cd401f9..ca4b45ff1 100644 --- a/src/virtualenv/create/via_global_ref/builtin/pypy/common.py +++ b/src/virtualenv/create/via_global_ref/builtin/pypy/common.py @@ -1,41 +1,39 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import abc - -from six import add_metaclass +from pathlib import Path from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest, RefMust, RefWhen -from virtualenv.util.path import Path - -from ..via_global_self_do import ViaGlobalRefVirtualenvBuiltin +from virtualenv.create.via_global_ref.builtin.via_global_self_do import ViaGlobalRefVirtualenvBuiltin -@add_metaclass(abc.ABCMeta) -class PyPy(ViaGlobalRefVirtualenvBuiltin): +class PyPy(ViaGlobalRefVirtualenvBuiltin, abc.ABC): @classmethod def can_describe(cls, interpreter): - return interpreter.implementation == "PyPy" and super(PyPy, cls).can_describe(interpreter) + return interpreter.implementation == "PyPy" and super().can_describe(interpreter) @classmethod def _executables(cls, interpreter): host = Path(interpreter.system_executable) - targets = sorted("{}{}".format(name, PyPy.suffix) for name in cls.exe_names(interpreter)) - must = RefMust.COPY if interpreter.version_info.major == 2 else RefMust.NA - yield host, targets, must, RefWhen.ANY + targets = sorted(f"{name}{PyPy.suffix}" for name in cls.exe_names(interpreter)) + yield host, targets, RefMust.NA, RefWhen.ANY + + @classmethod + def executables(cls, interpreter): + yield from super().sources(interpreter) @classmethod def exe_names(cls, interpreter): return { cls.exe_stem(), "python", - "python{}".format(interpreter.version_info.major), - "python{}.{}".format(*interpreter.version_info), + f"python{interpreter.version_info.major}", + f"python{interpreter.version_info.major}.{interpreter.version_info.minor}", } @classmethod def sources(cls, interpreter): - for src in super(PyPy, cls).sources(interpreter): - yield src + yield from cls.executables(interpreter) for host in cls._add_shared_libs(interpreter): yield PathRefToDest(host, dest=lambda self, s: self.bin_dir / s.name) @@ -43,9 +41,13 @@ def sources(cls, interpreter): def _add_shared_libs(cls, interpreter): # https://bitbucket.org/pypy/pypy/issue/1922/future-proofing-virtualenv python_dir = Path(interpreter.system_executable).resolve().parent - for src in cls._shared_libs(python_dir): - yield src + yield from cls._shared_libs(python_dir) @classmethod def _shared_libs(cls, python_dir): raise NotImplementedError + + +__all__ = [ + "PyPy", +] diff --git a/src/virtualenv/create/via_global_ref/builtin/pypy/pypy2.py b/src/virtualenv/create/via_global_ref/builtin/pypy/pypy2.py deleted file mode 100644 index 78349d44e..000000000 --- a/src/virtualenv/create/via_global_ref/builtin/pypy/pypy2.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import abc -import logging -import os - -from six import add_metaclass - -from virtualenv.create.describe import PosixSupports, WindowsSupports -from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest -from virtualenv.util.path import Path - -from ..python2.python2 import Python2 -from .common import PyPy - - -@add_metaclass(abc.ABCMeta) -class PyPy2(PyPy, Python2): - """ """ - - @classmethod - def exe_stem(cls): - return "pypy" - - @classmethod - def sources(cls, interpreter): - for src in super(PyPy2, cls).sources(interpreter): - yield src - # include folder needed on Python 2 as we don't have pyenv.cfg - host_include_marker = cls.host_include_marker(interpreter) - if host_include_marker.exists(): - yield PathRefToDest(host_include_marker.parent, dest=lambda self, _: self.include) - - @classmethod - def needs_stdlib_py_module(cls): - return True - - @classmethod - def host_include_marker(cls, interpreter): - return Path(interpreter.system_include) / "PyPy.h" - - @property - def include(self): - return self.dest / self.interpreter.install_path("headers") - - @classmethod - def modules(cls): - # pypy2 uses some modules before the site.py loads, so we need to include these too - return super(PyPy2, cls).modules() + [ - "os", - "copy_reg", - "genericpath", - "linecache", - "stat", - "UserDict", - "warnings", - ] - - @property - def lib_pypy(self): - return self.dest / "lib_pypy" - - def ensure_directories(self): - dirs = super(PyPy2, self).ensure_directories() - dirs.add(self.lib_pypy) - host_include_marker = self.host_include_marker(self.interpreter) - if host_include_marker.exists(): - dirs.add(self.include.parent) - else: - logging.debug("no include folders as can't find include marker %s", host_include_marker) - return dirs - - @property - def skip_rewrite(self): - """ - PyPy2 built-in imports are handled by this path entry, don't overwrite to not disable it - see: https://github.com/pypa/virtualenv/issues/1652 - """ - return 'or path.endswith("lib_pypy{}__extensions__") # PyPy2 built-in import marker'.format(os.sep) - - -class PyPy2Posix(PyPy2, PosixSupports): - """PyPy 2 on POSIX""" - - @classmethod - def modules(cls): - return super(PyPy2Posix, cls).modules() + ["posixpath"] - - @classmethod - def _shared_libs(cls, python_dir): - return python_dir.glob("libpypy*.*") - - @property - def lib(self): - return self.dest / "lib" - - @classmethod - def sources(cls, interpreter): - for src in super(PyPy2Posix, cls).sources(interpreter): - yield src - host_lib = Path(interpreter.system_prefix) / "lib" - if host_lib.exists(): - yield PathRefToDest(host_lib, dest=lambda self, _: self.lib) - - -class Pypy2Windows(PyPy2, WindowsSupports): - """PyPy 2 on Windows""" - - @classmethod - def modules(cls): - return super(Pypy2Windows, cls).modules() + ["ntpath"] - - @classmethod - def _shared_libs(cls, python_dir): - # No glob in python2 PathLib - for candidate in ["libpypy-c.dll", "libffi-7.dll", "libffi-8.dll"]: - dll = python_dir / candidate - if dll.exists(): - yield dll - - @classmethod - def sources(cls, interpreter): - for src in super(Pypy2Windows, cls).sources(interpreter): - yield src - yield PathRefToDest(Path(interpreter.system_prefix) / "libs", dest=lambda self, s: self.dest / s.name) diff --git a/src/virtualenv/create/via_global_ref/builtin/pypy/pypy3.py b/src/virtualenv/create/via_global_ref/builtin/pypy/pypy3.py index cc72c1459..fa61ebc95 100644 --- a/src/virtualenv/create/via_global_ref/builtin/pypy/pypy3.py +++ b/src/virtualenv/create/via_global_ref/builtin/pypy/pypy3.py @@ -1,34 +1,26 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import abc - -from six import add_metaclass +from pathlib import Path from virtualenv.create.describe import PosixSupports, Python3Supports, WindowsSupports from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest -from virtualenv.util.path import Path from .common import PyPy -@add_metaclass(abc.ABCMeta) -class PyPy3(PyPy, Python3Supports): +class PyPy3(PyPy, Python3Supports, abc.ABC): @classmethod def exe_stem(cls): return "pypy3" @classmethod def exe_names(cls, interpreter): - return super(PyPy3, cls).exe_names(interpreter) | {"pypy"} + return super().exe_names(interpreter) | {"pypy"} class PyPy3Posix(PyPy3, PosixSupports): - """PyPy 3 on POSIX""" - - @property - def stdlib(self): - """PyPy3 respects sysconfig only for the host python, virtual envs is instead lib/pythonx.y/site-packages""" - return self.dest / "lib" / "pypy{}".format(self.interpreter.version_release_str) / "site-packages" + """PyPy 3 on POSIX.""" @classmethod def _shared_libs(cls, python_dir): @@ -40,10 +32,9 @@ def to_lib(self, src): @classmethod def sources(cls, interpreter): - for src in super(PyPy3Posix, cls).sources(interpreter): - yield src + yield from super().sources(interpreter) # PyPy >= 3.8 supports a standard prefix installation, where older - # versions always used a portable/developent style installation. + # versions always used a portable/development style installation. # If this is a standard prefix installation, skip the below: if interpreter.system_prefix == "/usr": return @@ -64,22 +55,22 @@ def sources(cls, interpreter): class Pypy3Windows(PyPy3, WindowsSupports): - """PyPy 3 on Windows""" + """PyPy 3 on Windows.""" @property - def stdlib(self): - """PyPy3 respects sysconfig only for the host python, virtual envs is instead Lib/site-packages""" - return self.dest / "Lib" / "site-packages" - - @property - def bin_dir(self): - """PyPy3 needs to fallback to pypy definition""" - return self.dest / "Scripts" + def less_v37(self): + return self.interpreter.version_info.minor < 7 # noqa: PLR2004 @classmethod def _shared_libs(cls, python_dir): # glob for libpypy*.dll and libffi*.dll for pattern in ["libpypy*.dll", "libffi*.dll"]: srcs = python_dir.glob(pattern) - for src in srcs: - yield src + yield from srcs + + +__all__ = [ + "PyPy3", + "PyPy3Posix", + "Pypy3Windows", +] diff --git a/src/virtualenv/create/via_global_ref/builtin/python2/python2.py b/src/virtualenv/create/via_global_ref/builtin/python2/python2.py deleted file mode 100644 index cacd56ecf..000000000 --- a/src/virtualenv/create/via_global_ref/builtin/python2/python2.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import abc -import json -import os - -from six import add_metaclass - -from virtualenv.create.describe import Python2Supports -from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest -from virtualenv.info import IS_ZIPAPP -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_text -from virtualenv.util.zipapp import read as read_from_zipapp - -from ..via_global_self_do import ViaGlobalRefVirtualenvBuiltin - -HERE = Path(os.path.abspath(__file__)).parent - - -@add_metaclass(abc.ABCMeta) -class Python2(ViaGlobalRefVirtualenvBuiltin, Python2Supports): - def create(self): - """Perform operations needed to make the created environment work on Python 2""" - super(Python2, self).create() - # install a patched site-package, the default Python 2 site.py is not smart enough to understand pyvenv.cfg, - # so we inject a small shim that can do this, the location of this depends where it's on host - sys_std_plat = Path(self.interpreter.system_stdlib_platform) - site_py_in = ( - self.stdlib_platform - if ((sys_std_plat / "site.py").exists() or (sys_std_plat / "site.pyc").exists()) - else self.stdlib - ) - site_py = site_py_in / "site.py" - - custom_site = get_custom_site() - if IS_ZIPAPP: - custom_site_text = read_from_zipapp(custom_site) - else: - custom_site_text = custom_site.read_text() - expected = json.dumps([os.path.relpath(ensure_text(str(i)), ensure_text(str(site_py))) for i in self.libs]) - - custom_site_text = custom_site_text.replace("___EXPECTED_SITE_PACKAGES___", expected) - - reload_code = os.linesep.join(" {}".format(i) for i in self.reload_code.splitlines()).lstrip() - custom_site_text = custom_site_text.replace("# ___RELOAD_CODE___", reload_code) - - skip_rewrite = os.linesep.join(" {}".format(i) for i in self.skip_rewrite.splitlines()).lstrip() - custom_site_text = custom_site_text.replace("# ___SKIP_REWRITE____", skip_rewrite) - - site_py.write_text(custom_site_text) - - @property - def reload_code(self): - return 'reload(sys.modules["site"]) # noqa # call system site.py to setup import libraries' - - @property - def skip_rewrite(self): - return "" - - @classmethod - def sources(cls, interpreter): - for src in super(Python2, cls).sources(interpreter): - yield src - # install files needed to run site.py, either from stdlib or stdlib_platform, at least pyc, but both if exists - # if neither exists return the module file to trigger failure - mappings, needs_py_module = ( - cls.mappings(interpreter), - cls.needs_stdlib_py_module(), - ) - for req in cls.modules(): - module_file, to_module, module_exists = cls.from_stdlib(mappings, "{}.py".format(req)) - compiled_file, to_compiled, compiled_exists = cls.from_stdlib(mappings, "{}.pyc".format(req)) - if needs_py_module or module_exists or not compiled_exists: - yield PathRefToDest(module_file, dest=to_module) - if compiled_exists: - yield PathRefToDest(compiled_file, dest=to_compiled) - - @staticmethod - def from_stdlib(mappings, name): - for from_std, to_std in mappings: - src = from_std / name - if src.exists(): - return src, to_std, True - # if not exists, fallback to first in list - return mappings[0][0] / name, mappings[0][1], False - - @classmethod - def mappings(cls, interpreter): - mappings = [(Path(interpreter.system_stdlib_platform), cls.to_stdlib_platform)] - if interpreter.system_stdlib_platform != interpreter.system_stdlib: - mappings.append((Path(interpreter.system_stdlib), cls.to_stdlib)) - return mappings - - def to_stdlib(self, src): - return self.stdlib / src.name - - def to_stdlib_platform(self, src): - return self.stdlib_platform / src.name - - @classmethod - def needs_stdlib_py_module(cls): - raise NotImplementedError - - @classmethod - def modules(cls): - return [] - - -def get_custom_site(): - return HERE / "site.py" diff --git a/src/virtualenv/create/via_global_ref/builtin/python2/site.py b/src/virtualenv/create/via_global_ref/builtin/python2/site.py deleted file mode 100644 index 4decd8733..000000000 --- a/src/virtualenv/create/via_global_ref/builtin/python2/site.py +++ /dev/null @@ -1,190 +0,0 @@ -# -*- coding: utf-8 -*- -""" -A simple shim module to fix up things on Python 2 only. - -Note: until we setup correctly the paths we can only import built-ins. -""" -import sys - - -def main(): - """Patch what needed, and invoke the original site.py""" - here = __file__ # the distutils.install patterns will be injected relative to this site.py, save it here - config = read_pyvenv() - sys.real_prefix = sys.base_prefix = config["base-prefix"] - sys.base_exec_prefix = config["base-exec-prefix"] - sys.base_executable = config["base-executable"] - global_site_package_enabled = config.get("include-system-site-packages", False) == "true" - rewrite_standard_library_sys_path() - disable_user_site_package() - load_host_site(here) - if global_site_package_enabled: - add_global_site_package() - rewrite_getsitepackages(here) - - -def load_host_site(here): - """trigger reload of site.py - now it will use the standard library instance that will take care of init""" - # we have a duality here, we generate the platform and pure library path based on what distutils.install specifies - # because this is what pip will be using; the host site.py though may contain it's own pattern for where the - # platform and pure library paths should exist - - # notably on Ubuntu there's a patch for getsitepackages to point to - # - prefix + local/lib/pythonx.y/dist-packages - # - prefix + lib/pythonx.y/dist-packages - # while distutils.install.cmd still points both of these to - # - prefix + lib/python2.7/site-packages - - # to facilitate when the two match, or not we first reload the site.py, now triggering the import of host site.py, - # as this will ensure that initialization code within host site.py runs - - # ___RELOAD_CODE___ - - # and then if the distutils site packages are not on the sys.path we add them via add_site_dir; note we must add - # them by invoking add_site_dir to trigger the processing of pth files - - add_site_dir = sys.modules["site"].addsitedir - for path in get_site_packages_dirs(here): - add_site_dir(path) - - -def get_site_packages_dirs(here): - import json - import os - - site_packages = r""" - ___EXPECTED_SITE_PACKAGES___ - """ - - for path in json.loads(site_packages): - yield os.path.abspath(os.path.join(here, path.encode("utf-8"))) - - -sep = "\\" if sys.platform == "win32" else "/" # no os module here yet - poor mans version - - -def read_pyvenv(): - """read pyvenv.cfg""" - config_file = "{}{}pyvenv.cfg".format(sys.prefix, sep) - with open(config_file) as file_handler: - lines = file_handler.readlines() - config = {} - for line in lines: - try: - split_at = line.index("=") - except ValueError: - continue # ignore bad/empty lines - else: - config[line[:split_at].strip()] = line[split_at + 1 :].strip() - return config - - -def rewrite_standard_library_sys_path(): - """Once this site file is loaded the standard library paths have already been set, fix them up""" - exe, prefix, exec_prefix = get_exe_prefixes(base=False) - base_exe, base_prefix, base_exec = get_exe_prefixes(base=True) - exe_dir = exe[: exe.rfind(sep)] - for at, path in enumerate(sys.path): - path = abs_path(path) # replace old sys prefix path starts with new - skip_rewrite = path == exe_dir # don't fix the current executable location, notably on Windows this gets added - skip_rewrite = skip_rewrite # ___SKIP_REWRITE____ - if not skip_rewrite: - sys.path[at] = map_path(path, base_exe, exe_dir, exec_prefix, base_prefix, prefix, base_exec) - - # the rewrite above may have changed elements from PYTHONPATH, revert these if on - if sys.flags.ignore_environment: - return - import os - - python_paths = [] - if "PYTHONPATH" in os.environ and os.environ["PYTHONPATH"]: - for path in os.environ["PYTHONPATH"].split(os.pathsep): - if path not in python_paths: - python_paths.append(path) - sys.path[: len(python_paths)] = python_paths - - -def get_exe_prefixes(base=False): - return tuple(abs_path(getattr(sys, ("base_" if base else "") + i)) for i in ("executable", "prefix", "exec_prefix")) - - -def abs_path(value): - values, keep = value.split(sep), [] - at = len(values) - 1 - while at >= 0: - if values[at] == "..": - at -= 1 - else: - keep.append(values[at]) - at -= 1 - return sep.join(keep[::-1]) - - -def map_path(path, base_executable, exe_dir, exec_prefix, base_prefix, prefix, base_exec_prefix): - if path_starts_with(path, exe_dir): - # content inside the exe folder needs to remap to original executables folder - orig_exe_folder = base_executable[: base_executable.rfind(sep)] - return "{}{}".format(orig_exe_folder, path[len(exe_dir) :]) - elif path_starts_with(path, prefix): - return "{}{}".format(base_prefix, path[len(prefix) :]) - elif path_starts_with(path, exec_prefix): - return "{}{}".format(base_exec_prefix, path[len(exec_prefix) :]) - return path - - -def path_starts_with(directory, value): - return directory.startswith(value if value[-1] == sep else value + sep) - - -def disable_user_site_package(): - """Flip the switch on enable user site package""" - # sys.flags is a c-extension type, so we cannot monkeypatch it, replace it with a python class to flip it - sys.original_flags = sys.flags - - class Flags(object): - def __init__(self): - self.__dict__ = {key: getattr(sys.flags, key) for key in dir(sys.flags) if not key.startswith("_")} - - sys.flags = Flags() - sys.flags.no_user_site = 1 - - -def add_global_site_package(): - """add the global site package""" - import site - - # add user site package - sys.flags = sys.original_flags # restore original - site.ENABLE_USER_SITE = None # reset user site check - # add the global site package to the path - use new prefix and delegate to site.py - orig_prefixes = None - try: - orig_prefixes = site.PREFIXES - site.PREFIXES = [sys.base_prefix, sys.base_exec_prefix] - site.main() - finally: - site.PREFIXES = orig_prefixes + site.PREFIXES - - -# Debian and it's derivatives patch this function. We undo the damage -def rewrite_getsitepackages(here): - site = sys.modules["site"] - - site_package_dirs = get_site_packages_dirs(here) - orig_getsitepackages = site.getsitepackages - - def getsitepackages(): - sitepackages = orig_getsitepackages() - if sys.prefix not in site.PREFIXES or sys.exec_prefix not in site.PREFIXES: - # Someone messed with the prefixes, so we stop patching - return sitepackages - for path in site_package_dirs: - if path not in sitepackages: - sitepackages.insert(0, path) - - return sitepackages - - site.getsitepackages = getsitepackages - - -main() diff --git a/src/virtualenv/create/via_global_ref/builtin/ref.py b/src/virtualenv/create/via_global_ref/builtin/ref.py index 69f243bf9..e2fd45ffe 100644 --- a/src/virtualenv/create/via_global_ref/builtin/ref.py +++ b/src/virtualenv/create/via_global_ref/builtin/ref.py @@ -2,41 +2,38 @@ Virtual environments in the traditional sense are built as reference to the host python. This file allows declarative references to elements on the file system, allowing our system to automatically detect what modes it can support given the constraints: e.g. can the file system symlink, can the files be read, executed, etc. -""" -from __future__ import absolute_import, unicode_literals +""" # noqa: D205 + +from __future__ import annotations import os -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod from collections import OrderedDict from stat import S_IXGRP, S_IXOTH, S_IXUSR -from six import add_metaclass - from virtualenv.info import fs_is_case_sensitive, fs_supports_symlink from virtualenv.util.path import copy, make_exe, symlink -from virtualenv.util.six import ensure_text -class RefMust(object): +class RefMust: NA = "NA" COPY = "copy" SYMLINK = "symlink" -class RefWhen(object): +class RefWhen: ANY = "ANY" COPY = "copy" SYMLINK = "symlink" -@add_metaclass(ABCMeta) -class PathRef(object): - """Base class that checks if a file reference can be symlink/copied""" +class PathRef(ABC): + """Base class that checks if a file reference can be symlink/copied.""" FS_SUPPORTS_SYMLINK = fs_supports_symlink() FS_CASE_SENSITIVE = fs_is_case_sensitive() - def __init__(self, src, must=RefMust.NA, when=RefWhen.ANY): + def __init__(self, src, must=RefMust.NA, when=RefWhen.ANY) -> None: self.must = must self.when = when self.src = src @@ -48,8 +45,8 @@ def __init__(self, src, must=RefMust.NA, when=RefWhen.ANY): self._can_copy = None if self.exists else False self._can_symlink = None if self.exists else False - def __repr__(self): - return "{}(src={})".format(self.__class__.__name__, self.src) + def __repr__(self) -> str: + return f"{self.__class__.__name__}(src={self.src})" @property def can_read(self): @@ -61,7 +58,7 @@ def can_read(self): except OSError: self._can_read = False else: - self._can_read = os.access(ensure_text(str(self.src)), os.R_OK) + self._can_read = os.access(str(self.src), os.R_OK) return self._can_read @property @@ -94,12 +91,11 @@ def method(self, symlinks): return symlink if symlinks else copy -@add_metaclass(ABCMeta) -class ExePathRef(PathRef): - """Base class that checks if a executable can be references via symlink/copy""" +class ExePathRef(PathRef, ABC): + """Base class that checks if a executable can be references via symlink/copy.""" - def __init__(self, src, must=RefMust.NA, when=RefWhen.ANY): - super(ExePathRef, self).__init__(src, must, when) + def __init__(self, src, must=RefMust.NA, when=RefWhen.ANY) -> None: + super().__init__(src, must, when) self._can_run = None @property @@ -122,10 +118,10 @@ def can_run(self): class PathRefToDest(PathRef): - """Link a path on the file system""" + """Link a path on the file system.""" - def __init__(self, src, dest, must=RefMust.NA, when=RefWhen.ANY): - super(PathRefToDest, self).__init__(src, must, when) + def __init__(self, src, dest, must=RefMust.NA, when=RefWhen.ANY) -> None: + super().__init__(src, must, when) self.dest = dest def run(self, creator, symlinks): @@ -139,9 +135,9 @@ def run(self, creator, symlinks): class ExePathRefToDest(PathRefToDest, ExePathRef): - """Link a exe path on the file system""" + """Link a exe path on the file system.""" - def __init__(self, src, targets, dest, must=RefMust.NA, when=RefWhen.ANY): + def __init__(self, src, targets, dest, must=RefMust.NA, when=RefWhen.ANY) -> None: ExePathRef.__init__(self, src, must, when) PathRefToDest.__init__(self, src, dest, must, when) if not self.FS_CASE_SENSITIVE: @@ -168,5 +164,15 @@ def run(self, creator, symlinks): if not symlinks: make_exe(link_file) - def __repr__(self): - return "{}(src={}, alias={})".format(self.__class__.__name__, self.src, self.aliases) + def __repr__(self) -> str: + return f"{self.__class__.__name__}(src={self.src}, alias={self.aliases})" + + +__all__ = [ + "ExePathRef", + "ExePathRefToDest", + "PathRef", + "PathRefToDest", + "RefMust", + "RefWhen", +] diff --git a/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py b/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py index 863ae16e1..2f7f2f11a 100644 --- a/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py +++ b/src/virtualenv/create/via_global_ref/builtin/via_global_self_do.py @@ -1,31 +1,32 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from abc import ABCMeta +from abc import ABC -from six import add_metaclass - -from virtualenv.create.via_global_ref.builtin.ref import ExePathRefToDest, RefMust, RefWhen +from virtualenv.create.via_global_ref.api import ViaGlobalRefApi, ViaGlobalRefMeta +from virtualenv.create.via_global_ref.builtin.ref import ( + ExePathRefToDest, + RefMust, + RefWhen, +) from virtualenv.util.path import ensure_dir -from ..api import ViaGlobalRefApi, ViaGlobalRefMeta from .builtin_way import VirtualenvBuiltin class BuiltinViaGlobalRefMeta(ViaGlobalRefMeta): - def __init__(self): - super(BuiltinViaGlobalRefMeta, self).__init__() + def __init__(self) -> None: + super().__init__() self.sources = [] -@add_metaclass(ABCMeta) -class ViaGlobalRefVirtualenvBuiltin(ViaGlobalRefApi, VirtualenvBuiltin): - def __init__(self, options, interpreter): - super(ViaGlobalRefVirtualenvBuiltin, self).__init__(options, interpreter) +class ViaGlobalRefVirtualenvBuiltin(ViaGlobalRefApi, VirtualenvBuiltin, ABC): + def __init__(self, options, interpreter) -> None: + super().__init__(options, interpreter) self._sources = getattr(options.meta, "sources", None) # if we're created as a describer this might be missing @classmethod def can_create(cls, interpreter): - """By default all built-in methods assume that if we can describe it we can create it""" + """By default, all built-in methods assume that if we can describe it we can create it.""" # first we must be able to describe it if not cls.can_describe(interpreter): return None @@ -39,11 +40,11 @@ def _sources_can_be_applied(cls, interpreter, meta): for src in cls.sources(interpreter): if src.exists: if meta.can_copy and not src.can_copy: - meta.copy_error = "cannot copy {}".format(src) + meta.copy_error = f"cannot copy {src}" if meta.can_symlink and not src.can_symlink: - meta.symlink_error = "cannot symlink {}".format(src) + meta.symlink_error = f"cannot symlink {src}" else: - msg = "missing required file {}".format(src) + msg = f"missing required file {src}" if src.when == RefMust.NA: meta.error = msg elif src.when == RefMust.COPY: @@ -51,16 +52,13 @@ def _sources_can_be_applied(cls, interpreter, meta): elif src.when == RefMust.SYMLINK: meta.symlink_error = msg if not meta.can_copy and not meta.can_symlink: - meta.error = "neither copy or symlink supported, copy: {} symlink: {}".format( - meta.copy_error, - meta.symlink_error, - ) + meta.error = f"neither copy or symlink supported, copy: {meta.copy_error} symlink: {meta.symlink_error}" if meta.error: break meta.sources.append(src) @classmethod - def setup_meta(cls, interpreter): + def setup_meta(cls, interpreter): # noqa: ARG003 return BuiltinViaGlobalRefMeta() @classmethod @@ -98,7 +96,7 @@ def create(self): finally: if true_system_site != self.enable_system_site_package: self.enable_system_site_package = true_system_site - super(ViaGlobalRefVirtualenvBuiltin, self).create() + super().create() def ensure_directories(self): return {self.dest, self.bin_dir, self.script_dir, self.stdlib} | set(self.libs) @@ -106,9 +104,15 @@ def ensure_directories(self): def set_pyenv_cfg(self): """ We directly inject the base prefix and base exec prefix to avoid site.py needing to discover these - from home (which usually is done within the interpreter itself) - """ - super(ViaGlobalRefVirtualenvBuiltin, self).set_pyenv_cfg() + from home (which usually is done within the interpreter itself). + """ # noqa: D205 + super().set_pyenv_cfg() self.pyenv_cfg["base-prefix"] = self.interpreter.system_prefix self.pyenv_cfg["base-exec-prefix"] = self.interpreter.system_exec_prefix self.pyenv_cfg["base-executable"] = self.interpreter.system_executable + + +__all__ = [ + "BuiltinViaGlobalRefMeta", + "ViaGlobalRefVirtualenvBuiltin", +] diff --git a/src/virtualenv/create/via_global_ref/store.py b/src/virtualenv/create/via_global_ref/store.py index 134a53585..4be668921 100644 --- a/src/virtualenv/create/via_global_ref/store.py +++ b/src/virtualenv/create/via_global_ref/store.py @@ -1,6 +1,6 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from virtualenv.util.path import Path +from pathlib import Path def handle_store_python(meta, interpreter): @@ -12,7 +12,7 @@ def handle_store_python(meta, interpreter): def is_store_python(interpreter): parts = Path(interpreter.system_executable).parts return ( - len(parts) > 4 + len(parts) > 4 # noqa: PLR2004 and parts[-4] == "Microsoft" and parts[-3] == "WindowsApps" and parts[-2].startswith("PythonSoftwareFoundation.Python.3.") @@ -20,7 +20,7 @@ def is_store_python(interpreter): ) -__all__ = ( +__all__ = [ "handle_store_python", "is_store_python", -) +] diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py index aaa67947f..7d6f32e39 100644 --- a/src/virtualenv/create/via_global_ref/venv.py +++ b/src/virtualenv/create/via_global_ref/venv.py @@ -1,34 +1,39 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations # noqa: A005 import logging from copy import copy from virtualenv.create.via_global_ref.store import handle_store_python from virtualenv.discovery.py_info import PythonInfo -from virtualenv.util.error import ProcessCallFailed +from virtualenv.util.error import ProcessCallFailedError from virtualenv.util.path import ensure_dir from virtualenv.util.subprocess import run_cmd from .api import ViaGlobalRefApi, ViaGlobalRefMeta +from .builtin.cpython.mac_os import CPython3macOsBrew +from .builtin.pypy.pypy3 import Pypy3Windows + +LOGGER = logging.getLogger(__name__) class Venv(ViaGlobalRefApi): - def __init__(self, options, interpreter): + def __init__(self, options, interpreter) -> None: self.describe = options.describe - super(Venv, self).__init__(options, interpreter) - self.can_be_inline = ( - interpreter is PythonInfo.current() and interpreter.executable == interpreter.system_executable - ) + super().__init__(options, interpreter) + current = PythonInfo.current() + self.can_be_inline = interpreter is current and interpreter.executable == interpreter.system_executable self._context = None def _args(self): - return super(Venv, self)._args() + ([("describe", self.describe.__class__.__name__)] if self.describe else []) + return super()._args() + ([("describe", self.describe.__class__.__name__)] if self.describe else []) @classmethod def can_create(cls, interpreter): if interpreter.has_venv: + if CPython3macOsBrew.can_describe(interpreter): + return CPython3macOsBrew.setup_meta(interpreter) meta = ViaGlobalRefMeta() - if interpreter.platform == "win32" and interpreter.version_info.major == 3: + if interpreter.platform == "win32": meta = handle_store_python(meta, interpreter) return meta return None @@ -40,10 +45,22 @@ def create(self): self.create_via_sub_process() for lib in self.libs: ensure_dir(lib) - super(Venv, self).create() + super().create() + self.executables_for_win_pypy_less_v37() + + def executables_for_win_pypy_less_v37(self): + """ + PyPy <= 3.6 (v7.3.3) for Windows contains only pypy3.exe and pypy3w.exe + Venv does not handle non-existing exe sources, e.g. python.exe, so this + patch does it. + """ # noqa: D205 + creator = self.describe + if isinstance(creator, Pypy3Windows) and creator.less_v37: + for exe in creator.executables(self.interpreter): + exe.run(creator, self.symlinks) def create_inline(self): - from venv import EnvBuilder + from venv import EnvBuilder # noqa: PLC0415 builder = EnvBuilder( system_site_packages=self.enable_system_site_package, @@ -55,29 +72,33 @@ def create_inline(self): def create_via_sub_process(self): cmd = self.get_host_create_cmd() - logging.info("using host built-in venv to create via %s", " ".join(cmd)) + LOGGER.info("using host built-in venv to create via %s", " ".join(cmd)) code, out, err = run_cmd(cmd) if code != 0: - raise ProcessCallFailed(code, out, err, cmd) + raise ProcessCallFailedError(code, out, err, cmd) def get_host_create_cmd(self): cmd = [self.interpreter.system_executable, "-m", "venv", "--without-pip"] if self.enable_system_site_package: cmd.append("--system-site-packages") - cmd.append("--symlinks" if self.symlinks else "--copies") - cmd.append(str(self.dest)) + cmd.extend(("--symlinks" if self.symlinks else "--copies", str(self.dest))) return cmd def set_pyenv_cfg(self): # prefer venv options over ours, but keep our extra venv_content = copy(self.pyenv_cfg.refresh()) - super(Venv, self).set_pyenv_cfg() + super().set_pyenv_cfg() self.pyenv_cfg.update(venv_content) def __getattribute__(self, item): describe = object.__getattribute__(self, "describe") if describe is not None and hasattr(describe, item): element = getattr(describe, item) - if not callable(element) or item in ("script",): + if not callable(element) or item == "script": return element return object.__getattribute__(self, item) + + +__all__ = [ + "Venv", +] diff --git a/src/virtualenv/discovery/__init__.py b/src/virtualenv/discovery/__init__.py index 01e6d4f49..e69de29bb 100644 --- a/src/virtualenv/discovery/__init__.py +++ b/src/virtualenv/discovery/__init__.py @@ -1 +0,0 @@ -from __future__ import absolute_import, unicode_literals diff --git a/src/virtualenv/discovery/builtin.py b/src/virtualenv/discovery/builtin.py index 52f7398cf..00c595b46 100644 --- a/src/virtualenv/discovery/builtin.py +++ b/src/virtualenv/discovery/builtin.py @@ -1,26 +1,39 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import os import sys +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING, Callable -from virtualenv.info import IS_WIN -from virtualenv.util.six import ensure_str, ensure_text +from virtualenv.info import IS_WIN, fs_path_id from .discover import Discover from .py_info import PythonInfo from .py_spec import PythonSpec +if TYPE_CHECKING: + from argparse import ArgumentParser + from collections.abc import Generator, Iterable, Mapping, Sequence + + from virtualenv.app_data.base import AppData +LOGGER = logging.getLogger(__name__) + class Builtin(Discover): - def __init__(self, options): - super(Builtin, self).__init__(options) - self.python_spec = options.python if options.python else [sys.executable] + python_spec: Sequence[str] + app_data: AppData + try_first_with: Sequence[str] + + def __init__(self, options) -> None: + super().__init__(options) + self.python_spec = options.python or [sys.executable] self.app_data = options.app_data self.try_first_with = options.try_first_with @classmethod - def add_parser_arguments(cls, parser): + def add_parser_arguments(cls, parser: ArgumentParser) -> None: parser.add_argument( "-p", "--python", @@ -42,40 +55,46 @@ def add_parser_arguments(cls, parser): help="try first these interpreters before starting the discovery", ) - def run(self): + def run(self) -> PythonInfo | None: for python_spec in self.python_spec: result = get_interpreter(python_spec, self.try_first_with, self.app_data, self._env) if result is not None: return result return None - def __repr__(self): - return ensure_str(self.__unicode__()) - - def __unicode__(self): + def __repr__(self) -> str: spec = self.python_spec[0] if len(self.python_spec) == 1 else self.python_spec - return "{} discover of python_spec={!r}".format(self.__class__.__name__, spec) + return f"{self.__class__.__name__} discover of python_spec={spec!r}" -def get_interpreter(key, try_first_with, app_data=None, env=None): +def get_interpreter( + key, try_first_with: Iterable[str], app_data: AppData | None = None, env: Mapping[str, str] | None = None +) -> PythonInfo | None: spec = PythonSpec.from_string_spec(key) - logging.info("find interpreter for spec %r", spec) + LOGGER.info("find interpreter for spec %r", spec) proposed_paths = set() env = os.environ if env is None else env for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, app_data, env): key = interpreter.system_executable, impl_must_match if key in proposed_paths: continue - logging.info("proposed %s", interpreter) + LOGGER.info("proposed %s", interpreter) if interpreter.satisfies(spec, impl_must_match): - logging.debug("accepted %s", interpreter) + LOGGER.debug("accepted %s", interpreter) return interpreter proposed_paths.add(key) + return None -def propose_interpreters(spec, try_first_with, app_data, env=None): +def propose_interpreters( # noqa: C901, PLR0912, PLR0915 + spec: PythonSpec, + try_first_with: Iterable[str], + app_data: AppData | None = None, + env: Mapping[str, str] | None = None, +) -> Generator[tuple[PythonInfo, bool], None, None]: # 0. try with first env = os.environ if env is None else env + tested_exes: set[str] = set() for py_exe in try_first_with: path = os.path.abspath(py_exe) try: @@ -83,7 +102,12 @@ def propose_interpreters(spec, try_first_with, app_data, env=None): except OSError: pass else: - yield PythonInfo.from_exe(os.path.abspath(path), app_data, env=env), True + exe_raw = os.path.abspath(path) + exe_id = fs_path_id(exe_raw) + if exe_id in tested_exes: + continue + tested_exes.add(exe_id) + yield PythonInfo.from_exe(exe_raw, app_data, env=env), True # 1. if it's a path and exists if spec.path is not None: @@ -93,94 +117,112 @@ def propose_interpreters(spec, try_first_with, app_data, env=None): if spec.is_abs: raise else: - yield PythonInfo.from_exe(os.path.abspath(spec.path), app_data, env=env), True + exe_raw = os.path.abspath(spec.path) + exe_id = fs_path_id(exe_raw) + if exe_id not in tested_exes: + tested_exes.add(exe_id) + yield PythonInfo.from_exe(exe_raw, app_data, env=env), True if spec.is_abs: return else: # 2. otherwise try with the current - yield PythonInfo.current_system(app_data), True + current_python = PythonInfo.current_system(app_data) + exe_raw = str(current_python.executable) + exe_id = fs_path_id(exe_raw) + if exe_id not in tested_exes: + tested_exes.add(exe_id) + yield current_python, True # 3. otherwise fallback to platform default logic if IS_WIN: - from .windows import propose_interpreters + from .windows import propose_interpreters # noqa: PLC0415 for interpreter in propose_interpreters(spec, app_data, env): + exe_raw = str(interpreter.executable) + exe_id = fs_path_id(exe_raw) + if exe_id in tested_exes: + continue + tested_exes.add(exe_id) yield interpreter, True # finally just find on path, the path order matters (as the candidates are less easy to control by end user) - paths = get_paths(env) - tested_exes = set() - for pos, path in enumerate(paths): - path = ensure_text(path) - logging.debug(LazyPathDump(pos, path, env)) - for candidate, match in possible_specs(spec): - found = check_path(candidate, path) - if found is not None: - exe = os.path.abspath(found) - if exe not in tested_exes: - tested_exes.add(exe) - interpreter = PathPythonInfo.from_exe(exe, app_data, raise_on_error=False, env=env) - if interpreter is not None: - yield interpreter, match - - -def get_paths(env): - path = env.get(str("PATH"), None) + find_candidates = path_exe_finder(spec) + for pos, path in enumerate(get_paths(env)): + LOGGER.debug(LazyPathDump(pos, path, env)) + for exe, impl_must_match in find_candidates(path): + exe_raw = str(exe) + exe_id = fs_path_id(exe_raw) + if exe_id in tested_exes: + continue + tested_exes.add(exe_id) + interpreter = PathPythonInfo.from_exe(exe_raw, app_data, raise_on_error=False, env=env) + if interpreter is not None: + yield interpreter, impl_must_match + + +def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]: + path = env.get("PATH", None) if path is None: try: path = os.confstr("CS_PATH") except (AttributeError, ValueError): path = os.defpath - if not path: - paths = [] - else: - paths = [p for p in path.split(os.pathsep) if os.path.exists(p)] - return paths + if path: + for p in map(Path, path.split(os.pathsep)): + with suppress(OSError): + if p.exists(): + yield p -class LazyPathDump(object): - def __init__(self, pos, path, env): +class LazyPathDump: + def __init__(self, pos: int, path: Path, env: Mapping[str, str]) -> None: self.pos = pos self.path = path self.env = env - def __repr__(self): - return ensure_str(self.__unicode__()) - - def __unicode__(self): - content = "discover PATH[{}]={}".format(self.pos, self.path) - if self.env.get(str("_VIRTUALENV_DEBUG")): # this is the over the board debug + def __repr__(self) -> str: + content = f"discover PATH[{self.pos}]={self.path}" + if self.env.get("_VIRTUALENV_DEBUG"): # this is the over the board debug content += " with =>" - for file_name in os.listdir(self.path): + for file_path in self.path.iterdir(): try: - file_path = os.path.join(self.path, file_name) - if os.path.isdir(file_path) or not os.access(file_path, os.X_OK): + if file_path.is_dir() or not (file_path.stat().st_mode & os.X_OK): continue except OSError: pass content += " " - content += file_name + content += file_path.name return content -def check_path(candidate, path): - _, ext = os.path.splitext(candidate) - if sys.platform == "win32" and ext != ".exe": - candidate = candidate + ".exe" - if os.path.isfile(candidate): - return candidate - candidate = os.path.join(path, candidate) - if os.path.isfile(candidate): - return candidate - return None +def path_exe_finder(spec: PythonSpec) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]: + """Given a spec, return a function that can be called on a path to find all matching files in it.""" + pat = spec.generate_re(windows=sys.platform == "win32") + direct = spec.str_spec + if sys.platform == "win32": + direct = f"{direct}.exe" + + def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]: + # 4. then maybe it's something exact on PATH - if it was direct lookup implementation no longer counts + direct_path = path / direct + if direct_path.exists(): + yield direct_path, False + # 5. or from the spec we can deduce if a name on path matches + for exe in path.iterdir(): + match = pat.fullmatch(exe.name) + if match: + # the implementation must match when we find “python[ver]” + yield exe.absolute(), match["impl"] == "python" -def possible_specs(spec): - # 4. then maybe it's something exact on PATH - if it was direct lookup implementation no longer counts - yield spec.str_spec, False - # 5. or from the spec we can deduce a name on path that matches - for exe, match in spec.generate_names(): - yield exe, match + return path_exes class PathPythonInfo(PythonInfo): - """ """ + """python info from path.""" + + +__all__ = [ + "Builtin", + "PathPythonInfo", + "get_interpreter", +] diff --git a/src/virtualenv/discovery/cached_py_info.py b/src/virtualenv/discovery/cached_py_info.py index 31beff52f..846856d0e 100644 --- a/src/virtualenv/discovery/cached_py_info.py +++ b/src/virtualenv/discovery/cached_py_info.py @@ -3,39 +3,41 @@ We acquire the python information by running an interrogation script via subprocess trigger. This operation is not cheap, especially not on Windows. To not have to pay this hefty cost every time we apply multiple levels of caching. -""" -from __future__ import absolute_import, unicode_literals +""" # noqa: D205 + +from __future__ import annotations import logging import os -import pipes +import random import sys from collections import OrderedDict +from pathlib import Path +from shlex import quote +from string import ascii_lowercase, ascii_uppercase, digits +from subprocess import Popen from virtualenv.app_data import AppDataDisabled from virtualenv.discovery.py_info import PythonInfo -from virtualenv.info import PY2 -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_text -from virtualenv.util.subprocess import Popen, subprocess +from virtualenv.util.subprocess import subprocess _CACHE = OrderedDict() _CACHE[Path(sys.executable)] = PythonInfo() +LOGGER = logging.getLogger(__name__) -def from_exe(cls, app_data, exe, env=None, raise_on_error=True, ignore_cache=False): +def from_exe(cls, app_data, exe, env=None, raise_on_error=True, ignore_cache=False): # noqa: FBT002, PLR0913 env = os.environ if env is None else env result = _get_from_cache(cls, app_data, exe, env, ignore_cache=ignore_cache) if isinstance(result, Exception): if raise_on_error: raise result - else: - logging.info("%s", str(result)) + LOGGER.info("%s", result) result = None return result -def _get_from_cache(cls, app_data, exe, env, ignore_cache=True): +def _get_from_cache(cls, app_data, exe, env, ignore_cache=True): # noqa: FBT002 # note here we cannot resolve symlinks, as the symlink may trigger different prefix information if there's a # pyenv.cfg somewhere alongside on python3.5+ exe_path = Path(exe) @@ -51,7 +53,7 @@ def _get_from_cache(cls, app_data, exe, env, ignore_cache=True): def _get_via_file_cache(cls, app_data, path, exe, env): - path_text = ensure_text(str(path)) + path_text = str(path) try: path_modified = path.stat().st_mtime except OSError: @@ -64,7 +66,7 @@ def _get_via_file_cache(cls, app_data, path, exe, env): data = py_info_store.read() of_path, of_st_mtime, of_content = data["path"], data["st_mtime"], data["content"] if of_path == path_text and of_st_mtime == path_modified: - py_info = cls._from_dict({k: v for k, v in of_content.items()}) + py_info = cls._from_dict(of_content.copy()) sys_exe = py_info.system_executable if sys_exe is not None and not os.path.exists(sys_exe): py_info_store.remove() @@ -74,21 +76,41 @@ def _get_via_file_cache(cls, app_data, path, exe, env): if py_info is None: # if not loaded run and save failure, py_info = _run_subprocess(cls, exe, app_data, env) if failure is None: - data = {"st_mtime": path_modified, "path": path_text, "content": py_info._to_dict()} + data = {"st_mtime": path_modified, "path": path_text, "content": py_info._to_dict()} # noqa: SLF001 py_info_store.write(data) else: py_info = failure return py_info +COOKIE_LENGTH: int = 32 + + +def gen_cookie(): + return "".join( + random.choice(f"{ascii_lowercase}{ascii_uppercase}{digits}") # noqa: S311 + for _ in range(COOKIE_LENGTH) + ) + + def _run_subprocess(cls, exe, app_data, env): py_info_script = Path(os.path.abspath(__file__)).parent / "py_info.py" + # Cookies allow to split the serialized stdout output generated by the script collecting the info from the output + # generated by something else. The right way to deal with it is to create an anonymous pipe and pass its descriptor + # to the child and output to it. But AFAIK all of them are either not cross-platform or too big to implement and are + # not in the stdlib. So the easiest and the shortest way I could mind is just using the cookies. + # We generate pseudorandom cookies because it easy to implement and avoids breakage from outputting modules source + # code, i.e. by debug output libraries. We reverse the cookies to avoid breakages resulting from variable values + # appearing in debug output. + + start_cookie = gen_cookie() + end_cookie = gen_cookie() with app_data.ensure_extracted(py_info_script) as py_info_script: - cmd = [exe, str(py_info_script)] + cmd = [exe, str(py_info_script), start_cookie, end_cookie] # prevent sys.prefix from leaking into the child process - see https://bugs.python.org/issue22490 env = env.copy() env.pop("__PYVENV_LAUNCHER__", None) - logging.debug("get interpreter info via cmd: %s", LogCmd(cmd)) + LOGGER.debug("get interpreter info via cmd: %s", LogCmd(cmd)) try: process = Popen( cmd, @@ -97,6 +119,7 @@ def _run_subprocess(cls, exe, app_data, env): stderr=subprocess.PIPE, stdout=subprocess.PIPE, env=env, + encoding="utf-8", ) out, err = process.communicate() code = process.returncode @@ -104,49 +127,53 @@ def _run_subprocess(cls, exe, app_data, env): out, err, code = "", os_error.strerror, os_error.errno result, failure = None, None if code == 0: + out_starts = out.find(start_cookie[::-1]) + + if out_starts > -1: + pre_cookie = out[:out_starts] + + if pre_cookie: + sys.stdout.write(pre_cookie) + + out = out[out_starts + COOKIE_LENGTH :] + + out_ends = out.find(end_cookie[::-1]) + + if out_ends > -1: + post_cookie = out[out_ends + COOKIE_LENGTH :] + + if post_cookie: + sys.stdout.write(post_cookie) + + out = out[:out_ends] + result = cls._from_json(out) result.executable = exe # keep original executable as this may contain initialization code else: - msg = "failed to query {} with code {}{}{}".format( - exe, - code, - " out: {!r}".format(out) if out else "", - " err: {!r}".format(err) if err else "", - ) - failure = RuntimeError(msg) + msg = f"{exe} with code {code}{f' out: {out!r}' if out else ''}{f' err: {err!r}' if err else ''}" + failure = RuntimeError(f"failed to query {msg}") return failure, result -class LogCmd(object): - def __init__(self, cmd, env=None): +class LogCmd: + def __init__(self, cmd, env=None) -> None: self.cmd = cmd self.env = env - def __repr__(self): - def e(v): - return v.decode("utf-8") if isinstance(v, bytes) else v - - cmd_repr = e(" ").join(pipes.quote(e(c)) for c in self.cmd) + def __repr__(self) -> str: + cmd_repr = " ".join(quote(str(c)) for c in self.cmd) if self.env is not None: - cmd_repr += e(" env of {!r}").format(self.env) - if PY2: - return cmd_repr.encode("utf-8") + cmd_repr = f"{cmd_repr} env of {self.env!r}" return cmd_repr - def __unicode__(self): - raw = repr(self) - if PY2: - return raw.decode("utf-8") - return raw - def clear(app_data): app_data.py_info_clear() _CACHE.clear() -___all___ = ( +___all___ = [ "from_exe", "clear", "LogCmd", -) +] diff --git a/src/virtualenv/discovery/discover.py b/src/virtualenv/discovery/discover.py index 72748c3fa..0aaa17c8e 100644 --- a/src/virtualenv/discovery/discover.py +++ b/src/virtualenv/discovery/discover.py @@ -1,25 +1,23 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod -from six import add_metaclass - -@add_metaclass(ABCMeta) -class Discover(object): - """Discover and provide the requested Python interpreter""" +class Discover(ABC): + """Discover and provide the requested Python interpreter.""" @classmethod def add_parser_arguments(cls, parser): - """Add CLI arguments for this discovery mechanisms. + """ + Add CLI arguments for this discovery mechanisms. :param parser: the CLI parser """ raise NotImplementedError - # noinspection PyUnusedLocal - def __init__(self, options): - """Create a new discovery mechanism. + def __init__(self, options) -> None: + """ + Create a new discovery mechanism. :param options: the parsed options as defined within :meth:`add_parser_arguments` """ @@ -29,8 +27,8 @@ def __init__(self, options): @abstractmethod def run(self): - """Discovers an interpreter. - + """ + Discovers an interpreter. :return: the interpreter ready to use for virtual environment creation """ @@ -38,10 +36,13 @@ def run(self): @property def interpreter(self): - """ - :return: the interpreter as returned by :meth:`run`, cached - """ + """:return: the interpreter as returned by :meth:`run`, cached""" if self._has_run is False: self._interpreter = self.run() self._has_run = True return self._interpreter + + +__all__ = [ + "Discover", +] diff --git a/src/virtualenv/discovery/py_info.py b/src/virtualenv/discovery/py_info.py index df2cc13fc..4895e2408 100644 --- a/src/virtualenv/discovery/py_info.py +++ b/src/virtualenv/discovery/py_info.py @@ -1,9 +1,10 @@ """ -The PythonInfo contains information about a concrete instance of a Python interpreter +The PythonInfo contains information about a concrete instance of a Python interpreter. Note: this file is also used to query target interpreters, so can only use standard library methods """ -from __future__ import absolute_import, print_function + +from __future__ import annotations import json import logging @@ -16,51 +17,53 @@ from collections import OrderedDict, namedtuple from string import digits -VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]) +VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]) # noqa: PYI024 +LOGGER = logging.getLogger(__name__) def _get_path_extensions(): - return list(OrderedDict.fromkeys([""] + os.environ.get("PATHEXT", "").lower().split(os.pathsep))) + return list(OrderedDict.fromkeys(["", *os.environ.get("PATHEXT", "").lower().split(os.pathsep)])) EXTENSIONS = _get_path_extensions() _CONF_VAR_RE = re.compile(r"\{\w+\}") -class PythonInfo(object): - """Contains information for a Python interpreter""" - - def __init__(self): - def u(v): - return v.decode("utf-8") if isinstance(v, bytes) else v +class PythonInfo: # noqa: PLR0904 + """Contains information for a Python interpreter.""" + def __init__(self) -> None: # noqa: PLR0915 def abs_path(v): return None if v is None else os.path.abspath(v) # unroll relative elements from path (e.g. ..) # qualifies the python - self.platform = u(sys.platform) - self.implementation = u(platform.python_implementation()) + self.platform = sys.platform + self.implementation = platform.python_implementation() if self.implementation == "PyPy": - self.pypy_version_info = tuple(u(i) for i in sys.pypy_version_info) + self.pypy_version_info = tuple(sys.pypy_version_info) # this is a tuple in earlier, struct later, unify to our own named tuple - self.version_info = VersionInfo(*list(u(i) for i in sys.version_info)) + self.version_info = VersionInfo(*sys.version_info) self.architecture = 64 if sys.maxsize > 2**32 else 32 - self.version = u(sys.version) - self.os = u(os.name) + # Used to determine some file names. + # See `CPython3Windows.python_zip()`. + self.version_nodot = sysconfig.get_config_var("py_version_nodot") + + self.version = sys.version + self.os = os.name # information about the prefix - determines python home - self.prefix = u(abs_path(getattr(sys, "prefix", None))) # prefix we think - self.base_prefix = u(abs_path(getattr(sys, "base_prefix", None))) # venv - self.real_prefix = u(abs_path(getattr(sys, "real_prefix", None))) # old virtualenv + self.prefix = abs_path(getattr(sys, "prefix", None)) # prefix we think + self.base_prefix = abs_path(getattr(sys, "base_prefix", None)) # venv + self.real_prefix = abs_path(getattr(sys, "real_prefix", None)) # old virtualenv # information about the exec prefix - dynamic stdlib modules - self.base_exec_prefix = u(abs_path(getattr(sys, "base_exec_prefix", None))) - self.exec_prefix = u(abs_path(getattr(sys, "exec_prefix", None))) + self.base_exec_prefix = abs_path(getattr(sys, "base_exec_prefix", None)) + self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None)) - self.executable = u(abs_path(sys.executable)) # the executable we were invoked via - self.original_executable = u(abs_path(self.executable)) # the executable as known by the interpreter + self.executable = abs_path(sys.executable) # the executable we were invoked via + self.original_executable = abs_path(self.executable) # the executable as known by the interpreter self.system_executable = self._fast_get_system_executable() # the executable we are based of (if available) try: @@ -69,26 +72,38 @@ def abs_path(v): except ImportError: has = False self.has_venv = has - self.path = [u(i) for i in sys.path] - self.file_system_encoding = u(sys.getfilesystemencoding()) - self.stdout_encoding = u(getattr(sys.stdout, "encoding", None)) + self.path = sys.path + self.file_system_encoding = sys.getfilesystemencoding() + self.stdout_encoding = getattr(sys.stdout, "encoding", None) - if "venv" in sysconfig.get_scheme_names(): + scheme_names = sysconfig.get_scheme_names() + + if "venv" in scheme_names: self.sysconfig_scheme = "venv" self.sysconfig_paths = { - u(i): u(sysconfig.get_path(i, expand=False, scheme="venv")) for i in sysconfig.get_path_names() + i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names() + } + # we cannot use distutils at all if "venv" exists, distutils don't know it + self.distutils_install = {} + # debian / ubuntu python 3.10 without `python3-distutils` will report + # mangled `local/bin` / etc. names for the default prefix + # intentionally select `posix_prefix` which is the unaltered posix-like paths + elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: + self.sysconfig_scheme = "posix_prefix" + self.sysconfig_paths = { + i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names() } # we cannot use distutils at all if "venv" exists, distutils don't know it self.distutils_install = {} else: self.sysconfig_scheme = None - self.sysconfig_paths = {u(i): u(sysconfig.get_path(i, expand=False)) for i in sysconfig.get_path_names()} - self.distutils_install = {u(k): u(v) for k, v in self._distutils_install().items()} + self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()} + self.distutils_install = self._distutils_install().copy() # https://bugs.python.org/issue22199 makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None)) self.sysconfig = { - u(k): u(v) + k: v for k, v in [ # a list of content to store from sysconfig ("makefile_filename", makefile()), @@ -98,30 +113,46 @@ def abs_path(v): config_var_keys = set() for element in self.sysconfig_paths.values(): - for k in _CONF_VAR_RE.findall(element): - config_var_keys.add(u(k[1:-1])) + config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element)) config_var_keys.add("PYTHONFRAMEWORK") - self.sysconfig_vars = {u(i): u(sysconfig.get_config_var(i) or "") for i in config_var_keys} - if self.implementation == "PyPy" and sys.version_info.major == 2: - self.sysconfig_vars["implementation_lower"] = "python" + self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys} - confs = {k: (self.system_prefix if v.startswith(self.prefix) else v) for k, v in self.sysconfig_vars.items()} + confs = { + k: (self.system_prefix if v is not None and v.startswith(self.prefix) else v) + for k, v in self.sysconfig_vars.items() + } self.system_stdlib = self.sysconfig_path("stdlib", confs) self.system_stdlib_platform = self.sysconfig_path("platstdlib", confs) self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None)) self._creators = None def _fast_get_system_executable(self): - """Try to get the system executable by just looking at properties""" - if self.real_prefix or ( + """Try to get the system executable by just looking at properties.""" + if self.real_prefix or ( # noqa: PLR1702 self.base_prefix is not None and self.base_prefix != self.prefix ): # if this is a virtual environment if self.real_prefix is None: base_executable = getattr(sys, "_base_executable", None) # some platforms may set this to help us - if base_executable is not None: # use the saved system executable if present + if base_executable is not None: # noqa: SIM102 # use the saved system executable if present if sys.executable != base_executable: # we know we're in a virtual environment, cannot be us - return base_executable + if os.path.exists(base_executable): + return base_executable + # Python may return "python" because it was invoked from the POSIX virtual environment + # however some installs/distributions do not provide a version-less "python" binary in + # the system install location (see PEP 394) so try to fallback to a versioned binary. + # + # Gate this to Python 3.11 as `sys._base_executable` path resolution is now relative to + # the 'home' key from pyvenv.cfg which often points to the system install location. + major, minor = self.version_info.major, self.version_info.minor + if self.os == "posix" and (major, minor) >= (3, 11): + # search relative to the directory of sys._base_executable + base_dir = os.path.dirname(base_executable) + for base_executable in [ + os.path.join(base_dir, exe) for exe in (f"python{major}", f"python{major}.{minor}") + ]: + if os.path.exists(base_executable): + return base_executable return None # in this case we just can't tell easily without poking around FS and calling them, bail # if we're not in a virtual environment, this is already a system python, so return the original executable # note we must choose the original and not the pure executable as shim scripts might throw us off @@ -144,14 +175,14 @@ def _distutils_install(): with warnings.catch_warnings(): # disable warning for PEP-632 warnings.simplefilter("ignore") try: - from distutils import dist - from distutils.command.install import SCHEME_KEYS + from distutils import dist # noqa: PLC0415 + from distutils.command.install import SCHEME_KEYS # noqa: PLC0415 except ImportError: # if removed or not installed ignore return {} d = dist.Distribution({"script_args": "--no-user-cfg"}) # conf files not parsed so they do not hijack paths if hasattr(sys, "_framework"): - sys._framework = None # disable macOS static paths for framework + sys._framework = None # disable macOS static paths for framework # noqa: SLF001 with warnings.catch_warnings(): # disable warning for PEP-632 warnings.simplefilter("ignore") @@ -159,8 +190,7 @@ def _distutils_install(): i.prefix = os.sep # paths generated are relative to prefix that contains the path sep, this makes it relative i.finalize_options() - result = {key: (getattr(i, "install_{}".format(key))[1:]).lstrip(os.sep) for key in SCHEME_KEYS} - return result + return {key: (getattr(i, f"install_{key}")[1:]).lstrip(os.sep) for key in SCHEME_KEYS} @property def version_str(self): @@ -173,7 +203,7 @@ def version_release_str(self): @property def python_name(self): version_info = self.version_info - return "python{}.{}".format(version_info.major, version_info.minor) + return f"python{version_info.major}.{version_info.minor}" @property def is_old_virtualenv(self): @@ -181,21 +211,21 @@ def is_old_virtualenv(self): @property def is_venv(self): - return self.base_prefix is not None and self.version_info.major == 3 + return self.base_prefix is not None def sysconfig_path(self, key, config_var=None, sep=os.sep): pattern = self.sysconfig_paths[key] if config_var is None: config_var = self.sysconfig_vars else: - base = {k: v for k, v in self.sysconfig_vars.items()} + base = self.sysconfig_vars.copy() base.update(config_var) config_var = base return pattern.format(**config_var).replace("/", sep) - def creators(self, refresh=False): + def creators(self, refresh=False): # noqa: FBT002 if self._creators is None or refresh is True: - from virtualenv.run.plugin.creators import CreatorSelector + from virtualenv.run.plugin.creators import CreatorSelector # noqa: PLC0415 self._creators = CreatorSelector.for_interpreter(self) return self._creators @@ -204,7 +234,10 @@ def creators(self, refresh=False): def system_include(self): path = self.sysconfig_path( "include", - {k: (self.system_prefix if v.startswith(self.prefix) else v) for k, v in self.sysconfig_vars.items()}, + { + k: (self.system_prefix if v is not None and v.startswith(self.prefix) else v) + for k, v in self.sysconfig_vars.items() + }, ) if not os.path.exists(path): # some broken packaging don't respect the sysconfig, fallback to distutils path # the pattern include the distribution name too at the end, remove that via the parent call @@ -221,23 +254,17 @@ def system_prefix(self): def system_exec_prefix(self): return self.real_prefix or self.base_exec_prefix or self.exec_prefix - def __unicode__(self): - content = repr(self) - if sys.version_info == 2: - content = content.decode("utf-8") - return content - - def __repr__(self): + def __repr__(self) -> str: return "{}({!r})".format( self.__class__.__name__, {k: v for k, v in self.__dict__.items() if not k.startswith("_")}, ) - def __str__(self): - content = "{}({})".format( + def __str__(self) -> str: + return "{}({})".format( self.__class__.__name__, ", ".join( - "{}={}".format(k, v) + f"{k}={v}" for k, v in ( ("spec", self.spec), ( @@ -248,22 +275,18 @@ def __str__(self): ), ( "original" - if ( - self.original_executable != self.system_executable - and self.original_executable != self.executable - ) + if self.original_executable not in {self.system_executable, self.executable} else None, self.original_executable, ), ("exe", self.executable), ("platform", self.platform), ("version", repr(self.version)), - ("encoding_fs_io", "{}-{}".format(self.file_system_encoding, self.stdout_encoding)), + ("encoding_fs_io", f"{self.file_system_encoding}-{self.stdout_encoding}"), ) if k is not None ), ) - return content @property def spec(self): @@ -272,13 +295,13 @@ def spec(self): @classmethod def clear_cache(cls, app_data): # this method is not used by itself, so here and called functions can import stuff locally - from virtualenv.discovery.cached_py_info import clear + from virtualenv.discovery.cached_py_info import clear # noqa: PLC0415 clear(app_data) cls._cache_exe_discovery.clear() - def satisfies(self, spec, impl_must_match): - """check if a given specification can be satisfied by the this python interpreter instance""" + def satisfies(self, spec, impl_must_match): # noqa: C901 + """Check if a given specification can be satisfied by the this python interpreter instance.""" if spec.path: if self.executable == os.path.abspath(spec.path): return True # if the path is a our own executable path we're done @@ -293,9 +316,12 @@ def satisfies(self, spec, impl_must_match): if basename != spec_path: return False - if impl_must_match: - if spec.implementation is not None and spec.implementation.lower() != self.implementation.lower(): - return False + if ( + impl_must_match + and spec.implementation is not None + and spec.implementation.lower() != self.implementation.lower() + ): + return False if spec.architecture is not None and spec.architecture != self.architecture: return False @@ -313,17 +339,17 @@ def current(cls, app_data=None): """ This locates the current host interpreter information. This might be different than what we run into in case the host python has been upgraded from underneath us. - """ + """ # noqa: D205 if cls._current is None: cls._current = cls.from_exe(sys.executable, app_data, raise_on_error=True, resolve_to_host=False) return cls._current @classmethod - def current_system(cls, app_data=None): + def current_system(cls, app_data=None) -> PythonInfo: """ This locates the current host interpreter information. This might be different than what we run into in case the host python has been upgraded from underneath us. - """ + """ # noqa: D205 if cls._current_system is None: cls._current_system = cls.from_exe(sys.executable, app_data, raise_on_error=True, resolve_to_host=True) return cls._current_system @@ -333,27 +359,35 @@ def _to_json(self): return json.dumps(self._to_dict(), indent=2) def _to_dict(self): - data = {var: (getattr(self, var) if var not in ("_creators",) else None) for var in vars(self)} - # noinspection PyProtectedMember + data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)} + data["version_info"] = data["version_info"]._asdict() # namedtuple to dictionary return data @classmethod - def from_exe(cls, exe, app_data=None, raise_on_error=True, ignore_cache=False, resolve_to_host=True, env=None): - """Given a path to an executable get the python information""" + def from_exe( # noqa: PLR0913 + cls, + exe, + app_data=None, + raise_on_error=True, # noqa: FBT002 + ignore_cache=False, # noqa: FBT002 + resolve_to_host=True, # noqa: FBT002 + env=None, + ): + """Given a path to an executable get the python information.""" # this method is not used by itself, so here and called functions can import stuff locally - from virtualenv.discovery.cached_py_info import from_exe + from virtualenv.discovery.cached_py_info import from_exe # noqa: PLC0415 env = os.environ if env is None else env proposed = from_exe(cls, app_data, exe, env=env, raise_on_error=raise_on_error, ignore_cache=ignore_cache) - # noinspection PyProtectedMember + if isinstance(proposed, PythonInfo) and resolve_to_host: try: - proposed = proposed._resolve_to_system(app_data, proposed) + proposed = proposed._resolve_to_system(app_data, proposed) # noqa: SLF001 except Exception as exception: if raise_on_error: - raise exception - logging.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception) + raise + LOGGER.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception) proposed = None return proposed @@ -361,13 +395,13 @@ def from_exe(cls, exe, app_data=None, raise_on_error=True, ignore_cache=False, r def _from_json(cls, payload): # the dictionary unroll here is to protect against pypy bug of interpreter crashing raw = json.loads(payload) - return cls._from_dict({k: v for k, v in raw.items()}) + return cls._from_dict(raw.copy()) @classmethod def _from_dict(cls, data): data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure result = cls() - result.__dict__ = {k: v for k, v in data.items()} + result.__dict__ = data.copy() return result @classmethod @@ -379,13 +413,14 @@ def _resolve_to_system(cls, app_data, target): if prefix in prefixes: if len(prefixes) == 1: # if we're linking back to ourselves accept ourselves with a WARNING - logging.info("%r links back to itself via prefixes", target) + LOGGER.info("%r links back to itself via prefixes", target) target.system_executable = target.executable break for at, (p, t) in enumerate(prefixes.items(), start=1): - logging.error("%d: prefix=%s, info=%r", at, p, t) - logging.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target) - raise RuntimeError("prefixes are causing a circle {}".format("|".join(prefixes.keys()))) + LOGGER.error("%d: prefix=%s, info=%r", at, p, t) + LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target) + msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys())) + raise RuntimeError(msg) prefixes[prefix] = target target = target.discover_exe(app_data, prefix=prefix, exact=False) if target.executable != target.system_executable: @@ -393,14 +428,14 @@ def _resolve_to_system(cls, app_data, target): target.executable = start_executable return target - _cache_exe_discovery = {} + _cache_exe_discovery = {} # noqa: RUF012 - def discover_exe(self, app_data, prefix, exact=True, env=None): + def discover_exe(self, app_data, prefix, exact=True, env=None): # noqa: FBT002 key = prefix, exact if key in self._cache_exe_discovery and prefix: - logging.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key]) + LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key]) return self._cache_exe_discovery[key] - logging.debug("discover exe for %s in %s", self, prefix) + LOGGER.debug("discover exe for %s in %s", self, prefix) # we don't know explicitly here, do some guess work - our executable name should tell possible_names = self._find_possible_exe_names() possible_folders = self._find_possible_folders(prefix) @@ -416,12 +451,12 @@ def discover_exe(self, app_data, prefix, exact=True, env=None): info = self._select_most_likely(discovered, self) folders = os.pathsep.join(possible_folders) self._cache_exe_discovery[key] = info - logging.debug("no exact match found, chosen most similar of %s within base folders %s", info, folders) + LOGGER.debug("no exact match found, chosen most similar of %s within base folders %s", info, folders) return info msg = "failed to detect {} in {}".format("|".join(possible_names), os.pathsep.join(possible_folders)) raise RuntimeError(msg) - def _check_exe(self, app_data, folder, name, exact, discovered, env): + def _check_exe(self, app_data, folder, name, exact, discovered, env): # noqa: PLR0913 exe_path = os.path.join(folder, name) if not os.path.exists(exe_path): return None @@ -435,7 +470,7 @@ def _check_exe(self, app_data, folder, name, exact, discovered, env): if item == "version_info": found, searched = ".".join(str(i) for i in found), ".".join(str(i) for i in searched) executable = info.executable - logging.debug("refused interpreter %s because %s differs %s != %s", executable, item, found, searched) + LOGGER.debug("refused interpreter %s because %s differs %s != %s", executable, item, found, searched) if exact is False: discovered.append(info) break @@ -459,12 +494,10 @@ def sort_by(info): info.version_info.releaselevel == target.version_info.releaselevel, info.version_info.serial == target.version_info.serial, ] - priority = sum((1 << pos if match else 0) for pos, match in enumerate(reversed(matches))) - return priority + return sum((1 << pos if match else 0) for pos, match in enumerate(reversed(matches))) sorted_discovered = sorted(discovered, key=sort_by, reverse=True) # sort by priority in decreasing order - most_likely = sorted_discovered[0] - return most_likely + return sorted_discovered[0] def _find_possible_folders(self, inside_folder): candidate_folder = OrderedDict() @@ -473,25 +506,25 @@ def _find_possible_folders(self, inside_folder): executables[self.executable] = None executables[os.path.realpath(self.original_executable)] = None executables[self.original_executable] = None - for exe in executables.keys(): + for exe in executables: base = os.path.dirname(exe) # following path pattern of the current if base.startswith(self.prefix): relative = base[len(self.prefix) :] - candidate_folder["{}{}".format(inside_folder, relative)] = None + candidate_folder[f"{inside_folder}{relative}"] = None # or at root level candidate_folder[inside_folder] = None - return list(i for i in candidate_folder.keys() if os.path.exists(i)) + return [i for i in candidate_folder if os.path.exists(i)] def _find_possible_exe_names(self): name_candidate = OrderedDict() for name in self._possible_base(): for at in (3, 2, 1, 0): version = ".".join(str(i) for i in self.version_info[:at]) - for arch in ["-{}".format(self.architecture), ""]: + for arch in [f"-{self.architecture}", ""]: for ext in EXTENSIONS: - candidate = "{}{}{}{}".format(name, version, arch, ext) + candidate = f"{name}{version}{arch}{ext}" name_candidate[candidate] = None return list(name_candidate.keys()) @@ -507,7 +540,7 @@ def _possible_base(self): for base in possible_base: lower = base.lower() yield lower - from virtualenv.info import fs_is_case_sensitive + from virtualenv.info import fs_is_case_sensitive # noqa: PLC0415 if fs_is_case_sensitive(): if base != lower: @@ -519,5 +552,22 @@ def _possible_base(self): if __name__ == "__main__": # dump a JSON representation of the current python - # noinspection PyProtectedMember - print(PythonInfo()._to_json()) + + argv = sys.argv[1:] + + if len(argv) >= 1: + start_cookie = argv[0] + argv = argv[1:] + else: + start_cookie = "" + + if len(argv) >= 1: + end_cookie = argv[0] + argv = argv[1:] + else: + end_cookie = "" + + sys.argv = sys.argv[:1] + argv + + info = PythonInfo()._to_json() # noqa: SLF001 + sys.stdout.write("".join((start_cookie[::-1], info, end_cookie[::-1]))) diff --git a/src/virtualenv/discovery/py_spec.py b/src/virtualenv/discovery/py_spec.py index cb63e1516..dcd84f423 100644 --- a/src/virtualenv/discovery/py_spec.py +++ b/src/virtualenv/discovery/py_spec.py @@ -1,22 +1,26 @@ -"""A Python specification is an abstract requirement definition of a interpreter""" -from __future__ import absolute_import, unicode_literals +"""A Python specification is an abstract requirement definition of an interpreter.""" + +from __future__ import annotations import os import re -import sys -from collections import OrderedDict - -from virtualenv.info import fs_is_case_sensitive -from virtualenv.util.six import ensure_str PATTERN = re.compile(r"^(?P[a-zA-Z]+)?(?P[0-9.]+)?(?:-(?P32|64))?$") -IS_WIN = sys.platform == "win32" -class PythonSpec(object): - """Contains specification about a Python Interpreter""" +class PythonSpec: + """Contains specification about a Python Interpreter.""" - def __init__(self, str_spec, implementation, major, minor, micro, architecture, path): + def __init__( # noqa: PLR0913 + self, + str_spec: str, + implementation: str | None, + major: int | None, + minor: int | None, + micro: int | None, + architecture: int | None, + path: str | None, + ) -> None: self.str_spec = str_spec self.implementation = implementation self.major = major @@ -26,9 +30,9 @@ def __init__(self, str_spec, implementation, major, minor, micro, architecture, self.path = path @classmethod - def from_string_spec(cls, string_spec): + def from_string_spec(cls, string_spec: str): # noqa: C901, PLR0912 impl, major, minor, micro, arch, path = None, None, None, None, None, None - if os.path.isabs(string_spec): + if os.path.isabs(string_spec): # noqa: PLR1702 path = string_spec else: ok = False @@ -43,24 +47,24 @@ def _int_or_none(val): version = groups["version"] if version is not None: versions = tuple(int(i) for i in version.split(".") if i) - if len(versions) > 3: - raise ValueError - if len(versions) == 3: + if len(versions) > 3: # noqa: PLR2004 + raise ValueError # noqa: TRY301 + if len(versions) == 3: # noqa: PLR2004 major, minor, micro = versions - elif len(versions) == 2: + elif len(versions) == 2: # noqa: PLR2004 major, minor = versions elif len(versions) == 1: version_data = versions[0] major = int(str(version_data)[0]) # first digit major - if version_data > 9: + if version_data > 9: # noqa: PLR2004 minor = int(str(version_data)[1:]) ok = True except ValueError: pass else: impl = groups["impl"] - if impl == "py" or impl == "python": - impl = "CPython" + if impl in {"py", "python"}: + impl = None arch = _int_or_none(groups["arch"]) if not ok: @@ -68,34 +72,33 @@ def _int_or_none(val): return cls(string_spec, impl, major, minor, micro, arch, path) - def generate_names(self): - impls = OrderedDict() - if self.implementation: - # first consider implementation as it is - impls[self.implementation] = False - if fs_is_case_sensitive(): - # for case sensitive file systems consider lower and upper case versions too - # trivia: MacBooks and all pre 2018 Windows-es were case insensitive by default - impls[self.implementation.lower()] = False - impls[self.implementation.upper()] = False - impls["python"] = True # finally consider python as alias, implementation must match now - version = self.major, self.minor, self.micro - try: - version = version[: version.index(None)] - except ValueError: - pass - for impl, match in impls.items(): - for at in range(len(version), -1, -1): - cur_ver = version[0:at] - spec = "{}{}".format(impl, ".".join(str(i) for i in cur_ver)) - yield spec, match + def generate_re(self, *, windows: bool) -> re.Pattern: + """Generate a regular expression for matching against a filename.""" + version = r"{}(\.{}(\.{})?)?".format( + *(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)) + ) + impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}" + suffix = r"\.exe" if windows else "" + version_conditional = ( + "?" + # Windows Python executables are almost always unversioned + if windows + # Spec is an empty string + or self.major is None + else "" + ) + # Try matching `direct` first, so the `direct` group is filled when possible. + return re.compile( + rf"(?P{impl})(?P{version}){version_conditional}{suffix}$", + flags=re.IGNORECASE, + ) @property def is_abs(self): return self.path is not None and os.path.isabs(self.path) def satisfies(self, spec): - """called when there's a candidate metadata spec to see if compatible - e.g. PEP-514 on Windows""" + """Called when there's a candidate metadata spec to see if compatible - e.g. PEP-514 on Windows.""" if spec.is_abs and self.is_abs and self.path != spec.path: return False if spec.implementation is not None and spec.implementation.lower() != self.implementation.lower(): @@ -108,15 +111,12 @@ def satisfies(self, spec): return False return True - def __unicode__(self): - return "{}({})".format( - type(self).__name__, - ", ".join( - "{}={}".format(k, getattr(self, k)) - for k in ("implementation", "major", "minor", "micro", "architecture", "path") - if getattr(self, k) is not None - ), - ) + def __repr__(self) -> str: + name = type(self).__name__ + params = "implementation", "major", "minor", "micro", "architecture", "path" + return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})" + - def __repr__(self): - return ensure_str(self.__unicode__()) +__all__ = [ + "PythonSpec", +] diff --git a/src/virtualenv/discovery/windows/__init__.py b/src/virtualenv/discovery/windows/__init__.py index 259be976b..9efd5b6ab 100644 --- a/src/virtualenv/discovery/windows/__init__.py +++ b/src/virtualenv/discovery/windows/__init__.py @@ -1,12 +1,19 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations + +from virtualenv.discovery.py_info import PythonInfo +from virtualenv.discovery.py_spec import PythonSpec -from ..py_info import PythonInfo -from ..py_spec import PythonSpec from .pep514 import discover_pythons +# Map of well-known organizations (as per PEP 514 Company Windows Registry key part) versus Python implementation +_IMPLEMENTATION_BY_ORG = { + "ContinuumAnalytics": "CPython", + "PythonCore": "CPython", +} + class Pep514PythonInfo(PythonInfo): - """ """ + """A Python information acquired from PEP-514.""" def propose_interpreters(spec, cache_dir, env): @@ -16,16 +23,25 @@ def propose_interpreters(spec, cache_dir, env): # and prefer PythonCore over conda pythons (as virtualenv is mostly used by non conda tools) existing = list(discover_pythons()) existing.sort( - key=lambda i: tuple(-1 if j is None else j for j in i[1:4]) + (1 if i[0] == "PythonCore" else 0,), reverse=True + key=lambda i: (*tuple(-1 if j is None else j for j in i[1:4]), 1 if i[0] == "PythonCore" else 0), + reverse=True, ) for name, major, minor, arch, exe, _ in existing: - # pre-filter - if name in ("PythonCore", "ContinuumAnalytics"): - name = "CPython" - registry_spec = PythonSpec(None, name, major, minor, None, arch, exe) - if registry_spec.satisfies(spec): + # Map well-known/most common organizations to a Python implementation, use the org name as a fallback for + # backwards compatibility. + implementation = _IMPLEMENTATION_BY_ORG.get(name, name) + + # Pre-filtering based on Windows Registry metadata, for CPython only + skip_pre_filter = implementation.lower() != "cpython" + registry_spec = PythonSpec(None, implementation, major, minor, None, arch, exe) + if skip_pre_filter or registry_spec.satisfies(spec): interpreter = Pep514PythonInfo.from_exe(exe, cache_dir, env=env, raise_on_error=False) - if interpreter is not None: - if interpreter.satisfies(spec, impl_must_match=True): - yield interpreter + if interpreter is not None and interpreter.satisfies(spec, impl_must_match=True): + yield interpreter # Final filtering/matching using interpreter metadata + + +__all__ = [ + "Pep514PythonInfo", + "propose_interpreters", +] diff --git a/src/virtualenv/discovery/windows/pep514.py b/src/virtualenv/discovery/windows/pep514.py index 048436a60..8bc9e3060 100644 --- a/src/virtualenv/discovery/windows/pep514.py +++ b/src/virtualenv/discovery/windows/pep514.py @@ -1,18 +1,12 @@ -"""Implement https://www.python.org/dev/peps/pep-0514/ to discover interpreters - Windows only""" -from __future__ import absolute_import, print_function, unicode_literals +"""Implement https://www.python.org/dev/peps/pep-0514/ to discover interpreters - Windows only.""" + +from __future__ import annotations import os import re +import winreg from logging import basicConfig, getLogger -import six - -if six.PY3: - import winreg -else: - # noinspection PyUnresolvedReferences - import _winreg as winreg - LOGGER = getLogger(__name__) @@ -39,8 +33,7 @@ def discover_pythons(): (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_64KEY, 64), (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_32KEY, 32), ]: - for spec in process_set(hive, hive_name, key, flags, default_arch): - yield spec + yield from process_set(hive, hive_name, key, flags, default_arch) def process_set(hive, hive_name, key, flags, default_arch): @@ -49,8 +42,7 @@ def process_set(hive, hive_name, key, flags, default_arch): for company in enum_keys(root_key): if company == "PyLauncher": # reserved continue - for spec in process_company(hive_name, company, root_key, default_arch): - yield spec + yield from process_company(hive_name, company, root_key, default_arch) except OSError: pass @@ -74,35 +66,36 @@ def process_tag(hive_name, company, company_key, tag, default_arch): if exe_data is not None: exe, args = exe_data return company, major, minor, arch, exe, args + return None + return None + return None def load_exe(hive_name, company, company_key, tag): - key_path = "{}/{}/{}".format(hive_name, company, tag) + key_path = f"{hive_name}/{company}/{tag}" try: - with winreg.OpenKeyEx(company_key, r"{}\InstallPath".format(tag)) as ip_key: - with ip_key: - exe = get_value(ip_key, "ExecutablePath") - if exe is None: - ip = get_value(ip_key, None) - if ip is None: - msg(key_path, "no ExecutablePath or default for it") - - else: - exe = os.path.join(ip, str("python.exe")) - if exe is not None and os.path.exists(exe): - args = get_value(ip_key, "ExecutableArguments") - return exe, args + with winreg.OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key, ip_key: + exe = get_value(ip_key, "ExecutablePath") + if exe is None: + ip = get_value(ip_key, None) + if ip is None: + msg(key_path, "no ExecutablePath or default for it") + else: - msg(key_path, "could not load exe with value {}".format(exe)) + exe = os.path.join(ip, "python.exe") + if exe is not None and os.path.exists(exe): + args = get_value(ip_key, "ExecutableArguments") + return exe, args + msg(key_path, f"could not load exe with value {exe}") except OSError: - msg("{}/{}".format(key_path, "InstallPath"), "missing") + msg(f"{key_path}/InstallPath", "missing") return None def load_arch_data(hive_name, company, tag, tag_key, default_arch): arch_str = get_value(tag_key, "SysArchitecture") if arch_str is not None: - key_path = "{}/{}/{}/SysArchitecture".format(hive_name, company, tag) + key_path = f"{hive_name}/{company}/{tag}/SysArchitecture" try: return parse_arch(arch_str) except ValueError as sys_arch: @@ -111,20 +104,20 @@ def load_arch_data(hive_name, company, tag, tag_key, default_arch): def parse_arch(arch_str): - if isinstance(arch_str, six.string_types): + if isinstance(arch_str, str): match = re.match(r"^(\d+)bit$", arch_str) if match: return int(next(iter(match.groups()))) - error = "invalid format {}".format(arch_str) + error = f"invalid format {arch_str}" else: - error = "arch is not string: {}".format(repr(arch_str)) + error = f"arch is not string: {arch_str!r}" raise ValueError(error) def load_version_data(hive_name, company, tag, tag_key): for candidate, key_path in [ - (get_value(tag_key, "SysVersion"), "{}/{}/{}/SysVersion".format(hive_name, company, tag)), - (tag, "{}/{}/{}".format(hive_name, company, tag)), + (get_value(tag_key, "SysVersion"), f"{hive_name}/{company}/{tag}/SysVersion"), + (tag, f"{hive_name}/{company}/{tag}"), ]: if candidate is not None: try: @@ -135,26 +128,24 @@ def load_version_data(hive_name, company, tag, tag_key): def parse_version(version_str): - if isinstance(version_str, six.string_types): + if isinstance(version_str, str): match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?$", version_str) if match: return tuple(int(i) if i is not None else None for i in match.groups()) - error = "invalid format {}".format(version_str) + error = f"invalid format {version_str}" else: - error = "version is not string: {}".format(repr(version_str)) + error = f"version is not string: {version_str!r}" raise ValueError(error) def msg(path, what): - LOGGER.warning("PEP-514 violation in Windows Registry at {} error: {}".format(path, what)) + LOGGER.warning("PEP-514 violation in Windows Registry at %s error: %s", path, what) def _run(): basicConfig() - interpreters = [] - for spec in discover_pythons(): - interpreters.append(repr(spec)) - print("\n".join(sorted(interpreters))) + interpreters = [repr(spec) for spec in discover_pythons()] + print("\n".join(sorted(interpreters))) # noqa: T201 if __name__ == "__main__": diff --git a/src/virtualenv/info.py b/src/virtualenv/info.py index 7d5e86d78..6f8c2bdf3 100644 --- a/src/virtualenv/info.py +++ b/src/virtualenv/info.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import os @@ -9,29 +9,26 @@ IMPLEMENTATION = platform.python_implementation() IS_PYPY = IMPLEMENTATION == "PyPy" IS_CPYTHON = IMPLEMENTATION == "CPython" -PY3 = sys.version_info[0] == 3 -PY2 = sys.version_info[0] == 2 IS_WIN = sys.platform == "win32" IS_MAC_ARM64 = sys.platform == "darwin" and platform.machine() == "arm64" ROOT = os.path.realpath(os.path.join(os.path.abspath(__file__), os.path.pardir, os.path.pardir)) IS_ZIPAPP = os.path.isfile(ROOT) -WIN_CPYTHON_2 = IS_CPYTHON and IS_WIN and PY2 - _CAN_SYMLINK = _FS_CASE_SENSITIVE = _CFG_DIR = _DATA_DIR = None +LOGGER = logging.getLogger(__name__) def fs_is_case_sensitive(): - global _FS_CASE_SENSITIVE + global _FS_CASE_SENSITIVE # noqa: PLW0603 if _FS_CASE_SENSITIVE is None: with tempfile.NamedTemporaryFile(prefix="TmP") as tmp_file: _FS_CASE_SENSITIVE = not os.path.exists(tmp_file.name.lower()) - logging.debug("filesystem is %scase-sensitive", "" if _FS_CASE_SENSITIVE else "not ") + LOGGER.debug("filesystem is %scase-sensitive", "" if _FS_CASE_SENSITIVE else "not ") return _FS_CASE_SENSITIVE def fs_supports_symlink(): - global _CAN_SYMLINK + global _CAN_SYMLINK # noqa: PLW0603 if _CAN_SYMLINK is None: can = False @@ -39,28 +36,31 @@ def fs_supports_symlink(): if IS_WIN: with tempfile.NamedTemporaryFile(prefix="TmP") as tmp_file: temp_dir = os.path.dirname(tmp_file.name) - dest = os.path.join(temp_dir, "{}-{}".format(tmp_file.name, "b")) + dest = os.path.join(temp_dir, f"{tmp_file.name}-{'b'}") try: os.symlink(tmp_file.name, dest) can = True except (OSError, NotImplementedError): pass - logging.debug("symlink on filesystem does%s work", "" if can else " not") + LOGGER.debug("symlink on filesystem does%s work", "" if can else " not") else: can = True _CAN_SYMLINK = can return _CAN_SYMLINK +def fs_path_id(path: str) -> str: + return path.casefold() if fs_is_case_sensitive() else path + + __all__ = ( - "IS_PYPY", "IS_CPYTHON", - "PY3", - "PY2", + "IS_MAC_ARM64", + "IS_PYPY", "IS_WIN", + "IS_ZIPAPP", + "ROOT", "fs_is_case_sensitive", + "fs_path_id", "fs_supports_symlink", - "ROOT", - "IS_ZIPAPP", - "WIN_CPYTHON_2", ) diff --git a/src/virtualenv/report.py b/src/virtualenv/report.py index 2a2954f10..c9682a8f6 100644 --- a/src/virtualenv/report.py +++ b/src/virtualenv/report.py @@ -1,10 +1,8 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import sys -from virtualenv.util.six import ensure_str - LEVELS = { 0: logging.CRITICAL, 1: logging.ERROR, @@ -18,25 +16,24 @@ LOGGER = logging.getLogger() -def setup_report(verbosity, show_pid=False): +def setup_report(verbosity, show_pid=False): # noqa: FBT002 _clean_handlers(LOGGER) - if verbosity > MAX_LEVEL: - verbosity = MAX_LEVEL # pragma: no cover + verbosity = min(verbosity, MAX_LEVEL) # pragma: no cover level = LEVELS[verbosity] msg_format = "%(message)s" if level <= logging.DEBUG: locate = "module" - msg_format = "%(relativeCreated)d {} [%(levelname)s %({})s:%(lineno)d]".format(msg_format, locate) + msg_format = f"%(relativeCreated)d {msg_format} [%(levelname)s %({locate})s:%(lineno)d]" if show_pid: - msg_format = "[%(process)d] " + msg_format - formatter = logging.Formatter(ensure_str(msg_format)) + msg_format = f"[%(process)d] {msg_format}" + formatter = logging.Formatter(msg_format) stream_handler = logging.StreamHandler(stream=sys.stdout) stream_handler.setLevel(level) LOGGER.setLevel(logging.NOTSET) stream_handler.setFormatter(formatter) LOGGER.addHandler(stream_handler) level_name = logging.getLevelName(level) - logging.debug("setup logging to %s", level_name) + LOGGER.debug("setup logging to %s", level_name) logging.getLogger("distlib").setLevel(logging.ERROR) return verbosity @@ -46,8 +43,8 @@ def _clean_handlers(log): log.removeHandler(log_handler) -__all__ = ( +__all__ = [ "LEVELS", "MAX_LEVEL", "setup_report", -) +] diff --git a/src/virtualenv/run/__init__.py b/src/virtualenv/run/__init__.py index e8e7ab138..48647ec7c 100644 --- a/src/virtualenv/run/__init__.py +++ b/src/virtualenv/run/__init__.py @@ -1,22 +1,23 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import os from functools import partial -from ..app_data import make_app_data -from ..config.cli.parser import VirtualEnvConfigParser -from ..report import LEVELS, setup_report -from ..run.session import Session -from ..seed.wheels.periodic_update import manual_upgrade -from ..version import __version__ +from virtualenv.app_data import make_app_data +from virtualenv.config.cli.parser import VirtualEnvConfigParser +from virtualenv.report import LEVELS, setup_report +from virtualenv.run.session import Session +from virtualenv.seed.wheels.periodic_update import manual_upgrade +from virtualenv.version import __version__ + from .plugin.activators import ActivationSelector from .plugin.creators import CreatorSelector from .plugin.discovery import get_discover from .plugin.seeders import SeederSelector -def cli_run(args, options=None, setup_logging=True, env=None): +def cli_run(args, options=None, setup_logging=True, env=None): # noqa: FBT002 """ Create a virtual environment given some command line interface arguments. @@ -33,7 +34,7 @@ def cli_run(args, options=None, setup_logging=True, env=None): return of_session -def session_via_cli(args, options=None, setup_logging=True, env=None): +def session_via_cli(args, options=None, setup_logging=True, env=None): # noqa: FBT002 """ Create a virtualenv session (same as cli_run, but this does not perform the creation). Use this if you just want to query what the virtual environment would look like, but not actually create it. @@ -43,16 +44,22 @@ def session_via_cli(args, options=None, setup_logging=True, env=None): :param setup_logging: ``True`` if setup logging handlers, ``False`` to use handlers already registered :param env: environment variables to use :return: the session object of the creation (its structure for now is experimental and might change on short notice) - """ + """ # noqa: D205 env = os.environ if env is None else env parser, elements = build_parser(args, options, setup_logging, env) options = parser.parse_args(args) creator, seeder, activators = tuple(e.create(options) for e in elements) # create types - of_session = Session(options.verbosity, options.app_data, parser._interpreter, creator, seeder, activators) # noqa - return of_session + return Session( + options.verbosity, + options.app_data, + parser._interpreter, # noqa: SLF001 + creator, + seeder, + activators, + ) -def build_parser(args=None, options=None, setup_logging=True, env=None): +def build_parser(args=None, options=None, setup_logging=True, env=None): # noqa: FBT002 parser = VirtualEnvConfigParser(options, os.environ if env is None else env) add_version_flag(parser) parser.add_argument( @@ -67,9 +74,10 @@ def build_parser(args=None, options=None, setup_logging=True, env=None): handle_extra_commands(options) discover = get_discover(parser, args) - parser._interpreter = interpreter = discover.interpreter + parser._interpreter = interpreter = discover.interpreter # noqa: SLF001 if interpreter is None: - raise RuntimeError("failed to find interpreter for {}".format(discover)) + msg = f"failed to find interpreter for {discover}" + raise RuntimeError(msg) elements = [ CreatorSelector(interpreter, parser), SeederSelector(interpreter, parser), @@ -83,7 +91,7 @@ def build_parser(args=None, options=None, setup_logging=True, env=None): def build_parser_only(args=None): - """Used to provide a parser for the doc generation""" + """Used to provide a parser for the doc generation.""" return build_parser(args)[0] @@ -125,18 +133,18 @@ def load_app_data(args, parser, options): def add_version_flag(parser): - import virtualenv + import virtualenv # noqa: PLC0415 parser.add_argument( "--version", action="version", - version="%(prog)s {} from {}".format(__version__, virtualenv.__file__), + version=f"%(prog)s {__version__} from {virtualenv.__file__}", help="display the version of the virtualenv package and its location, then exit", ) def _do_report_setup(parser, args, setup_logging): - level_map = ", ".join("{}={}".format(logging.getLevelName(l), c) for c, l in sorted(list(LEVELS.items()))) + level_map = ", ".join(f"{logging.getLevelName(line)}={c}" for c, line in sorted(LEVELS.items())) msg = "verbosity = verbose - quiet, default {}, mapping => {}" verbosity_group = parser.add_argument_group( title="verbosity", @@ -150,7 +158,7 @@ def _do_report_setup(parser, args, setup_logging): setup_report(option.verbosity) -__all__ = ( +__all__ = [ "cli_run", "session_via_cli", -) +] diff --git a/src/virtualenv/run/plugin/activators.py b/src/virtualenv/run/plugin/activators.py index 8180981b1..a0e866948 100644 --- a/src/virtualenv/run/plugin/activators.py +++ b/src/virtualenv/run/plugin/activators.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from argparse import ArgumentTypeError from collections import OrderedDict @@ -7,19 +7,19 @@ class ActivationSelector(ComponentBuilder): - def __init__(self, interpreter, parser): + def __init__(self, interpreter, parser) -> None: self.default = None possible = OrderedDict( (k, v) for k, v in self.options("virtualenv.activate").items() if v.supports(interpreter) ) - super(ActivationSelector, self).__init__(interpreter, parser, "activators", possible) + super().__init__(interpreter, parser, "activators", possible) self.parser.description = "options for activation scripts" self.active = None def add_selector_arg_parse(self, name, choices): self.default = ",".join(choices) self.parser.add_argument( - "--{}".format(name), + f"--{name}", default=self.default, metavar="comma_sep_list", required=False, @@ -31,7 +31,8 @@ def _extract_activators(self, entered_str): elements = [e.strip() for e in entered_str.split(",") if e.strip()] missing = [e for e in elements if e not in self.possible] if missing: - raise ArgumentTypeError("the following activators are not available {}".format(",".join(missing))) + msg = f"the following activators are not available {','.join(missing)}" + raise ArgumentTypeError(msg) return elements def handle_selected_arg_parse(self, options): @@ -54,3 +55,8 @@ def handle_selected_arg_parse(self, options): def create(self, options): return [activator_class(options) for activator_class in self.active.values()] + + +__all__ = [ + "ActivationSelector", +] diff --git a/src/virtualenv/run/plugin/base.py b/src/virtualenv/run/plugin/base.py index 048c76a41..97c4792ec 100644 --- a/src/virtualenv/run/plugin/base.py +++ b/src/virtualenv/run/plugin/base.py @@ -1,19 +1,13 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import sys from collections import OrderedDict +from importlib.metadata import entry_points -if sys.version_info >= (3, 8): - from importlib.metadata import entry_points +importlib_metadata_version = () - importlib_metadata_version = () -else: - from importlib_metadata import entry_points, version - importlib_metadata_version = tuple(int(i) for i in version("importlib_metadata").split(".")[:2]) - - -class PluginLoader(object): +class PluginLoader: _OPTIONS = None _ENTRY_POINTS = None @@ -21,8 +15,7 @@ class PluginLoader(object): def entry_points_for(cls, key): if sys.version_info >= (3, 10) or importlib_metadata_version >= (3, 6): return OrderedDict((e.name, e.load()) for e in cls.entry_points().select(group=key)) - else: - return OrderedDict((e.name, e.load()) for e in cls.entry_points().get(key, {})) + return OrderedDict((e.name, e.load()) for e in cls.entry_points().get(key, {})) @staticmethod def entry_points(): @@ -32,7 +25,7 @@ def entry_points(): class ComponentBuilder(PluginLoader): - def __init__(self, interpreter, parser, name, possible): + def __init__(self, interpreter, parser, name, possible) -> None: self.interpreter = interpreter self.name = name self._impl_class = None @@ -52,14 +45,21 @@ def add_selector_arg_parse(self, name, choices): def handle_selected_arg_parse(self, options): selected = getattr(options, self.name) if selected not in self.possible: - raise RuntimeError("No implementation for {}".format(self.interpreter)) + msg = f"No implementation for {self.interpreter}" + raise RuntimeError(msg) self._impl_class = self.possible[selected] self.populate_selected_argparse(selected, options.app_data) return selected def populate_selected_argparse(self, selected, app_data): - self.parser.description = "options for {} {}".format(self.name, selected) + self.parser.description = f"options for {self.name} {selected}" self._impl_class.add_parser_arguments(self.parser, self.interpreter, app_data) def create(self, options): return self._impl_class(options, self.interpreter) + + +__all__ = [ + "ComponentBuilder", + "PluginLoader", +] diff --git a/src/virtualenv/run/plugin/creators.py b/src/virtualenv/run/plugin/creators.py index ef4177a59..6bb11845d 100644 --- a/src/virtualenv/run/plugin/creators.py +++ b/src/virtualenv/run/plugin/creators.py @@ -1,19 +1,28 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from collections import OrderedDict, defaultdict, namedtuple +from collections import OrderedDict, defaultdict +from typing import TYPE_CHECKING, NamedTuple from virtualenv.create.describe import Describe from virtualenv.create.via_global_ref.builtin.builtin_way import VirtualenvBuiltin from .base import ComponentBuilder -CreatorInfo = namedtuple("CreatorInfo", ["key_to_class", "key_to_meta", "describe", "builtin_key"]) +if TYPE_CHECKING: + from virtualenv.create.creator import Creator, CreatorMeta + + +class CreatorInfo(NamedTuple): + key_to_class: dict[str, type[Creator]] + key_to_meta: dict[str, CreatorMeta] + describe: type[Describe] | None + builtin_key: str class CreatorSelector(ComponentBuilder): - def __init__(self, interpreter, parser): + def __init__(self, interpreter, parser) -> None: creators, self.key_to_meta, self.describe, self.builtin_key = self.for_interpreter(interpreter) - super(CreatorSelector, self).__init__(interpreter, parser, "creator", creators) + super().__init__(interpreter, parser, "creator", creators) @classmethod def for_interpreter(cls, interpreter): @@ -21,7 +30,8 @@ def for_interpreter(cls, interpreter): errors = defaultdict(list) for key, creator_class in cls.options("virtualenv.create").items(): if key == "builtin": - raise RuntimeError("builtin creator is a reserved name") + msg = "builtin creator is a reserved name" + raise RuntimeError(msg) meta = creator_class.can_create(interpreter) if meta: if meta.error: @@ -37,10 +47,10 @@ def for_interpreter(cls, interpreter): describe = creator_class if not key_to_meta: if errors: - rows = ["{} for creators {}".format(k, ", ".join(i.__name__ for i in v)) for k, v in errors.items()] + rows = [f"{k} for creators {', '.join(i.__name__ for i in v)}" for k, v in errors.items()] raise RuntimeError("\n".join(rows)) - else: - raise RuntimeError("No virtualenv implementation for {}".format(interpreter)) + msg = f"No virtualenv implementation for {interpreter}" + raise RuntimeError(msg) return CreatorInfo( key_to_class=key_to_class, key_to_meta=key_to_meta, @@ -53,13 +63,11 @@ def add_selector_arg_parse(self, name, choices): choices = sorted(choices, key=lambda a: 0 if a == "builtin" else 1) default_value = self._get_default(choices) self.parser.add_argument( - "--{}".format(name), + f"--{name}", choices=choices, default=default_value, required=False, - help="create environment via{}".format( - "" if self.builtin_key is None else " (builtin = {})".format(self.builtin_key), - ), + help=f"create environment via{'' if self.builtin_key is None else f' (builtin = {self.builtin_key})'}", ) @staticmethod @@ -67,11 +75,17 @@ def _get_default(choices): return next(iter(choices)) def populate_selected_argparse(self, selected, app_data): - self.parser.description = "options for {} {}".format(self.name, selected) + self.parser.description = f"options for {self.name} {selected}" self._impl_class.add_parser_arguments(self.parser, self.interpreter, self.key_to_meta[selected], app_data) def create(self, options): options.meta = self.key_to_meta[getattr(options, self.name)] if not issubclass(self._impl_class, Describe): options.describe = self.describe(options, self.interpreter) - return super(CreatorSelector, self).create(options) + return super().create(options) + + +__all__ = [ + "CreatorInfo", + "CreatorSelector", +] diff --git a/src/virtualenv/run/plugin/discovery.py b/src/virtualenv/run/plugin/discovery.py index ac9b7f526..a963042d0 100644 --- a/src/virtualenv/run/plugin/discovery.py +++ b/src/virtualenv/run/plugin/discovery.py @@ -1,10 +1,10 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from .base import PluginLoader class Discovery(PluginLoader): - """ """ + """Discovery plugins.""" def get_discover(parser, args): @@ -27,9 +27,14 @@ def get_discover(parser, args): discover_class = discover_types[options.discovery] discover_class.add_parser_arguments(discovery_parser) options, _ = parser.parse_known_args(args, namespace=options) - discover = discover_class(options) - return discover + return discover_class(options) def _get_default_discovery(discover_types): return list(discover_types.keys()) + + +__all__ = [ + "Discovery", + "get_discover", +] diff --git a/src/virtualenv/run/plugin/seeders.py b/src/virtualenv/run/plugin/seeders.py index d182c6f73..b1da34c5d 100644 --- a/src/virtualenv/run/plugin/seeders.py +++ b/src/virtualenv/run/plugin/seeders.py @@ -1,16 +1,16 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from .base import ComponentBuilder class SeederSelector(ComponentBuilder): - def __init__(self, interpreter, parser): + def __init__(self, interpreter, parser) -> None: possible = self.options("virtualenv.seed") - super(SeederSelector, self).__init__(interpreter, parser, "seeder", possible) + super().__init__(interpreter, parser, "seeder", possible) def add_selector_arg_parse(self, name, choices): self.parser.add_argument( - "--{}".format(name), + f"--{name}", choices=choices, default=self._get_default(), required=False, @@ -29,7 +29,12 @@ def _get_default(): return "app-data" def handle_selected_arg_parse(self, options): - return super(SeederSelector, self).handle_selected_arg_parse(options) + return super().handle_selected_arg_parse(options) def create(self, options): return self._impl_class(options) + + +__all__ = [ + "SeederSelector", +] diff --git a/src/virtualenv/run/session.py b/src/virtualenv/run/session.py index 24836d285..def795328 100644 --- a/src/virtualenv/run/session.py +++ b/src/virtualenv/run/session.py @@ -1,15 +1,15 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import json import logging -from virtualenv.util.six import ensure_text +LOGGER = logging.getLogger(__name__) -class Session(object): - """Represents a virtual environment creation session""" +class Session: + """Represents a virtual environment creation session.""" - def __init__(self, verbosity, app_data, interpreter, creator, seeder, activators): + def __init__(self, verbosity, app_data, interpreter, creator, seeder, activators) -> None: # noqa: PLR0913 self._verbosity = verbosity self._app_data = app_data self._interpreter = interpreter @@ -19,27 +19,27 @@ def __init__(self, verbosity, app_data, interpreter, creator, seeder, activators @property def verbosity(self): - """The verbosity of the run""" + """The verbosity of the run.""" return self._verbosity @property def interpreter(self): - """Create a virtual environment based on this reference interpreter""" + """Create a virtual environment based on this reference interpreter.""" return self._interpreter @property def creator(self): - """The creator used to build the virtual environment (must be compatible with the interpreter)""" + """The creator used to build the virtual environment (must be compatible with the interpreter).""" return self._creator @property def seeder(self): - """The mechanism used to provide the seed packages (pip, setuptools, wheel)""" + """The mechanism used to provide the seed packages (pip, setuptools, wheel).""" return self._seeder @property def activators(self): - """Activators used to generate activations scripts""" + """Activators used to generate activations scripts.""" return self._activators def run(self): @@ -49,22 +49,20 @@ def run(self): self.creator.pyenv_cfg.write() def _create(self): - logging.info("create virtual environment via %s", ensure_text(str(self.creator))) + LOGGER.info("create virtual environment via %s", self.creator) self.creator.run() - logging.debug(_DEBUG_MARKER) - logging.debug("%s", _Debug(self.creator)) + LOGGER.debug(_DEBUG_MARKER) + LOGGER.debug("%s", _Debug(self.creator)) def _seed(self): if self.seeder is not None and self.seeder.enabled: - logging.info("add seed packages via %s", self.seeder) + LOGGER.info("add seed packages via %s", self.seeder) self.seeder.run(self.creator) def _activate(self): if self.activators: - logging.info( - "add activators for %s", - ", ".join(type(i).__name__.replace("Activator", "") for i in self.activators), - ) + active = ", ".join(type(i).__name__.replace("Activator", "") for i in self.activators) + LOGGER.info("add activators for %s", active) for activator in self.activators: activator.generate(self.creator) @@ -78,14 +76,16 @@ def __exit__(self, exc_type, exc_val, exc_tb): _DEBUG_MARKER = "=" * 30 + " target debug " + "=" * 30 -class _Debug(object): - """lazily populate debug""" +class _Debug: + """lazily populate debug.""" - def __init__(self, creator): + def __init__(self, creator) -> None: self.creator = creator - def __unicode__(self): - return ensure_text(repr(self)) - - def __repr__(self): + def __repr__(self) -> str: return json.dumps(self.creator.debug, indent=2) + + +__all__ = [ + "Session", +] diff --git a/src/virtualenv/seed/__init__.py b/src/virtualenv/seed/__init__.py index 01e6d4f49..e69de29bb 100644 --- a/src/virtualenv/seed/__init__.py +++ b/src/virtualenv/seed/__init__.py @@ -1 +0,0 @@ -from __future__ import absolute_import, unicode_literals diff --git a/src/virtualenv/seed/embed/base_embed.py b/src/virtualenv/seed/embed/base_embed.py index c794e834d..864cc49ca 100644 --- a/src/virtualenv/seed/embed/base_embed.py +++ b/src/virtualenv/seed/embed/base_embed.py @@ -1,22 +1,17 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from abc import ABCMeta +from abc import ABC +from pathlib import Path -from six import add_metaclass - -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_str, ensure_text - -from ..seeder import Seeder -from ..wheels import Version +from virtualenv.seed.seeder import Seeder +from virtualenv.seed.wheels import Version PERIODIC_UPDATE_ON_BY_DEFAULT = True -@add_metaclass(ABCMeta) -class BaseEmbed(Seeder): - def __init__(self, options): - super(BaseEmbed, self).__init__(options, enabled=options.no_seed is False) +class BaseEmbed(Seeder, ABC): + def __init__(self, options) -> None: + super().__init__(options, enabled=options.no_seed is False) self.download = options.download self.extra_search_dir = [i.resolve() for i in options.extra_search_dir if i.exists()] @@ -35,36 +30,36 @@ def __init__(self, options): self.enabled = False @classmethod - def distributions(cls): + def distributions(cls) -> dict[str, Version]: return { "pip": Version.bundle, "setuptools": Version.bundle, "wheel": Version.bundle, } - def distribution_to_versions(self): + def distribution_to_versions(self) -> dict[str, str]: return { - distribution: getattr(self, "{}_version".format(distribution)) + distribution: getattr(self, f"{distribution}_version") for distribution in self.distributions() - if getattr(self, "no_{}".format(distribution)) is False + if getattr(self, f"no_{distribution}") is False and getattr(self, f"{distribution}_version") != "none" } @classmethod - def add_parser_arguments(cls, parser, interpreter, app_data): + def add_parser_arguments(cls, parser, interpreter, app_data): # noqa: ARG003 group = parser.add_mutually_exclusive_group() group.add_argument( "--no-download", "--never-download", dest="download", action="store_false", - help="pass to disable download of the latest {} from PyPI".format("/".join(cls.distributions())), + help=f"pass to disable download of the latest {'/'.join(cls.distributions())} from PyPI", default=True, ) group.add_argument( "--download", dest="download", action="store_true", - help="pass to enable download of the latest {} from PyPI".format("/".join(cls.distributions())), + help=f"pass to enable download of the latest {'/'.join(cls.distributions())} from PyPI", default=False, ) parser.add_argument( @@ -76,19 +71,21 @@ def add_parser_arguments(cls, parser, interpreter, app_data): default=[], ) for distribution, default in cls.distributions().items(): + if interpreter.version_info[:2] >= (3, 12) and distribution in {"wheel", "setuptools"}: + default = "none" # noqa: PLW2901 parser.add_argument( - "--{}".format(distribution), + f"--{distribution}", dest=distribution, metavar="version", - help="version of {} to install as seed: embed, bundle or exact version".format(distribution), + help=f"version of {distribution} to install as seed: embed, bundle, none or exact version", default=default, ) for distribution in cls.distributions(): parser.add_argument( - "--no-{}".format(distribution), - dest="no_{}".format(distribution), + f"--no-{distribution}", + dest=f"no_{distribution}", action="store_true", - help="do not install {}".format(distribution), + help=f"do not install {distribution}", default=False, ) parser.add_argument( @@ -99,20 +96,23 @@ def add_parser_arguments(cls, parser, interpreter, app_data): default=not PERIODIC_UPDATE_ON_BY_DEFAULT, ) - def __unicode__(self): + def __repr__(self) -> str: result = self.__class__.__name__ result += "(" if self.extra_search_dir: - result += "extra_search_dir={},".format(", ".join(ensure_text(str(i)) for i in self.extra_search_dir)) - result += "download={},".format(self.download) + result += f"extra_search_dir={', '.join(str(i) for i in self.extra_search_dir)}," + result += f"download={self.download}," for distribution in self.distributions(): - if getattr(self, "no_{}".format(distribution)): + if getattr(self, f"no_{distribution}"): continue - result += " {}{},".format( - distribution, - "={}".format(getattr(self, "{}_version".format(distribution), None) or "latest"), - ) + version = getattr(self, f"{distribution}_version", None) + if version == "none": + continue + ver = f"={version or 'latest'}" + result += f" {distribution}{ver}," return result[:-1] + ")" - def __repr__(self): - return ensure_str(self.__unicode__()) + +__all__ = [ + "BaseEmbed", +] diff --git a/src/virtualenv/seed/embed/pip_invoke.py b/src/virtualenv/seed/embed/pip_invoke.py index c935c0216..b733c5148 100644 --- a/src/virtualenv/seed/embed/pip_invoke.py +++ b/src/virtualenv/seed/embed/pip_invoke.py @@ -1,18 +1,19 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging from contextlib import contextmanager +from subprocess import Popen from virtualenv.discovery.cached_py_info import LogCmd from virtualenv.seed.embed.base_embed import BaseEmbed -from virtualenv.util.subprocess import Popen +from virtualenv.seed.wheels import Version, get_wheel, pip_wheel_env_run -from ..wheels import Version, get_wheel, pip_wheel_env_run +LOGGER = logging.getLogger(__name__) class PipInvoke(BaseEmbed): - def __init__(self, options): - super(PipInvoke, self).__init__(options) + def __init__(self, options) -> None: + super().__init__(options) def run(self, creator): if not self.enabled: @@ -24,11 +25,12 @@ def run(self, creator): @staticmethod def _execute(cmd, env): - logging.debug("pip seed by running: %s", LogCmd(cmd, env)) + LOGGER.debug("pip seed by running: %s", LogCmd(cmd, env)) process = Popen(cmd, env=env) process.communicate() if process.returncode != 0: - raise RuntimeError("failed seed with code {}".format(process.returncode)) + msg = f"failed seed with code {process.returncode}" + raise RuntimeError(msg) return process @contextmanager @@ -49,9 +51,15 @@ def get_pip_install_cmd(self, exe, for_py_version): env=self.env, ) if wheel is None: - raise RuntimeError("could not get wheel for distribution {}".format(dist)) + msg = f"could not get wheel for distribution {dist}" + raise RuntimeError(msg) folders.add(str(wheel.path.parent)) cmd.append(Version.as_pip_req(dist, wheel.version)) for folder in sorted(folders): cmd.extend(["--find-links", str(folder)]) yield cmd + + +__all__ = [ + "PipInvoke", +] diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py index 35e0ccae9..6cddef839 100644 --- a/src/virtualenv/seed/embed/via_app_data/pip_install/base.py +++ b/src/virtualenv/seed/embed/via_app_data/pip_install/base.py @@ -1,24 +1,24 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import os import re import zipfile -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod +from configparser import ConfigParser from itertools import chain +from pathlib import Path from tempfile import mkdtemp from distlib.scripts import ScriptMaker, enquote_executable -from six import PY3, add_metaclass -from virtualenv.util import ConfigParser -from virtualenv.util.path import Path, safe_delete -from virtualenv.util.six import ensure_text +from virtualenv.util.path import safe_delete +LOGGER = logging.getLogger(__name__) -@add_metaclass(ABCMeta) -class PipInstall(object): - def __init__(self, wheel, creator, image_folder): + +class PipInstall(ABC): + def __init__(self, wheel, creator, image_folder) -> None: self._wheel = wheel self._creator = creator self._image_dir = image_folder @@ -42,11 +42,11 @@ def install(self, version_info): script_dir = self._creator.script_dir for name, module in self._console_scripts.items(): consoles.update(self._create_console_entry_point(name, module, script_dir, version_info)) - logging.debug("generated console scripts %s", " ".join(i.name for i in consoles)) + LOGGER.debug("generated console scripts %s", " ".join(i.name for i in consoles)) def build_image(self): # 1. first extract the wheel - logging.debug("build install image for %s to %s", self._wheel.name, self._image_dir) + LOGGER.debug("build install image for %s to %s", self._wheel.name, self._image_dir) with zipfile.ZipFile(str(self._wheel)) as zip_ref: self._shorten_path_if_needed(zip_ref) zip_ref.extractall(str(self._image_dir)) @@ -62,37 +62,34 @@ def _shorten_path_if_needed(self, zip_ref): # https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation zip_max_len = max(len(i) for i in zip_ref.namelist()) path_len = zip_max_len + len(to_folder) - if path_len > 260: + if path_len > 260: # noqa: PLR2004 self._image_dir.mkdir(exist_ok=True) # to get a short path must exist - from virtualenv.util.path import get_short_path_name + from virtualenv.util.path import get_short_path_name # noqa: PLC0415 to_folder = get_short_path_name(to_folder) self._image_dir = Path(to_folder) def _records_text(self, files): - record_data = "\n".join( - "{},,".format(os.path.relpath(ensure_text(str(rec)), ensure_text(str(self._image_dir)))) for rec in files - ) - return record_data + return "\n".join(f"{os.path.relpath(str(rec), str(self._image_dir))},," for rec in files) def _generate_new_files(self): new_files = set() installer = self._dist_info / "INSTALLER" - installer.write_text("pip\n") + installer.write_text("pip\n", encoding="utf-8") new_files.add(installer) # inject a no-op root element, as workaround for bug in https://github.com/pypa/pip/issues/7226 - marker = self._image_dir / "{}.virtualenv".format(self._dist_info.stem) - marker.write_text("") + marker = self._image_dir / f"{self._dist_info.stem}.virtualenv" + marker.write_text("", encoding="utf-8") new_files.add(marker) folder = mkdtemp() try: to_folder = Path(folder) - rel = os.path.relpath(ensure_text(str(self._creator.script_dir)), ensure_text(str(self._creator.purelib))) + rel = os.path.relpath(str(self._creator.script_dir), str(self._creator.purelib)) version_info = self._creator.interpreter.version_info for name, module in self._console_scripts.items(): new_files.update( - Path(os.path.normpath(ensure_text(str(self._image_dir / rel / i.name)))) + Path(os.path.normpath(str(self._image_dir / rel / i.name))) for i in self._create_console_entry_point(name, module, to_folder, version_info) ) finally: @@ -111,7 +108,7 @@ def _dist_info(self): self.__dist_info = filename break else: - msg = "no .dist-info at {}, has {}".format(self._image_dir, ", ".join(files)) # pragma: no cover + msg = f"no .dist-info at {self._image_dir}, has {', '.join(files)}" raise RuntimeError(msg) # pragma: no cover return self.__dist_info @@ -127,29 +124,27 @@ def _console_scripts(self): self._console_entry_points = {} entry_points = self._dist_info / "entry_points.txt" if entry_points.exists(): - parser = ConfigParser.ConfigParser() - with entry_points.open() as file_handler: - reader = getattr(parser, "read_file" if PY3 else "readfp") - reader(file_handler) + parser = ConfigParser() + with entry_points.open(encoding="utf-8") as file_handler: + parser.read_file(file_handler) if "console_scripts" in parser.sections(): for name, value in parser.items("console_scripts"): match = re.match(r"(.*?)-?\d\.?\d*", name) - if match: - name = match.groups(1)[0] - self._console_entry_points[name] = value + our_name = match.groups(1)[0] if match else name + self._console_entry_points[our_name] = value return self._console_entry_points def _create_console_entry_point(self, name, value, to_folder, version_info): result = [] maker = ScriptMakerCustom(to_folder, version_info, self._creator.exe, name) - specification = "{} = {}".format(name, value) + specification = f"{name} = {value}" new_files = maker.make(specification) result.extend(Path(i) for i in new_files) return result def _uninstall_previous_version(self): dist_name = self._dist_info.stem.split("-")[0] - in_folders = chain.from_iterable([i.iterdir() for i in {self._creator.purelib, self._creator.platlib}]) + in_folders = chain.from_iterable([i.iterdir() for i in (self._creator.purelib, self._creator.platlib)]) paths = (p for p in in_folders if p.stem.split("-")[0] == dist_name and p.suffix == ".dist-info" and p.is_dir()) existing_dist = next(paths, None) if existing_dist is not None: @@ -158,14 +153,20 @@ def _uninstall_previous_version(self): @staticmethod def _uninstall_dist(dist): dist_base = dist.parent - logging.debug("uninstall existing distribution %s from %s", dist.stem, dist_base) + LOGGER.debug("uninstall existing distribution %s from %s", dist.stem, dist_base) top_txt = dist / "top_level.txt" # add top level packages at folder level - paths = {dist.parent / i.strip() for i in top_txt.read_text().splitlines()} if top_txt.exists() else set() + paths = ( + {dist.parent / i.strip() for i in top_txt.read_text(encoding="utf-8").splitlines()} + if top_txt.exists() + else set() + ) paths.add(dist) # add the dist-info folder itself base_dirs, record = paths.copy(), dist / "RECORD" # collect entries in record that we did not register yet - for name in (i.split(",")[0] for i in record.read_text().splitlines()) if record.exists() else (): + for name in ( + (i.split(",")[0] for i in record.read_text(encoding="utf-8").splitlines()) if record.exists() else () + ): path = dist_base / name if not any(p in base_dirs for p in path.parents): # only add if not already added as a base dir paths.add(path) @@ -186,8 +187,8 @@ def has_image(self): class ScriptMakerCustom(ScriptMaker): - def __init__(self, target_dir, version_info, executable, name): - super(ScriptMakerCustom, self).__init__(None, str(target_dir)) + def __init__(self, target_dir, version_info, executable, name) -> None: + super().__init__(None, str(target_dir)) self.clobber = True # overwrite self.set_mode = True # ensure they are executable self.executable = enquote_executable(str(executable)) @@ -196,5 +197,10 @@ def __init__(self, target_dir, version_info, executable, name): self._name = name def _write_script(self, names, shebang, script_bytes, filenames, ext): - names.add("{}{}.{}".format(self._name, *self.version_info)) - super(ScriptMakerCustom, self)._write_script(names, shebang, script_bytes, filenames, ext) + names.add(f"{self._name}{self.version_info[0]}.{self.version_info[1]}") + super()._write_script(names, shebang, script_bytes, filenames, ext) + + +__all__ = [ + "PipInstall", +] diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/copy.py b/src/virtualenv/seed/embed/via_app_data/pip_install/copy.py index 29d0bc88d..af50e8d12 100644 --- a/src/virtualenv/seed/embed/via_app_data/pip_install/copy.py +++ b/src/virtualenv/seed/embed/via_app_data/pip_install/copy.py @@ -1,9 +1,9 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations # noqa: A005 import os +from pathlib import Path -from virtualenv.util.path import Path, copy -from virtualenv.util.six import ensure_text +from virtualenv.util.path import copy from .base import PipInstall @@ -14,22 +14,27 @@ def _sync(self, src, dst): def _generate_new_files(self): # create the pyc files - new_files = super(CopyPipInstall, self)._generate_new_files() + new_files = super()._generate_new_files() new_files.update(self._cache_files()) return new_files def _cache_files(self): version = self._creator.interpreter.version_info - py_c_ext = ".{}-{}{}.pyc".format(self._creator.interpreter.implementation.lower(), version.major, version.minor) - for root, dirs, files in os.walk(ensure_text(str(self._image_dir)), topdown=True): + py_c_ext = f".{self._creator.interpreter.implementation.lower()}-{version.major}{version.minor}.pyc" + for root, dirs, files in os.walk(str(self._image_dir), topdown=True): root_path = Path(root) for name in files: if name.endswith(".py"): - yield root_path / "{}{}".format(name[:-3], py_c_ext) + yield root_path / f"{name[:-3]}{py_c_ext}" for name in dirs: yield root_path / name / "__pycache__" def _fix_records(self, new_files): extra_record_data_str = self._records_text(new_files) - with open(ensure_text(str(self._dist_info / "RECORD")), "ab") as file_handler: + with (self._dist_info / "RECORD").open("ab") as file_handler: file_handler.write(extra_record_data_str.encode("utf-8")) + + +__all__ = [ + "CopyPipInstall", +] diff --git a/src/virtualenv/seed/embed/via_app_data/pip_install/symlink.py b/src/virtualenv/seed/embed/via_app_data/pip_install/symlink.py index f958b6545..7eb9f5f47 100644 --- a/src/virtualenv/seed/embed/via_app_data/pip_install/symlink.py +++ b/src/virtualenv/seed/embed/via_app_data/pip_install/symlink.py @@ -1,29 +1,22 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os -import subprocess from stat import S_IREAD, S_IRGRP, S_IROTH +from subprocess import PIPE, Popen from virtualenv.util.path import safe_delete, set_tree -from virtualenv.util.six import ensure_text -from virtualenv.util.subprocess import Popen from .base import PipInstall class SymlinkPipInstall(PipInstall): def _sync(self, src, dst): - src_str = ensure_text(str(src)) - dest_str = ensure_text(str(dst)) - os.symlink(src_str, dest_str) + os.symlink(str(src), str(dst)) def _generate_new_files(self): # create the pyc files, as the build image will be R/O - process = Popen( - [ensure_text(str(self._creator.exe)), "-m", "compileall", ensure_text(str(self._image_dir))], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) + cmd = [str(self._creator.exe), "-m", "compileall", str(self._image_dir)] + process = Popen(cmd, stdout=PIPE, stderr=PIPE) process.communicate() # the root pyc is shared, so we'll not symlink that - but still add the pyc files to the RECORD for close root_py_cache = self._image_dir / "__pycache__" @@ -32,7 +25,7 @@ def _generate_new_files(self): new_files.update(root_py_cache.iterdir()) new_files.add(root_py_cache) safe_delete(root_py_cache) - core_new_files = super(SymlinkPipInstall, self)._generate_new_files() + core_new_files = super()._generate_new_files() # remove files that are within the image folder deeper than one level (as these will be not linked directly) for file in core_new_files: try: @@ -47,15 +40,19 @@ def _generate_new_files(self): def _fix_records(self, new_files): new_files.update(i for i in self._image_dir.iterdir()) extra_record_data_str = self._records_text(sorted(new_files, key=str)) - with open(ensure_text(str(self._dist_info / "RECORD")), "wb") as file_handler: - file_handler.write(extra_record_data_str.encode("utf-8")) + (self._dist_info / "RECORD").write_text(extra_record_data_str, encoding="utf-8") def build_image(self): - super(SymlinkPipInstall, self).build_image() + super().build_image() # protect the image by making it read only set_tree(self._image_dir, S_IREAD | S_IRGRP | S_IROTH) def clear(self): if self._image_dir.exists(): safe_delete(self._image_dir) - super(SymlinkPipInstall, self).clear() + super().clear() + + +__all__ = [ + "SymlinkPipInstall", +] diff --git a/src/virtualenv/seed/embed/via_app_data/via_app_data.py b/src/virtualenv/seed/embed/via_app_data/via_app_data.py index 9a98a709f..a2e9630c6 100644 --- a/src/virtualenv/seed/embed/via_app_data/via_app_data.py +++ b/src/virtualenv/seed/embed/via_app_data/via_app_data.py @@ -1,38 +1,40 @@ -"""Bootstrap""" -from __future__ import absolute_import, unicode_literals +"""Bootstrap.""" + +from __future__ import annotations import logging import sys import traceback from contextlib import contextmanager +from pathlib import Path from subprocess import CalledProcessError from threading import Lock, Thread from virtualenv.info import fs_supports_symlink from virtualenv.seed.embed.base_embed import BaseEmbed from virtualenv.seed.wheels import get_wheel -from virtualenv.util.path import Path from .pip_install.copy import CopyPipInstall from .pip_install.symlink import SymlinkPipInstall +LOGGER = logging.getLogger(__name__) + class FromAppData(BaseEmbed): - def __init__(self, options): - super(FromAppData, self).__init__(options) + def __init__(self, options) -> None: + super().__init__(options) self.symlinks = options.symlink_app_data @classmethod def add_parser_arguments(cls, parser, interpreter, app_data): - super(FromAppData, cls).add_parser_arguments(parser, interpreter, app_data) + super().add_parser_arguments(parser, interpreter, app_data) can_symlink = app_data.transient is False and fs_supports_symlink() + sym = "" if can_symlink else "not supported - " parser.add_argument( "--symlink-app-data", dest="symlink_app_data", action="store_true" if can_symlink else "store_false", - help="{} symlink the python packages from the app-data folder (requires seed pip>=19.3)".format( - "" if can_symlink else "not supported - ", - ), + help=f"{sym} symlink the python packages from the app-data folder (requires seed pip>=19.3)", default=False, ) @@ -46,7 +48,7 @@ def run(self, creator): def _install(name, wheel): try: - logging.debug("install %s from wheel %s via %s", name, wheel, installer_class.__name__) + LOGGER.debug("install %s from wheel %s via %s", name, wheel, installer_class.__name__) key = Path(installer_class.__name__) / wheel.path.stem wheel_img = self.app_data.wheel_image(creator.interpreter.version_release_str, key) installer = installer_class(wheel.path, creator, wheel_img) @@ -55,23 +57,23 @@ def _install(name, wheel): if not installer.has_image(): installer.build_image() installer.install(creator.interpreter.version_info) - except Exception: # noqa + except Exception: # noqa: BLE001 exceptions[name] = sys.exc_info() - threads = list(Thread(target=_install, args=(n, w)) for n, w in name_to_whl.items()) + threads = [Thread(target=_install, args=(n, w)) for n, w in name_to_whl.items()] for thread in threads: thread.start() for thread in threads: thread.join() if exceptions: - messages = ["failed to build image {} because:".format(", ".join(exceptions.keys()))] + messages = [f"failed to build image {', '.join(exceptions.keys())} because:"] for value in exceptions.values(): exc_type, exc_value, exc_traceback = value messages.append("".join(traceback.format_exception(exc_type, exc_value, exc_traceback))) raise RuntimeError("\n".join(messages)) @contextmanager - def _get_seed_wheels(self, creator): + def _get_seed_wheels(self, creator): # noqa: C901 name_to_whl, lock, fail = {}, Lock(), {} def _get(distribution, version): @@ -93,48 +95,52 @@ def _get(distribution, version): ) if result is not None: break - except Exception as exception: # noqa - logging.exception("fail") + except Exception as exception: + LOGGER.exception("fail") failure = exception if failure: if isinstance(failure, CalledProcessError): - msg = "failed to download {}".format(distribution) + msg = f"failed to download {distribution}" if version is not None: - msg += " version {}".format(version) - msg += ", pip download exit code {}".format(failure.returncode) - output = failure.output if sys.version_info < (3, 5) else (failure.output + failure.stderr) + msg += f" version {version}" + msg += f", pip download exit code {failure.returncode}" + output = failure.output + failure.stderr if output: msg += "\n" msg += output else: msg = repr(failure) - logging.error(msg) + LOGGER.error(msg) with lock: fail[distribution] = version else: with lock: name_to_whl[distribution] = result - threads = list( + threads = [ Thread(target=_get, args=(distribution, version)) for distribution, version in self.distribution_to_versions().items() - ) + ] for thread in threads: thread.start() for thread in threads: thread.join() if fail: - raise RuntimeError("seed failed due to failing to download wheels {}".format(", ".join(fail.keys()))) + msg = f"seed failed due to failing to download wheels {', '.join(fail.keys())}" + raise RuntimeError(msg) yield name_to_whl def installer_class(self, pip_version_tuple): - if self.symlinks and pip_version_tuple: - # symlink support requires pip 19.3+ - if pip_version_tuple >= (19, 3): - return SymlinkPipInstall + if self.symlinks and pip_version_tuple and pip_version_tuple >= (19, 3): # symlink support requires pip 19.3+ + return SymlinkPipInstall return CopyPipInstall - def __unicode__(self): - base = super(FromAppData, self).__unicode__() - msg = ", via={}, app_data_dir={}".format("symlink" if self.symlinks else "copy", self.app_data) - return base[:-1] + msg + base[-1] + def __repr__(self) -> str: + msg = f", via={'symlink' if self.symlinks else 'copy'}, app_data_dir={self.app_data}" + base = super().__repr__() + return f"{base[:-1]}{msg}{base[-1]}" + + +__all__ = [ + "FromAppData", +] diff --git a/src/virtualenv/seed/seeder.py b/src/virtualenv/seed/seeder.py index 852e85254..58fd8f416 100644 --- a/src/virtualenv/seed/seeder.py +++ b/src/virtualenv/seed/seeder.py @@ -1,17 +1,14 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod -from six import add_metaclass - -@add_metaclass(ABCMeta) -class Seeder(object): +class Seeder(ABC): """A seeder will install some seed packages into a virtual environment.""" - # noinspection PyUnusedLocal - def __init__(self, options, enabled): + def __init__(self, options, enabled) -> None: """ + Create. :param options: the parsed options as defined within :meth:`add_parser_arguments` :param enabled: a flag weather the seeder is enabled or not @@ -32,9 +29,15 @@ def add_parser_arguments(cls, parser, interpreter, app_data): @abstractmethod def run(self, creator): - """Perform the seed operation. + """ + Perform the seed operation. :param creator: the creator (based of :class:`virtualenv.create.creator.Creator`) we used to create this \ virtual environment """ raise NotImplementedError + + +__all__ = [ + "Seeder", +] diff --git a/src/virtualenv/seed/wheels/__init__.py b/src/virtualenv/seed/wheels/__init__.py index dbffe2e43..20a398a30 100644 --- a/src/virtualenv/seed/wheels/__init__.py +++ b/src/virtualenv/seed/wheels/__init__.py @@ -1,11 +1,11 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from .acquire import get_wheel, pip_wheel_env_run from .util import Version, Wheel -__all__ = ( - "get_wheel", - "pip_wheel_env_run", +__all__ = [ "Version", "Wheel", -) + "get_wheel", + "pip_wheel_env_run", +] diff --git a/src/virtualenv/seed/wheels/acquire.py b/src/virtualenv/seed/wheels/acquire.py index 374402658..5ca610fcd 100644 --- a/src/virtualenv/seed/wheels/acquire.py +++ b/src/virtualenv/seed/wheels/acquire.py @@ -1,23 +1,31 @@ -"""Bootstrap""" -from __future__ import absolute_import, unicode_literals +"""Bootstrap.""" + +from __future__ import annotations import logging import sys from operator import eq, lt - -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_str -from virtualenv.util.subprocess import Popen, subprocess +from pathlib import Path +from subprocess import PIPE, CalledProcessError, Popen from .bundle import from_bundle from .periodic_update import add_wheel_to_update_log from .util import Version, Wheel, discover_wheels +LOGGER = logging.getLogger(__name__) -def get_wheel(distribution, version, for_py_version, search_dirs, download, app_data, do_periodic_update, env): - """ - Get a wheel with the given distribution-version-for_py_version trio, by using the extra search dir + download - """ + +def get_wheel( # noqa: PLR0913 + distribution, + version, + for_py_version, + search_dirs, + download, + app_data, + do_periodic_update, + env, +): + """Get a wheel with the given distribution-version-for_py_version trio, by using the extra search dir + download.""" # not all wheels are compatible with all python versions, so we need to py version qualify it wheel = None @@ -42,9 +50,9 @@ def get_wheel(distribution, version, for_py_version, search_dirs, download, app_ return wheel -def download_wheel(distribution, version_spec, for_py_version, search_dirs, app_data, to_folder, env): - to_download = "{}{}".format(distribution, version_spec or "") - logging.debug("download wheel %s %s to %s", to_download, for_py_version, to_folder) +def download_wheel(distribution, version_spec, for_py_version, search_dirs, app_data, to_folder, env): # noqa: PLR0913 + to_download = f"{distribution}{version_spec or ''}" + LOGGER.debug("download wheel %s %s to %s", to_download, for_py_version, to_folder) cmd = [ sys.executable, "-m", @@ -63,34 +71,30 @@ def download_wheel(distribution, version_spec, for_py_version, search_dirs, app_ ] # pip has no interface in python - must be a new sub-process env = pip_wheel_env_run(search_dirs, app_data, env) - process = Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) + process = Popen(cmd, env=env, stdout=PIPE, stderr=PIPE, universal_newlines=True, encoding="utf-8") out, err = process.communicate() if process.returncode != 0: - kwargs = {"output": out} - if sys.version_info < (3, 5): - kwargs["output"] += err - else: - kwargs["stderr"] = err - raise subprocess.CalledProcessError(process.returncode, cmd, **kwargs) + kwargs = {"output": out, "stderr": err} + raise CalledProcessError(process.returncode, cmd, **kwargs) result = _find_downloaded_wheel(distribution, version_spec, for_py_version, to_folder, out) - logging.debug("downloaded wheel %s", result.name) + LOGGER.debug("downloaded wheel %s", result.name) return result def _find_downloaded_wheel(distribution, version_spec, for_py_version, to_folder, out): for line in out.splitlines(): - line = line.lstrip() + stripped_line = line.lstrip() for marker in ("Saved ", "File was already downloaded "): - if line.startswith(marker): - return Wheel(Path(line[len(marker) :]).absolute()) - # if for some reason the output does not match fallback to latest version with that spec + if stripped_line.startswith(marker): + return Wheel(Path(stripped_line[len(marker) :]).absolute()) + # if for some reason the output does not match fallback to the latest version with that spec return find_compatible_in_house(distribution, version_spec, for_py_version, to_folder) def find_compatible_in_house(distribution, version_spec, for_py_version, in_folder): wheels = discover_wheels(in_folder, distribution, None, for_py_version) start, end = 0, len(wheels) - if version_spec is not None: + if version_spec is not None and version_spec: if version_spec.startswith("<"): from_pos, op = 1, lt elif version_spec.startswith("=="): @@ -104,18 +108,12 @@ def find_compatible_in_house(distribution, version_spec, for_py_version, in_fold def pip_wheel_env_run(search_dirs, app_data, env): - for_py_version = "{}.{}".format(*sys.version_info[0:2]) env = env.copy() - env.update( - { - ensure_str(k): str(v) # python 2 requires these to be string only (non-unicode) - for k, v in {"PIP_USE_WHEEL": "1", "PIP_USER": "0", "PIP_NO_INPUT": "1"}.items() - }, - ) + env.update({"PIP_USE_WHEEL": "1", "PIP_USER": "0", "PIP_NO_INPUT": "1"}) wheel = get_wheel( distribution="pip", version=None, - for_py_version=for_py_version, + for_py_version=f"{sys.version_info.major}.{sys.version_info.minor}", search_dirs=search_dirs, download=False, app_data=app_data, @@ -123,6 +121,14 @@ def pip_wheel_env_run(search_dirs, app_data, env): env=env, ) if wheel is None: - raise RuntimeError("could not find the embedded pip") - env[str("PYTHONPATH")] = str(wheel.path) + msg = "could not find the embedded pip" + raise RuntimeError(msg) + env["PYTHONPATH"] = str(wheel.path) return env + + +__all__ = [ + "download_wheel", + "get_wheel", + "pip_wheel_env_run", +] diff --git a/src/virtualenv/seed/wheels/bundle.py b/src/virtualenv/seed/wheels/bundle.py index 39cd3d336..523e45ca2 100644 --- a/src/virtualenv/seed/wheels/bundle.py +++ b/src/virtualenv/seed/wheels/bundle.py @@ -1,31 +1,26 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations + +from virtualenv.seed.wheels.embed import get_embed_wheel -from ..wheels.embed import get_embed_wheel from .periodic_update import periodic_update from .util import Version, Wheel, discover_wheels -def from_bundle(distribution, version, for_py_version, search_dirs, app_data, do_periodic_update, env): - """ - Load the bundled wheel to a cache directory. - """ +def from_bundle(distribution, version, for_py_version, search_dirs, app_data, do_periodic_update, env): # noqa: PLR0913 + """Load the bundled wheel to a cache directory.""" of_version = Version.of_version(version) wheel = load_embed_wheel(app_data, distribution, for_py_version, of_version) if version != Version.embed: # 2. check if we have upgraded embed if app_data.can_update: - wheel = periodic_update( - distribution, of_version, for_py_version, wheel, search_dirs, app_data, do_periodic_update, env - ) + per = do_periodic_update + wheel = periodic_update(distribution, of_version, for_py_version, wheel, search_dirs, app_data, per, env) # 3. acquire from extra search dir found_wheel = from_dir(distribution, of_version, for_py_version, search_dirs) - if found_wheel is not None: - if wheel is None: - wheel = found_wheel - elif found_wheel.version_tuple > wheel.version_tuple: - wheel = found_wheel + if found_wheel is not None and (wheel is None or found_wheel.version_tuple > wheel.version_tuple): + wheel = found_wheel return wheel @@ -42,10 +37,14 @@ def load_embed_wheel(app_data, distribution, for_py_version, version): def from_dir(distribution, version, for_py_version, directories): - """ - Load a compatible wheel from a given folder. - """ + """Load a compatible wheel from a given folder.""" for folder in directories: for wheel in discover_wheels(folder, distribution, version, for_py_version): return wheel return None + + +__all__ = [ + "from_bundle", + "load_embed_wheel", +] diff --git a/src/virtualenv/seed/wheels/embed/__init__.py b/src/virtualenv/seed/wheels/embed/__init__.py index 5efbd7e5a..1939716f4 100644 --- a/src/virtualenv/seed/wheels/embed/__init__.py +++ b/src/virtualenv/seed/wheels/embed/__init__.py @@ -1,52 +1,48 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations + +from pathlib import Path from virtualenv.seed.wheels.util import Wheel -from virtualenv.util.path import Path BUNDLE_FOLDER = Path(__file__).absolute().parent BUNDLE_SUPPORT = { - "3.11": { - "pip": "pip-22.0.4-py3-none-any.whl", - "setuptools": "setuptools-62.1.0-py3-none-any.whl", - "wheel": "wheel-0.37.1-py2.py3-none-any.whl", - }, - "3.10": { - "pip": "pip-22.0.4-py3-none-any.whl", - "setuptools": "setuptools-62.1.0-py3-none-any.whl", - "wheel": "wheel-0.37.1-py2.py3-none-any.whl", + "3.8": { + "pip": "pip-24.3.1-py3-none-any.whl", + "setuptools": "setuptools-75.3.0-py3-none-any.whl", + "wheel": "wheel-0.45.1-py3-none-any.whl", }, "3.9": { - "pip": "pip-22.0.4-py3-none-any.whl", - "setuptools": "setuptools-62.1.0-py3-none-any.whl", - "wheel": "wheel-0.37.1-py2.py3-none-any.whl", + "pip": "pip-24.3.1-py3-none-any.whl", + "setuptools": "setuptools-75.6.0-py3-none-any.whl", + "wheel": "wheel-0.45.1-py3-none-any.whl", }, - "3.8": { - "pip": "pip-22.0.4-py3-none-any.whl", - "setuptools": "setuptools-62.1.0-py3-none-any.whl", - "wheel": "wheel-0.37.1-py2.py3-none-any.whl", + "3.10": { + "pip": "pip-24.3.1-py3-none-any.whl", + "setuptools": "setuptools-75.6.0-py3-none-any.whl", + "wheel": "wheel-0.45.1-py3-none-any.whl", }, - "3.7": { - "pip": "pip-22.0.4-py3-none-any.whl", - "setuptools": "setuptools-62.1.0-py3-none-any.whl", - "wheel": "wheel-0.37.1-py2.py3-none-any.whl", + "3.11": { + "pip": "pip-24.3.1-py3-none-any.whl", + "setuptools": "setuptools-75.6.0-py3-none-any.whl", + "wheel": "wheel-0.45.1-py3-none-any.whl", }, - "3.6": { - "pip": "pip-21.3.1-py3-none-any.whl", - "setuptools": "setuptools-59.6.0-py3-none-any.whl", - "wheel": "wheel-0.37.1-py2.py3-none-any.whl", + "3.12": { + "pip": "pip-24.3.1-py3-none-any.whl", + "setuptools": "setuptools-75.6.0-py3-none-any.whl", + "wheel": "wheel-0.45.1-py3-none-any.whl", }, - "3.5": { - "pip": "pip-20.3.4-py2.py3-none-any.whl", - "setuptools": "setuptools-50.3.2-py3-none-any.whl", - "wheel": "wheel-0.37.1-py2.py3-none-any.whl", + "3.13": { + "pip": "pip-24.3.1-py3-none-any.whl", + "setuptools": "setuptools-75.6.0-py3-none-any.whl", + "wheel": "wheel-0.45.1-py3-none-any.whl", }, - "2.7": { - "pip": "pip-20.3.4-py2.py3-none-any.whl", - "setuptools": "setuptools-44.1.1-py2.py3-none-any.whl", - "wheel": "wheel-0.37.1-py2.py3-none-any.whl", + "3.14": { + "pip": "pip-24.3.1-py3-none-any.whl", + "setuptools": "setuptools-75.6.0-py3-none-any.whl", + "wheel": "wheel-0.45.1-py3-none-any.whl", }, } -MAX = "3.11" +MAX = "3.8" def get_embed_wheel(distribution, for_py_version): @@ -54,9 +50,9 @@ def get_embed_wheel(distribution, for_py_version): return Wheel.from_path(path) -__all__ = ( - "get_embed_wheel", +__all__ = [ + "BUNDLE_FOLDER", "BUNDLE_SUPPORT", "MAX", - "BUNDLE_FOLDER", -) + "get_embed_wheel", +] diff --git a/src/virtualenv/seed/wheels/embed/pip-20.3.4-py2.py3-none-any.whl b/src/virtualenv/seed/wheels/embed/pip-20.3.4-py2.py3-none-any.whl deleted file mode 100644 index 95de4d7a5..000000000 Binary files a/src/virtualenv/seed/wheels/embed/pip-20.3.4-py2.py3-none-any.whl and /dev/null differ diff --git a/src/virtualenv/seed/wheels/embed/pip-21.3.1-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/pip-21.3.1-py3-none-any.whl deleted file mode 100644 index 769bae6c8..000000000 Binary files a/src/virtualenv/seed/wheels/embed/pip-21.3.1-py3-none-any.whl and /dev/null differ diff --git a/src/virtualenv/seed/wheels/embed/pip-22.0.4-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/pip-22.0.4-py3-none-any.whl deleted file mode 100644 index 7ba048e24..000000000 Binary files a/src/virtualenv/seed/wheels/embed/pip-22.0.4-py3-none-any.whl and /dev/null differ diff --git a/src/virtualenv/seed/wheels/embed/pip-24.3.1-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/pip-24.3.1-py3-none-any.whl new file mode 100644 index 000000000..5f1d35be6 Binary files /dev/null and b/src/virtualenv/seed/wheels/embed/pip-24.3.1-py3-none-any.whl differ diff --git a/src/virtualenv/seed/wheels/embed/setuptools-44.1.1-py2.py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-44.1.1-py2.py3-none-any.whl deleted file mode 100644 index bf28513c9..000000000 Binary files a/src/virtualenv/seed/wheels/embed/setuptools-44.1.1-py2.py3-none-any.whl and /dev/null differ diff --git a/src/virtualenv/seed/wheels/embed/setuptools-50.3.2-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-50.3.2-py3-none-any.whl deleted file mode 100644 index 56d1bf92d..000000000 Binary files a/src/virtualenv/seed/wheels/embed/setuptools-50.3.2-py3-none-any.whl and /dev/null differ diff --git a/src/virtualenv/seed/wheels/embed/setuptools-59.6.0-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-59.6.0-py3-none-any.whl deleted file mode 100644 index 08d7e943c..000000000 Binary files a/src/virtualenv/seed/wheels/embed/setuptools-59.6.0-py3-none-any.whl and /dev/null differ diff --git a/src/virtualenv/seed/wheels/embed/setuptools-62.1.0-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-62.1.0-py3-none-any.whl deleted file mode 100644 index 0a56be0ed..000000000 Binary files a/src/virtualenv/seed/wheels/embed/setuptools-62.1.0-py3-none-any.whl and /dev/null differ diff --git a/src/virtualenv/seed/wheels/embed/setuptools-75.3.0-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-75.3.0-py3-none-any.whl new file mode 100644 index 000000000..b6a97ce19 Binary files /dev/null and b/src/virtualenv/seed/wheels/embed/setuptools-75.3.0-py3-none-any.whl differ diff --git a/src/virtualenv/seed/wheels/embed/setuptools-75.6.0-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-75.6.0-py3-none-any.whl new file mode 100644 index 000000000..d43cfdb98 Binary files /dev/null and b/src/virtualenv/seed/wheels/embed/setuptools-75.6.0-py3-none-any.whl differ diff --git a/src/virtualenv/seed/wheels/embed/wheel-0.37.1-py2.py3-none-any.whl b/src/virtualenv/seed/wheels/embed/wheel-0.37.1-py2.py3-none-any.whl deleted file mode 100644 index e6c4d95f5..000000000 Binary files a/src/virtualenv/seed/wheels/embed/wheel-0.37.1-py2.py3-none-any.whl and /dev/null differ diff --git a/src/virtualenv/seed/wheels/embed/wheel-0.45.1-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/wheel-0.45.1-py3-none-any.whl new file mode 100644 index 000000000..589308a21 Binary files /dev/null and b/src/virtualenv/seed/wheels/embed/wheel-0.45.1-py3-none-any.whl differ diff --git a/src/virtualenv/seed/wheels/periodic_update.py b/src/virtualenv/seed/wheels/periodic_update.py index 4f0336bc7..ac627b3bd 100644 --- a/src/virtualenv/seed/wheels/periodic_update.py +++ b/src/virtualenv/seed/wheels/periodic_update.py @@ -1,56 +1,52 @@ -""" -Periodically update bundled versions. -""" +"""Periodically update bundled versions.""" -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import json import logging import os import ssl -import subprocess import sys -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from itertools import groupby +from pathlib import Path from shutil import copy2 +from subprocess import DEVNULL, Popen from textwrap import dedent from threading import Thread - -from six.moves.urllib.error import URLError -from six.moves.urllib.request import urlopen +from urllib.error import URLError +from urllib.request import urlopen from virtualenv.app_data import AppDataDiskFolder -from virtualenv.info import PY2 -from virtualenv.util.path import Path -from virtualenv.util.subprocess import CREATE_NO_WINDOW, Popen - -from ..wheels.embed import BUNDLE_SUPPORT -from ..wheels.util import Wheel - -if PY2: - # on Python 2 datetime.strptime throws the error below if the import did not trigger on main thread - # Failed to import _strptime because the import lock is held by - try: - import _strptime # noqa - except ImportError: # pragma: no cov - pass # pragma: no cov - +from virtualenv.seed.wheels.embed import BUNDLE_SUPPORT +from virtualenv.seed.wheels.util import Wheel +from virtualenv.util.subprocess import CREATE_NO_WINDOW +LOGGER = logging.getLogger(__name__) GRACE_PERIOD_CI = timedelta(hours=1) # prevent version switch in the middle of a CI run GRACE_PERIOD_MINOR = timedelta(days=28) UPDATE_PERIOD = timedelta(days=14) UPDATE_ABORTED_DELAY = timedelta(hours=1) -def periodic_update(distribution, of_version, for_py_version, wheel, search_dirs, app_data, do_periodic_update, env): +def periodic_update( # noqa: PLR0913 + distribution, + of_version, + for_py_version, + wheel, + search_dirs, + app_data, + do_periodic_update, + env, +): if do_periodic_update: handle_auto_update(distribution, for_py_version, wheel, search_dirs, app_data, env) - now = datetime.now() + now = datetime.now(tz=timezone.utc) def _update_wheel(ver): updated_wheel = Wheel(app_data.house / ver.filename) - logging.debug("using %supdated wheel %s", "periodically " if updated_wheel else "", updated_wheel) + LOGGER.debug("using %supdated wheel %s", "periodically " if updated_wheel else "", updated_wheel) return updated_wheel u_log = UpdateLog.from_app_data(app_data, distribution, for_py_version) @@ -72,25 +68,25 @@ def _update_wheel(ver): return wheel -def handle_auto_update(distribution, for_py_version, wheel, search_dirs, app_data, env): +def handle_auto_update(distribution, for_py_version, wheel, search_dirs, app_data, env): # noqa: PLR0913 embed_update_log = app_data.embed_update_log(distribution, for_py_version) u_log = UpdateLog.from_dict(embed_update_log.read()) if u_log.needs_update: u_log.periodic = True - u_log.started = datetime.now() + u_log.started = datetime.now(tz=timezone.utc) embed_update_log.write(u_log.to_dict()) trigger_update(distribution, for_py_version, wheel, search_dirs, app_data, periodic=True, env=env) def add_wheel_to_update_log(wheel, for_py_version, app_data): embed_update_log = app_data.embed_update_log(wheel.distribution, for_py_version) - logging.debug("adding %s information to %s", wheel.name, embed_update_log.file) + LOGGER.debug("adding %s information to %s", wheel.name, embed_update_log.file) u_log = UpdateLog.from_dict(embed_update_log.read()) if any(version.filename == wheel.name for version in u_log.versions): - logging.warning("%s already present in %s", wheel.name, embed_update_log.file) + LOGGER.warning("%s already present in %s", wheel.name, embed_update_log.file) return # we don't need a release date for sources other than "periodic" - version = NewVersion(wheel.name, datetime.now(), None, "download") + version = NewVersion(wheel.name, datetime.now(tz=timezone.utc), None, "download") u_log.versions.append(version) # always write at the end for proper updates embed_update_log.write(u_log.to_dict()) @@ -103,11 +99,11 @@ def dump_datetime(value): def load_datetime(value): - return None if value is None else datetime.strptime(value, DATETIME_FMT) + return None if value is None else datetime.strptime(value, DATETIME_FMT).replace(tzinfo=timezone.utc) -class NewVersion(object): - def __init__(self, filename, found_date, release_date, source): +class NewVersion: # noqa: PLW1641 + def __init__(self, filename, found_date, release_date, source) -> None: self.filename = filename self.found_date = found_date self.release_date = release_date @@ -130,28 +126,24 @@ def to_dict(self): "source": self.source, } - def use(self, now, ignore_grace_period_minor=False, ignore_grace_period_ci=False): + def use(self, now, ignore_grace_period_minor=False, ignore_grace_period_ci=False): # noqa: FBT002 if self.source == "manual": return True - elif self.source == "periodic": - if self.found_date < now - GRACE_PERIOD_CI or ignore_grace_period_ci: - if not ignore_grace_period_minor: - compare_from = self.release_date or self.found_date - return now - compare_from >= GRACE_PERIOD_MINOR - return True + if self.source == "periodic" and (self.found_date < now - GRACE_PERIOD_CI or ignore_grace_period_ci): + if not ignore_grace_period_minor: + compare_from = self.release_date or self.found_date + return now - compare_from >= GRACE_PERIOD_MINOR + return True return False - def __repr__(self): - return "{}(filename={}), found_date={}, release_date={}, source={})".format( - self.__class__.__name__, - self.filename, - self.found_date, - self.release_date, - self.source, + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(filename={self.filename}), found_date={self.found_date}, " + f"release_date={self.release_date}, source={self.source})" ) def __eq__(self, other): - return type(self) == type(other) and all( + return type(self) == type(other) and all( # noqa: E721 getattr(self, k) == getattr(other, k) for k in ["filename", "release_date", "found_date", "source"] ) @@ -163,8 +155,8 @@ def wheel(self): return Wheel(Path(self.filename)) -class UpdateLog(object): - def __init__(self, started, completed, versions, periodic): +class UpdateLog: + def __init__(self, started, completed, versions, periodic) -> None: self.started = started self.completed = completed self.versions = versions @@ -196,19 +188,18 @@ def to_dict(self): @property def needs_update(self): - now = datetime.now() + now = datetime.now(tz=timezone.utc) if self.completed is None: # never completed return self._check_start(now) - else: - if now - self.completed <= UPDATE_PERIOD: - return False - return self._check_start(now) + if now - self.completed <= UPDATE_PERIOD: + return False + return self._check_start(now) def _check_start(self, now): return self.started is None or now - self.started > UPDATE_ABORTED_DELAY -def trigger_update(distribution, for_py_version, wheel, search_dirs, app_data, env, periodic): +def trigger_update(distribution, for_py_version, wheel, search_dirs, app_data, env, periodic): # noqa: PLR0913 wheel_path = None if wheel is None else str(wheel.path) cmd = [ sys.executable, @@ -224,34 +215,44 @@ def trigger_update(distribution, for_py_version, wheel, search_dirs, app_data, e .strip() .format(distribution, for_py_version, wheel_path, str(app_data), [str(p) for p in search_dirs], periodic), ] - debug = env.get(str("_VIRTUALENV_PERIODIC_UPDATE_INLINE")) == str("1") - pipe = None if debug else subprocess.PIPE + debug = env.get("_VIRTUALENV_PERIODIC_UPDATE_INLINE") == "1" + pipe = None if debug else DEVNULL kwargs = {"stdout": pipe, "stderr": pipe} if not debug and sys.platform == "win32": kwargs["creationflags"] = CREATE_NO_WINDOW process = Popen(cmd, **kwargs) - logging.info( + LOGGER.info( "triggered periodic upgrade of %s%s (for python %s) via background process having PID %d", distribution, - "" if wheel is None else "=={}".format(wheel.version), + "" if wheel is None else f"=={wheel.version}", for_py_version, process.pid, ) if debug: process.communicate() # on purpose not called to make it a background process + else: + # set the returncode here -> no ResourceWarning on main process exit if the subprocess still runs + process.returncode = 0 -def do_update(distribution, for_py_version, embed_filename, app_data, search_dirs, periodic): +def do_update(distribution, for_py_version, embed_filename, app_data, search_dirs, periodic): # noqa: PLR0913 versions = None try: versions = _run_do_update(app_data, distribution, embed_filename, for_py_version, periodic, search_dirs) finally: - logging.debug("done %s %s with %s", distribution, for_py_version, versions) + LOGGER.debug("done %s %s with %s", distribution, for_py_version, versions) return versions -def _run_do_update(app_data, distribution, embed_filename, for_py_version, periodic, search_dirs): - from virtualenv.seed.wheels import acquire +def _run_do_update( # noqa: C901, PLR0913 + app_data, + distribution, + embed_filename, + for_py_version, + periodic, + search_dirs, +): + from virtualenv.seed.wheels import acquire # noqa: PLC0415 wheel_filename = None if embed_filename is None else Path(embed_filename) embed_version = None if wheel_filename is None else Wheel(wheel_filename).version_tuple @@ -260,7 +261,7 @@ def _run_do_update(app_data, distribution, embed_filename, for_py_version, perio wheelhouse = app_data.house embed_update_log = app_data.embed_update_log(distribution, for_py_version) u_log = UpdateLog.from_dict(embed_update_log.read()) - now = datetime.now() + now = datetime.now(tz=timezone.utc) update_versions, other_versions = [], [] for version in u_log.versions: @@ -283,10 +284,10 @@ def _run_do_update(app_data, distribution, embed_filename, for_py_version, perio copy2(str(wheel_filename), str(wheelhouse)) last, last_version, versions, filenames = None, None, [], set() while last is None or not last.use(now, ignore_grace_period_ci=True): - download_time = datetime.now() + download_time = datetime.now(tz=timezone.utc) dest = acquire.download_wheel( distribution=distribution, - version_spec=None if last_version is None else "<{}".format(last_version), + version_spec=None if last_version is None else f"<{last_version}", for_py_version=for_py_version, search_dirs=search_dirs, app_data=app_data, @@ -297,21 +298,20 @@ def _run_do_update(app_data, distribution, embed_filename, for_py_version, perio break release_date = release_date_for_wheel_path(dest.path) last = NewVersion(filename=dest.path.name, release_date=release_date, found_date=download_time, source=source) - logging.info("detected %s in %s", last, datetime.now() - download_time) + LOGGER.info("detected %s in %s", last, datetime.now(tz=timezone.utc) - download_time) versions.append(last) filenames.add(last.filename) last_wheel = last.wheel last_version = last_wheel.version - if embed_version is not None: - if embed_version >= last_wheel.version_tuple: # stop download if we reach the embed version - break + if embed_version is not None and embed_version >= last_wheel.version_tuple: + break # stop download if we reach the embed version u_log.periodic = periodic if not u_log.periodic: u_log.started = now # update other_versions by removing version we just found other_versions = [version for version in other_versions if version.filename not in filenames] u_log.versions = versions + update_versions + other_versions - u_log.completed = datetime.now() + u_log.completed = datetime.now(tz=timezone.utc) embed_update_log.write(u_log.to_dict()) return versions @@ -324,16 +324,16 @@ def release_date_for_wheel_path(dest): if content is not None: try: upload_time = content["releases"][wheel.version][0]["upload_time"] - return datetime.strptime(upload_time, "%Y-%m-%dT%H:%M:%S") - except Exception as exception: - logging.error("could not load release date %s because %r", content, exception) + return datetime.strptime(upload_time, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) + except Exception as exception: # noqa: BLE001 + LOGGER.error("could not load release date %s because %r", content, exception) # noqa: TRY400 return None def _request_context(): yield None # fallback to non verified HTTPS (the information we request is not sensitive, so fallback) - yield ssl._create_unverified_context() # noqa + yield ssl._create_unverified_context() # noqa: S323, SLF001 _PYPI_CACHE = {} @@ -346,17 +346,17 @@ def _pypi_get_distribution_info_cached(distribution): def _pypi_get_distribution_info(distribution): - content, url = None, "https://pypi.org/pypi/{}/json".format(distribution) + content, url = None, f"https://pypi.org/pypi/{distribution}/json" try: for context in _request_context(): try: - with urlopen(url, context=context) as file_handler: + with urlopen(url, context=context) as file_handler: # noqa: S310 content = json.load(file_handler) break except URLError as exception: - logging.error("failed to access %s because %r", url, exception) - except Exception as exception: - logging.error("failed to access %s because %r", url, exception) + LOGGER.error("failed to access %s because %r", url, exception) # noqa: TRY400 + except Exception as exception: # noqa: BLE001 + LOGGER.error("failed to access %s because %r", url, exception) # noqa: TRY400 return content @@ -365,7 +365,7 @@ def manual_upgrade(app_data, env): for for_py_version, distribution_to_package in BUNDLE_SUPPORT.items(): # load extra search dir for the given for_py - for distribution in distribution_to_package.keys(): + for distribution in distribution_to_package: thread = Thread(target=_run_manual_upgrade, args=(app_data, distribution, for_py_version, env)) thread.start() threads.append(thread) @@ -375,8 +375,8 @@ def manual_upgrade(app_data, env): def _run_manual_upgrade(app_data, distribution, for_py_version, env): - start = datetime.now() - from .bundle import from_bundle + start = datetime.now(tz=timezone.utc) + from .bundle import from_bundle # noqa: PLC0415 current = from_bundle( distribution=distribution, @@ -387,7 +387,7 @@ def _run_manual_upgrade(app_data, distribution, for_py_version, env): do_periodic_update=False, env=env, ) - logging.warning( + LOGGER.warning( "upgrade %s for python %s with current %s", distribution, for_py_version, @@ -401,28 +401,28 @@ def _run_manual_upgrade(app_data, distribution, for_py_version, env): search_dirs=[], periodic=False, ) - msg = "upgraded %s for python %s in %s {}".format( - "new entries found:\n%s" if versions else "no new versions found", - ) + args = [ distribution, for_py_version, - datetime.now() - start, + datetime.now(tz=timezone.utc) - start, ] if versions: - args.append("\n".join("\t{}".format(v) for v in versions)) - logging.warning(msg, *args) + args.append("\n".join(f"\t{v}" for v in versions)) + ver_update = "new entries found:\n%s" if versions else "no new versions found" + msg = f"upgraded %s for python %s in %s {ver_update}" + LOGGER.warning(msg, *args) -__all__ = ( - "add_wheel_to_update_log", - "periodic_update", - "do_update", - "manual_upgrade", +__all__ = [ "NewVersion", "UpdateLog", - "load_datetime", + "add_wheel_to_update_log", + "do_update", "dump_datetime", - "trigger_update", + "load_datetime", + "manual_upgrade", + "periodic_update", "release_date_for_wheel_path", -) + "trigger_update", +] diff --git a/src/virtualenv/seed/wheels/util.py b/src/virtualenv/seed/wheels/util.py index 1240eb2d2..2bc01ae27 100644 --- a/src/virtualenv/seed/wheels/util.py +++ b/src/virtualenv/seed/wheels/util.py @@ -1,13 +1,11 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from operator import attrgetter from zipfile import ZipFile -from virtualenv.util.six import ensure_text - -class Wheel(object): - def __init__(self, path): +class Wheel: + def __init__(self, path) -> None: # https://www.python.org/dev/peps/pep-0427/#file-name-convention # The wheel filename is {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl self.path = path @@ -15,7 +13,7 @@ def __init__(self, path): @classmethod def from_path(cls, path): - if path is not None and path.suffix == ".whl" and len(path.stem.split("-")) >= 5: + if path is not None and path.suffix == ".whl" and len(path.stem.split("-")) >= 5: # noqa: PLR2004 return cls(path) return None @@ -37,7 +35,7 @@ def as_version_tuple(version): for part in version.split(".")[0:3]: try: result.append(int(part)) - except ValueError: + except ValueError: # noqa: PERF203 break if not result: raise ValueError(version) @@ -48,8 +46,8 @@ def name(self): return self.path.name def support_py(self, py_version): - name = "{}.dist-info/METADATA".format("-".join(self.path.stem.split("-")[0:2])) - with ZipFile(ensure_text(str(self.path)), "r") as zip_file: + name = f"{'-'.join(self.path.stem.split('-')[0:2])}.dist-info/METADATA" + with ZipFile(str(self.path), "r") as zip_file: metadata = zip_file.read(name).decode("utf-8") marker = "Requires-Python:" requires = next((i[len(marker) :] for i in metadata.splitlines() if i.startswith(marker)), None) @@ -74,10 +72,10 @@ def support_py(self, py_version): break return True - def __repr__(self): - return "{}({})".format(self.__class__.__name__, self.path) + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.path})" - def __str__(self): + def __str__(self) -> str: return str(self.path) @@ -85,10 +83,13 @@ def discover_wheels(from_folder, distribution, version, for_py_version): wheels = [] for filename in from_folder.iterdir(): wheel = Wheel.from_path(filename) - if wheel and wheel.distribution == distribution: - if version is None or wheel.version == version: - if wheel.support_py(for_py_version): - wheels.append(wheel) + if ( + wheel + and wheel.distribution == distribution + and (version is None or wheel.version == version) + and wheel.support_py(for_py_version) + ): + wheels.append(wheel) return sorted(wheels, key=attrgetter("version_tuple", "distribution"), reverse=True) @@ -97,10 +98,7 @@ class Version: bundle = "bundle" embed = "embed" #: custom version handlers - non_version = ( - bundle, - embed, - ) + non_version = (bundle, embed) @staticmethod def of_version(value): @@ -108,9 +106,16 @@ def of_version(value): @staticmethod def as_pip_req(distribution, version): - return "{}{}".format(distribution, Version.as_version_spec(version)) + return f"{distribution}{Version.as_version_spec(version)}" @staticmethod def as_version_spec(version): of_version = Version.of_version(version) - return "" if of_version is None else "=={}".format(of_version) + return "" if of_version is None else f"=={of_version}" + + +__all__ = [ + "Version", + "Wheel", + "discover_wheels", +] diff --git a/src/virtualenv/util/__init__.py b/src/virtualenv/util/__init__.py index 32d02925b..e69de29bb 100644 --- a/src/virtualenv/util/__init__.py +++ b/src/virtualenv/util/__init__.py @@ -1,11 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import sys - -if sys.version_info[0] == 3: - import configparser as ConfigParser -else: - import ConfigParser - - -__all__ = ("ConfigParser",) diff --git a/src/virtualenv/util/error.py b/src/virtualenv/util/error.py index ac5aa502d..a317ddc18 100644 --- a/src/virtualenv/util/error.py +++ b/src/virtualenv/util/error.py @@ -1,12 +1,13 @@ -"""Errors""" -from __future__ import absolute_import, unicode_literals +"""Errors.""" +from __future__ import annotations -class ProcessCallFailed(RuntimeError): - """Failed a process call""" - def __init__(self, code, out, err, cmd): - super(ProcessCallFailed, self).__init__(code, out, err, cmd) +class ProcessCallFailedError(RuntimeError): + """Failed a process call.""" + + def __init__(self, code, out, err, cmd) -> None: + super().__init__(code, out, err, cmd) self.code = code self.out = out self.err = err diff --git a/src/virtualenv/util/lock.py b/src/virtualenv/util/lock.py index 1cf968b42..b8c9cf833 100644 --- a/src/virtualenv/util/lock.py +++ b/src/virtualenv/util/lock.py @@ -1,40 +1,43 @@ -"""holds locking functionality that works across processes""" -from __future__ import absolute_import, unicode_literals +"""holds locking functionality that works across processes.""" + +from __future__ import annotations import logging import os -from abc import ABCMeta, abstractmethod -from contextlib import contextmanager +from abc import ABC, abstractmethod +from contextlib import contextmanager, suppress +from pathlib import Path from threading import Lock, RLock from filelock import FileLock, Timeout -from six import add_metaclass -from virtualenv.util.path import Path +LOGGER = logging.getLogger(__name__) class _CountedFileLock(FileLock): - def __init__(self, lock_file): + def __init__(self, lock_file) -> None: parent = os.path.dirname(lock_file) if not os.path.isdir(parent): - try: + with suppress(OSError): os.makedirs(parent) - except OSError: - pass - super(_CountedFileLock, self).__init__(lock_file) + + super().__init__(lock_file) self.count = 0 self.thread_safe = RLock() def acquire(self, timeout=None, poll_interval=0.05): - with self.thread_safe: - if self.count == 0: - super(_CountedFileLock, self).acquire(timeout, poll_interval) - self.count += 1 + if not self.thread_safe.acquire(timeout=-1 if timeout is None else timeout): + raise Timeout(self.lock_file) + if self.count == 0: + super().acquire(timeout, poll_interval) + self.count += 1 - def release(self, force=False): + def release(self, force=False): # noqa: FBT002 with self.thread_safe: + if self.count > 0: + self.thread_safe.release() if self.count == 1: - super(_CountedFileLock, self).release(force=force) + super().release(force=force) self.count = max(self.count - 1, 0) @@ -42,20 +45,16 @@ def release(self, force=False): _store_lock = Lock() -@add_metaclass(ABCMeta) -class PathLockBase(object): - def __init__(self, folder): +class PathLockBase(ABC): + def __init__(self, folder) -> None: path = Path(folder) self.path = path.resolve() if path.exists() else path - def __repr__(self): - return "{}({})".format(self.__class__.__name__, self.path) - - def __div__(self, other): - return type(self)(self.path / other) + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.path})" def __truediv__(self, other): - return self.__div__(other) + return type(self)(self.path / other) @abstractmethod def __enter__(self): @@ -67,22 +66,22 @@ def __exit__(self, exc_type, exc_val, exc_tb): @abstractmethod @contextmanager - def lock_for_key(self, name, no_block=False): + def lock_for_key(self, name, no_block=False): # noqa: FBT002 raise NotImplementedError @abstractmethod @contextmanager - def non_reentrant_lock_for_key(name): + def non_reentrant_lock_for_key(self, name): raise NotImplementedError class ReentrantFileLock(PathLockBase): - def __init__(self, folder): - super(ReentrantFileLock, self).__init__(folder) + def __init__(self, folder) -> None: + super().__init__(folder) self._lock = None def _create_lock(self, name=""): - lock_file = str(self.path / "{}.lock".format(name)) + lock_file = str(self.path / f"{name}.lock") with _store_lock: if lock_file not in _lock_store: _lock_store[lock_file] = _CountedFileLock(lock_file) @@ -91,12 +90,11 @@ def _create_lock(self, name=""): @staticmethod def _del_lock(lock): if lock is not None: - with _store_lock: - with lock.thread_safe: - if lock.count == 0: - _lock_store.pop(lock.lock_file, None) + with _store_lock, lock.thread_safe: + if lock.count == 0: + _lock_store.pop(lock.lock_file, None) - def __del__(self): + def __del__(self) -> None: self._del_lock(self._lock) def __enter__(self): @@ -108,20 +106,19 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._del_lock(self._lock) self._lock = None - def _lock_file(self, lock, no_block=False): + def _lock_file(self, lock, no_block=False): # noqa: FBT002 # multiple processes might be trying to get a first lock... so we cannot check if this directory exist without # a lock, but that lock might then become expensive, and it's not clear where that lock should live. # Instead here we just ignore if we fail to create the directory. - try: + with suppress(OSError): os.makedirs(str(self.path)) - except OSError: - pass + try: lock.acquire(0.0001) except Timeout: if no_block: raise - logging.debug("lock file %s present, will block until released", lock.lock_file) + LOGGER.debug("lock file %s present, will block until released", lock.lock_file) lock.release() # release the acquire try from above lock.acquire() @@ -130,7 +127,7 @@ def _release(lock): lock.release() @contextmanager - def lock_for_key(self, name, no_block=False): + def lock_for_key(self, name, no_block=False): # noqa: FBT002 lock = self._create_lock(name) try: try: @@ -144,7 +141,7 @@ def lock_for_key(self, name, no_block=False): @contextmanager def non_reentrant_lock_for_key(self, name): - with _CountedFileLock(str(self.path / "{}.lock".format(name))): + with _CountedFileLock(str(self.path / f"{name}.lock")): yield @@ -156,16 +153,16 @@ def __exit__(self, exc_type, exc_val, exc_tb): raise NotImplementedError @contextmanager - def lock_for_key(self, name, no_block=False): + def lock_for_key(self, name, no_block=False): # noqa: ARG002, FBT002 yield @contextmanager - def non_reentrant_lock_for_key(self, name): + def non_reentrant_lock_for_key(self, name): # noqa: ARG002 yield -__all__ = ( +__all__ = [ "NoOpFileLock", "ReentrantFileLock", "Timeout", -) +] diff --git a/src/virtualenv/util/path/__init__.py b/src/virtualenv/util/path/__init__.py index dc628de83..c144d8dfd 100644 --- a/src/virtualenv/util/path/__init__.py +++ b/src/virtualenv/util/path/__init__.py @@ -1,18 +1,16 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -from ._pathlib import Path from ._permission import make_exe, set_tree from ._sync import copy, copytree, ensure_dir, safe_delete, symlink from ._win import get_short_path_name -__all__ = ( - "ensure_dir", - "symlink", +__all__ = [ "copy", "copytree", - "Path", + "ensure_dir", + "get_short_path_name", "make_exe", - "set_tree", "safe_delete", - "get_short_path_name", -) + "set_tree", + "symlink", +] diff --git a/src/virtualenv/util/path/_pathlib/__init__.py b/src/virtualenv/util/path/_pathlib/__init__.py deleted file mode 100644 index 746c8aed2..000000000 --- a/src/virtualenv/util/path/_pathlib/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import sys - -import six - -if six.PY3: - from pathlib import Path -else: - if sys.platform == "win32": - # workaround for https://github.com/mcmtroffaes/pathlib2/issues/56 - from .via_os_path import Path - else: - from pathlib2 import Path - - -__all__ = ("Path",) diff --git a/src/virtualenv/util/path/_pathlib/via_os_path.py b/src/virtualenv/util/path/_pathlib/via_os_path.py deleted file mode 100644 index b876f025e..000000000 --- a/src/virtualenv/util/path/_pathlib/via_os_path.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import os -import platform -from contextlib import contextmanager - -from virtualenv.util.six import ensure_str, ensure_text - -IS_PYPY = platform.python_implementation() == "PyPy" - - -class Path(object): - def __init__(self, path): - if isinstance(path, Path): - _path = path._path - else: - _path = ensure_text(path) - if IS_PYPY: - _path = _path.encode("utf-8") - self._path = _path - - def __repr__(self): - return ensure_str("Path({})".format(ensure_text(self._path))) - - def __unicode__(self): - return ensure_text(self._path) - - def __str__(self): - return ensure_str(self._path) - - def __div__(self, other): - if isinstance(other, Path): - right = other._path - else: - right = ensure_text(other) - if IS_PYPY: - right = right.encode("utf-8") - return Path(os.path.join(self._path, right)) - - def __truediv__(self, other): - return self.__div__(other) - - def __eq__(self, other): - return self._path == (other._path if isinstance(other, Path) else None) - - def __ne__(self, other): - return not (self == other) - - def __hash__(self): - return hash(self._path) - - def as_posix(self): - return str(self).replace(os.sep, "/") - - def exists(self): - return os.path.exists(self._path) - - @property - def parent(self): - return Path(os.path.abspath(os.path.join(self._path, os.path.pardir))) - - def resolve(self): - return Path(os.path.realpath(self._path)) - - @property - def name(self): - return os.path.basename(self._path) - - @property - def parts(self): - return self._path.split(os.sep) - - def is_file(self): - return os.path.isfile(self._path) - - def is_dir(self): - return os.path.isdir(self._path) - - def mkdir(self, parents=True, exist_ok=True): - try: - os.makedirs(self._path) - except OSError: - if not exist_ok: - raise - - def read_text(self, encoding="utf-8"): - return self.read_bytes().decode(encoding) - - def read_bytes(self): - with open(self._path, "rb") as file_handler: - return file_handler.read() - - def write_bytes(self, content): - with open(self._path, "wb") as file_handler: - file_handler.write(content) - - def write_text(self, text, encoding="utf-8"): - self.write_bytes(text.encode(encoding)) - - def iterdir(self): - for p in os.listdir(self._path): - yield Path(os.path.join(self._path, p)) - - @property - def suffix(self): - _, ext = os.path.splitext(self.name) - return ext - - @property - def stem(self): - base, _ = os.path.splitext(self.name) - return base - - @contextmanager - def open(self, mode="r"): - with open(self._path, mode) as file_handler: - yield file_handler - - @property - def parents(self): - result = [] - parts = self.parts - for i in range(len(parts) - 1): - result.append(Path(os.sep.join(parts[0 : i + 1]))) - return result[::-1] - - def unlink(self): - os.remove(self._path) - - def with_name(self, name): - return self.parent / name - - def is_symlink(self): - return os.path.islink(self._path) - - def relative_to(self, other): - if not self._path.startswith(other._path): - raise ValueError("{} does not start with {}".format(self._path, other._path)) - return Path(os.sep.join(self.parts[len(other.parts) :])) - - def stat(self): - return os.stat(self._path) - - def chmod(self, mode): - os.chmod(self._path, mode) - - def absolute(self): - return Path(os.path.abspath(self._path)) - - -__all__ = ("Path",) diff --git a/src/virtualenv/util/path/_permission.py b/src/virtualenv/util/path/_permission.py index 73bb6e81a..8dcad0ce9 100644 --- a/src/virtualenv/util/path/_permission.py +++ b/src/virtualenv/util/path/_permission.py @@ -1,10 +1,8 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os from stat import S_IXGRP, S_IXOTH, S_IXUSR -from virtualenv.util.six import ensure_text - def make_exe(filename): original_mode = filename.stat().st_mode @@ -21,7 +19,7 @@ def make_exe(filename): def set_tree(folder, stat): - for root, _, files in os.walk(ensure_text(str(folder))): + for root, _, files in os.walk(str(folder)): for filename in files: os.chmod(os.path.join(root, filename), stat) diff --git a/src/virtualenv/util/path/_sync.py b/src/virtualenv/util/path/_sync.py index 05f19d02c..02a6f6e9e 100644 --- a/src/virtualenv/util/path/_sync.py +++ b/src/virtualenv/util/path/_sync.py @@ -1,46 +1,37 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import os import shutil +import sys from stat import S_IWUSR -from six import PY2 - -from virtualenv.info import IS_CPYTHON, IS_WIN -from virtualenv.util.six import ensure_text - -if PY2 and IS_CPYTHON and IS_WIN: # CPython2 on Windows supports unicode paths if passed as unicode - - def norm(src): - return ensure_text(str(src)) - -else: - norm = str +LOGGER = logging.getLogger(__name__) def ensure_dir(path): if not path.exists(): - logging.debug("create folder %s", ensure_text(str(path))) - os.makedirs(norm(path)) + LOGGER.debug("create folder %s", str(path)) + os.makedirs(str(path)) def ensure_safe_to_do(src, dest): if src == dest: - raise ValueError("source and destination is the same {}".format(src)) + msg = f"source and destination is the same {src}" + raise ValueError(msg) if not dest.exists(): return if dest.is_dir() and not dest.is_symlink(): - logging.debug("remove directory %s", dest) + LOGGER.debug("remove directory %s", dest) safe_delete(dest) else: - logging.debug("remove file %s", dest) + LOGGER.debug("remove file %s", dest) dest.unlink() def symlink(src, dest): ensure_safe_to_do(src, dest) - logging.debug("symlink %s", _Debug(src, dest)) + LOGGER.debug("symlink %s", _Debug(src, dest)) dest.symlink_to(src, target_is_directory=src.is_dir()) @@ -48,8 +39,8 @@ def copy(src, dest): ensure_safe_to_do(src, dest) is_dir = src.is_dir() method = copytree if is_dir else shutil.copy - logging.debug("copy %s", _Debug(src, dest)) - method(norm(src), norm(dest)) + LOGGER.debug("copy %s", _Debug(src, dest)) + method(str(src), str(dest)) def copytree(src, dest): @@ -64,34 +55,31 @@ def copytree(src, dest): def safe_delete(dest): - def onerror(func, path, exc_info): + def onerror(func, path, exc_info): # noqa: ARG001 if not os.access(path, os.W_OK): os.chmod(path, S_IWUSR) func(path) else: - raise + raise # noqa: PLE0704 - shutil.rmtree(ensure_text(str(dest)), ignore_errors=True, onerror=onerror) + kwargs = {"onexc" if sys.version_info >= (3, 12) else "onerror": onerror} + shutil.rmtree(str(dest), ignore_errors=True, **kwargs) -class _Debug(object): - def __init__(self, src, dest): +class _Debug: + def __init__(self, src, dest) -> None: self.src = src self.dest = dest - def __str__(self): - return "{}{} to {}".format( - "directory " if self.src.is_dir() else "", - ensure_text(str(self.src)), - ensure_text(str(self.dest)), - ) + def __str__(self) -> str: + return f"{'directory ' if self.src.is_dir() else ''}{self.src!s} to {self.dest!s}" -__all__ = ( - "ensure_dir", - "symlink", +__all__ = [ "copy", - "symlink", "copytree", + "ensure_dir", "safe_delete", -) + "symlink", + "symlink", +] diff --git a/src/virtualenv/util/path/_win.py b/src/virtualenv/util/path/_win.py index 02e16d07e..aa67ca770 100644 --- a/src/virtualenv/util/path/_win.py +++ b/src/virtualenv/util/path/_win.py @@ -1,12 +1,12 @@ +from __future__ import annotations + + def get_short_path_name(long_name): - """ - Gets the short path name of a given long path. - http://stackoverflow.com/a/23598461/200291 - """ - import ctypes - from ctypes import wintypes + """Gets the short path name of a given long path - http://stackoverflow.com/a/23598461/200291.""" + import ctypes # noqa: PLC0415 + from ctypes import wintypes # noqa: PLC0415 - _GetShortPathNameW = ctypes.windll.kernel32.GetShortPathNameW + _GetShortPathNameW = ctypes.windll.kernel32.GetShortPathNameW # noqa: N806 _GetShortPathNameW.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD] _GetShortPathNameW.restype = wintypes.DWORD output_buf_size = 0 @@ -15,5 +15,9 @@ def get_short_path_name(long_name): needed = _GetShortPathNameW(long_name, output_buf, output_buf_size) if output_buf_size >= needed: return output_buf.value - else: - output_buf_size = needed + output_buf_size = needed + + +__all__ = [ + "get_short_path_name", +] diff --git a/src/virtualenv/util/six.py b/src/virtualenv/util/six.py deleted file mode 100644 index 199cbed98..000000000 --- a/src/virtualenv/util/six.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Backward compatibility layer with older version of six. - -This is used to avoid virtualenv requiring a version of six newer than what -the system may have. -""" -from __future__ import absolute_import - -from six import PY2, PY3, binary_type, text_type - -try: - from six import ensure_text -except ImportError: - - def ensure_text(s, encoding="utf-8", errors="strict"): - """Coerce *s* to six.text_type. - For Python 2: - - `unicode` -> `unicode` - - `str` -> `unicode` - For Python 3: - - `str` -> `str` - - `bytes` -> decoded to `str` - """ - if isinstance(s, binary_type): - return s.decode(encoding, errors) - elif isinstance(s, text_type): - return s - else: - raise TypeError("not expecting type '%s'" % type(s)) - - -try: - from six import ensure_str -except ImportError: - - def ensure_str(s, encoding="utf-8", errors="strict"): - """Coerce *s* to `str`. - For Python 2: - - `unicode` -> encoded to `str` - - `str` -> `str` - For Python 3: - - `str` -> `str` - - `bytes` -> decoded to `str` - """ - if not isinstance(s, (text_type, binary_type)): - raise TypeError("not expecting type '%s'" % type(s)) - if PY2 and isinstance(s, text_type): - s = s.encode(encoding, errors) - elif PY3 and isinstance(s, binary_type): - s = s.decode(encoding, errors) - return s diff --git a/src/virtualenv/util/subprocess/__init__.py b/src/virtualenv/util/subprocess/__init__.py index f5066268f..e6d5fc885 100644 --- a/src/virtualenv/util/subprocess/__init__.py +++ b/src/virtualenv/util/subprocess/__init__.py @@ -1,40 +1,30 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import subprocess -import sys - -import six - -if six.PY2 and sys.platform == "win32": - from . import _win_subprocess - - Popen = _win_subprocess.Popen -else: - Popen = subprocess.Popen - CREATE_NO_WINDOW = 0x80000000 def run_cmd(cmd): try: - process = Popen( + process = subprocess.Popen( cmd, universal_newlines=True, stdin=subprocess.PIPE, stderr=subprocess.PIPE, stdout=subprocess.PIPE, + encoding="utf-8", ) out, err = process.communicate() # input disabled code = process.returncode - except OSError as os_error: - code, out, err = os_error.errno, "", os_error.strerror + except OSError as error: + code, out, err = error.errno, "", error.strerror + if code == 2 and "file" in err: # noqa: PLR2004 + err = str(error) # FileNotFoundError in Python >= 3.3 return code, out, err __all__ = ( - "subprocess", - "Popen", - "run_cmd", "CREATE_NO_WINDOW", + "run_cmd", ) diff --git a/src/virtualenv/util/subprocess/_win_subprocess.py b/src/virtualenv/util/subprocess/_win_subprocess.py deleted file mode 100644 index ce531979a..000000000 --- a/src/virtualenv/util/subprocess/_win_subprocess.py +++ /dev/null @@ -1,176 +0,0 @@ -# flake8: noqa -# fmt: off -## issue: https://bugs.python.org/issue19264 - -import ctypes -import os -import platform -import subprocess -from ctypes import Structure, WinError, byref, c_char_p, c_void_p, c_wchar, c_wchar_p, sizeof, windll -from ctypes.wintypes import BOOL, BYTE, DWORD, HANDLE, LPCWSTR, LPVOID, LPWSTR, WORD - -import _subprocess - -## -## Types -## - -CREATE_UNICODE_ENVIRONMENT = 0x00000400 -LPCTSTR = c_char_p -LPTSTR = c_wchar_p -LPSECURITY_ATTRIBUTES = c_void_p -LPBYTE = ctypes.POINTER(BYTE) - -class STARTUPINFOW(Structure): - _fields_ = [ - ("cb", DWORD), ("lpReserved", LPWSTR), - ("lpDesktop", LPWSTR), ("lpTitle", LPWSTR), - ("dwX", DWORD), ("dwY", DWORD), - ("dwXSize", DWORD), ("dwYSize", DWORD), - ("dwXCountChars", DWORD), ("dwYCountChars", DWORD), - ("dwFillAtrribute", DWORD), ("dwFlags", DWORD), - ("wShowWindow", WORD), ("cbReserved2", WORD), - ("lpReserved2", LPBYTE), ("hStdInput", HANDLE), - ("hStdOutput", HANDLE), ("hStdError", HANDLE), - ] - -LPSTARTUPINFOW = ctypes.POINTER(STARTUPINFOW) - - -class PROCESS_INFORMATION(Structure): - _fields_ = [ - ("hProcess", HANDLE), ("hThread", HANDLE), - ("dwProcessId", DWORD), ("dwThreadId", DWORD), - ] - -LPPROCESS_INFORMATION = ctypes.POINTER(PROCESS_INFORMATION) - - -class DUMMY_HANDLE(ctypes.c_void_p): - - def __init__(self, *a, **kw): - super(DUMMY_HANDLE, self).__init__(*a, **kw) - self.closed = False - - def Close(self): - if not self.closed: - windll.kernel32.CloseHandle(self) - self.closed = True - - def __int__(self): - return self.value - - -CreateProcessW = windll.kernel32.CreateProcessW -CreateProcessW.argtypes = [ - LPCWSTR, LPWSTR, LPSECURITY_ATTRIBUTES, - LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, - LPSTARTUPINFOW, LPPROCESS_INFORMATION, -] -CreateProcessW.restype = BOOL - - -## -## Patched functions/classes -## - -def CreateProcess( - executable, args, _p_attr, _t_attr, - inherit_handles, creation_flags, env, cwd, - startup_info, -): - """Create a process supporting unicode executable and args for win32 - - Python implementation of CreateProcess using CreateProcessW for Win32 - - """ - - si = STARTUPINFOW( - dwFlags=startup_info.dwFlags, - wShowWindow=startup_info.wShowWindow, - cb=sizeof(STARTUPINFOW), - ## XXXvlab: not sure of the casting here to ints. - hStdInput=startup_info.hStdInput if startup_info.hStdInput is None else int(startup_info.hStdInput), - hStdOutput=startup_info.hStdOutput if startup_info.hStdOutput is None else int(startup_info.hStdOutput), - hStdError=startup_info.hStdError if startup_info.hStdError is None else int(startup_info.hStdError), - ) - - wenv = None - if env is not None: - ## LPCWSTR seems to be c_wchar_p, so let's say CWSTR is c_wchar - env = ( - unicode("").join([ - unicode("%s=%s\0") % (k, v) - for k, v in env.items() - ]) - ) + unicode("\0") - wenv = (c_wchar * len(env))() - wenv.value = env - - wcwd = None - if cwd is not None: - wcwd = unicode(cwd) - - pi = PROCESS_INFORMATION() - creation_flags |= CREATE_UNICODE_ENVIRONMENT - - if CreateProcessW( - executable, args, None, None, - inherit_handles, creation_flags, - wenv, wcwd, byref(si), byref(pi), - ): - return ( - DUMMY_HANDLE(pi.hProcess), DUMMY_HANDLE(pi.hThread), - pi.dwProcessId, pi.dwThreadId, - ) - raise WinError() - - -class Popen(subprocess.Popen): - """This superseeds Popen and corrects a bug in cPython 2.7 implem""" - - def _execute_child( - self, args, executable, preexec_fn, close_fds, - cwd, env, universal_newlines, - startupinfo, creationflags, shell, to_close, - p2cread, p2cwrite, - c2pread, c2pwrite, - errread, errwrite, - ): - """Code from part of _execute_child from Python 2.7 (9fbb65e) - - There are only 2 little changes concerning the construction of - the the final string in shell mode: we preempt the creation of - the command string when shell is True, because original function - will try to encode unicode args which we want to avoid to be able to - sending it as-is to ``CreateProcess``. - - """ - if startupinfo is None: - startupinfo = subprocess.STARTUPINFO() - if not isinstance(args, subprocess.types.StringTypes): - args = [i if isinstance(i, bytes) else i.encode('utf-8') for i in args] - args = subprocess.list2cmdline(args) - if platform.python_implementation() == "CPython": - args = args.decode('utf-8') - startupinfo.dwFlags |= _subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = _subprocess.SW_HIDE - env = os.environ if env is None else env - comspec = env.get("COMSPEC", unicode("cmd.exe")) - if ( - _subprocess.GetVersion() >= 0x80000000 or - os.path.basename(comspec).lower() == "command.com" - ): - w9xpopen = self._find_w9xpopen() - args = unicode('"%s" %s') % (w9xpopen, args) - creationflags |= _subprocess.CREATE_NEW_CONSOLE - - super(Popen, self)._execute_child( - args, executable, - preexec_fn, close_fds, cwd, env, universal_newlines, - startupinfo, creationflags, False, to_close, p2cread, - p2cwrite, c2pread, c2pwrite, errread, errwrite, - ) - -_subprocess.CreateProcess = CreateProcess -# fmt: on diff --git a/src/virtualenv/util/zipapp.py b/src/virtualenv/util/zipapp.py index 85d9294f4..764c5c277 100644 --- a/src/virtualenv/util/zipapp.py +++ b/src/virtualenv/util/zipapp.py @@ -1,33 +1,43 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations # noqa: A005 import logging import os import zipfile from virtualenv.info import IS_WIN, ROOT -from virtualenv.util.six import ensure_text + +LOGGER = logging.getLogger(__name__) def read(full_path): sub_file = _get_path_within_zip(full_path) - with zipfile.ZipFile(ROOT, "r") as zip_file: - with zip_file.open(sub_file) as file_handler: - return file_handler.read().decode("utf-8") + with zipfile.ZipFile(ROOT, "r") as zip_file, zip_file.open(sub_file) as file_handler: + return file_handler.read().decode("utf-8") def extract(full_path, dest): - logging.debug("extract %s to %s", full_path, dest) + LOGGER.debug("extract %s to %s", full_path, dest) sub_file = _get_path_within_zip(full_path) with zipfile.ZipFile(ROOT, "r") as zip_file: info = zip_file.getinfo(sub_file) info.filename = dest.name - zip_file.extract(info, ensure_text(str(dest.parent))) + zip_file.extract(info, str(dest.parent)) def _get_path_within_zip(full_path): - full_path = os.path.abspath(str(full_path)) - sub_file = full_path[len(ROOT) + 1 :] + full_path = os.path.realpath(os.path.abspath(str(full_path))) + prefix = f"{ROOT}{os.sep}" + if not full_path.startswith(prefix): + msg = f"full_path={full_path} should start with prefix={prefix}." + raise RuntimeError(msg) + sub_file = full_path[len(prefix) :] if IS_WIN: # paths are always UNIX separators, even on Windows, though __file__ still follows platform default sub_file = sub_file.replace(os.sep, "/") return sub_file + + +__all__ = [ + "extract", + "read", +] diff --git a/tasks/__main__zipapp.py b/tasks/__main__zipapp.py index 3aa97ca24..9118e2007 100644 --- a/tasks/__main__zipapp.py +++ b/tasks/__main__zipapp.py @@ -1,30 +1,35 @@ +from __future__ import annotations + import json import os import sys import zipfile +from functools import cached_property +from importlib.abc import SourceLoader +from importlib.util import spec_from_file_location ABS_HERE = os.path.abspath(os.path.dirname(__file__)) -NEW_IMPORT_SYSTEM = sys.version_info[0] == 3 -class VersionPlatformSelect(object): - def __init__(self): - self.archive = ABS_HERE - self._zip_file = zipfile.ZipFile(ABS_HERE, "r") +class VersionPlatformSelect: + def __init__(self) -> None: + zipapp = ABS_HERE + self.archive = zipapp + self._zip_file = zipfile.ZipFile(zipapp) self.modules = self._load("modules.json") self.distributions = self._load("distributions.json") self.__cache = {} def _load(self, of_file): version = ".".join(str(i) for i in sys.version_info[0:2]) - per_version = json.loads(self.get_data(of_file).decode("utf-8")) + per_version = json.loads(self.get_data(of_file).decode()) all_platforms = per_version[version] if version in per_version else per_version["3.9"] content = all_platforms.get("==any", {}) # start will all platforms - not_us = "!={}".format(sys.platform) + not_us = f"!={sys.platform}" for key, value in all_platforms.items(): # now override that with not platform if key.startswith("!=") and key != not_us: content.update(value) - content.update(all_platforms.get("=={}".format(sys.platform), {})) # and finish it off with our platform + content.update(all_platforms.get(f"=={sys.platform}", {})) # and finish it off with our platform return content def __enter__(self): @@ -35,8 +40,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): def find_mod(self, fullname): if fullname in self.modules: - result = self.modules[fullname] - return result + return self.modules[fullname] + return None def get_filename(self, fullname): zip_path = self.find_mod(fullname) @@ -60,29 +65,69 @@ def find_distributions(self, context): result = dist_class(file_loader=self.get_data, dist_path=self.distributions[name]) yield result - def __repr__(self): - return "{}(path={})".format(self.__class__.__name__, ABS_HERE) + def __repr__(self) -> str: + return f"{self.__class__.__name__}(path={ABS_HERE})" - def _register_distutils_finder(self): + def _register_distutils_finder(self): # noqa: C901 if "distlib" not in self.modules: return - class DistlibFinder(object): - def __init__(self, path, loader): + class Resource: + def __init__(self, path: str, name: str, loader: SourceLoader) -> None: + self.path = os.path.join(path, name) + self._name = name + self.loader = loader + + @cached_property + def name(self) -> str: + return os.path.basename(self._name) + + @property + def bytes(self) -> bytes: + return self.loader.get_data(self._name) + + @property + def is_container(self) -> bool: + return len(self.resources) > 1 + + @cached_property + def resources(self) -> list[str]: + return [ + i.filename + for i in ( + (j for j in zip_file.filelist if j.filename.startswith(f"{self._name}/")) + if self._name + else zip_file.filelist + ) + ] + + class DistlibFinder: + def __init__(self, path, loader) -> None: self.path = path self.loader = loader def find(self, name): - class Resource(object): - def __init__(self, content): - self.bytes = content - - full_path = os.path.join(self.path, name) - return Resource(self.loader.get_data(full_path)) - - # noinspection PyPackageRequirements - from distlib.resources import register_finder - + return Resource(self.path, name, self.loader) + + def iterator(self, resource_name): + resource = self.find(resource_name) + if resource is not None: + todo = [resource] + while todo: + resource = todo.pop(0) + yield resource + if resource.is_container: + resource_name = resource.name + for name in resource.resources: + child = self.find(f"{resource_name}/{name}" if resource_name else name) + if child.is_container: + todo.append(child) + else: + yield child + + from distlib.resources import register_finder # noqa: PLC0415 + + zip_file = self._zip_file register_finder(self, lambda module: DistlibFinder(os.path.dirname(module.__file__), self)) @@ -90,17 +135,12 @@ def __init__(self, content): def versioned_distribution_class(): - global _VER_DISTRIBUTION_CLASS + global _VER_DISTRIBUTION_CLASS # noqa: PLW0603 if _VER_DISTRIBUTION_CLASS is None: - if sys.version_info >= (3, 8): - # noinspection PyCompatibility - from importlib.metadata import Distribution - else: - # noinspection PyUnresolvedReferences - from importlib_metadata import Distribution + from importlib.metadata import Distribution # noqa: PLC0415 class VersionedDistribution(Distribution): - def __init__(self, file_loader, dist_path): + def __init__(self, file_loader, dist_path) -> None: self.file_loader = file_loader self.dist_path = dist_path @@ -114,51 +154,22 @@ def locate_file(self, path): return _VER_DISTRIBUTION_CLASS -if NEW_IMPORT_SYSTEM: - # noinspection PyCompatibility - # noinspection PyCompatibility - from importlib.abc import SourceLoader - from importlib.util import spec_from_file_location - - class VersionedFindLoad(VersionPlatformSelect, SourceLoader): - def find_spec(self, fullname, path, target=None): - zip_path = self.find_mod(fullname) - if zip_path is not None: - spec = spec_from_file_location(name=fullname, loader=self) - return spec - - def module_repr(self, module): - raise NotImplementedError - -else: - # noinspection PyDeprecation - from imp import new_module - - class VersionedFindLoad(VersionPlatformSelect): - def find_module(self, fullname, path=None): - return self if self.find_mod(fullname) else None - - def load_module(self, fullname): - filename = self.get_filename(fullname) - code = self.get_data(filename) - mod = sys.modules.setdefault(fullname, new_module(fullname)) - mod.__file__ = filename - mod.__loader__ = self - is_package = filename.endswith("__init__.py") - if is_package: - mod.__path__ = [os.path.dirname(filename)] - mod.__package__ = fullname - else: - mod.__package__ = fullname.rpartition(".")[0] - exec(code, mod.__dict__) - return mod +class VersionedFindLoad(VersionPlatformSelect, SourceLoader): + def find_spec(self, fullname, path, target=None): # noqa: ARG002 + zip_path = self.find_mod(fullname) + if zip_path is not None: + return spec_from_file_location(name=fullname, loader=self) + return None + + def module_repr(self, module): + raise NotImplementedError def run(): with VersionedFindLoad() as finder: sys.meta_path.insert(0, finder) - finder._register_distutils_finder() - from virtualenv.__main__ import run as run_virtualenv + finder._register_distutils_finder() # noqa: SLF001 + from virtualenv.__main__ import run as run_virtualenv # noqa: PLC0415, PLC2701 run_virtualenv() diff --git a/tasks/make_zipapp.py b/tasks/make_zipapp.py index aa6f62509..608efcf8c 100644 --- a/tasks/make_zipapp.py +++ b/tasks/make_zipapp.py @@ -1,9 +1,11 @@ -"""https://docs.python.org/3/library/zipapp.html""" +"""https://docs.python.org/3/library/zipapp.html.""" + +from __future__ import annotations + import argparse import io import json import os -import pipes import shutil import subprocess import sys @@ -12,6 +14,7 @@ from collections import defaultdict, deque from email import message_from_string from pathlib import Path, PurePosixPath +from shlex import quote from stat import S_IWUSR from tempfile import TemporaryDirectory @@ -20,7 +23,7 @@ HERE = Path(__file__).parent.absolute() -VERSIONS = ["3.{}".format(i) for i in range(10, 4, -1)] + ["2.7"] +VERSIONS = [f"3.{i}" for i in range(13, 7, -1)] def main(): @@ -46,22 +49,22 @@ def create_zipapp(dest, packages): zip_app.writestr("__main__.py", (HERE / "__main__zipapp.py").read_bytes()) bio.seek(0) zipapp.create_archive(bio, dest) - print("zipapp created at {}".format(dest)) + print(f"zipapp created at {dest} with size {os.path.getsize(dest) / 1024 / 1024:.2f}MB") # noqa: T201 -def write_packages_to_zipapp(base, dist, modules, packages, zip_app): +def write_packages_to_zipapp(base, dist, modules, packages, zip_app): # noqa: C901, PLR0912 has = set() - for name, p_w_v in packages.items(): + for name, p_w_v in packages.items(): # noqa: PLR1702 for platform, w_v in p_w_v.items(): for wheel_data in w_v.values(): wheel = wheel_data.wheel with zipfile.ZipFile(str(wheel)) as wheel_zip: for filename in wheel_zip.namelist(): - if name in ("virtualenv",): + if name == "virtualenv": dest = PurePosixPath(filename) else: dest = base / wheel.stem / filename - if dest.suffix in (".so", ".pyi"): + if dest.suffix in {".so", ".pyi"}: continue if dest.suffix == ".py": key = filename[:-3].replace("/", ".").replace("__init__", "").rstrip(".") @@ -76,21 +79,21 @@ def write_packages_to_zipapp(base, dist, modules, packages, zip_app): has.add(dest_str) if "/tests/" in dest_str or "/docs/" in dest_str: continue - print(dest_str) + print(dest_str) # noqa: T201 content = wheel_zip.read(filename) zip_app.writestr(dest_str, content) del content -class WheelDownloader(object): - def __init__(self, into): +class WheelDownloader: + def __init__(self, into) -> None: if into.exists(): shutil.rmtree(into) into.mkdir(parents=True) self.into = into self.collected = defaultdict(lambda: defaultdict(dict)) self.pip_cmd = [str(Path(sys.executable).parent / "pip")] - self._cmd = self.pip_cmd + ["download", "-q", "--no-deps", "--dest", str(self.into)] + self._cmd = [*self.pip_cmd, "download", "-q", "--no-deps", "--no-cache-dir", "--dest", str(self.into)] def run(self, target, versions): whl = self.build_sdist(target) @@ -104,7 +107,8 @@ def run(self, target, versions): whl = self._get_wheel(dep, platform[2:] if platform and platform.startswith("==") else None, version) if whl is None: if dep_str not in wheel_store: - raise RuntimeError("failed to get {}, have {}".format(dep_str, wheel_store)) + msg = f"failed to get {dep_str}, have {wheel_store}" + raise RuntimeError(msg) whl = wheel_store[dep_str] else: wheel_store[dep_str] = whl @@ -114,12 +118,19 @@ def run(self, target, versions): def _get_wheel(self, dep, platform, version): if isinstance(dep, Requirement): before = set(self.into.iterdir()) - if self._download(platform, False, "--python-version", version, "--only-binary", ":all:", str(dep)): - self._download(platform, True, "--python-version", version, str(dep)) + if self._download( + platform, + False, # noqa: FBT003 + "--python-version", + version, + "--only-binary", + ":all:", + str(dep), + ): + self._download(platform, True, "--python-version", version, str(dep)) # noqa: FBT003 after = set(self.into.iterdir()) new_files = after - before - # print(dep, new_files) - assert len(new_files) <= 1 + assert len(new_files) <= 1 # noqa: S101 if not len(new_files): return None new_file = next(iter(new_files)) @@ -127,7 +138,7 @@ def _get_wheel(self, dep, platform, version): return new_file dep = new_file new_file = self.build_sdist(dep) - assert new_file.suffix == ".whl" + assert new_file.suffix == ".whl" # noqa: S101 return new_file def _download(self, platform, stop_print_on_fail, *args): @@ -139,7 +150,7 @@ def _download(self, platform, stop_print_on_fail, *args): @staticmethod def get_dependencies(whl, version): with zipfile.ZipFile(str(whl), "r") as zip_file: - name = "/".join(["{}.dist-info".format("-".join(whl.name.split("-")[0:2])), "METADATA"]) + name = "/".join([f"{'-'.join(whl.name.split('-')[0:2])}.dist-info", "METADATA"]) with zip_file.open(name) as file_handler: metadata = message_from_string(file_handler.read().decode("utf-8")) deps = metadata.get_all("Requires-Dist") @@ -147,15 +158,18 @@ def get_dependencies(whl, version): return for dep in deps: req = Requirement(dep) - markers = getattr(req.marker, "_markers", tuple()) or () - if any(m for m in markers if isinstance(m, tuple) and len(m) == 3 and m[0].value == "extra"): + markers = getattr(req.marker, "_markers", ()) or () + if any( + m + for m in markers + if isinstance(m, tuple) and len(m) == 3 and m[0].value == "extra" # noqa: PLR2004 + ): continue py_versions = WheelDownloader._marker_at(markers, "python_version") if py_versions: marker = Marker('python_version < "1"') - marker._markers = [ - markers[ver] - for ver in sorted(list(i for i in set(py_versions) | {i - 1 for i in py_versions} if i >= 0)) + marker._markers = [ # noqa: SLF001 + markers[ver] for ver in sorted(i for i in set(py_versions) | {i - 1 for i in py_versions} if i >= 0) ] matches_python = marker.evaluate({"python_version": version}) if not matches_python: @@ -166,8 +180,8 @@ def get_dependencies(whl, version): platforms = [] platform_positions = WheelDownloader._marker_at(markers, "sys_platform") deleted = 0 - for pos in platform_positions: # can only be ore meaningfully - platform = "{}{}".format(markers[pos][1].value, markers[pos][2].value) + for pos in platform_positions: # can only be or meaningfully + platform = f"{markers[pos][1].value}{markers[pos][2].value}" deleted += WheelDownloader._del_marker_at(markers, pos - deleted) platforms.append(platform) if not platforms: @@ -177,11 +191,11 @@ def get_dependencies(whl, version): @staticmethod def _marker_at(markers, key): - positions = [] - for i, m in enumerate(markers): - if isinstance(m, tuple) and len(m) == 3 and m[0].value == key: - positions.append(i) - return positions + return [ + i + for i, m in enumerate(markers) + if isinstance(m, tuple) and len(m) == 3 and m[0].value == key # noqa: PLR2004 + ] @staticmethod def _del_marker_at(markers, at): @@ -207,7 +221,7 @@ def build_sdist(self, target): return self._build_sdist(self.into, folder) finally: # permission error on Windows <3.7 https://bugs.python.org/issue26660 - def onerror(func, path, exc_info): + def onerror(func, path, exc_info): # noqa: ARG001 os.chmod(path, S_IWUSR) func(path) @@ -218,20 +232,26 @@ def onerror(func, path, exc_info): def _build_sdist(self, folder, target): if not folder.exists() or not list(folder.iterdir()): - cmd = self.pip_cmd + ["wheel", "-w", str(folder), "--no-deps", str(target), "-q"] + cmd = [*self.pip_cmd, "wheel", "-w", str(folder), "--no-deps", str(target), "-q"] run_suppress_output(cmd, stop_print_on_fail=True) - return list(folder.iterdir())[0] + return next(iter(folder.iterdir())) -def run_suppress_output(cmd, stop_print_on_fail=False): - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) +def run_suppress_output(cmd, stop_print_on_fail=False): # noqa: FBT002 + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + encoding="utf-8", + ) out, err = process.communicate() if stop_print_on_fail and process.returncode != 0: - print("exit with {} of {}".format(process.returncode, " ".join(pipes.quote(i) for i in cmd)), file=sys.stdout) + print(f"exit with {process.returncode} of {' '.join(quote(i) for i in cmd)}", file=sys.stdout) # noqa: T201 if out: - print(out, file=sys.stdout) + print(out, file=sys.stdout) # noqa: T201 if err: - print(err, file=sys.stderr) + print(err, file=sys.stderr) # noqa: T201 raise SystemExit(process.returncode) return process.returncode @@ -244,25 +264,25 @@ def get_wheels_for_support_versions(folder): for pkg, platform_to_wheel in collected.items(): name = Requirement(pkg).name for platform, wheel in platform_to_wheel.items(): - platform = platform or "==any" - wheel_versions = packages[name][platform][wheel.name] + pl = platform or "==any" + wheel_versions = packages[name][pl][wheel.name] wheel_versions.versions.append(version) wheel_versions.wheel = wheel for name, p_w_v in packages.items(): for platform, w_v in p_w_v.items(): - print("{} - {}".format(name, platform)) + print(f"{name} - {platform}") # noqa: T201 for wheel, wheel_versions in w_v.items(): - print("{} of {} (use {})".format(" ".join(wheel_versions.versions), wheel, wheel_versions.wheel)) + print(f"{' '.join(wheel_versions.versions)} of {wheel} (use {wheel_versions.wheel})") # noqa: T201 return packages -class WheelForVersion(object): - def __init__(self, wheel=None, versions=None): +class WheelForVersion: + def __init__(self, wheel=None, versions=None) -> None: self.wheel = wheel - self.versions = versions if versions else [] + self.versions = versions or [] - def __repr__(self): - return "{}({!r}, {!r})".format(self.__class__.__name__, self.wheel, self.versions) + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.wheel!r}, {self.versions!r})" if __name__ == "__main__": diff --git a/tasks/pick_tox_env.py b/tasks/pick_tox_env.py new file mode 100644 index 000000000..fb0088458 --- /dev/null +++ b/tasks/pick_tox_env.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +py = sys.argv[1] +if py.startswith("brew@"): + py = py[len("brew@") :] +env = f"TOXENV={py}" +with Path(os.environ["GITHUB_ENV"]).open("ta", encoding="utf-8") as file_handler: + file_handler.write(env) diff --git a/tasks/release.py b/tasks/release.py index fa797d354..feda8b3c2 100644 --- a/tasks/release.py +++ b/tasks/release.py @@ -1,8 +1,9 @@ -# -*- coding: utf-8 -*- -"""Handles creating a release PR""" +"""Handles creating a release PR.""" + +from __future__ import annotations + from pathlib import Path from subprocess import check_call -from typing import Tuple from git import Commit, Head, Remote, Repo, TagReference from packaging.version import Version @@ -15,19 +16,20 @@ def main(version_str: str) -> None: repo = Repo(str(ROOT_SRC_DIR)) if repo.is_dirty(): - raise RuntimeError("Current repository is dirty. Please commit any changes and try again.") + msg = "Current repository is dirty. Please commit any changes and try again." + raise RuntimeError(msg) upstream, release_branch = create_release_branch(repo, version) release_commit = release_changelog(repo, version) tag = tag_release_commit(release_commit, repo, version) - print("push release commit") + print("push release commit") # noqa: T201 repo.git.push(upstream.name, release_branch) - print("push release tag") + print("push release tag") # noqa: T201 repo.git.push(upstream.name, tag) - print("All done! ✨ 🍰 ✨") + print("All done! ✨ 🍰 ✨") # noqa: T201 -def create_release_branch(repo: Repo, version: Version) -> Tuple[Remote, Head]: - print("create release branch from upstream main") +def create_release_branch(repo: Repo, version: Version) -> tuple[Remote, Head]: + print("create release branch from upstream main") # noqa: T201 upstream = get_upstream(repo) upstream.fetch() branch_name = f"release-{version}" @@ -46,25 +48,24 @@ def get_upstream(repo: Repo) -> Remote: if url.endswith(upstream_remote): return remote urls.add(url) - raise RuntimeError(f"could not find {upstream_remote} remote, has {urls}") + msg = f"could not find {upstream_remote} remote, has {urls}" + raise RuntimeError(msg) def release_changelog(repo: Repo, version: Version) -> Commit: - print("generate release commit") - check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_SRC_DIR)) - release_commit = repo.index.commit(f"release {version}") - return release_commit + print("generate release commit") # noqa: T201 + check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_SRC_DIR)) # noqa: S607 + return repo.index.commit(f"release {version}") def tag_release_commit(release_commit, repo, version) -> TagReference: - print("tag release commit") + print("tag release commit") # noqa: T201 existing_tags = [x.name for x in repo.tags] if version in existing_tags: - print("delete existing tag {}".format(version)) + print(f"delete existing tag {version}") # noqa: T201 repo.delete_tag(version) - print("create tag {}".format(version)) - tag = repo.create_tag(version, ref=release_commit, force=True) - return tag + print(f"create tag {version}") # noqa: T201 + return repo.create_tag(version, ref=release_commit, force=True) if __name__ == "__main__": diff --git a/tasks/update_embedded.py b/tasks/update_embedded.py index 814455941..933e033f6 100755 --- a/tasks/update_embedded.py +++ b/tasks/update_embedded.py @@ -1,22 +1,16 @@ -#!/usr/bin/env python -""" -Helper script to rebuild virtualenv.py from virtualenv_support -""" -from __future__ import print_function, unicode_literals +"""Helper script to rebuild virtualenv.py from virtualenv_support.""" # noqa: EXE002 + +from __future__ import annotations import codecs +import locale import os import re -import sys from zlib import crc32 as _crc32 -if sys.version_info < (3,): - print("requires Python 3 (use tox from Python 3 if invoked via tox)") - raise SystemExit(1) - def crc32(data): - """Python version idempotent""" + """Python version idempotent.""" return _crc32(data.encode()) & 0xFFFFFFFF @@ -26,12 +20,12 @@ def crc32(data): gzip = codecs.lookup("zlib") b64 = codecs.lookup("base64") -file_regex = re.compile(r'# file (.*?)\n([a-zA-Z][a-zA-Z0-9_]+) = convert\(\n {4}"""\n(.*?)"""\n\)', re.S) +file_regex = re.compile(r'# file (.*?)\n([a-zA-Z][a-zA-Z0-9_]+) = convert\(\n {4}"""\n(.*?)"""\n\)', re.DOTALL) file_template = '# file {filename}\n{variable} = convert(\n """\n{data}"""\n)' def rebuild(script_path): - with open(script_path, "rt") as current_fh: + with script_path.open(encoding=locale.getpreferredencoding(False)) as current_fh: # noqa: FBT003 script_content = current_fh.read() script_parts = [] match_end = 0 @@ -53,35 +47,34 @@ def rebuild(script_path): def handle_file(previous_content, filename, variable_name, previous_encoded): - print("Found file {}".format(filename)) + print(f"Found file {filename}") # noqa: T201 current_path = os.path.realpath(os.path.join(here, "..", "src", "virtualenv_embedded", filename)) _, file_type = os.path.splitext(current_path) - keep_line_ending = file_type in (".bat",) - with open(current_path, "rt", encoding="utf-8", newline="" if keep_line_ending else None) as current_fh: + keep_line_ending = file_type == ".bat" + with open(current_path, encoding="utf-8", newline="" if keep_line_ending else None) as current_fh: current_text = current_fh.read() current_crc = crc32(current_text) current_encoded = b64.encode(gzip.encode(current_text.encode())[0])[0].decode() if current_encoded == previous_encoded: - print(" File up to date (crc: {:08x})".format(current_crc)) + print(f" File up to date (crc: {current_crc:08x})") # noqa: T201 return False, previous_content # Else: content has changed previous_text = gzip.decode(b64.decode(previous_encoded.encode())[0])[0].decode() previous_crc = crc32(previous_text) - print(" Content changed (crc: {:08x} -> {:08x})".format(previous_crc, current_crc)) + print(f" Content changed (crc: {previous_crc:08x} -> {current_crc:08x})") # noqa: T201 new_part = file_template.format(filename=filename, variable=variable_name, data=current_encoded) return True, new_part def report(exit_code, new, next_match, current, script_path): if new != current: - print("Content updated; overwriting... ", end="") - with open(script_path, "wt") as current_fh: - current_fh.write(new) - print("done.") + print("Content updated; overwriting... ", end="") # noqa: T201 + script_path.write_bytes(new) + print("done.") # noqa: T201 else: - print("No changes in content") + print("No changes in content") # noqa: T201 if next_match is None: - print("No variables were matched/found") + print("No variables were matched/found") # noqa: T201 raise SystemExit(exit_code) diff --git a/tasks/upgrade_wheels.py b/tasks/upgrade_wheels.py index 6bc70d06e..9fd58fe00 100644 --- a/tasks/upgrade_wheels.py +++ b/tasks/upgrade_wheels.py @@ -1,7 +1,6 @@ -""" -Helper script to rebuild virtualenv_support. Downloads the wheel files using pip -""" -from __future__ import absolute_import, unicode_literals +"""Helper script to rebuild virtualenv_support. Downloads the wheel files using pip.""" + +from __future__ import annotations import os import shutil @@ -16,7 +15,7 @@ STRICT = "UPGRADE_ADVISORY" not in os.environ BUNDLED = ["pip", "setuptools", "wheel"] -SUPPORT = list(reversed([(2, 7)] + [(3, i) for i in range(5, 12)])) +SUPPORT = [(3, i) for i in range(8, 15)] DEST = Path(__file__).resolve().parents[1] / "src" / "virtualenv" / "seed" / "wheels" / "embed" @@ -28,6 +27,7 @@ def download(ver, dest, package): "pip", "--disable-pip-version-check", "download", + "--no-cache-dir", "--only-binary=:all:", "--python-version", ver, @@ -38,7 +38,7 @@ def download(ver, dest, package): ) -def run(): +def run(): # noqa: C901 old_batch = {i.name for i in DEST.iterdir() if i.suffix == ".whl"} with TemporaryDirectory() as temp: temp_path = Path(temp) @@ -55,7 +55,7 @@ def run(): thread.start() for thread in targets: thread.join() - new_batch = {i.name: i for f in folders.keys() for i in Path(f).iterdir()} + new_batch = {i.name: i for f in folders for i in Path(f).iterdir()} new_packages = new_batch.keys() - old_batch remove_packages = old_batch - new_batch.keys() @@ -67,34 +67,41 @@ def run(): added = collect_package_versions(new_packages) removed = collect_package_versions(remove_packages) - outcome = (1 if STRICT else 0) if (added or removed) else 0 + print(f"Outcome {outcome} added {added} removed {removed}") # noqa: T201 + lines = ["Upgrade embedded wheels:", ""] for key, versions in added.items(): - text = "* upgrade embedded {} to {}".format(key, fmt_version(versions)) + text = f"* {key} to {fmt_version(versions)}" if key in removed: - text += " from {}".format(removed[key]) + rem = ", ".join(f"``{i}``" for i in removed[key]) + text += f" from {rem}" del removed[key] - print(text) + lines.append(text) for key, versions in removed.items(): - print("* removed embedded {} of {}".format(key, fmt_version(versions))) - - support_table = OrderedDict((".".join(str(j) for j in i), list()) for i in SUPPORT) + lines.append(f"Removed {key} of {fmt_version(versions)}") + lines.append("") + changelog = "\n".join(lines) + print(changelog) # noqa: T201 + if len(lines) >= 4: # noqa: PLR2004 + (Path(__file__).parents[1] / "docs" / "changelog" / "u.bugfix.rst").write_text(changelog, encoding="utf-8") + support_table = OrderedDict((".".join(str(j) for j in i), []) for i in SUPPORT) for package in sorted(new_batch.keys()): for folder, version in sorted(folders.items()): if (folder / package).exists(): support_table[version].append(package) support_table = {k: OrderedDict((i.split("-")[0], i) for i in v) for k, v in support_table.items()} - + bundle = ",".join( + f"{v!r}: {{ {','.join(f'{p!r}: {f!r}' for p, f in line.items())} }}" for v, line in support_table.items() + ) msg = dedent( - """ - from __future__ import absolute_import, unicode_literals + f""" + from pathlib import Path from virtualenv.seed.wheels.util import Wheel - from virtualenv.util.path import Path BUNDLE_FOLDER = Path(__file__).absolute().parent - BUNDLE_SUPPORT = {{ {0} }} - MAX = {1} + BUNDLE_SUPPORT = {{ {bundle} }} + MAX = {next(iter(support_table.keys()))!r} def get_embed_wheel(distribution, for_py_version): @@ -102,38 +109,32 @@ def get_embed_wheel(distribution, for_py_version): return Wheel.from_path(path) - __all__ = ( + __all__ = [ "get_embed_wheel", "BUNDLE_SUPPORT", "MAX", "BUNDLE_FOLDER", - ) + ] - """.format( - ",".join( - "{!r}: {{ {} }}".format(v, ",".join("{!r}: {!r}".format(p, f) for p, f in l.items())) - for v, l in support_table.items() - ), - repr(next(iter(support_table.keys()))), - ), + """, ) dest_target = DEST / "__init__.py" - dest_target.write_text(msg) - - subprocess.run([sys.executable, "-m", "black", str(dest_target)]) + dest_target.write_text(msg, encoding="utf-8") + subprocess.run([sys.executable, "-m", "ruff", "check", str(dest_target), "--fix", "--unsafe-fixes"]) + subprocess.run([sys.executable, "-m", "ruff", "format", str(dest_target), "--preview"]) raise SystemExit(outcome) def fmt_version(versions): - return ", ".join("``{}``".format(v) for v in versions) + return ", ".join(f"``{v}``" for v in versions) def collect_package_versions(new_packages): result = defaultdict(list) for package in new_packages: split = package.split("-") - if len(split) < 2: + if len(split) < 2: # noqa: PLR2004 raise ValueError(package) key, version = split[0:2] result[key].append(version) diff --git a/tests/conftest.py b/tests/conftest.py index ad8643b9c..0310b5525 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import os @@ -7,17 +6,15 @@ import sys from contextlib import contextmanager from functools import partial +from pathlib import Path +from typing import ClassVar import pytest -import six from virtualenv.app_data import AppDataDiskFolder -from virtualenv.discovery.builtin import get_interpreter from virtualenv.discovery.py_info import PythonInfo from virtualenv.info import IS_PYPY, IS_WIN, fs_supports_symlink from virtualenv.report import LOGGER -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_str, ensure_text def pytest_addoption(parser): @@ -27,8 +24,8 @@ def pytest_addoption(parser): def pytest_configure(config): """Ensure randomly is called before we re-order""" manager = config.pluginmanager - # noinspection PyProtectedMember - order = manager.hook.pytest_collection_modifyitems._nonwrappers + + order = manager.hook.pytest_collection_modifyitems.get_hookimpls() dest = next((i for i, p in enumerate(order) if p.plugin is manager.getplugin("randomly")), None) if dest is not None: from_pos = next(i for i, p in enumerate(order) if p.plugin is manager.getplugin(__file__)) @@ -51,7 +48,7 @@ def pytest_collection_modifyitems(config, items): @pytest.fixture(scope="session") -def has_symlink_support(tmp_path_factory): +def has_symlink_support(tmp_path_factory): # noqa: ARG001 return fs_supports_symlink() @@ -59,21 +56,19 @@ def has_symlink_support(tmp_path_factory): def link_folder(has_symlink_support): if has_symlink_support: return os.symlink - elif sys.platform == "win32" and sys.version_info[0:2] > (3, 4): + if sys.platform == "win32": # on Windows junctions may be used instead - import _winapi # Cpython3.5 has builtin implementation for junctions + import _winapi # noqa: PLC0415 return getattr(_winapi, "CreateJunction", None) - else: - return None + return None @pytest.fixture(scope="session") def link_file(has_symlink_support): if has_symlink_support: return os.symlink - else: - return None + return None @pytest.fixture(scope="session") @@ -88,20 +83,19 @@ def _link(src, dest): else: shutil.copytree(s_src, s_dest) clean = partial(shutil.rmtree, str(dest)) + elif link_file: + link_file(s_src, s_dest) else: - if link_file: - link_file(s_src, s_dest) - else: - shutil.copy2(s_src, s_dest) + shutil.copy2(s_src, s_dest) return clean return _link @pytest.fixture(autouse=True) -def ensure_logging_stable(): +def _ensure_logging_stable(): logger_level = LOGGER.level - handlers = [i for i in LOGGER.handlers] + handlers = list(LOGGER.handlers) filelock_logger = logging.getLogger("filelock") fl_level = filelock_logger.level yield @@ -114,16 +108,16 @@ def ensure_logging_stable(): @pytest.fixture(autouse=True) -def check_cwd_not_changed_by_test(): +def _check_cwd_not_changed_by_test(): old = os.getcwd() yield new = os.getcwd() if old != new: - pytest.fail("tests changed cwd: {!r} => {!r}".format(old, new)) + pytest.fail(f"tests changed cwd: {old!r} => {new!r}") @pytest.fixture(autouse=True) -def ensure_py_info_cache_empty(session_app_data): +def _ensure_py_info_cache_empty(session_app_data): PythonInfo.clear_cache(session_app_data) yield PythonInfo.clear_cache(session_app_data) @@ -132,7 +126,7 @@ def ensure_py_info_cache_empty(session_app_data): @contextmanager def change_os_environ(key, value): env_var = key - previous = os.environ[env_var] if env_var in os.environ else None + previous = os.environ.get(env_var, None) os.environ[env_var] = value try: yield @@ -142,37 +136,17 @@ def change_os_environ(key, value): @pytest.fixture(autouse=True, scope="session") -def ignore_global_config(tmp_path_factory): +def _ignore_global_config(tmp_path_factory): filename = str(tmp_path_factory.mktemp("folder") / "virtualenv-test-suite.ini") - with change_os_environ(ensure_str("VIRTUALENV_CONFIG_FILE"), filename): + with change_os_environ("VIRTUALENV_CONFIG_FILE", filename): yield -@pytest.fixture(autouse=True, scope="session") -def pip_cert(tmp_path_factory): - # workaround for https://github.com/pypa/pip/issues/8984 - if the certificate is explicitly set no error can happen - key = ensure_str("PIP_CERT") - if key in os.environ: - yield - else: - cert = tmp_path_factory.mktemp("folder") / "cert" - import pkgutil - - cert_data = pkgutil.get_data("pip._vendor.certifi", "cacert.pem") - cert.write_bytes(cert_data) - with change_os_environ(key, str(cert)): - yield - - @pytest.fixture(autouse=True) -def check_os_environ_stable(): +def _check_os_environ_stable(): old = os.environ.copy() # ensure we don't inherit parent env variables - to_clean = { - k - for k in os.environ.keys() - if k.startswith(str("VIRTUALENV_")) or str("VIRTUAL_ENV") in k or k.startswith(str("TOX_")) - } + to_clean = {k for k in os.environ if k.startswith(("VIRTUALENV_", "TOX_")) or "VIRTUAL_ENV" in k} cleaned = {k: os.environ[k] for k, v in os.environ.items()} override = { "VIRTUALENV_NO_PERIODIC_UPDATE": "1", @@ -188,25 +162,25 @@ def check_os_environ_stable(): raise finally: try: - for key in override.keys(): + for key in override: del os.environ[str(key)] if is_exception is False: new = os.environ extra = {k: new[k] for k in set(new) - set(old)} miss = {k: old[k] for k in set(old) - set(new) - to_clean} diff = { - "{} = {} vs {}".format(k, old[k], new[k]) + f"{k} = {old[k]} vs {new[k]}" for k in set(old) & set(new) - if old[k] != new[k] and not k.startswith(str("PYTEST_")) + if old[k] != new[k] and not k.startswith("PYTEST_") } if extra or miss or diff: msg = "tests changed environ" if extra: - msg += " extra {}".format(extra) + msg += f" extra {extra}" if miss: - msg += " miss {}".format(miss) + msg += f" miss {miss}" if diff: - msg += " diff {}".format(diff) + msg += f" diff {diff}" pytest.fail(msg) finally: os.environ.update(cleaned) @@ -221,9 +195,9 @@ def coverage_env(monkeypatch, link, request): """ Enable coverage report collection on the created virtual environments by injecting the coverage project """ - if COVERAGE_RUN and "no_coverage" not in request.fixturenames: + if COVERAGE_RUN and "_no_coverage" not in request.fixturenames: # we inject right after creation, we cannot collect coverage on site.py - used for helper scripts, such as debug - from virtualenv import run + from virtualenv import run # noqa: PLC0415 def _session_via_cli(args, options, setup_logging, env=None): session = prev_run(args, options, setup_logging, env) @@ -232,7 +206,7 @@ def _session_via_cli(args, options, setup_logging, env=None): def create_run(): result = old_run() obj["cov"] = EnableCoverage(link) - obj["cov"].__enter__(session.creator) + obj["cov"].__enter__(session.creator) # noqa: PLC2801 return result monkeypatch.setattr(session.creator, "run", create_run) @@ -259,24 +233,26 @@ def finish(): yield finish -# no_coverage tells coverage_env to disable coverage injection for no_coverage user. +# _no_coverage tells coverage_env to disable coverage injection for _no_coverage user. @pytest.fixture -def no_coverage(): +def _no_coverage(): pass if COVERAGE_RUN: import coverage - class EnableCoverage(object): - _COV_FILE = Path(coverage.__file__) - _ROOT_COV_FILES_AND_FOLDERS = [i for i in _COV_FILE.parents[1].iterdir() if i.name.startswith("coverage")] + class EnableCoverage: + _COV_FILE: ClassVar[Path] = Path(coverage.__file__) + _ROOT_COV_FILES_AND_FOLDERS: ClassVar[list[Path]] = [ + i for i in _COV_FILE.parents[1].iterdir() if i.name.startswith("coverage") + ] - def __init__(self, link): + def __init__(self, link) -> None: self.link = link self.targets = [] - def __enter__(self, creator): + def __enter__(self, creator): # noqa: PLE0302 site_packages = creator.purelib for entry in self._ROOT_COV_FILES_AND_FOLDERS: target = site_packages / entry.name @@ -294,12 +270,16 @@ def __exit__(self, exc_type, exc_val, exc_tb): @pytest.fixture(scope="session") def is_inside_ci(): - yield bool(os.environ.get(str("CI_RUN"))) + return bool(os.environ.get("CI_RUN")) @pytest.fixture(scope="session") def special_char_name(): - base = "e-$ èрт🚒♞中片-j" + base = "'\";&&e-$ èрт🚒♞中片-j" + if IS_WIN: + # get rid of invalid characters on Windows + base = base.replace('"', "") + base = base.replace(";", "") # workaround for pypy3 https://bitbucket.org/pypy/pypy/issues/3147/venv-non-ascii-support-windows encoding = "ascii" if IS_WIN else sys.getfilesystemencoding() # let's not include characters that the file system cannot encode) @@ -309,18 +289,15 @@ def special_char_name(): trip = char.encode(encoding, errors="strict").decode(encoding) if char == trip: result += char - except ValueError: + except ValueError: # noqa: PERF203 continue assert result return result -@pytest.fixture() +@pytest.fixture def special_name_dir(tmp_path, special_char_name): - dest = Path(str(tmp_path)) / special_char_name - yield dest - if six.PY2 and sys.platform == "win32" and not IS_PYPY: # pytest python2 windows does not support unicode delete - shutil.rmtree(ensure_text(str(dest))) + return Path(str(tmp_path)) / special_char_name @pytest.fixture(scope="session") @@ -337,7 +314,7 @@ def current_fastest(current_creators): def session_app_data(tmp_path_factory): temp_folder = tmp_path_factory.mktemp("session-app-data") app_data = AppDataDiskFolder(folder=str(temp_folder)) - with change_env_var(str("VIRTUALENV_OVERRIDE_APP_DATA"), str(app_data.lock.path)): + with change_env_var("VIRTUALENV_OVERRIDE_APP_DATA", str(app_data.lock.path)): yield app_data @@ -354,37 +331,38 @@ def change_env_var(key, value): yield finally: if already_set: - os.environ[key] = prev_value # type: ignore + os.environ[key] = prev_value else: del os.environ[key] # pragma: no cover -@pytest.fixture() +@pytest.fixture def temp_app_data(monkeypatch, tmp_path): app_data = tmp_path / "app-data" - monkeypatch.setenv(str("VIRTUALENV_OVERRIDE_APP_DATA"), str(app_data)) + monkeypatch.setenv("VIRTUALENV_OVERRIDE_APP_DATA", str(app_data)) return app_data -@pytest.fixture(scope="session") -def cross_python(is_inside_ci, session_app_data): - spec = str(2 if sys.version_info[0] == 3 else 3) - interpreter = get_interpreter(spec, [], session_app_data) - if interpreter is None: - msg = "could not find {}".format(spec) - if is_inside_ci: - raise RuntimeError(msg) - pytest.skip(msg=msg) - yield interpreter - - @pytest.fixture(scope="session") def for_py_version(): - return "{}.{}".format(*sys.version_info[0:2]) + return f"{sys.version_info.major}.{sys.version_info.minor}" -@pytest.fixture() -def skip_if_test_in_system(session_app_data): +@pytest.fixture +def _skip_if_test_in_system(session_app_data): current = PythonInfo.current(session_app_data) if current.system_executable is not None: pytest.skip("test not valid if run under system") + + +if IS_PYPY or (IS_WIN and sys.version_info[0:2] >= (3, 13)): # https://github.com/adamchainz/time-machine/issues/456 + + @pytest.fixture + def time_freeze(freezer): + return freezer.move_to + +else: + + @pytest.fixture + def time_freeze(time_machine): + return lambda s: time_machine.move_to(s, tick=False) diff --git a/tests/integration/test_cachedir_tag.py b/tests/integration/test_cachedir_tag.py new file mode 100644 index 000000000..324bb036f --- /dev/null +++ b/tests/integration/test_cachedir_tag.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import shutil +import sys +from subprocess import check_output, run +from typing import TYPE_CHECKING + +import pytest + +from virtualenv import cli_run + +if TYPE_CHECKING: + from pathlib import Path + +# gtar => gnu-tar on macOS +TAR = next((target for target in ("gtar", "tar") if shutil.which(target)), None) + + +def compatible_is_tar_present() -> bool: + return TAR and "--exclude-caches" in check_output(args=[TAR, "--help"], text=True) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Windows does not have tar") +@pytest.mark.skipif(not compatible_is_tar_present(), reason="Compatible tar is not installed") +def test_cachedir_tag_ignored_by_tag(tmp_path: Path) -> None: + venv = tmp_path / ".venv" + cli_run(["--activators", "", "--without-pip", str(venv)]) + + args = [TAR, "--create", "--file", "/dev/null", "--exclude-caches", "--verbose", venv.name] + tar_result = run(args=args, capture_output=True, text=True, cwd=tmp_path) + assert tar_result.stdout == ".venv/\n.venv/CACHEDIR.TAG\n" + assert tar_result.stderr == f"{TAR}: .venv/: contains a cache directory tag CACHEDIR.TAG; contents not dumped\n" diff --git a/tests/integration/test_run_int.py b/tests/integration/test_run_int.py index 632d6259f..e1dc6d3f5 100644 --- a/tests/integration/test_run_int.py +++ b/tests/integration/test_run_int.py @@ -1,22 +1,23 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -import sys +from typing import TYPE_CHECKING import pytest from virtualenv import cli_run from virtualenv.info import IS_PYPY -from virtualenv.util.six import ensure_text from virtualenv.util.subprocess import run_cmd +if TYPE_CHECKING: + from pathlib import Path -@pytest.mark.skipif(IS_PYPY, reason="setuptools distutil1s patching does not work") -def test_app_data_pinning(tmp_path): - version = "19.1.1" if sys.version_info[0:2] == (3, 4) else "19.3.1" - result = cli_run([ensure_text(str(tmp_path)), "--pip", version, "--activators", "", "--seeder", "app-data"]) - code, out, err = run_cmd([str(result.creator.script("pip")), "list", "--disable-pip-version-check"]) + +@pytest.mark.skipif(IS_PYPY, reason="setuptools distutils patching does not work") +def test_app_data_pinning(tmp_path: Path) -> None: + version = "23.1" + result = cli_run([str(tmp_path), "--pip", version, "--activators", "", "--seeder", "app-data"]) + code, out, _ = run_cmd([str(result.creator.script("pip")), "list", "--disable-pip-version-check"]) assert not code - assert not err for line in out.splitlines(): parts = line.split() if parts and parts[0] == "pip": diff --git a/tests/integration/test_zipapp.py b/tests/integration/test_zipapp.py index e5198849d..b14344d16 100644 --- a/tests/integration/test_zipapp.py +++ b/tests/integration/test_zipapp.py @@ -1,16 +1,16 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import shutil import subprocess -import sys +from contextlib import suppress +from pathlib import Path import pytest from flaky import flaky from virtualenv.discovery.py_info import PythonInfo +from virtualenv.info import fs_supports_symlink from virtualenv.run import cli_run -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_text HERE = Path(__file__).parent CURRENT = PythonInfo.current_system() @@ -19,21 +19,21 @@ @pytest.fixture(scope="session") def zipapp_build_env(tmp_path_factory): create_env_path = None - if sys.version_info[0:2] >= (3, 5) and CURRENT.implementation != "PyPy": + if CURRENT.implementation != "PyPy": exe = CURRENT.executable # guaranteed to contain a recent enough pip (tox.ini) else: create_env_path = tmp_path_factory.mktemp("zipapp-create-env") exe, found = None, False # prefer CPython as builder as pypy is slow for impl in ["cpython", ""]: - for version in range(8, 4, -1): - try: + for version in range(11, 6, -1): + with suppress(Exception): # create a virtual environment which is also guaranteed to contain a recent enough pip (bundled) session = cli_run( [ "-vvv", "-p", - "{}3.{}".format(impl, version), + f"{impl}3.{version}", "--activators", "", str(create_env_path), @@ -44,13 +44,12 @@ def zipapp_build_env(tmp_path_factory): exe = str(session.creator.exe) found = True break - except Exception: - pass if found: break else: - raise RuntimeError("could not find a python to build zipapp") - cmd = [str(Path(exe).parent / "pip"), "install", "pip>=19.3", "packaging>=20"] + msg = "could not find a python to build zipapp" + raise RuntimeError(msg) + cmd = [str(Path(exe).parent / "pip"), "install", "pip>=23", "packaging>=23"] subprocess.check_call(cmd) yield exe if create_env_path is not None: @@ -60,7 +59,7 @@ def zipapp_build_env(tmp_path_factory): @pytest.fixture(scope="session") def zipapp(zipapp_build_env, tmp_path_factory): into = tmp_path_factory.mktemp("zipapp") - path = Path(HERE).parent.parent / "tasks" / "make_zipapp.py" + path = HERE.parent.parent / "tasks" / "make_zipapp.py" filename = into / "virtualenv.pyz" cmd = [zipapp_build_env, str(path), "--dest", str(filename)] subprocess.check_call(cmd) @@ -76,19 +75,37 @@ def zipapp_test_env(tmp_path_factory): shutil.rmtree(str(base_path)) -@pytest.fixture() -def call_zipapp(zipapp, monkeypatch, tmp_path, zipapp_test_env, temp_app_data): +@pytest.fixture +def call_zipapp(zipapp, tmp_path, zipapp_test_env, temp_app_data): # noqa: ARG001 def _run(*args): - cmd = [str(zipapp_test_env), str(zipapp), "-vv", ensure_text(str(tmp_path / "env"))] + list(args) + cmd = [str(zipapp_test_env), str(zipapp), "-vv", str(tmp_path / "env"), *list(args)] subprocess.check_call(cmd) return _run +@pytest.fixture +def call_zipapp_symlink(zipapp, tmp_path, zipapp_test_env, temp_app_data): # noqa: ARG001 + def _run(*args): + symlinked = zipapp.parent / "symlinked_virtualenv.pyz" + symlinked.symlink_to(str(zipapp)) + cmd = [str(zipapp_test_env), str(symlinked), "-vv", str(tmp_path / "env"), *list(args)] + subprocess.check_call(cmd) + + return _run + + +@pytest.mark.skipif(not fs_supports_symlink(), reason="symlink not supported") +def test_zipapp_in_symlink(capsys, call_zipapp_symlink): + call_zipapp_symlink("--reset-app-data") + _out, err = capsys.readouterr() + assert not err + + @flaky(max_runs=2, min_passes=1) def test_zipapp_help(call_zipapp, capsys): call_zipapp("-h") - out, err = capsys.readouterr() + _out, err = capsys.readouterr() assert not err diff --git a/tests/unit/activation/conftest.py b/tests/unit/activation/conftest.py index 6f2dd431c..e320038ea 100644 --- a/tests/unit/activation/conftest.py +++ b/tests/unit/activation/conftest.py @@ -1,25 +1,20 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os -import pipes import re -import shutil import subprocess import sys from os.path import dirname, normcase +from pathlib import Path +from subprocess import Popen import pytest -import six -from virtualenv.info import IS_PYPY, WIN_CPYTHON_2 from virtualenv.run import cli_run -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_str, ensure_text -from virtualenv.util.subprocess import Popen -class ActivationTester(object): - def __init__(self, of_class, session, cmd, activate_script, extension): +class ActivationTester: + def __init__(self, of_class, session, cmd, activate_script, extension) -> None: self.of_class = of_class self._creator = session.creator self._version_cmd = [cmd, "--version"] @@ -42,182 +37,182 @@ def get_version(self, raise_on_fail): universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + encoding="utf-8", ) out, err = process.communicate() - result = out if out else err - self._version = result - return result except Exception as exception: self._version = exception if raise_on_fail: raise - return RuntimeError("{} is not available due {}".format(self, exception)) + return RuntimeError(f"{self} is not available due {exception}") + else: + result = out or err + self._version = result + return result return self._version - def __unicode__(self): - return "{}(\nversion={!r},\ncreator={},\ninterpreter={})".format( - self.__class__.__name__, - self._version, - six.text_type(self._creator), - six.text_type(self._creator.interpreter), + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(\nversion={self._version!r},\ncreator={self._creator},\n" + f"interpreter={self._creator.interpreter})" ) - def __repr__(self): - return ensure_str(self.__unicode__()) - def __call__(self, monkeypatch, tmp_path): activate_script = self._creator.bin_dir / self.activate_script # check line endings are correct type script_content = activate_script.read_bytes() for line in script_content.split(b"\n")[:-1]: - cr = b"\r" if sys.version_info.major == 2 else 13 if self.unix_line_ending: - assert line == b"" or line[-1] != cr, script_content.decode("utf-8") + assert line == b"" or line[-1] != 13, script_content.decode("utf-8") else: - assert line[-1] == cr, script_content.decode("utf-8") + assert line[-1] == 13, script_content.decode("utf-8") test_script = self._generate_test_script(activate_script, tmp_path) - monkeypatch.chdir(ensure_text(str(tmp_path))) + monkeypatch.chdir(tmp_path) - monkeypatch.delenv(str("VIRTUAL_ENV"), raising=False) - invoke, env = self._invoke_script + [ensure_text(str(test_script))], self.env(tmp_path) + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + invoke, env = [*self._invoke_script, str(test_script)], self.env(tmp_path) try: process = Popen(invoke, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) - _raw, _ = process.communicate() - raw = _raw.decode("utf-8") + raw_, _ = process.communicate() + raw = raw_.decode() + assert process.returncode == 0, raw except subprocess.CalledProcessError as exception: - output = ensure_text((exception.output + exception.stderr) if six.PY3 else exception.output) - assert not exception.returncode, output - return + output = exception.output + exception.stderr + assert not exception.returncode, output # noqa: PT017 + return None - out = re.sub(r"pydev debugger: process \d+ is connecting\n\n", "", raw, re.M).strip().splitlines() + out = re.sub(r"pydev debugger: process \d+ is connecting\n\n", "", raw, flags=re.MULTILINE).strip().splitlines() self.assert_output(out, raw, tmp_path) return env, activate_script def non_source_activate(self, activate_script): - return self._invoke_script + [str(activate_script)] + return [*self._invoke_script, str(activate_script)] - # noinspection PyMethodMayBeStatic - def env(self, tmp_path): + def env(self, tmp_path): # noqa: ARG002 env = os.environ.copy() # add the current python executable folder to the path so we already have another python on the path # also keep the path so the shells (fish, bash, etc can be discovered) - env[str("PYTHONIOENCODING")] = str("utf-8") - env[str("PATH")] = os.pathsep.join([dirname(sys.executable)] + env.get(str("PATH"), str("")).split(os.pathsep)) + env["PYTHONIOENCODING"] = "utf-8" + env["PATH"] = os.pathsep.join([dirname(sys.executable), *env.get("PATH", "").split(os.pathsep)]) # clear up some environment variables so they don't affect the tests - for key in [k for k in env.keys() if k.startswith(str("_OLD")) or k.startswith(str("VIRTUALENV_"))]: + for key in [k for k in env if k.startswith(("_OLD", "VIRTUALENV_"))]: del env[key] return env def _generate_test_script(self, activate_script, tmp_path): commands = self._get_test_lines(activate_script) - script = ensure_text(os.linesep).join(commands) - test_script = tmp_path / "script.{}".format(self.extension) - with open(ensure_text(str(test_script)), "wb") as file_handler: + script = os.linesep.join(commands) + test_script = tmp_path / f"script.{self.extension}" + with test_script.open("wb") as file_handler: file_handler.write(script.encode(self.script_encoding)) return test_script def _get_test_lines(self, activate_script): - commands = [ + return [ self.print_python_exe(), self.print_os_env_var("VIRTUAL_ENV"), + self.print_os_env_var("VIRTUAL_ENV_PROMPT"), self.activate_call(activate_script), self.print_python_exe(), self.print_os_env_var("VIRTUAL_ENV"), + self.print_os_env_var("VIRTUAL_ENV_PROMPT"), self.print_prompt(), # \\ loads documentation from the virtualenv site packages self.pydoc_call, self.deactivate, self.print_python_exe(), self.print_os_env_var("VIRTUAL_ENV"), + self.print_os_env_var("VIRTUAL_ENV_PROMPT"), "", # just finish with an empty new line ] - return commands def assert_output(self, out, raw, tmp_path): # pre-activation assert out[0], raw assert out[1] == "None", raw + assert out[2] == "None", raw # post-activation expected = self._creator.exe.parent / os.path.basename(sys.executable) - assert self.norm_path(out[2]) == self.norm_path(expected), raw - assert self.norm_path(out[3]) == self.norm_path(self._creator.dest).replace("\\\\", "\\"), raw + assert self.norm_path(out[3]) == self.norm_path(expected), raw + assert self.norm_path(out[4]) == self.norm_path(self._creator.dest).replace("\\\\", "\\"), raw + assert out[5] == self._creator.env_name # Some attempts to test the prompt output print more than 1 line. # So we need to check if the prompt exists on any of them. - prompt_text = "({}) ".format(self._creator.env_name) - assert any(prompt_text in line for line in out[4:-3]), raw + prompt_text = f"({self._creator.env_name}) " + assert any(prompt_text in line for line in out[6:-4]), raw - assert out[-3] == "wrote pydoc_test.html", raw + assert out[-4] == "wrote pydoc_test.html", raw content = tmp_path / "pydoc_test.html" assert content.exists(), raw # post deactivation, same as before - assert out[-2] == out[0], raw + assert out[-3] == out[0], raw + assert out[-2] == "None", raw assert out[-1] == "None", raw def quote(self, s): - return pipes.quote(s) + return self.of_class.quote(s) def python_cmd(self, cmd): - return "{} -c {}".format(os.path.basename(sys.executable), self.quote(cmd)) + return f"{os.path.basename(sys.executable)} -c {self.quote(cmd)}" def print_python_exe(self): - return self.python_cmd( - "import sys; print(sys.executable{})".format( - "" if six.PY3 or IS_PYPY else ".decode(sys.getfilesystemencoding())", - ), - ) + return self.python_cmd("import sys; print(sys.executable)") def print_os_env_var(self, var): - val = '"{}"'.format(var) - return self.python_cmd( - "import os; import sys; v = os.environ.get({}); print({})".format( - val, - "v" if six.PY3 or IS_PYPY else "None if v is None else v.decode(sys.getfilesystemencoding())", - ), - ) + val = f'"{var}"' + return self.python_cmd(f"import os; import sys; v = os.environ.get({val}); print(v)") def print_prompt(self): return NotImplemented def activate_call(self, script): - cmd = self.quote(ensure_text(str(self.activate_cmd))) - scr = self.quote(ensure_text(str(script))) - return "{} {}".format(cmd, scr).strip() + cmd = self.quote(str(self.activate_cmd)) + scr = self.quote(str(script)) + return f"{cmd} {scr}".strip() @staticmethod def norm_path(path): # python may return Windows short paths, normalize if not isinstance(path, Path): path = Path(path) - path = ensure_text(str(path.resolve())) + path = str(path.resolve()) if sys.platform != "win32": result = path else: - from ctypes import create_unicode_buffer, windll + from ctypes import create_unicode_buffer, windll # noqa: PLC0415 buffer_cont = create_unicode_buffer(256) get_long_path_name = windll.kernel32.GetLongPathNameW - get_long_path_name(six.text_type(path), buffer_cont, 256) + get_long_path_name(str(path), buffer_cont, 256) result = buffer_cont.value or path return normcase(result) class RaiseOnNonSourceCall(ActivationTester): - def __init__(self, of_class, session, cmd, activate_script, extension, non_source_fail_message): - super(RaiseOnNonSourceCall, self).__init__(of_class, session, cmd, activate_script, extension) + def __init__( # noqa: PLR0913 + self, + of_class, + session, + cmd, + activate_script, + extension, + non_source_fail_message, + ) -> None: + super().__init__(of_class, session, cmd, activate_script, extension) self.non_source_fail_message = non_source_fail_message def __call__(self, monkeypatch, tmp_path): - env, activate_script = super(RaiseOnNonSourceCall, self).__call__(monkeypatch, tmp_path) + env, activate_script = super().__call__(monkeypatch, tmp_path) process = Popen( self.non_source_activate(activate_script), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, ) - out, _err = process.communicate() + _out, _err = process.communicate() err = _err.decode("utf-8") assert process.returncode assert self.non_source_fail_message in err @@ -235,27 +230,25 @@ def raise_on_non_source_class(): @pytest.fixture(scope="session", params=[True, False], ids=["with_prompt", "no_prompt"]) def activation_python(request, tmp_path_factory, special_char_name, current_fastest): - dest = os.path.join(ensure_text(str(tmp_path_factory.mktemp("activation-tester-env"))), special_char_name) + dest = os.path.join(str(tmp_path_factory.mktemp("activation-tester-env")), special_char_name) cmd = ["--without-pip", dest, "--creator", current_fastest, "-vv", "--no-periodic-update"] if request.param: cmd += ["--prompt", special_char_name] session = cli_run(cmd) pydoc_test = session.creator.purelib / "pydoc_test.py" - pydoc_test.write_text('"""This is pydoc_test.py"""') - yield session - if WIN_CPYTHON_2: # PY2 windows does not support unicode delete - shutil.rmtree(dest) + pydoc_test.write_text('"""This is pydoc_test.py"""', encoding="utf-8") + return session -@pytest.fixture() +@pytest.fixture def activation_tester(activation_python, monkeypatch, tmp_path, is_inside_ci): def _tester(tester_class): tester = tester_class(activation_python) if not tester.of_class.supports(activation_python.creator.interpreter): - pytest.skip("{} not supported".format(tester.of_class.__name__)) + pytest.skip(f"{tester.of_class.__name__} not supported") version = tester.get_version(raise_on_fail=is_inside_ci) - if not isinstance(version, six.string_types): - pytest.skip(msg=six.text_type(version)) + if not isinstance(version, str): + pytest.skip(reason=str(version)) return tester(monkeypatch, tmp_path) return _tester diff --git a/tests/unit/activation/test_activate_this.py b/tests/unit/activation/test_activate_this.py deleted file mode 100644 index 53f4b3f53..000000000 --- a/tests/unit/activation/test_activate_this.py +++ /dev/null @@ -1,26 +0,0 @@ -from virtualenv.activation import PythonActivator -from virtualenv.config.cli.parser import VirtualEnvOptions -from virtualenv.run import session_via_cli - - -def test_python_activator_cross(session_app_data, cross_python, special_name_dir): - options = VirtualEnvOptions() - cli_args = [ - str(special_name_dir), - "-p", - str(cross_python.executable), - "--app-data", - str(session_app_data.lock.path), - "--without-pip", - "--activators", - "", - ] - session = session_via_cli(cli_args, options) - activator = PythonActivator(options) - session.creator.bin_dir.mkdir(parents=True) - results = activator.generate(session.creator) - assert len(results) == 1 - result = results[0] - content = result.read_text() - # check that the repr strings have been correctly stripped - assert "\"'" not in content diff --git a/tests/unit/activation/test_activation_support.py b/tests/unit/activation/test_activation_support.py index d493c23ae..e25fc96e8 100644 --- a/tests/unit/activation/test_activation_support.py +++ b/tests/unit/activation/test_activation_support.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from argparse import Namespace diff --git a/tests/unit/activation/test_activator.py b/tests/unit/activation/test_activator.py index 4a8a51c66..b6b5b4986 100644 --- a/tests/unit/activation/test_activator.py +++ b/tests/unit/activation/test_activator.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from argparse import Namespace diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py index 612ad378c..d89f1606a 100644 --- a/tests/unit/activation/test_bash.py +++ b/tests/unit/activation/test_bash.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import pytest @@ -7,10 +7,11 @@ @pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") -def test_bash(raise_on_non_source_class, activation_tester): +@pytest.mark.parametrize("hashing_enabled", [True, False]) +def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester): class Bash(raise_on_non_source_class): - def __init__(self, session): - super(Bash, self).__init__( + def __init__(self, session) -> None: + super().__init__( BashActivator, session, "bash", @@ -18,6 +19,11 @@ def __init__(self, session): "sh", "You must source this script: $ source ", ) + self.deactivate += " || exit 1" + self._invoke_script.append("-h" if hashing_enabled else "+h") + + def activate_call(self, script): + return super().activate_call(script) + " || exit 1" def print_prompt(self): return self.print_os_env_var("PS1") diff --git a/tests/unit/activation/test_batch.py b/tests/unit/activation/test_batch.py index 973f0bad8..13d84442e 100644 --- a/tests/unit/activation/test_batch.py +++ b/tests/unit/activation/test_batch.py @@ -1,33 +1,82 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -import pipes +import pytest from virtualenv.activation import BatchActivator -def test_batch(activation_tester_class, activation_tester, tmp_path, activation_python): +@pytest.mark.usefixtures("activation_python") +def test_batch(activation_tester_class, activation_tester, tmp_path): version_script = tmp_path / "version.bat" - version_script.write_text("ver") + version_script.write_text("ver", encoding="utf-8") class Batch(activation_tester_class): - def __init__(self, session): - super(Batch, self).__init__(BatchActivator, session, None, "activate.bat", "bat") + def __init__(self, session) -> None: + super().__init__(BatchActivator, session, None, "activate.bat", "bat") self._version_cmd = [str(version_script)] self._invoke_script = [] self.deactivate = "call deactivate" self.activate_cmd = "call" - self.pydoc_call = "call {}".format(self.pydoc_call) + self.pydoc_call = f"call {self.pydoc_call}" self.unix_line_ending = False def _get_test_lines(self, activate_script): - # for BATCH utf-8 support need change the character code page to 650001 - return ["@echo off", "", "chcp 65001 1>NUL"] + super(Batch, self)._get_test_lines(activate_script) + return ["@echo off", *super()._get_test_lines(activate_script)] def quote(self, s): - """double quotes needs to be single, and single need to be double""" - return "".join(("'" if c == '"' else ('"' if c == "'" else c)) for c in pipes.quote(s)) + if '"' in s or " " in s: + text = s.replace('"', r"\"") + return f'"{text}"' + return s def print_prompt(self): - return "echo %PROMPT%" + return 'echo "%PROMPT%"' + + activation_tester(Batch) + + +@pytest.mark.usefixtures("activation_python") +def test_batch_output(activation_tester_class, activation_tester, tmp_path): + version_script = tmp_path / "version.bat" + version_script.write_text("ver", encoding="utf-8") + + class Batch(activation_tester_class): + def __init__(self, session) -> None: + super().__init__(BatchActivator, session, None, "activate.bat", "bat") + self._version_cmd = [str(version_script)] + self._invoke_script = [] + self.deactivate = "call deactivate" + self.activate_cmd = "call" + self.pydoc_call = f"call {self.pydoc_call}" + self.unix_line_ending = False + + def _get_test_lines(self, activate_script): + """ + Build intermediary script which will be then called. + In the script just activate environment, call echo to get current + echo setting, and then deactivate. This ensures that echo setting + is preserved and no unwanted output appears. + """ + intermediary_script_path = str(tmp_path / "intermediary.bat") + activate_script_quoted = self.quote(str(activate_script)) + return [ + "@echo on", + f"@echo @call {activate_script_quoted} > {intermediary_script_path}", + f"@echo @echo >> {intermediary_script_path}", + f"@echo @deactivate >> {intermediary_script_path}", + f"@call {intermediary_script_path}", + ] + + def assert_output(self, out, raw, tmp_path): # noqa: ARG002 + assert out[0] == "ECHO is on.", raw + + def quote(self, s): + if '"' in s or " " in s: + text = s.replace('"', r"\"") + return f'"{text}"' + return s + + def print_prompt(self): + return 'echo "%PROMPT%"' activation_tester(Batch) diff --git a/tests/unit/activation/test_csh.py b/tests/unit/activation/test_csh.py index 1fa5146de..309ae811e 100644 --- a/tests/unit/activation/test_csh.py +++ b/tests/unit/activation/test_csh.py @@ -1,14 +1,16 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations from virtualenv.activation import CShellActivator def test_csh(activation_tester_class, activation_tester): class Csh(activation_tester_class): - def __init__(self, session): - super(Csh, self).__init__(CShellActivator, session, "csh", "activate.csh", "csh") + def __init__(self, session) -> None: + super().__init__(CShellActivator, session, "csh", "activate.csh", "csh") def print_prompt(self): - return "echo 'source \"$VIRTUAL_ENV/bin/activate.csh\"; echo $prompt' | csh -i" + # Original csh doesn't print the last newline, + # breaking the test; hence the trailing echo. + return "echo 'source \"$VIRTUAL_ENV/bin/activate.csh\"; echo $prompt' | csh -i ; echo" activation_tester(Csh) diff --git a/tests/unit/activation/test_fish.py b/tests/unit/activation/test_fish.py index 7b229e01b..6a92a2790 100644 --- a/tests/unit/activation/test_fish.py +++ b/tests/unit/activation/test_fish.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import pytest @@ -8,14 +8,14 @@ @pytest.mark.skipif(IS_WIN, reason="we have not setup fish in CI yet") def test_fish(activation_tester_class, activation_tester, monkeypatch, tmp_path): - monkeypatch.setenv(str("HOME"), str(tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path)) fish_conf_dir = tmp_path / ".config" / "fish" fish_conf_dir.mkdir(parents=True) - (fish_conf_dir / "config.fish").write_text("") + (fish_conf_dir / "config.fish").write_text("", encoding="utf-8") class Fish(activation_tester_class): - def __init__(self, session): - super(Fish, self).__init__(FishActivator, session, "fish", "activate.fish", "fish") + def __init__(self, session) -> None: + super().__init__(FishActivator, session, "fish", "activate.fish", "fish") def print_prompt(self): return "fish_prompt" diff --git a/tests/unit/activation/test_nushell.py b/tests/unit/activation/test_nushell.py index 65c4028bb..fbf75e397 100644 --- a/tests/unit/activation/test_nushell.py +++ b/tests/unit/activation/test_nushell.py @@ -1,12 +1,6 @@ -from __future__ import absolute_import, unicode_literals - -import sys - -if sys.version_info > (3,): - from shutil import which -else: - from distutils.spawn import find_executable as which +from __future__ import annotations +from shutil import which from virtualenv.activation import NushellActivator from virtualenv.info import IS_WIN @@ -14,16 +8,23 @@ def test_nushell(activation_tester_class, activation_tester): class Nushell(activation_tester_class): - def __init__(self, session): + def __init__(self, session) -> None: cmd = which("nu") if cmd is None and IS_WIN: cmd = "c:\\program files\\nu\\bin\\nu.exe" - super(Nushell, self).__init__(NushellActivator, session, cmd, "activate.nu", "nu") + super().__init__(NushellActivator, session, cmd, "activate.nu", "nu") + self.activate_cmd = "overlay use" self.unix_line_ending = not IS_WIN def print_prompt(self): - return r"$env.VIRTUAL_PROMPT" + return r"print $env.VIRTUAL_PREFIX" + + def activate_call(self, script): + # Commands are called without quotes in Nushell + cmd = self.activate_cmd + scr = self.quote(str(script)) + return f"{cmd} {scr}".strip() activation_tester(Nushell) diff --git a/tests/unit/activation/test_powershell.py b/tests/unit/activation/test_powershell.py index f3705cda1..dab5748d7 100644 --- a/tests/unit/activation/test_powershell.py +++ b/tests/unit/activation/test_powershell.py @@ -1,6 +1,5 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -import pipes import sys import pytest @@ -13,21 +12,16 @@ def test_powershell(activation_tester_class, activation_tester, monkeypatch): monkeypatch.setenv("TERM", "xterm") class PowerShell(activation_tester_class): - def __init__(self, session): + def __init__(self, session) -> None: cmd = "powershell.exe" if sys.platform == "win32" else "pwsh" - super(PowerShell, self).__init__(PowerShellActivator, session, cmd, "activate.ps1", "ps1") + super().__init__(PowerShellActivator, session, cmd, "activate.ps1", "ps1") self._version_cmd = [cmd, "-c", "$PSVersionTable"] self._invoke_script = [cmd, "-ExecutionPolicy", "ByPass", "-File"] self.activate_cmd = "." - self.script_encoding = "utf-16" - - def quote(self, s): - """powershell double double quote needed for quotes within single quotes""" - return pipes.quote(s).replace('"', '""') + self.script_encoding = "utf-8-sig" def _get_test_lines(self, activate_script): - # for BATCH utf-8 support need change the character code page to 650001 - return super(PowerShell, self)._get_test_lines(activate_script) + return super()._get_test_lines(activate_script) def invoke_script(self): return [self.cmd, "-File"] @@ -35,4 +29,19 @@ def invoke_script(self): def print_prompt(self): return "prompt" + def quote(self, s): + """ + Tester will pass strings to native commands on Windows so extra + parsing rules are used. Check `PowerShellActivator.quote` for more + details. + """ + text = PowerShellActivator.quote(s) + return text.replace('"', '""') if sys.platform == "win32" else text + + def activate_call(self, script): + # Commands are called without quotes in PowerShell + cmd = self.activate_cmd + scr = self.quote(str(script)) + return f"{cmd} {scr}".strip() + activation_tester(PowerShell) diff --git a/tests/unit/activation/test_python_activator.py b/tests/unit/activation/test_python_activator.py index 0b42e114c..24a3561c5 100644 --- a/tests/unit/activation/test_python_activator.py +++ b/tests/unit/activation/test_python_activator.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os import sys @@ -6,95 +6,87 @@ from textwrap import dedent from virtualenv.activation import PythonActivator -from virtualenv.info import IS_WIN, WIN_CPYTHON_2 -from virtualenv.util.six import ensure_text +from virtualenv.info import IS_WIN def test_python(raise_on_non_source_class, activation_tester): class Python(raise_on_non_source_class): - def __init__(self, session): - super(Python, self).__init__( + def __init__(self, session) -> None: + super().__init__( PythonActivator, session, sys.executable, activate_script="activate_this.py", extension="py", - non_source_fail_message="You must use exec(open(this_file).read(), {'__file__': this_file}))", + non_source_fail_message="You must use import runpy; runpy.run_path(this_file)", ) self.unix_line_ending = not IS_WIN def env(self, tmp_path): env = os.environ.copy() - env[str("PYTHONIOENCODING")] = str("utf-8") - for key in {"VIRTUAL_ENV", "PYTHONPATH"}: + env["PYTHONIOENCODING"] = "utf-8" + for key in ("VIRTUAL_ENV", "PYTHONPATH"): env.pop(str(key), None) - env[str("PATH")] = os.pathsep.join([str(tmp_path), str(tmp_path / "other")]) + env["PATH"] = os.pathsep.join([str(tmp_path), str(tmp_path / "other")]) return env @staticmethod def _get_test_lines(activate_script): - raw = """ + raw = f""" import os import sys import platform + import runpy def print_r(value): print(repr(value)) print_r(os.environ.get("VIRTUAL_ENV")) + print_r(os.environ.get("VIRTUAL_ENV_PROMPT")) print_r(os.environ.get("PATH").split(os.pathsep)) print_r(sys.path) - file_at = {!r} + file_at = {str(activate_script)!r} # CPython 2 requires non-ascii path open to be unicode - with open(file_at{}, "r") as file_handler: - content = file_handler.read() - exec(content, {{"__file__": file_at}}) - + runpy.run_path(file_at) print_r(os.environ.get("VIRTUAL_ENV")) + print_r(os.environ.get("VIRTUAL_ENV_PROMPT")) print_r(os.environ.get("PATH").split(os.pathsep)) print_r(sys.path) import pydoc_test print_r(pydoc_test.__file__) - """.format( - str(activate_script), - ".decode('utf-8')" if WIN_CPYTHON_2 else "", - ) - result = dedent(raw).splitlines() - return result + """ + return dedent(raw).splitlines() - def assert_output(self, out, raw, tmp_path): + def assert_output(self, out, raw, tmp_path): # noqa: ARG002 out = [literal_eval(i) for i in out] assert out[0] is None # start with VIRTUAL_ENV None + assert out[1] is None # likewise for VIRTUAL_ENV_PROMPT + + prev_path = out[2] + prev_sys_path = out[3] + assert out[4] == str(self._creator.dest) # VIRTUAL_ENV now points to the virtual env folder - prev_path = out[1] - prev_sys_path = out[2] - assert out[3] == str(self._creator.dest) # VIRTUAL_ENV now points to the virtual env folder + assert out[5] == str(self._creator.env_name) # VIRTUAL_ENV_PROMPT now has the env name - new_path = out[4] # PATH now starts with bin path of current - assert ([str(self._creator.bin_dir)] + prev_path) == new_path + new_path = out[6] # PATH now starts with bin path of current + assert ([str(self._creator.bin_dir), *prev_path]) == new_path # sys path contains the site package at its start - new_sys_path = out[5] + new_sys_path = out[7] - new_lib_paths = {ensure_text(j) if WIN_CPYTHON_2 else j for j in {str(i) for i in self._creator.libs}} + new_lib_paths = {str(i) for i in self._creator.libs} assert prev_sys_path == new_sys_path[len(new_lib_paths) :] assert new_lib_paths == set(new_sys_path[: len(new_lib_paths)]) # manage to import from activate site package dest = self.norm_path(self._creator.purelib / "pydoc_test.py") - found = self.norm_path(out[6].decode(sys.getfilesystemencoding()) if WIN_CPYTHON_2 else out[6]) + found = self.norm_path(out[8]) assert found.startswith(dest) def non_source_activate(self, activate_script): act = str(activate_script) - if WIN_CPYTHON_2: - act = ensure_text(act) - cmd = self._invoke_script + [ - "-c", - "exec(open({}).read())".format(repr(act)), - ] - return cmd + return [*self._invoke_script, "-c", f"exec(open({act!r}).read())"] activation_tester(Python) diff --git a/tests/unit/config/cli/test_parser.py b/tests/unit/config/cli/test_parser.py index a2cd4e0d3..1dc7e055a 100644 --- a/tests/unit/config/cli/test_parser.py +++ b/tests/unit/config/cli/test_parser.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os from contextlib import contextmanager @@ -10,9 +10,9 @@ from virtualenv.run import session_via_cli -@pytest.fixture() +@pytest.fixture def gen_parser_no_conf_env(monkeypatch, tmp_path): - keys_to_delete = {key for key in os.environ if key.startswith(str("VIRTUALENV_"))} + keys_to_delete = {key for key in os.environ if key.startswith("VIRTUALENV_")} for key in keys_to_delete: monkeypatch.delenv(key) monkeypatch.setenv(IniConfig.VIRTUALENV_CONFIG_FILE_ENV_VAR, str(tmp_path / "missing")) diff --git a/tests/unit/config/test___main__.py b/tests/unit/config/test___main__.py index b974712c2..fc6b3eef4 100644 --- a/tests/unit/config/test___main__.py +++ b/tests/unit/config/test___main__.py @@ -1,26 +1,35 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import re import sys +from subprocess import PIPE, Popen, check_output +from typing import TYPE_CHECKING import pytest from virtualenv.__main__ import run_with_catch -from virtualenv.util.error import ProcessCallFailed -from virtualenv.util.subprocess import Popen, subprocess +from virtualenv.util.error import ProcessCallFailedError + +if TYPE_CHECKING: + from pathlib import Path def test_main(): - process = Popen([sys.executable, "-m", "virtualenv", "--help"], universal_newlines=True, stdout=subprocess.PIPE) + process = Popen( + [sys.executable, "-m", "virtualenv", "--help"], + universal_newlines=True, + stdout=PIPE, + encoding="utf-8", + ) out, _ = process.communicate() assert not process.returncode assert out -@pytest.fixture() +@pytest.fixture def raise_on_session_done(mocker): def _func(exception): - from virtualenv.run import session_via_cli + from virtualenv.run import session_via_cli # noqa: PLC0415 prev_session = session_via_cli @@ -34,12 +43,12 @@ def _session_via_cli(args, options=None, setup_logging=True, env=None): def test_fail_no_traceback(raise_on_session_done, tmp_path, capsys): - raise_on_session_done(ProcessCallFailed(code=2, out="out\n", err="err\n", cmd=["something"])) + raise_on_session_done(ProcessCallFailedError(code=2, out="out\n", err="err\n", cmd=["something"])) with pytest.raises(SystemExit) as context: run_with_catch([str(tmp_path)]) assert context.value.code == 2 out, err = capsys.readouterr() - assert out == "subprocess call failed for [{}] with code 2\nout\nSystemExit: 2\n".format(repr("something")) + assert out == f"subprocess call failed for [{'something'!r}] with code 2\nout\nSystemExit: 2\n" assert err == "err\n" @@ -49,14 +58,15 @@ def test_fail_with_traceback(raise_on_session_done, tmp_path, capsys): with pytest.raises(TypeError, match="something bad"): run_with_catch([str(tmp_path), "--with-traceback"]) out, err = capsys.readouterr() - assert out == "" - assert err == "" + assert not out + assert not err -def test_session_report_full(session_app_data, tmp_path, capsys): - run_with_catch([str(tmp_path)]) +@pytest.mark.usefixtures("session_app_data") +def test_session_report_full(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + run_with_catch([str(tmp_path), "--setuptools", "bundle", "--wheel", "bundle"]) out, err = capsys.readouterr() - assert err == "" + assert not err lines = out.splitlines() regexes = [ r"created virtual environment .* in \d+ms", @@ -70,14 +80,15 @@ def test_session_report_full(session_app_data, tmp_path, capsys): def _match_regexes(lines, regexes): for line, regex in zip(lines, regexes): - comp_regex = re.compile(r"^{}$".format(regex)) + comp_regex = re.compile(rf"^{regex}$") assert comp_regex.match(line), line -def test_session_report_minimal(session_app_data, tmp_path, capsys): +@pytest.mark.usefixtures("session_app_data") +def test_session_report_minimal(tmp_path, capsys): run_with_catch([str(tmp_path), "--activators", "", "--without-pip"]) out, err = capsys.readouterr() - assert err == "" + assert not err lines = out.splitlines() regexes = [ r"created virtual environment .* in \d+ms", @@ -86,11 +97,13 @@ def test_session_report_minimal(session_app_data, tmp_path, capsys): _match_regexes(lines, regexes) -def test_session_report_subprocess(session_app_data, tmp_path): +@pytest.mark.usefixtures("session_app_data") +def test_session_report_subprocess(tmp_path): # when called via a subprocess the logging framework should flush and POSIX line normalization happen - out = subprocess.check_output( + out = check_output( [sys.executable, "-m", "virtualenv", str(tmp_path), "--activators", "powershell", "--without-pip"], - universal_newlines=True, + text=True, + encoding="utf-8", ) lines = out.split("\n") regexes = [ diff --git a/tests/unit/config/test_env_var.py b/tests/unit/config/test_env_var.py index 34b216f4d..5f364978d 100644 --- a/tests/unit/config/test_env_var.py +++ b/tests/unit/config/test_env_var.py @@ -1,30 +1,34 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os +from pathlib import Path import pytest from virtualenv.config.cli.parser import VirtualEnvOptions from virtualenv.config.ini import IniConfig +from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew +from virtualenv.discovery.py_info import PythonInfo from virtualenv.run import session_via_cli -from virtualenv.util.path import Path -@pytest.fixture() -def empty_conf(tmp_path, monkeypatch): +@pytest.fixture +def _empty_conf(tmp_path, monkeypatch): conf = tmp_path / "conf.ini" monkeypatch.setenv(IniConfig.VIRTUALENV_CONFIG_FILE_ENV_VAR, str(conf)) - conf.write_text("[virtualenv]") + conf.write_text("[virtualenv]", encoding="utf-8") -def test_value_ok(monkeypatch, empty_conf): - monkeypatch.setenv(str("VIRTUALENV_VERBOSE"), str("5")) +@pytest.mark.usefixtures("_empty_conf") +def test_value_ok(monkeypatch): + monkeypatch.setenv("VIRTUALENV_VERBOSE", "5") result = session_via_cli(["venv"]) assert result.verbosity == 5 -def test_value_bad(monkeypatch, caplog, empty_conf): - monkeypatch.setenv(str("VIRTUALENV_VERBOSE"), str("a")) +@pytest.mark.usefixtures("_empty_conf") +def test_value_bad(monkeypatch, caplog): + monkeypatch.setenv("VIRTUALENV_VERBOSE", "a") result = session_via_cli(["venv"]) assert result.verbosity == 2 assert len(caplog.messages) == 1 @@ -34,36 +38,36 @@ def test_value_bad(monkeypatch, caplog, empty_conf): def test_python_via_env_var(monkeypatch): options = VirtualEnvOptions() - monkeypatch.setenv(str("VIRTUALENV_PYTHON"), str("python3")) + monkeypatch.setenv("VIRTUALENV_PYTHON", "python3") session_via_cli(["venv"], options=options) assert options.python == ["python3"] def test_python_multi_value_via_env_var(monkeypatch): options = VirtualEnvOptions() - monkeypatch.setenv(str("VIRTUALENV_PYTHON"), str("python3,python2")) + monkeypatch.setenv("VIRTUALENV_PYTHON", "python3,python2") session_via_cli(["venv"], options=options) assert options.python == ["python3", "python2"] def test_python_multi_value_newline_via_env_var(monkeypatch): options = VirtualEnvOptions() - monkeypatch.setenv(str("VIRTUALENV_PYTHON"), str("python3\npython2")) + monkeypatch.setenv("VIRTUALENV_PYTHON", "python3\npython2") session_via_cli(["venv"], options=options) assert options.python == ["python3", "python2"] def test_python_multi_value_prefer_newline_via_env_var(monkeypatch): options = VirtualEnvOptions() - monkeypatch.setenv(str("VIRTUALENV_PYTHON"), str("python3\npython2,python27")) + monkeypatch.setenv("VIRTUALENV_PYTHON", "python3\npython2,python27") session_via_cli(["venv"], options=options) assert options.python == ["python3", "python2,python27"] def test_extra_search_dir_via_env_var(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - value = "a{}0{}b{}c".format(os.linesep, os.linesep, os.pathsep) - monkeypatch.setenv(str("VIRTUALENV_EXTRA_SEARCH_DIR"), str(value)) + value = f"a{os.linesep}0{os.linesep}b{os.pathsep}c" + monkeypatch.setenv("VIRTUALENV_EXTRA_SEARCH_DIR", str(value)) (tmp_path / "a").mkdir() (tmp_path / "b").mkdir() (tmp_path / "c").mkdir() @@ -71,10 +75,12 @@ def test_extra_search_dir_via_env_var(tmp_path, monkeypatch): assert result.seeder.extra_search_dir == [Path("a").resolve(), Path("b").resolve(), Path("c").resolve()] -def test_value_alias(monkeypatch, mocker, empty_conf): - from virtualenv.config.cli.parser import VirtualEnvConfigParser +@pytest.mark.usefixtures("_empty_conf") +@pytest.mark.skipif(is_macos_brew(PythonInfo.current_system()), reason="no copy on brew") +def test_value_alias(monkeypatch, mocker): + from virtualenv.config.cli.parser import VirtualEnvConfigParser # noqa: PLC0415 - prev = VirtualEnvConfigParser._fix_default + prev = VirtualEnvConfigParser._fix_default # noqa: SLF001 def func(self, action): if action.dest == "symlinks": @@ -85,8 +91,8 @@ def func(self, action): mocker.patch("virtualenv.run.VirtualEnvConfigParser._fix_default", side_effect=func, autospec=True) - monkeypatch.delenv(str("SYMLINKS"), raising=False) - monkeypatch.delenv(str("VIRTUALENV_COPIES"), raising=False) - monkeypatch.setenv(str("VIRTUALENV_ALWAYS_COPY"), str("1")) + monkeypatch.delenv("SYMLINKS", raising=False) + monkeypatch.delenv("VIRTUALENV_COPIES", raising=False) + monkeypatch.setenv("VIRTUALENV_ALWAYS_COPY", "1") result = session_via_cli(["venv"]) assert result.creator.symlinks is False diff --git a/tests/unit/config/test_ini.py b/tests/unit/config/test_ini.py index 0f8725664..a1621fa30 100644 --- a/tests/unit/config/test_ini.py +++ b/tests/unit/config/test_ini.py @@ -1,15 +1,20 @@ -from __future__ import unicode_literals +from __future__ import annotations +import sys from textwrap import dedent import pytest -from virtualenv.info import fs_supports_symlink +from virtualenv.info import IS_PYPY, IS_WIN, fs_supports_symlink from virtualenv.run import session_via_cli -from virtualenv.util.six import ensure_str @pytest.mark.skipif(not fs_supports_symlink(), reason="symlink is not supported") +@pytest.mark.xfail( + # https://doc.pypy.org/en/latest/install.html?highlight=symlink#download-a-pre-built-pypy + IS_PYPY and IS_WIN and sys.version_info[0:2] >= (3, 9), + reason="symlink is not supported", +) def test_ini_can_be_overwritten_by_flag(tmp_path, monkeypatch): custom_ini = tmp_path / "conf.ini" custom_ini.write_text( @@ -19,8 +24,9 @@ def test_ini_can_be_overwritten_by_flag(tmp_path, monkeypatch): copies = True """, ), + encoding="utf-8", ) - monkeypatch.setenv(ensure_str("VIRTUALENV_CONFIG_FILE"), str(custom_ini)) + monkeypatch.setenv("VIRTUALENV_CONFIG_FILE", str(custom_ini)) result = session_via_cli(["venv", "--symlinks"]) diff --git a/tests/unit/create/conftest.py b/tests/unit/create/conftest.py index c709adeae..58d390c5c 100644 --- a/tests/unit/create/conftest.py +++ b/tests/unit/create/conftest.py @@ -6,91 +6,38 @@ - invoking from an old style virtualenv (<17.0.0) - invoking from our own venv """ -from __future__ import absolute_import, unicode_literals -import subprocess +from __future__ import annotations + import sys +from subprocess import Popen import pytest from virtualenv.discovery.py_info import PythonInfo -from virtualenv.info import IS_WIN -from virtualenv.run import cli_run -from virtualenv.util.path import Path -from virtualenv.util.subprocess import Popen CURRENT = PythonInfo.current_system() -# noinspection PyUnusedLocal -def root(tmp_path_factory, session_app_data): +def root(tmp_path_factory, session_app_data): # noqa: ARG001 return CURRENT.system_executable def venv(tmp_path_factory, session_app_data): if CURRENT.is_venv: return sys.executable - elif CURRENT.version_info.major == 3: - root_python = root(tmp_path_factory, session_app_data) - dest = tmp_path_factory.mktemp("venv") - process = Popen([str(root_python), "-m", "venv", "--without-pip", str(dest)]) - process.communicate() - # sadly creating a virtual environment does not tell us where the executable lives in general case - # so discover using some heuristic - exe_path = CURRENT.discover_exe(prefix=str(dest)).original_executable - return exe_path - - -def old_virtualenv(tmp_path_factory, session_app_data): - if CURRENT.is_old_virtualenv: - return CURRENT.executable - else: - env_for_old_virtualenv = tmp_path_factory.mktemp("env-for-old-virtualenv") - result = cli_run(["--no-download", "--activators", "", str(env_for_old_virtualenv), "--no-periodic-update"]) - # noinspection PyBroadException - try: - process = Popen( - [ - str(result.creator.script("pip")), - "install", - "--no-index", - "--disable-pip-version-check", - str(Path(__file__).resolve().parent / "virtualenv-16.7.9-py2.py3-none-any.whl"), - "-v", - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - _, __ = process.communicate() - assert not process.returncode - except Exception: - return RuntimeError("failed to install old virtualenv") - # noinspection PyBroadException - try: - old_virtualenv_at = tmp_path_factory.mktemp("old-virtualenv") - cmd = [ - str(result.creator.script("virtualenv")), - str(old_virtualenv_at), - "--no-pip", - "--no-setuptools", - "--no-wheel", - ] - process = Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - _, __ = process.communicate() - assert not process.returncode - if result.creator.interpreter.implementation == "PyPy" and IS_WIN: - # old virtualenv creates pypy paths wrong on windows, so have to hardcode it - return str(old_virtualenv_at / "bin" / "pypy.exe") - exe_path = CURRENT.discover_exe(session_app_data, prefix=str(old_virtualenv_at)).original_executable - return exe_path - except Exception as exception: - return RuntimeError("failed to create old virtualenv {}".format(exception)) + root_python = root(tmp_path_factory, session_app_data) + dest = tmp_path_factory.mktemp("venv") + process = Popen([str(root_python), "-m", "venv", "--without-pip", str(dest)]) + process.communicate() + # sadly creating a virtual environment does not tell us where the executable lives in general case + # so discover using some heuristic + return CURRENT.discover_exe(prefix=str(dest)).original_executable PYTHON = { "root": root, "venv": venv, - "old_virtualenv": old_virtualenv, } @@ -98,7 +45,7 @@ def old_virtualenv(tmp_path_factory, session_app_data): def python(request, tmp_path_factory, session_app_data): result = request.param(tmp_path_factory, session_app_data) if isinstance(result, Exception): - pytest.skip("could not resolve interpreter based on {} because {}".format(request.param.__name__, result)) + pytest.skip(f"could not resolve interpreter based on {request.param.__name__} because {result}") if result is None: - pytest.skip("requires interpreter with {}".format(request.param.__name__)) + pytest.skip(f"requires interpreter with {request.param.__name__}") return result diff --git a/tests/unit/create/console_app/demo/__init__.py b/tests/unit/create/console_app/demo/__init__.py index a7e1f5a77..d7f2575eb 100644 --- a/tests/unit/create/console_app/demo/__init__.py +++ b/tests/unit/create/console_app/demo/__init__.py @@ -1,5 +1,8 @@ +from __future__ import annotations + + def run(): - print("magic") + print("magic") # noqa: T201 if __name__ == "__main__": diff --git a/tests/unit/create/console_app/demo/__main__.py b/tests/unit/create/console_app/demo/__main__.py index a7e1f5a77..d7f2575eb 100644 --- a/tests/unit/create/console_app/demo/__main__.py +++ b/tests/unit/create/console_app/demo/__main__.py @@ -1,5 +1,8 @@ +from __future__ import annotations + + def run(): - print("magic") + print("magic") # noqa: T201 if __name__ == "__main__": diff --git a/tests/unit/create/console_app/setup.py b/tests/unit/create/console_app/setup.py index 606849326..a03590f54 100644 --- a/tests/unit/create/console_app/setup.py +++ b/tests/unit/create/console_app/setup.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from setuptools import setup setup() diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py index 424a9b385..2e4078d1c 100644 --- a/tests/unit/create/test_creator.py +++ b/tests/unit/create/test_creator.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import ast import difflib @@ -11,9 +11,11 @@ import stat import subprocess import sys +import textwrap import zipfile from collections import OrderedDict from itertools import product +from pathlib import Path from stat import S_IREAD, S_IRGRP, S_IROTH from textwrap import dedent from threading import Thread @@ -23,24 +25,21 @@ from virtualenv.__main__ import run, run_with_catch from virtualenv.create.creator import DEBUG_SCRIPT, Creator, get_env_debug_info from virtualenv.create.pyenv_cfg import PyEnvCfg -from virtualenv.create.via_global_ref.builtin.cpython.cpython2 import CPython2PosixBase +from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix -from virtualenv.create.via_global_ref.builtin.python2.python2 import Python2 from virtualenv.discovery.py_info import PythonInfo -from virtualenv.info import IS_PYPY, IS_WIN, PY2, PY3, fs_is_case_sensitive +from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive from virtualenv.run import cli_run, session_via_cli -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_str, ensure_text CURRENT = PythonInfo.current_system() def test_os_path_sep_not_allowed(tmp_path, capsys): - target = str(tmp_path / "a{}b".format(os.pathsep)) + target = str(tmp_path / f"a{os.pathsep}b") err = _non_success_exit_code(capsys, target) msg = ( - "destination {!r} must not contain the path separator ({}) as this" - " would break the activation scripts".format(target, os.pathsep) + f"destination {target!r} must not contain the path separator ({os.pathsep})" + f" as this would break the activation scripts" ) assert msg in err, err @@ -56,39 +55,36 @@ def _non_success_exit_code(capsys, target): def test_destination_exists_file(tmp_path, capsys): target = tmp_path / "out" - target.write_text("") + target.write_text("", encoding="utf-8") err = _non_success_exit_code(capsys, str(target)) - msg = "the destination {} already exists and is a file".format(str(target)) + msg = f"the destination {target!s} already exists and is a file" assert msg in err, err @pytest.mark.skipif(sys.platform == "win32", reason="Windows only applies R/O to files") def test_destination_not_write_able(tmp_path, capsys): - if hasattr(os, "geteuid"): - if os.geteuid() == 0: - pytest.skip("no way to check permission restriction when running under root") + if hasattr(os, "geteuid") and os.geteuid() == 0: + pytest.skip("no way to check permission restriction when running under root") target = tmp_path prev_mod = target.stat().st_mode target.chmod(S_IREAD | S_IRGRP | S_IROTH) try: err = _non_success_exit_code(capsys, str(target)) - msg = "the destination . is not write-able at {}".format(str(target)) + msg = f"the destination . is not write-able at {target!s}" assert msg in err, err finally: target.chmod(prev_mod) def cleanup_sys_path(paths): - from virtualenv.create.creator import HERE + from virtualenv.create.creator import HERE # noqa: PLC0415 paths = [p.resolve() for p in (Path(os.path.abspath(i)) for i in paths) if p.exists()] to_remove = [Path(HERE)] - if os.environ.get(str("PYCHARM_HELPERS_DIR")): - to_remove.append(Path(os.environ[str("PYCHARM_HELPERS_DIR")]).parent) - to_remove.append(Path(os.path.expanduser("~")) / ".PyCharm") - result = [i for i in paths if not any(str(i).startswith(str(t)) for t in to_remove)] - return result + if os.environ.get("PYCHARM_HELPERS_DIR"): + to_remove.extend((Path(os.environ["PYCHARM_HELPERS_DIR"]).parent, Path(os.path.expanduser("~")) / ".PyCharm")) + return [i for i in paths if not any(str(i).startswith(str(t)) for t in to_remove)] @pytest.fixture(scope="session") @@ -96,53 +92,44 @@ def system(session_app_data): return get_env_debug_info(Path(CURRENT.system_executable), DEBUG_SCRIPT, session_app_data, os.environ) -CURRENT_CREATORS = list(i for i in CURRENT.creators().key_to_class.keys() if i != "builtin") +CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] CREATE_METHODS = [] for k, v in CURRENT.creators().key_to_meta.items(): if k in CURRENT_CREATORS: if v.can_copy: + if k == "venv" and CURRENT.implementation == "PyPy" and CURRENT.pypy_version_info >= [7, 3, 13]: + continue # https://foss.heptapod.net/pypy/pypy/-/issues/4019 CREATE_METHODS.append((k, "copies")) if v.can_symlink: CREATE_METHODS.append((k, "symlinks")) -_VENV_BUG_ON = ( - IS_PYPY - and CURRENT.version_info[0:3] == (3, 6, 9) - and CURRENT.pypy_version_info[0:2] == [7, 3, 0] - and CURRENT.platform == "linux" -) @pytest.mark.parametrize( - "creator, isolated", - [ - pytest.param( - *i, - marks=pytest.mark.xfail( - reason="https://bitbucket.org/pypy/pypy/issues/3159/pypy36-730-venv-fails-with-copies-on-linux", - strict=True, - ) - ) - if _VENV_BUG_ON and i[0][0] == "venv" and i[0][1] == "copies" - else i - for i in product(CREATE_METHODS, ["isolated", "global"]) - ], - ids=lambda i: "-".join(i) if isinstance(i, tuple) else i, + ("creator", "isolated"), + [pytest.param(*i, id=f"{'-'.join(i[0])}-{i[1]}") for i in product(CREATE_METHODS, ["isolated", "global"])], ) -def test_create_no_seed(python, creator, isolated, system, coverage_env, special_name_dir): +def test_create_no_seed( # noqa: C901, PLR0912, PLR0913, PLR0915 + python, + creator, + isolated, + system, + coverage_env, + special_name_dir, +): dest = special_name_dir creator_key, method = creator cmd = [ "-v", "-v", "-p", - ensure_text(python), - ensure_text(str(dest)), + str(python), + str(dest), "--without-pip", "--activators", "", "--creator", creator_key, - "--{}".format(method), + f"--{method}", ] if isolated == "global": cmd.append("--system-site-packages") @@ -154,27 +141,24 @@ def test_create_no_seed(python, creator, isolated, system, coverage_env, special # force a close of these on system where the limit is low-ish (e.g. MacOS 256) gc.collect() purelib = creator.purelib - patch_files = {purelib / "{}.{}".format("_virtualenv", i) for i in ("py", "pyc", "pth")} + patch_files = {purelib / f"{'_virtualenv'}.{i}" for i in ("py", "pyc", "pth")} patch_files.add(purelib / "__pycache__") content = set(creator.purelib.iterdir()) - patch_files - assert not content, "\n".join(ensure_text(str(i)) for i in content) - assert creator.env_name == ensure_text(dest.name) + assert not content, "\n".join(str(i) for i in content) + assert creator.env_name == str(dest.name) debug = creator.debug - assert "exception" not in debug, "{}\n{}\n{}".format(debug.get("exception"), debug.get("out"), debug.get("err")) + assert "exception" not in debug, f"{debug.get('exception')}\n{debug.get('out')}\n{debug.get('err')}" sys_path = cleanup_sys_path(debug["sys"]["path"]) system_sys_path = cleanup_sys_path(system["sys"]["path"]) our_paths = set(sys_path) - set(system_sys_path) - our_paths_repr = "\n".join(ensure_text(repr(i)) for i in our_paths) + our_paths_repr = "\n".join(repr(i) for i in our_paths) # ensure we have at least one extra path added assert len(our_paths) >= 1, our_paths_repr # ensure all additional paths are related to the virtual environment for path in our_paths: - msg = "\n{}\ndoes not start with {}\nhas:\n{}".format( - ensure_text(str(path)), - ensure_text(str(dest)), - "\n".join(ensure_text(str(p)) for p in system_sys_path), - ) + msg = "\n".join(str(p) for p in system_sys_path) + msg = f"\n{path!s}\ndoes not start with {dest!s}\nhas:\n{msg}" assert str(path).startswith(str(dest)), msg # ensure there's at least a site-packages folder as part of the virtual environment added assert any(p for p in our_paths if p.parts[-1] == "site-packages"), our_paths_repr @@ -182,10 +166,8 @@ def test_create_no_seed(python, creator, isolated, system, coverage_env, special # ensure the global site package is added or not, depending on flag global_sys_path = system_sys_path[-1] if isolated == "isolated": - msg = "global sys path {} is in virtual environment sys path:\n{}".format( - ensure_text(str(global_sys_path)), - "\n".join(ensure_text(str(j)) for j in sys_path), - ) + msg = "\n".join(str(j) for j in sys_path) + msg = f"global sys path {global_sys_path!s} is in virtual environment sys path:\n{msg}" assert global_sys_path not in sys_path, msg else: common = [] @@ -196,7 +178,7 @@ def test_create_no_seed(python, creator, isolated, system, coverage_env, special break def list_to_str(iterable): - return [ensure_text(str(i)) for i in iterable] + return [str(i) for i in iterable] assert common, "\n".join(difflib.unified_diff(list_to_str(sys_path), list_to_str(system_sys_path))) @@ -207,7 +189,7 @@ def list_to_str(iterable): if sys.platform == "win32": exes = ("python.exe",) else: - exes = ("python", "python{}".format(*sys.version_info), "python{}.{}".format(*sys.version_info)) + exes = ("python", f"python{sys.version_info.major}", f"python{sys.version_info.major}.{sys.version_info.minor}") if creator_key == "venv": # for venv some repackaging does not includes the pythonx.y exes = exes[:-1] @@ -230,23 +212,50 @@ def list_to_str(iterable): if CPython3Posix.pyvenv_launch_patch_active(PythonInfo.from_exe(python)) and creator_key != "venv": result = subprocess.check_output( [str(creator.exe), "-c", 'import os; print(os.environ.get("__PYVENV_LAUNCHER__"))'], - universal_newlines=True, + text=True, ).strip() assert result == "None" - if isinstance(creator, CPython2PosixBase): - make_file = debug["makefile_filename"] - assert os.path.exists(make_file) + git_ignore = (dest / ".gitignore").read_text(encoding="utf-8") + if creator_key == "venv" and sys.version_info >= (3, 13): + comment = "# Created by venv; see https://docs.python.org/3/library/venv.html" + else: + comment = "# created by virtualenv automatically" + assert git_ignore.splitlines() == [comment, "*"] + - git_ignore = (dest / ".gitignore").read_text() - assert git_ignore.splitlines() == ["# created by virtualenv automatically", "*"] +def test_create_cachedir_tag(tmp_path): + cachedir_tag_file = tmp_path / "CACHEDIR.TAG" + cli_run([str(tmp_path), "--without-pip", "--activators", ""]) + + expected = """ + Signature: 8a477f597d28d172789f06886806bc55 + # This file is a cache directory tag created by Python virtualenv. + # For information about cache directory tags, see: + # https://bford.info/cachedir/ + """ + assert cachedir_tag_file.read_text(encoding="utf-8") == textwrap.dedent(expected).strip() + + +def test_create_cachedir_tag_exists(tmp_path: Path) -> None: + cachedir_tag_file = tmp_path / "CACHEDIR.TAG" + cachedir_tag_file.write_text("magic", encoding="utf-8") + cli_run([str(tmp_path), "--without-pip", "--activators", ""]) + assert cachedir_tag_file.read_text(encoding="utf-8") == "magic" + + +def test_create_cachedir_tag_exists_override(tmp_path: Path) -> None: + cachedir_tag_file = tmp_path / "CACHEDIR.TAG" + cachedir_tag_file.write_text("magic", encoding="utf-8") + cli_run([str(tmp_path), "--without-pip", "--activators", ""]) + assert cachedir_tag_file.read_text(encoding="utf-8") == "magic" def test_create_vcs_ignore_exists(tmp_path): git_ignore = tmp_path / ".gitignore" - git_ignore.write_text("magic") + git_ignore.write_text("magic", encoding="utf-8") cli_run([str(tmp_path), "--without-pip", "--activators", ""]) - assert git_ignore.read_text() == "magic" + assert git_ignore.read_text(encoding="utf-8") == "magic" def test_create_vcs_ignore_override(tmp_path): @@ -257,16 +266,15 @@ def test_create_vcs_ignore_override(tmp_path): def test_create_vcs_ignore_exists_override(tmp_path): git_ignore = tmp_path / ".gitignore" - git_ignore.write_text("magic") + git_ignore.write_text("magic", encoding="utf-8") cli_run([str(tmp_path), "--without-pip", "--no-vcs-ignore", "--activators", ""]) - assert git_ignore.read_text() == "magic" + assert git_ignore.read_text(encoding="utf-8") == "magic" @pytest.mark.skipif(not CURRENT.has_venv, reason="requires interpreter with venv") def test_venv_fails_not_inline(tmp_path, capsys, mocker): - if hasattr(os, "geteuid"): - if os.geteuid() == 0: - pytest.skip("no way to check permission restriction when running under root") + if hasattr(os, "geteuid") and os.geteuid() == 0: + pytest.skip("no way to check permission restriction when running under root") def _session_via_cli(args, options=None, setup_logging=True, env=None): session = session_via_cli(args, options, setup_logging, env) @@ -276,7 +284,7 @@ def _session_via_cli(args, options=None, setup_logging=True, env=None): mocker.patch("virtualenv.run.session_via_cli", side_effect=_session_via_cli) before = tmp_path.stat().st_mode cfg_path = tmp_path / "pyvenv.cfg" - cfg_path.write_text(ensure_text("")) + cfg_path.write_text("", encoding="utf-8") cfg = str(cfg_path) try: os.chmod(cfg, stat.S_IREAD | stat.S_IRGRP | stat.S_IROTH) @@ -291,22 +299,6 @@ def _session_via_cli(args, options=None, setup_logging=True, env=None): assert "Error:" in err, err -@pytest.mark.skipif(not sys.version_info[0] == 2, reason="python 2 only tests") -def test_debug_bad_virtualenv(tmp_path): - cmd = [str(tmp_path), "--without-pip"] - result = cli_run(cmd) - # if the site.py is removed/altered the debug should fail as no one is around to fix the paths - site_py = result.creator.stdlib / "site.py" - site_py.unlink() - # insert something that writes something on the stdout - site_py.write_text('import sys; sys.stdout.write(repr("std-out")); sys.stderr.write("std-err"); raise ValueError') - debug_info = result.creator.debug - assert debug_info["returncode"] - assert debug_info["err"].startswith("std-err") - assert "std-out" in debug_info["out"] - assert debug_info["exception"] - - @pytest.mark.parametrize("creator", CURRENT_CREATORS) @pytest.mark.parametrize("clear", [True, False], ids=["clear", "no_clear"]) def test_create_clear_resets(tmp_path, creator, clear, caplog): @@ -317,7 +309,7 @@ def test_create_clear_resets(tmp_path, creator, clear, caplog): cmd = [str(tmp_path), "--seeder", "app-data", "--without-pip", "--creator", creator, "-vvv"] cli_run(cmd) - marker.write_text("") # if we a marker file this should be gone on a clear run, remain otherwise + marker.write_text("", encoding="utf-8") # if we a marker file this should be gone on a clear run, remain otherwise assert marker.exists() cli_run(cmd + (["--clear"] if clear else [])) @@ -336,41 +328,38 @@ def test_prompt_set(tmp_path, creator, prompt): cfg = PyEnvCfg.from_file(result.creator.pyenv_cfg.path) if prompt is None: assert "prompt" not in cfg - else: - if creator != "venv": - assert "prompt" in cfg, list(cfg.content.keys()) - assert cfg["prompt"] == actual_prompt + elif creator != "venv": + assert "prompt" in cfg, list(cfg.content.keys()) + assert cfg["prompt"] == actual_prompt -@pytest.mark.slow -def test_cross_major(cross_python, coverage_env, tmp_path, session_app_data, current_fastest): - cmd = [ - "-p", - ensure_text(cross_python.executable), - ensure_text(str(tmp_path)), - "--no-setuptools", - "--no-wheel", - "--activators", - "", - ] +@pytest.mark.parametrize("creator", CURRENT_CREATORS) +def test_home_path_is_exe_parent(tmp_path, creator): + cmd = [str(tmp_path), "--seeder", "app-data", "--without-pip", "--creator", creator] + result = cli_run(cmd) - pip_scripts = {i.name.replace(".exe", "") for i in result.creator.script_dir.iterdir() if i.name.startswith("pip")} - major, minor = cross_python.version_info[0:2] - assert pip_scripts == { - "pip", - "pip{}".format(major), - "pip-{}.{}".format(major, minor), - "pip{}.{}".format(major, minor), - } - coverage_env() - env = PythonInfo.from_exe(str(result.creator.exe), session_app_data) - assert env.version_info.major != CURRENT.version_info.major + cfg = PyEnvCfg.from_file(result.creator.pyenv_cfg.path) + + # Cannot assume "home" path is a specific value as path resolution may change + # between versions (symlinks, framework paths, etc) but we can check that a + # python executable is present from the configured path per PEP 405 + if sys.platform == "win32": + exes = ("python.exe",) + else: + exes = ( + "python", + f"python{sys.version_info.major}", + f"python{sys.version_info.major}.{sys.version_info.minor}", + ) + assert any(os.path.exists(os.path.join(cfg["home"], exe)) for exe in exes) -def test_create_parallel(tmp_path, monkeypatch, temp_app_data): + +@pytest.mark.usefixtures("temp_app_data") +def test_create_parallel(tmp_path): def create(count): subprocess.check_call( - [sys.executable, "-m", "virtualenv", "-vvv", str(tmp_path / "venv{}".format(count)), "--without-pip"], + [sys.executable, "-m", "virtualenv", "-vvv", str(tmp_path / f"venv{count}"), "--without-pip"], ) threads = [Thread(target=create, args=(i,)) for i in range(1, 4)] @@ -393,7 +382,8 @@ def test_creator_replaces_altsep_in_dest(tmp_path): assert str(result) == dest.format(os.sep) -def test_create_long_path(current_fastest, tmp_path): +@pytest.mark.usefixtures("current_fastest") +def test_create_long_path(tmp_path): if sys.platform == "darwin": max_shebang_length = 512 else: @@ -409,8 +399,21 @@ def test_create_long_path(current_fastest, tmp_path): @pytest.mark.parametrize("creator", sorted(set(PythonInfo.current_system().creators().key_to_class) - {"builtin"})) -def test_create_distutils_cfg(creator, tmp_path, monkeypatch, session_app_data): - result = cli_run([ensure_text(str(tmp_path / "venv")), "--activators", "", "--creator", creator]) +@pytest.mark.usefixtures("session_app_data") +def test_create_distutils_cfg(creator, tmp_path, monkeypatch): + result = cli_run( + [ + str(tmp_path / "venv"), + "--activators", + "", + "--creator", + creator, + "--setuptools", + "bundle", + "--wheel", + "bundle", + ], + ) app = Path(__file__).parent / "console_app" dest = tmp_path / "console_app" @@ -418,20 +421,17 @@ def test_create_distutils_cfg(creator, tmp_path, monkeypatch, session_app_data): setup_cfg = dest / "setup.cfg" conf = dedent( - """ + f""" [install] - prefix={0}{1}prefix - install_purelib={0}{1}purelib - install_platlib={0}{1}platlib - install_headers={0}{1}headers - install_scripts={0}{1}scripts - install_data={0}{1}data - """.format( - tmp_path, - os.sep, - ), + prefix={tmp_path}{os.sep}prefix + install_purelib={tmp_path}{os.sep}purelib + install_platlib={tmp_path}{os.sep}platlib + install_headers={tmp_path}{os.sep}headers + install_scripts={tmp_path}{os.sep}scripts + install_data={tmp_path}{os.sep}data + """, ) - setup_cfg.write_text(setup_cfg.read_text() + conf) + setup_cfg.write_text(setup_cfg.read_text(encoding="utf-8") + conf, encoding="utf-8") monkeypatch.chdir(dest) # distutils will read the setup.cfg from the cwd, so change to that @@ -457,108 +457,20 @@ def list_files(path): for root, _, files in os.walk(path): level = root.replace(path, "").count(os.sep) indent = " " * 4 * level - result += "{}{}/\n".format(indent, os.path.basename(root)) + result += f"{indent}{os.path.basename(root)}/\n" sub = " " * 4 * (level + 1) for f in files: - result += "{}{}\n".format(sub, f) + result += f"{sub}{f}\n" return result -@pytest.mark.parametrize("python_path_on", [True, False], ids=["on", "off"]) -@pytest.mark.skipif(PY3, reason="we rewrite sys.path only on PY2") -def test_python_path(monkeypatch, tmp_path, python_path_on): - result = cli_run([ensure_text(str(tmp_path)), "--without-pip", "--activators", ""]) - monkeypatch.chdir(tmp_path) - case_sensitive = fs_is_case_sensitive() - - def _get_sys_path(flag=None): - cmd = [str(result.creator.exe)] - if flag: - cmd.append(flag) - cmd.extend(["-c", "import json; import sys; print(json.dumps(sys.path))"]) - return [i if case_sensitive else i.lower() for i in json.loads(subprocess.check_output(cmd))] - - monkeypatch.delenv(str("PYTHONPATH"), raising=False) - base = _get_sys_path() - - # note the value result.creator.interpreter.system_stdlib cannot be set, as that would disable our custom site.py - python_paths = [ - str(Path(result.creator.interpreter.prefix)), - str(Path(result.creator.interpreter.system_stdlib) / "b"), - str(result.creator.purelib / "a"), - str(result.creator.purelib), - str(result.creator.bin_dir), - str(tmp_path / "base"), - str(tmp_path / "base_sep") + os.sep, - "name", - "name{}".format(os.sep), - str(tmp_path.parent / (ensure_text(tmp_path.name) + "_suffix")), - ".", - "..", - "", - ] - python_path_env = os.pathsep.join(ensure_str(i) for i in python_paths) - monkeypatch.setenv(str("PYTHONPATH"), python_path_env) - - extra_all = _get_sys_path(None if python_path_on else "-E") - if python_path_on: - assert extra_all[0] == "" # the cwd is always injected at start as '' - extra_all = extra_all[1:] - assert base[0] == "" - base = base[1:] - - assert not (set(base) - set(extra_all)) # all base paths are present - abs_python_paths = list(OrderedDict((os.path.abspath(ensure_text(i)), None) for i in python_paths).keys()) - abs_python_paths = [i if case_sensitive else i.lower() for i in abs_python_paths] - - extra_as_python_path = extra_all[: len(abs_python_paths)] - assert abs_python_paths == extra_as_python_path # python paths are there at the start - - non_python_path = extra_all[len(abs_python_paths) :] - assert non_python_path == [i for i in base if i not in extra_as_python_path] - else: - assert base == extra_all - - -@pytest.mark.skipif( - not (CURRENT.implementation == "CPython" and PY2), - reason="stdlib components without py files only possible on CPython2", -) -@pytest.mark.parametrize( - "py, pyc", - list( - product( - [True, False] if Python2.from_stdlib(Python2.mappings(CURRENT), "os.py")[2] else [False], - [True, False] if Python2.from_stdlib(Python2.mappings(CURRENT), "os.pyc")[2] else [False], - ), - ), -) -def test_py_pyc_missing(tmp_path, mocker, session_app_data, py, pyc): - """Ensure that creation can succeed if os.pyc exists (even if os.py has been deleted)""" - previous = Python2.from_stdlib - - def from_stdlib(mappings, name): - path, to, exists = previous(mappings, name) - if name.endswith("py"): - exists = py - elif name.endswith("pyc"): - exists = pyc - return path, to, exists - - mocker.patch.object(Python2, "from_stdlib", side_effect=from_stdlib) - - result = cli_run([ensure_text(str(tmp_path)), "--without-pip", "--activators", "", "-vv"]) - py_at = Python2.from_stdlib(Python2.mappings(CURRENT), "os.py")[1](result.creator, Path("os.py")) - py = pyc is False or py # if pyc is False we fallback to serve the py, which will exist (as we only mock the check) - assert py_at.exists() is py - - pyc_at = Python2.from_stdlib(Python2.mappings(CURRENT), "osc.py")[1](result.creator, Path("os.pyc")) - assert pyc_at.exists() is pyc - - +@pytest.mark.skipif(is_macos_brew(CURRENT), reason="no copy on brew") +@pytest.mark.skip(reason="https://github.com/pypa/setuptools/issues/4640") def test_zip_importer_can_import_setuptools(tmp_path): """We're patching the loaders so might fail on r/o loaders, such as zipimporter on CPython<3.8""" - result = cli_run([str(tmp_path / "venv"), "--activators", "", "--no-pip", "--no-wheel", "--copies"]) + result = cli_run( + [str(tmp_path / "venv"), "--activators", "", "--no-pip", "--no-wheel", "--copies", "--setuptools", "bundle"], + ) zip_path = tmp_path / "site-packages.zip" with zipfile.ZipFile(str(zip_path), "w", zipfile.ZIP_DEFLATED) as zip_handler: lib = str(result.creator.purelib) @@ -574,7 +486,7 @@ def test_zip_importer_can_import_setuptools(tmp_path): else: folder.unlink() env = os.environ.copy() - env[str("PYTHONPATH")] = str(zip_path) + env["PYTHONPATH"] = str(zip_path) subprocess.check_call([str(result.creator.exe), "-c", "from setuptools.dist import Distribution"], env=env) @@ -583,55 +495,59 @@ def test_zip_importer_can_import_setuptools(tmp_path): # # coverage is disabled, because when coverage is active, it imports threading in default mode. @pytest.mark.xfail( - IS_PYPY and PY3 and sys.platform.startswith("darwin"), + IS_PYPY and sys.platform.startswith("darwin"), reason="https://foss.heptapod.net/pypy/pypy/-/issues/3269", ) -def test_no_preimport_threading(tmp_path, no_coverage): - session = cli_run([ensure_text(str(tmp_path))]) +@pytest.mark.usefixtures("_no_coverage") +def test_no_preimport_threading(tmp_path): + session = cli_run([str(tmp_path)]) out = subprocess.check_output( [str(session.creator.exe), "-c", r"import sys; print('\n'.join(sorted(sys.modules)))"], - universal_newlines=True, + text=True, + encoding="utf-8", ) imported = set(out.splitlines()) assert "threading" not in imported # verify that .pth files in site-packages/ are always processed even if $PYTHONPATH points to it. -def test_pth_in_site_vs_PYTHONPATH(tmp_path): - session = cli_run([ensure_text(str(tmp_path))]) - site_packages = str(session.creator.purelib) +def test_pth_in_site_vs_python_path(tmp_path): + session = cli_run([str(tmp_path)]) + site_packages = session.creator.purelib # install test.pth that sets sys.testpth='ok' - with open(os.path.join(site_packages, "test.pth"), "w") as f: - f.write('import sys; sys.testpth="ok"\n') + (session.creator.purelib / "test.pth").write_text('import sys; sys.testpth="ok"\n', encoding="utf-8") # verify that test.pth is activated when interpreter is run out = subprocess.check_output( [str(session.creator.exe), "-c", r"import sys; print(sys.testpth)"], - universal_newlines=True, + text=True, + encoding="utf-8", ) assert out == "ok\n" # same with $PYTHONPATH pointing to site_packages env = os.environ.copy() - path = [site_packages] + path = [str(site_packages)] if "PYTHONPATH" in env: path.append(env["PYTHONPATH"]) env["PYTHONPATH"] = os.pathsep.join(path) out = subprocess.check_output( [str(session.creator.exe), "-c", r"import sys; print(sys.testpth)"], - universal_newlines=True, + text=True, env=env, + encoding="utf-8", ) assert out == "ok\n" def test_getsitepackages_system_site(tmp_path): # Test without --system-site-packages - session = cli_run([ensure_text(str(tmp_path))]) + session = cli_run([str(tmp_path)]) system_site_packages = get_expected_system_site_packages(session) out = subprocess.check_output( [str(session.creator.exe), "-c", r"import site; print(site.getsitepackages())"], - universal_newlines=True, + text=True, + encoding="utf-8", ) site_packages = ast.literal_eval(out) @@ -639,15 +555,16 @@ def test_getsitepackages_system_site(tmp_path): assert system_site_package not in site_packages # Test with --system-site-packages - session = cli_run([ensure_text(str(tmp_path)), "--system-site-packages"]) + session = cli_run([str(tmp_path), "--system-site-packages"]) - system_site_packages = get_expected_system_site_packages(session) + system_site_packages = [str(Path(i).resolve()) for i in get_expected_system_site_packages(session)] out = subprocess.check_output( [str(session.creator.exe), "-c", r"import site; print(site.getsitepackages())"], - universal_newlines=True, + text=True, + encoding="utf-8", ) - site_packages = ast.literal_eval(out) + site_packages = [str(Path(i).resolve()) for i in ast.literal_eval(out)] for system_site_package in system_site_packages: assert system_site_package in site_packages @@ -666,11 +583,12 @@ def get_expected_system_site_packages(session): def test_get_site_packages(tmp_path): case_sensitive = fs_is_case_sensitive() - session = cli_run([ensure_text(str(tmp_path))]) + session = cli_run([str(tmp_path)]) env_site_packages = [str(session.creator.purelib), str(session.creator.platlib)] out = subprocess.check_output( [str(session.creator.exe), "-c", r"import site; print(site.getsitepackages())"], - universal_newlines=True, + text=True, + encoding="utf-8", ) site_packages = ast.literal_eval(out) @@ -680,3 +598,96 @@ def test_get_site_packages(tmp_path): for env_site_package in env_site_packages: assert env_site_package in site_packages + + +def test_debug_bad_virtualenv(tmp_path): + cmd = [str(tmp_path), "--without-pip"] + result = cli_run(cmd) + # if the site.py is removed/altered the debug should fail as no one is around to fix the paths + cust = result.creator.purelib / "_a.pth" + cust.write_text( + 'import sys; sys.stdout.write("std-out"); sys.stderr.write("std-err"); raise SystemExit(1)', + encoding="utf-8", + ) + debug_info = result.creator.debug + assert debug_info["returncode"] == 1 + assert "std-err" in debug_info["err"] + assert "std-out" in debug_info["out"] + assert debug_info["exception"] + + +@pytest.mark.parametrize("python_path_on", [True, False], ids=["on", "off"]) +def test_python_path(monkeypatch, tmp_path, python_path_on): + result = cli_run([str(tmp_path), "--without-pip", "--activators", ""]) + monkeypatch.chdir(tmp_path) + case_sensitive = fs_is_case_sensitive() + + def _get_sys_path(flag=None): + cmd = [str(result.creator.exe)] + if flag: + cmd.append(flag) + cmd.extend(["-c", "import json; import sys; print(json.dumps(sys.path))"]) + return [i if case_sensitive else i.lower() for i in json.loads(subprocess.check_output(cmd, encoding="utf-8"))] + + monkeypatch.delenv("PYTHONPATH", raising=False) + base = _get_sys_path() + + # note the value result.creator.interpreter.system_stdlib cannot be set, as that would disable our custom site.py + python_paths = [ + str(Path(result.creator.interpreter.prefix)), + str(Path(result.creator.interpreter.system_stdlib) / "b"), + str(result.creator.purelib / "a"), + str(result.creator.purelib), + str(result.creator.bin_dir), + str(tmp_path / "base"), + f"{tmp_path / 'base_sep'!s}{os.sep}", + "name", + f"name{os.sep}", + f"{tmp_path.parent}{f'{tmp_path.name}_suffix'}", + ".", + "..", + "", + ] + python_path_env = os.pathsep.join(python_paths) + monkeypatch.setenv("PYTHONPATH", python_path_env) + + extra_all = _get_sys_path(None if python_path_on else "-E") + if python_path_on: + assert not extra_all[0] # the cwd is always injected at start as '' + extra_all = extra_all[1:] + assert not base[0] + base = base[1:] + + assert not (set(base) - set(extra_all)) # all base paths are present + abs_python_paths = list(OrderedDict((os.path.abspath(str(i)), None) for i in python_paths).keys()) + abs_python_paths = [i if case_sensitive else i.lower() for i in abs_python_paths] + + extra_as_python_path = extra_all[: len(abs_python_paths)] + assert abs_python_paths == extra_as_python_path # python paths are there at the start + + non_python_path = extra_all[len(abs_python_paths) :] + assert non_python_path == [i for i in base if i not in extra_as_python_path] + else: + assert base == extra_all + + +# Make sure that the venv creator works on systems where vendor-delivered files +# (specifically venv scripts delivered with Python itself) are not writable. +# +# https://github.com/pypa/virtualenv/issues/2419 +@pytest.mark.skipif("venv" not in CURRENT_CREATORS, reason="test needs venv creator") +def test_venv_creator_without_write_perms(tmp_path, mocker): + from virtualenv.run.session import Session # noqa: PLC0415 + + prev = Session._create # noqa: SLF001 + + def func(self): + prev(self) + scripts_dir = self.creator.dest / "bin" + for script in scripts_dir.glob("*ctivate*"): + script.chmod(stat.S_IREAD | stat.S_IRGRP | stat.S_IROTH) + + mocker.patch("virtualenv.run.session.Session._create", side_effect=func, autospec=True) + + cmd = [str(tmp_path), "--seeder", "app-data", "--without-pip", "--creator", "venv"] + cli_run(cmd) diff --git a/tests/unit/create/test_interpreters.py b/tests/unit/create/test_interpreters.py index 59a09913f..ae4452b13 100644 --- a/tests/unit/create/test_interpreters.py +++ b/tests/unit/create/test_interpreters.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import sys from uuid import uuid4 @@ -14,7 +14,7 @@ def test_failed_to_find_bad_spec(): of_id = uuid4().hex with pytest.raises(RuntimeError) as context: cli_run(["-p", of_id]) - msg = repr(RuntimeError("failed to find interpreter for Builtin discover of python_spec={!r}".format(of_id))) + msg = repr(RuntimeError(f"failed to find interpreter for Builtin discover of python_spec={of_id!r}")) assert repr(context.value) == msg @@ -29,6 +29,4 @@ def test_failed_to_find_implementation(of_id, mocker): mocker.patch("virtualenv.run.plugin.creators.CreatorSelector._OPTIONS", return_value={}) with pytest.raises(RuntimeError) as context: cli_run(["-p", of_id]) - assert repr(context.value) == repr( - RuntimeError("No virtualenv implementation for {}".format(PythonInfo.current_system())), - ) + assert repr(context.value) == repr(RuntimeError(f"No virtualenv implementation for {PythonInfo.current_system()}")) diff --git a/tests/unit/create/via_global_ref/builtin/conftest.py b/tests/unit/create/via_global_ref/builtin/conftest.py new file mode 100644 index 000000000..a5808c58b --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/conftest.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from testing import path +from testing.py_info import read_fixture + +# Allows to import from `testing` into test submodules. +sys.path.append(str(Path(__file__).parent)) + + +@pytest.fixture +def py_info(py_info_name): + return read_fixture(py_info_name) + + +@pytest.fixture +def mock_files(mocker): + return lambda paths, files: path.mock_files(mocker, paths, files) + + +@pytest.fixture +def mock_pypy_libs(mocker): + return lambda pypy, libs: path.mock_pypy_libs(mocker, pypy, libs) diff --git a/tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_embed.json b/tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_embed.json new file mode 100644 index 000000000..e8d0d01c9 --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_embed.json @@ -0,0 +1,61 @@ +{ + "platform": "win32", + "implementation": "CPython", + "version_info": { + "major": 3, + "minor": 10, + "micro": 4, + "releaselevel": "final", + "serial": 0 + }, + "architecture": 64, + "version_nodot": "310", + "version": "3.10.4 (tags/v3.10.4:9d38120, Mar 23 2022, 23:13:41) [MSC v.1929 64 bit (AMD64)]", + "os": "nt", + "prefix": "c:\\path\\to\\python", + "base_prefix": "c:\\path\\to\\python", + "real_prefix": null, + "base_exec_prefix": "c:\\path\\to\\python", + "exec_prefix": "c:\\path\\to\\python", + "executable": "c:\\path\\to\\python\\python.exe", + "original_executable": "c:\\path\\to\\python\\python.exe", + "system_executable": "c:\\path\\to\\python\\python.exe", + "has_venv": false, + "path": [ + "c:\\path\\to\\python\\Scripts\\virtualenv.exe", + "c:\\path\\to\\python\\python310.zip", + "c:\\path\\to\\python", + "c:\\path\\to\\python\\Lib\\site-packages" + ], + "file_system_encoding": "utf-8", + "stdout_encoding": "utf-8", + "sysconfig_scheme": null, + "sysconfig_paths": { + "stdlib": "{installed_base}/Lib", + "platstdlib": "{base}/Lib", + "purelib": "{base}/Lib/site-packages", + "platlib": "{base}/Lib/site-packages", + "include": "{installed_base}/Include", + "scripts": "{base}/Scripts", + "data": "{base}" + }, + "distutils_install": { + "purelib": "Lib\\site-packages", + "platlib": "Lib\\site-packages", + "headers": "Include\\UNKNOWN", + "scripts": "Scripts", + "data": "" + }, + "sysconfig": { + "makefile_filename": "c:\\path\\to\\python\\Lib\\config\\Makefile" + }, + "sysconfig_vars": { + "PYTHONFRAMEWORK": "", + "installed_base": "c:\\path\\to\\python", + "base": "c:\\path\\to\\python" + }, + "system_stdlib": "c:\\path\\to\\python\\Lib", + "system_stdlib_platform": "c:\\path\\to\\python\\Lib", + "max_size": 9223372036854775807, + "_creators": null +} diff --git a/tests/unit/create/via_global_ref/builtin/cpython/test_cpython3_win.py b/tests/unit/create/via_global_ref/builtin/cpython/test_cpython3_win.py new file mode 100644 index 000000000..f831de114 --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/cpython/test_cpython3_win.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import pytest +from testing.helpers import contains_exe, contains_ref +from testing.path import join as path + +from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Windows + +CPYTHON3_PATH = ( + "virtualenv.create.via_global_ref.builtin.cpython.common.Path", + "virtualenv.create.via_global_ref.builtin.cpython.cpython3.Path", +) + + +@pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) +def test_2_exe_on_default_py_host(py_info, mock_files): + mock_files(CPYTHON3_PATH, [py_info.system_executable]) + sources = tuple(CPython3Windows.sources(interpreter=py_info)) + # Default Python exe. + assert contains_exe(sources, py_info.system_executable) + # Should always exist. + assert contains_exe(sources, path(py_info.prefix, "pythonw.exe")) + + +@pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) +def test_3_exe_on_not_default_py_host(py_info, mock_files): + # Not default python host. + py_info.system_executable = path(py_info.prefix, "python666.exe") + mock_files(CPYTHON3_PATH, [py_info.system_executable]) + sources = tuple(CPython3Windows.sources(interpreter=py_info)) + # Not default Python exe linked to both the default name and origin. + assert contains_exe(sources, py_info.system_executable, "python.exe") + assert contains_exe(sources, py_info.system_executable, "python666.exe") + # Should always exist. + assert contains_exe(sources, path(py_info.prefix, "pythonw.exe")) + + +@pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) +def test_only_shim(py_info, mock_files): + shim = path(py_info.system_stdlib, "venv\\scripts\\nt\\python.exe") + py_files = ( + path(py_info.prefix, "libcrypto-1_1.dll"), + path(py_info.prefix, "libffi-7.dll"), + path(py_info.prefix, "_asyncio.pyd"), + path(py_info.prefix, "_bz2.pyd"), + ) + mock_files(CPYTHON3_PATH, [shim, *py_files]) + sources = tuple(CPython3Windows.sources(interpreter=py_info)) + assert CPython3Windows.has_shim(interpreter=py_info) + assert contains_exe(sources, shim) + assert not contains_exe(sources, py_info.system_executable) + for file in py_files: + assert not contains_ref(sources, file) + + +@pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) +def test_exe_dll_pyd_without_shim(py_info, mock_files): + py_files = ( + path(py_info.prefix, "libcrypto-1_1.dll"), + path(py_info.prefix, "libffi-7.dll"), + path(py_info.prefix, "_asyncio.pyd"), + path(py_info.prefix, "_bz2.pyd"), + ) + mock_files(CPYTHON3_PATH, py_files) + sources = tuple(CPython3Windows.sources(interpreter=py_info)) + assert not CPython3Windows.has_shim(interpreter=py_info) + assert contains_exe(sources, py_info.system_executable) + for file in py_files: + assert contains_ref(sources, file) + + +@pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) +def test_python_zip_if_exists_and_set_in_path(py_info, mock_files): + python_zip_name = f"python{py_info.version_nodot}.zip" + python_zip = path(py_info.prefix, python_zip_name) + mock_files(CPYTHON3_PATH, [python_zip]) + sources = tuple(CPython3Windows.sources(interpreter=py_info)) + assert python_zip in py_info.path + assert contains_ref(sources, python_zip) + + +@pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) +def test_no_python_zip_if_exists_and_not_set_in_path(py_info, mock_files): + python_zip_name = f"python{py_info.version_nodot}.zip" + python_zip = path(py_info.prefix, python_zip_name) + py_info.path.remove(python_zip) + mock_files(CPYTHON3_PATH, [python_zip]) + sources = tuple(CPython3Windows.sources(interpreter=py_info)) + assert python_zip not in py_info.path + assert not contains_ref(sources, python_zip) + + +@pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) +def test_no_python_zip_if_not_exists(py_info, mock_files): + python_zip_name = f"python{py_info.version_nodot}.zip" + python_zip = path(py_info.prefix, python_zip_name) + # No `python_zip`, just python.exe file. + mock_files(CPYTHON3_PATH, [py_info.system_executable]) + sources = tuple(CPython3Windows.sources(interpreter=py_info)) + assert python_zip in py_info.path + assert not contains_ref(sources, python_zip) diff --git a/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy38.json b/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy38.json index 478d79977..0d91a969d 100644 --- a/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy38.json +++ b/tests/unit/create/via_global_ref/builtin/pypy/deb_pypy38.json @@ -21,7 +21,11 @@ "original_executable": "/usr/bin/pypy3", "system_executable": "/usr/bin/pypy3", "has_venv": true, - "path": ["/usr/lib/pypy3.8", "/usr/local/lib/pypy3.8/dist-packages", "/usr/lib/python3/dist-packages"], + "path": [ + "/usr/lib/pypy3.8", + "/usr/local/lib/pypy3.8/dist-packages", + "/usr/lib/python3/dist-packages" + ], "file_system_encoding": "utf-8", "stdout_encoding": "UTF-8", "sysconfig_scheme": null, diff --git a/tests/unit/create/via_global_ref/builtin/pypy/portable_pypy38.json b/tests/unit/create/via_global_ref/builtin/pypy/portable_pypy38.json index 2264fa432..0761455bb 100644 --- a/tests/unit/create/via_global_ref/builtin/pypy/portable_pypy38.json +++ b/tests/unit/create/via_global_ref/builtin/pypy/portable_pypy38.json @@ -21,7 +21,10 @@ "original_executable": "/tmp/pypy3.8-v7.3.8-linux64/bin/pypy", "system_executable": "/tmp/pypy3.8-v7.3.8-linux64/bin/pypy", "has_venv": true, - "path": ["/tmp/pypy3.8-v7.3.8-linux64/lib/pypy3.8", "/tmp/pypy3.8-v7.3.8-linux64/lib/pypy3.8/site-packages"], + "path": [ + "/tmp/pypy3.8-v7.3.8-linux64/lib/pypy3.8", + "/tmp/pypy3.8-v7.3.8-linux64/lib/pypy3.8/site-packages" + ], "file_system_encoding": "utf-8", "stdout_encoding": "UTF-8", "sysconfig_scheme": null, diff --git a/tests/unit/create/via_global_ref/builtin/pypy/test_pypy3.py b/tests/unit/create/via_global_ref/builtin/pypy/test_pypy3.py index c4d6860e7..3ae905fdb 100644 --- a/tests/unit/create/via_global_ref/builtin/pypy/test_pypy3.py +++ b/tests/unit/create/via_global_ref/builtin/pypy/test_pypy3.py @@ -1,104 +1,49 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -import fnmatch +import pytest +from testing.helpers import contains_exe, contains_ref +from testing.path import join as path from virtualenv.create.via_global_ref.builtin.pypy.pypy3 import PyPy3Posix -from virtualenv.create.via_global_ref.builtin.ref import ExePathRefToDest, PathRefToDest -from virtualenv.discovery.py_info import PythonInfo -from virtualenv.util.path import Path - -class FakePath(Path): - """ - A Path() fake that only knows about files in existing_paths and the - directories that contain them. - """ - - existing_paths = [] - - if hasattr(Path(""), "_flavour"): - _flavour = Path("")._flavour - - def exists(self): - return self.as_posix() in self.existing_paths or self.is_dir() - - def glob(self, glob): - pattern = self.as_posix() + "/" + glob - for path in fnmatch.filter(self.existing_paths, pattern): - yield FakePath(path) - - def is_dir(self): - prefix = self.as_posix() + "/" - return any(True for path in self.existing_paths if path.startswith(prefix)) - - def iterdir(self): - prefix = self.as_posix() + "/" - for path in self.existing_paths: - if path.startswith(prefix) and "/" not in path[len(prefix) :]: - yield FakePath(path) - - def resolve(self): - return self - - def __div__(self, key): - return FakePath(super(FakePath, self).__div__(key)) - - def __truediv__(self, key): - return FakePath(super(FakePath, self).__truediv__(key)) - - -def assert_contains_exe(sources, src): - """Assert that the one and only executeable in sources is src""" - exes = [source for source in sources if isinstance(source, ExePathRefToDest)] - assert len(exes) == 1 - exe = exes[0] - assert exe.src.as_posix() == src - - -def assert_contains_ref(sources, src): - """Assert that src appears in sources""" - assert any(source for source in sources if isinstance(source, PathRefToDest) and source.src.as_posix() == src) - - -def inject_fake_path(mocker, existing_paths): - """Inject FakePath in all the correct places, and set existing_paths""" - FakePath.existing_paths = existing_paths - mocker.patch("virtualenv.create.via_global_ref.builtin.pypy.common.Path", FakePath) - mocker.patch("virtualenv.create.via_global_ref.builtin.pypy.pypy3.Path", FakePath) - - -def _load_pypi_info(name): - return PythonInfo._from_json((Path(__file__).parent / "{}.json".format(name)).read_text()) - - -def test_portable_pypy3_virtualenvs_get_their_libs(mocker): - paths = ["/tmp/pypy3.8-v7.3.8-linux64/bin/pypy", "/tmp/pypy3.8-v7.3.8-linux64/lib/libgdbm.so.4"] - inject_fake_path(mocker, paths) - path = Path("/tmp/pypy3.8-v7.3.8-linux64/bin/libpypy3-c.so") - mocker.patch.object(PyPy3Posix, "_shared_libs", return_value=[path]) - - sources = list(PyPy3Posix.sources(interpreter=_load_pypi_info("portable_pypy38"))) - assert_contains_exe(sources, "/tmp/pypy3.8-v7.3.8-linux64/bin/pypy") +PYPY3_PATH = ( + "virtualenv.create.via_global_ref.builtin.pypy.common.Path", + "virtualenv.create.via_global_ref.builtin.pypy.pypy3.Path", +) + + +# In `PyPy3Posix.sources()` `host_lib` will be broken in Python 2 for Windows, +# so `py_file` will not be in sources. +@pytest.mark.parametrize("py_info_name", ["portable_pypy38"]) +def test_portable_pypy3_virtualenvs_get_their_libs(py_info, mock_files, mock_pypy_libs): + py_file = path(py_info.prefix, "lib/libgdbm.so.4") + mock_files(PYPY3_PATH, [py_info.system_executable, py_file]) + lib_file = path(py_info.prefix, "bin/libpypy3-c.so") + mock_pypy_libs(PyPy3Posix, [lib_file]) + sources = tuple(PyPy3Posix.sources(interpreter=py_info)) assert len(sources) > 2 - assert_contains_ref(sources, "/tmp/pypy3.8-v7.3.8-linux64/bin/libpypy3-c.so") - assert_contains_ref(sources, "/tmp/pypy3.8-v7.3.8-linux64/lib/libgdbm.so.4") + assert contains_exe(sources, py_info.system_executable) + assert contains_ref(sources, py_file) + assert contains_ref(sources, lib_file) -def test_debian_pypy37_virtualenvs(mocker): +@pytest.mark.parametrize("py_info_name", ["deb_pypy37"]) +def test_debian_pypy37_virtualenvs(py_info, mock_files, mock_pypy_libs): # Debian's pypy3 layout, installed to /usr, before 3.8 allowed a /usr prefix - inject_fake_path(mocker, ["/usr/bin/pypy3"]) - mocker.patch.object(PyPy3Posix, "_shared_libs", return_value=[Path("/usr/lib/pypy3/bin/libpypy3-c.so")]) - sources = list(PyPy3Posix.sources(interpreter=_load_pypi_info("deb_pypy37"))) - assert_contains_exe(sources, "/usr/bin/pypy3") - assert_contains_ref(sources, "/usr/lib/pypy3/bin/libpypy3-c.so") + mock_files(PYPY3_PATH, [py_info.system_executable]) + lib_file = path(py_info.prefix, "bin/libpypy3-c.so") + mock_pypy_libs(PyPy3Posix, [lib_file]) + sources = tuple(PyPy3Posix.sources(interpreter=py_info)) assert len(sources) == 2 + assert contains_exe(sources, py_info.system_executable) + assert contains_ref(sources, lib_file) -def test_debian_pypy38_virtualenvs_exclude_usr(mocker): - inject_fake_path(mocker, ["/usr/bin/pypy3", "/usr/lib/foo"]) +@pytest.mark.parametrize("py_info_name", ["deb_pypy38"]) +def test_debian_pypy38_virtualenvs_exclude_usr(py_info, mock_files, mock_pypy_libs): + mock_files(PYPY3_PATH, [py_info.system_executable, "/usr/lib/foo"]) # libpypy3-c.so lives on the ld search path - mocker.patch.object(PyPy3Posix, "_shared_libs", return_value=[]) - - sources = list(PyPy3Posix.sources(interpreter=_load_pypi_info("deb_pypy38"))) - assert_contains_exe(sources, "/usr/bin/pypy3") + mock_pypy_libs(PyPy3Posix, []) + sources = tuple(PyPy3Posix.sources(interpreter=py_info)) assert len(sources) == 1 + assert contains_exe(sources, py_info.system_executable) diff --git a/src/virtualenv/create/via_global_ref/builtin/python2/__init__.py b/tests/unit/create/via_global_ref/builtin/testing/__init__.py similarity index 100% rename from src/virtualenv/create/via_global_ref/builtin/python2/__init__.py rename to tests/unit/create/via_global_ref/builtin/testing/__init__.py diff --git a/tests/unit/create/via_global_ref/builtin/testing/helpers.py b/tests/unit/create/via_global_ref/builtin/testing/helpers.py new file mode 100644 index 000000000..e55c8d025 --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/testing/helpers.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from functools import reduce +from pathlib import Path + +from virtualenv.create.via_global_ref.builtin.ref import ExePathRefToDest, PathRef + + +def is_ref(source): + return isinstance(source, PathRef) + + +def is_exe(source): + return type(source) is ExePathRefToDest + + +def has_src(src): + return lambda ref: ref.src.as_posix() == Path(src).as_posix() + + +def has_target(target): + return lambda ref: ref.base == target + + +def apply_filter(values, function): + return filter(function, values) + + +def filterby(filters, sources): + return reduce(apply_filter, filters, sources) + + +def contains_exe(sources, src, target=None): + filters = is_exe, has_src(src), target and has_target(target) + return any(filterby(filters, sources)) + + +def contains_ref(sources, src): + filters = is_ref, has_src(src) + return any(filterby(filters, sources)) diff --git a/tests/unit/create/via_global_ref/builtin/testing/path.py b/tests/unit/create/via_global_ref/builtin/testing/path.py new file mode 100644 index 000000000..06ba921c5 --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/testing/path.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from itertools import chain +from operator import attrgetter as attr +from pathlib import Path + + +def is_name(path): + return str(path) == path.name + + +class FakeDataABC(ABC): + """Provides data to mock the `Path`""" + + @property + @abstractmethod + def filelist(self): + """To mock a dir, just mock any child file.""" + msg = "Collection of (str) file paths to mock" + raise NotImplementedError(msg) + + @property + def fake_files(self): + return map(type(self), self.filelist) + + @property + def fake_dirs(self): + return set(chain(*map(attr("parents"), self.fake_files))) + + @property + def contained_fake_names(self): + return filter(is_name, self.fake_content) + + @property + def fake_content(self): + return filter(None, map(self.fake_child, self.fake_files)) + + def fake_child(self, path): + try: + return path.relative_to(self) + except ValueError: + return None + + +class PathMockABC(FakeDataABC, Path): + """Mocks the behavior of `Path`""" + + _flavour = getattr(Path(), "_flavour", None) + if hasattr(_flavour, "altsep"): + # Allows to pass some tests for Windows via PosixPath. + _flavour.altsep = _flavour.altsep or "\\" + + # Python 3.13 renamed _flavour to parser + parser = getattr(Path(), "parser", None) + if hasattr(parser, "altsep"): + parser.altsep = parser.altsep or "\\" + + def exists(self): + return self.is_file() or self.is_dir() + + def is_file(self): + return self in self.fake_files + + def is_dir(self): + return self in self.fake_dirs + + def resolve(self): + return self + + def iterdir(self): + if not self.is_dir(): + msg = f"No such mocked dir: '{self}'" + raise FileNotFoundError(msg) + yield from map(self.joinpath, self.contained_fake_names) + + +def MetaPathMock(filelist): # noqa: N802 + """ + Metaclass that creates a `PathMock` class with the `filelist` defined. + """ + return type("PathMock", (PathMockABC,), {"filelist": filelist}) + + +def mock_files(mocker, pathlist, filelist): + PathMock = MetaPathMock(set(filelist)) # noqa: N806 + for path in pathlist: + mocker.patch(path, PathMock) + + +def mock_pypy_libs(mocker, pypy_creator_cls, libs): + paths = tuple(set(map(Path, libs))) + mocker.patch.object(pypy_creator_cls, "_shared_libs", return_value=paths) + + +def join(*chunks): + line = "".join(chunks) + sep = ("\\" in line and "\\") or ("/" in line and "/") or "/" + return sep.join(chunks) diff --git a/tests/unit/create/via_global_ref/builtin/testing/py_info.py b/tests/unit/create/via_global_ref/builtin/testing/py_info.py new file mode 100644 index 000000000..27a660c4a --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/testing/py_info.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pathlib import Path + +from virtualenv.discovery.py_info import PythonInfo + + +def fixture_file(fixture_name): + file_mask = f"*{fixture_name}.json" + files = Path(__file__).parent.parent.rglob(file_mask) + try: + return next(files) + except StopIteration as exc: + # Fixture file was not found in the testing root and its subdirs. + error = FileNotFoundError + raise error(file_mask) from exc + + +def read_fixture(fixture_name): + fixture_json = fixture_file(fixture_name).read_text(encoding="utf-8") + return PythonInfo._from_json(fixture_json) # noqa: SLF001 diff --git a/tests/unit/create/via_global_ref/greet/setup.py b/tests/unit/create/via_global_ref/greet/setup.py index c1b48f6e8..2965915b4 100644 --- a/tests/unit/create/via_global_ref/greet/setup.py +++ b/tests/unit/create/via_global_ref/greet/setup.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys from setuptools import Extension, setup @@ -8,7 +10,7 @@ ext_modules=[ Extension( "greet", - ["greet{}.c".format(sys.version_info[0])], # extension to package + [f"greet{sys.version_info[0]}.c"], # extension to package ), # C code to compile to run as extension ], ) diff --git a/tests/unit/create/via_global_ref/test_api.py b/tests/unit/create/via_global_ref/test_api.py index ade64d4dd..a863b0e45 100644 --- a/tests/unit/create/via_global_ref/test_api.py +++ b/tests/unit/create/via_global_ref/test_api.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from virtualenv.create.via_global_ref import api diff --git a/tests/unit/create/via_global_ref/test_build_c_ext.py b/tests/unit/create/via_global_ref/test_build_c_ext.py index 0086cd1c8..db1c16eb0 100644 --- a/tests/unit/create/via_global_ref/test_build_c_ext.py +++ b/tests/unit/create/via_global_ref/test_build_c_ext.py @@ -1,15 +1,15 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os import shutil import subprocess +from pathlib import Path +from subprocess import Popen import pytest from virtualenv.discovery.py_info import PythonInfo from virtualenv.run import cli_run -from virtualenv.util.path import Path -from virtualenv.util.subprocess import Popen CURRENT = PythonInfo.current_system() CREATOR_CLASSES = CURRENT.creators().key_to_class @@ -27,7 +27,7 @@ def builtin_shows_marker_missing(): @pytest.mark.xfail( - condition=bool(os.environ.get(str("CI_RUN"))), + condition=bool(os.environ.get("CI_RUN")), strict=False, reason="did not manage to setup CI to run with VC 14.1 C++ compiler, but passes locally", ) @@ -35,7 +35,7 @@ def builtin_shows_marker_missing(): not Path(CURRENT.system_include).exists() and not builtin_shows_marker_missing(), reason="Building C-Extensions requires header files with host python", ) -@pytest.mark.parametrize("creator", list(i for i in CREATOR_CLASSES.keys() if i != "builtin")) +@pytest.mark.parametrize("creator", [i for i in CREATOR_CLASSES if i != "builtin"]) def test_can_build_c_extensions(creator, tmp_path, coverage_env): env, greet = tmp_path / "env", str(tmp_path / "greet") shutil.copytree(str(Path(__file__).parent.resolve() / "greet"), greet) @@ -58,6 +58,7 @@ def test_can_build_c_extensions(creator, tmp_path, coverage_env): [str(session.creator.exe), "-c", "import greet; greet.greet('World')"], universal_newlines=True, stdout=subprocess.PIPE, + encoding="utf-8", ) out, _ = process.communicate() assert process.returncode == 0 diff --git a/tests/unit/discovery/py_info/test_py_info.py b/tests/unit/discovery/py_info/test_py_info.py index c82e50613..7e9994fa3 100644 --- a/tests/unit/discovery/py_info/test_py_info.py +++ b/tests/unit/discovery/py_info/test_py_info.py @@ -1,28 +1,29 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import copy +import functools import itertools import json import logging import os import sys import sysconfig -from collections import namedtuple +from pathlib import Path from textwrap import dedent +from typing import NamedTuple import pytest from virtualenv.discovery import cached_py_info from virtualenv.discovery.py_info import PythonInfo, VersionInfo from virtualenv.discovery.py_spec import PythonSpec -from virtualenv.info import IS_PYPY, fs_supports_symlink -from virtualenv.util.path import Path +from virtualenv.info import IS_PYPY, IS_WIN, fs_supports_symlink CURRENT = PythonInfo.current_system() def test_current_as_json(): - result = CURRENT._to_json() + result = CURRENT._to_json() # noqa: SLF001 parsed = json.loads(result) a, b, c, d, e = sys.version_info assert parsed["version_info"] == {"major": a, "minor": b, "micro": c, "releaselevel": d, "serial": e} @@ -57,8 +58,8 @@ def test_bad_exe_py_info_no_raise(tmp_path, caplog, capsys, session_app_data): "spec", itertools.chain( [sys.executable], - list( - "{}{}{}".format(impl, ".".join(str(i) for i in ver), arch) + [ + f"{impl}{'.'.join(str(i) for i in ver)}{arch}" for impl, ver, arch in itertools.product( ( [CURRENT.implementation] @@ -70,9 +71,9 @@ def test_bad_exe_py_info_no_raise(tmp_path, caplog, capsys, session_app_data): ) ), [sys.version_info[0 : i + 1] for i in range(3)], - ["", "-{}".format(CURRENT.architecture)], + ["", f"-{CURRENT.architecture}"], ) - ), + ], ), ) def test_satisfy_py_info(spec): @@ -83,7 +84,7 @@ def test_satisfy_py_info(spec): def test_satisfy_not_arch(): parsed_spec = PythonSpec.from_string_spec( - "{}-{}".format(CURRENT.implementation, 64 if CURRENT.architecture == 32 else 32), + f"{CURRENT.implementation}-{64 if CURRENT.architecture == 32 else 32}", ) matches = CURRENT.satisfies(parsed_spec, True) assert matches is False @@ -106,7 +107,7 @@ def _generate_not_match_current_interpreter_version(): @pytest.mark.parametrize("spec", _NON_MATCH_VER) def test_satisfy_not_version(spec): - parsed_spec = PythonSpec.from_string_spec("{}{}".format(CURRENT.implementation, spec)) + parsed_spec = PythonSpec.from_string_spec(f"{CURRENT.implementation}{spec}") matches = CURRENT.satisfies(parsed_spec, True) assert matches is False @@ -132,7 +133,7 @@ def test_py_info_cached_symlink_error(mocker, tmp_path, session_app_data): assert spy.call_count == 2 -def test_py_info_cache_clear(mocker, tmp_path, session_app_data): +def test_py_info_cache_clear(mocker, session_app_data): spy = mocker.spy(cached_py_info, "_run_subprocess") result = PythonInfo.from_exe(sys.executable, session_app_data) assert result is not None @@ -143,6 +144,12 @@ def test_py_info_cache_clear(mocker, tmp_path, session_app_data): assert spy.call_count >= 2 * count +@pytest.mark.skipif(not fs_supports_symlink(), reason="symlink is not supported") +@pytest.mark.xfail( + # https://doc.pypy.org/en/latest/install.html?highlight=symlink#download-a-pre-built-pypy + IS_PYPY and IS_WIN and sys.version_info[0:2] >= (3, 9), + reason="symlink is not supported", +) @pytest.mark.skipif(not fs_supports_symlink(), reason="symlink is not supported") def test_py_info_cached_symlink(mocker, tmp_path, session_app_data): spy = mocker.spy(cached_py_info, "_run_subprocess") @@ -157,18 +164,21 @@ def test_py_info_cached_symlink(mocker, tmp_path, session_app_data): new_exe.symlink_to(sys.executable) pyvenv = Path(sys.executable).parents[1] / "pyvenv.cfg" if pyvenv.exists(): - (tmp_path / pyvenv.name).write_text(pyvenv.read_text()) + (tmp_path / pyvenv.name).write_text(pyvenv.read_text(encoding="utf-8"), encoding="utf-8") new_exe_str = str(new_exe) second_result = PythonInfo.from_exe(new_exe_str, session_app_data) assert second_result.executable == new_exe_str assert spy.call_count == count + 1 # no longer needed the host invocation, but the new symlink is must -PyInfoMock = namedtuple("PyInfoMock", ["implementation", "architecture", "version_info"]) +class PyInfoMock(NamedTuple): + implementation: str + architecture: int + version_info: VersionInfo @pytest.mark.parametrize( - "target, position, discovered", + ("target", "position", "discovered"), [ ( PyInfoMock("CPython", 64, VersionInfo(3, 6, 8, "final", 0)), @@ -196,7 +206,15 @@ def test_py_info_cached_symlink(mocker, tmp_path, session_app_data): ), ], ) -def test_system_executable_no_exact_match(target, discovered, position, tmp_path, mocker, caplog, session_app_data): +def test_system_executable_no_exact_match( # noqa: PLR0913 + target, + discovered, + position, + tmp_path, + mocker, + caplog, + session_app_data, +): """Here we should fallback to other compatible""" caplog.set_level(logging.DEBUG) @@ -212,7 +230,7 @@ def _make_py_info(of): selected = None for pos, i in enumerate(discovered): path = tmp_path / str(pos) - path.write_text("") + path.write_text("", encoding="utf-8") py_info = _make_py_info(i) py_info.system_executable = CURRENT.system_executable py_info.executable = CURRENT.system_executable @@ -226,8 +244,7 @@ def _make_py_info(of): mocker.patch.object(target_py_info, "_find_possible_exe_names", return_value=names) mocker.patch.object(target_py_info, "_find_possible_folders", return_value=[str(tmp_path)]) - # noinspection PyUnusedLocal - def func(k, app_data, resolve_to_host, raise_on_error, env): + def func(k, app_data, resolve_to_host, raise_on_error, env): # noqa: ARG001 return discovered_with_path[k] mocker.patch.object(target_py_info, "from_exe", side_effect=func) @@ -235,7 +252,7 @@ def func(k, app_data, resolve_to_host, raise_on_error, env): target_py_info.system_executable = None target_py_info.executable = str(tmp_path) - mapped = target_py_info._resolve_to_system(session_app_data, target_py_info) + mapped = target_py_info._resolve_to_system(session_app_data, target_py_info) # noqa: SLF001 assert mapped.system_executable == CURRENT.system_executable found = discovered_with_path[mapped.base_executable] assert found is selected @@ -251,31 +268,25 @@ def func(k, app_data, resolve_to_host, raise_on_error, env): def test_py_info_ignores_distutils_config(monkeypatch, tmp_path): - (tmp_path / "setup.cfg").write_text( - dedent( - """ - [install] - prefix={0}{1}prefix - install_purelib={0}{1}purelib - install_platlib={0}{1}platlib - install_headers={0}{1}headers - install_scripts={0}{1}scripts - install_data={0}{1}data - """.format( - tmp_path, - os.sep, - ), - ), - ) + raw = f""" + [install] + prefix={tmp_path}{os.sep}prefix + install_purelib={tmp_path}{os.sep}purelib + install_platlib={tmp_path}{os.sep}platlib + install_headers={tmp_path}{os.sep}headers + install_scripts={tmp_path}{os.sep}scripts + install_data={tmp_path}{os.sep}data + """ + (tmp_path / "setup.cfg").write_text(dedent(raw), encoding="utf-8") monkeypatch.chdir(tmp_path) py_info = PythonInfo.from_exe(sys.executable) distutils = py_info.distutils_install for key, value in distutils.items(): - assert not value.startswith(str(tmp_path)), "{}={}".format(key, value) + assert not value.startswith(str(tmp_path)), f"{key}={value}" def test_discover_exe_on_path_non_spec_name_match(mocker): - suffixed_name = "python{}.{}m".format(CURRENT.version_info.major, CURRENT.version_info.minor) + suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}m" if sys.platform == "win32": suffixed_name += Path(CURRENT.original_executable).suffix spec = PythonSpec.from_string_spec(suffixed_name) @@ -284,34 +295,35 @@ def test_discover_exe_on_path_non_spec_name_match(mocker): def test_discover_exe_on_path_non_spec_name_not_match(mocker): - suffixed_name = "python{}.{}m".format(CURRENT.version_info.major, CURRENT.version_info.minor) + suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}m" if sys.platform == "win32": suffixed_name += Path(CURRENT.original_executable).suffix spec = PythonSpec.from_string_spec(suffixed_name) mocker.patch.object( CURRENT, "original_executable", - str(Path(CURRENT.executable).parent / "e{}".format(suffixed_name)), + str(Path(CURRENT.executable).parent / f"e{suffixed_name}"), ) assert CURRENT.satisfies(spec, impl_must_match=True) is False -@pytest.mark.skipif(IS_PYPY, reason="setuptools distutil1s patching does not work") +@pytest.mark.skipif(IS_PYPY, reason="setuptools distutils patching does not work") def test_py_info_setuptools(): - from setuptools.dist import Distribution + from setuptools.dist import Distribution # noqa: PLC0415 assert Distribution PythonInfo() -def test_py_info_to_system_raises(session_app_data, mocker, caplog, skip_if_test_in_system): +@pytest.mark.usefixtures("_skip_if_test_in_system") +def test_py_info_to_system_raises(session_app_data, mocker, caplog): caplog.set_level(logging.DEBUG) mocker.patch.object(PythonInfo, "_find_possible_folders", return_value=[]) result = PythonInfo.from_exe(sys.executable, app_data=session_app_data, raise_on_error=False) assert result is None log = caplog.records[-1] assert log.levelno == logging.INFO - expected = "ignore {} due cannot resolve system due to RuntimeError('failed to detect ".format(sys.executable) + expected = f"ignore {sys.executable} due cannot resolve system due to RuntimeError('failed to detect " assert expected in log.message @@ -346,10 +358,8 @@ def test_custom_venv_install_scheme_is_prefered(mocker): "venv": venv_scheme, } if getattr(sysconfig, "get_preferred_scheme", None): - sysconfig_install_schemes[sysconfig.get_preferred_scheme("prefix")] = default_scheme - - if sys.version_info[0] == 2: - sysconfig_install_schemes = _stringify_schemes_dict(sysconfig_install_schemes) + # define the prefix as sysconfig.get_preferred_scheme did before 3.11 + sysconfig_install_schemes["nt" if os.name == "nt" else "posix_prefix"] = default_scheme # On Python < 3.10, the distutils schemes are not derived from sysconfig schemes # So we mock them as well to assert the custom "venv" install scheme has priority @@ -365,9 +375,6 @@ def test_custom_venv_install_scheme_is_prefered(mocker): "nt": distutils_scheme, } - if sys.version_info[0] == 2: - distutils_schemes = _stringify_schemes_dict(distutils_schemes) - # We need to mock distutils first, so they don't see the mocked sysconfig, # if imported for the first time. # That can happen if the actual interpreter has the "venv" INSTALL_SCHEME @@ -378,6 +385,99 @@ def test_custom_venv_install_scheme_is_prefered(mocker): mocker.patch("sysconfig._INSTALL_SCHEMES", sysconfig_install_schemes) pyinfo = PythonInfo() - pyver = "{}.{}".format(pyinfo.version_info.major, pyinfo.version_info.minor) + pyver = f"{pyinfo.version_info.major}.{pyinfo.version_info.minor}" + assert pyinfo.install_path("scripts") == "bin" + assert pyinfo.install_path("purelib").replace(os.sep, "/") == f"lib/python{pyver}/site-packages" + + +@pytest.mark.skipif(not (os.name == "posix" and sys.version_info[:2] >= (3, 11)), reason="POSIX 3.11+ specific") +def test_fallback_existent_system_executable(mocker): + current = PythonInfo() + # Posix may execute a "python" out of a venv but try to set the base_executable + # to "python" out of the system installation path. PEP 394 informs distributions + # that "python" is not required and the standard `make install` does not provide one + + # Falsify some data to look like we're in a venv + current.prefix = current.exec_prefix = "/tmp/tmp.izZNCyINRj/venv" # noqa: S108 + current.executable = current.original_executable = os.path.join(current.prefix, "bin/python") + + # Since we don't know if the distribution we're on provides python, use a binary that should not exist + mocker.patch.object(sys, "_base_executable", os.path.join(os.path.dirname(current.system_executable), "idontexist")) + mocker.patch.object(sys, "executable", current.executable) + + # ensure it falls back to an alternate binary name that exists + current._fast_get_system_executable() # noqa: SLF001 + assert os.path.basename(current.system_executable) in [ + f"python{v}" for v in (current.version_info.major, f"{current.version_info.major}.{current.version_info.minor}") + ] + assert os.path.exists(current.system_executable) + + +@pytest.mark.skipif(sys.version_info[:2] != (3, 10), reason="3.10 specific") +def test_uses_posix_prefix_on_debian_3_10_without_venv(mocker): + # this is taken from ubuntu 22.04 /usr/lib/python3.10/sysconfig.py + sysconfig_install_schemes = { + "posix_prefix": { + "stdlib": "{installed_base}/{platlibdir}/python{py_version_short}", + "platstdlib": "{platbase}/{platlibdir}/python{py_version_short}", + "purelib": "{base}/lib/python{py_version_short}/site-packages", + "platlib": "{platbase}/{platlibdir}/python{py_version_short}/site-packages", + "include": "{installed_base}/include/python{py_version_short}{abiflags}", + "platinclude": "{installed_platbase}/include/python{py_version_short}{abiflags}", + "scripts": "{base}/bin", + "data": "{base}", + }, + "posix_home": { + "stdlib": "{installed_base}/lib/python", + "platstdlib": "{base}/lib/python", + "purelib": "{base}/lib/python", + "platlib": "{base}/lib/python", + "include": "{installed_base}/include/python", + "platinclude": "{installed_base}/include/python", + "scripts": "{base}/bin", + "data": "{base}", + }, + "nt": { + "stdlib": "{installed_base}/Lib", + "platstdlib": "{base}/Lib", + "purelib": "{base}/Lib/site-packages", + "platlib": "{base}/Lib/site-packages", + "include": "{installed_base}/Include", + "platinclude": "{installed_base}/Include", + "scripts": "{base}/Scripts", + "data": "{base}", + }, + "deb_system": { + "stdlib": "{installed_base}/{platlibdir}/python{py_version_short}", + "platstdlib": "{platbase}/{platlibdir}/python{py_version_short}", + "purelib": "{base}/lib/python3/dist-packages", + "platlib": "{platbase}/{platlibdir}/python3/dist-packages", + "include": "{installed_base}/include/python{py_version_short}{abiflags}", + "platinclude": "{installed_platbase}/include/python{py_version_short}{abiflags}", + "scripts": "{base}/bin", + "data": "{base}", + }, + "posix_local": { + "stdlib": "{installed_base}/{platlibdir}/python{py_version_short}", + "platstdlib": "{platbase}/{platlibdir}/python{py_version_short}", + "purelib": "{base}/local/lib/python{py_version_short}/dist-packages", + "platlib": "{platbase}/local/lib/python{py_version_short}/dist-packages", + "include": "{installed_base}/local/include/python{py_version_short}{abiflags}", + "platinclude": "{installed_platbase}/local/include/python{py_version_short}{abiflags}", + "scripts": "{base}/local/bin", + "data": "{base}", + }, + } + # reset the default in case we're on a system which doesn't have this problem + sysconfig_get_path = functools.partial(sysconfig.get_path, scheme="posix_local") + + # make it look like python3-distutils is not available + mocker.patch.dict(sys.modules, {"distutils.command": None}) + mocker.patch("sysconfig._INSTALL_SCHEMES", sysconfig_install_schemes) + mocker.patch("sysconfig.get_path", sysconfig_get_path) + mocker.patch("sysconfig.get_default_scheme", return_value="posix_local") + + pyinfo = PythonInfo() + pyver = f"{pyinfo.version_info.major}.{pyinfo.version_info.minor}" assert pyinfo.install_path("scripts") == "bin" - assert pyinfo.install_path("purelib").replace(os.sep, "/") == "lib/python{}/site-packages".format(pyver) + assert pyinfo.install_path("purelib").replace(os.sep, "/") == f"lib/python{pyver}/site-packages" diff --git a/tests/unit/discovery/py_info/test_py_info_exe_based_of.py b/tests/unit/discovery/py_info/test_py_info_exe_based_of.py index f232e7aca..c843ca7be 100644 --- a/tests/unit/discovery/py_info/test_py_info_exe_based_of.py +++ b/tests/unit/discovery/py_info/test_py_info_exe_based_of.py @@ -1,18 +1,18 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import os +from pathlib import Path import pytest from virtualenv.discovery.py_info import EXTENSIONS, PythonInfo from virtualenv.info import IS_WIN, fs_is_case_sensitive, fs_supports_symlink -from virtualenv.util.path import Path CURRENT = PythonInfo.current() -def test_discover_empty_folder(tmp_path, monkeypatch, session_app_data): +def test_discover_empty_folder(tmp_path, session_app_data): with pytest.raises(RuntimeError): CURRENT.discover_exe(session_app_data, prefix=str(tmp_path)) @@ -26,19 +26,19 @@ def test_discover_empty_folder(tmp_path, monkeypatch, session_app_data): @pytest.mark.parametrize("arch", [CURRENT.architecture, ""]) @pytest.mark.parametrize("version", [".".join(str(i) for i in CURRENT.version_info[0:i]) for i in range(3, 0, -1)]) @pytest.mark.parametrize("impl", [CURRENT.implementation, "python"]) -def test_discover_ok(tmp_path, monkeypatch, suffix, impl, version, arch, into, caplog, session_app_data): +def test_discover_ok(tmp_path, suffix, impl, version, arch, into, caplog, session_app_data): # noqa: PLR0913 caplog.set_level(logging.DEBUG) folder = tmp_path / into folder.mkdir(parents=True, exist_ok=True) - name = "{}{}".format(impl, version) + name = f"{impl}{version}" if arch: - name += "-{}".format(arch) + name += f"-{arch}" name += suffix dest = folder / name os.symlink(CURRENT.executable, str(dest)) pyvenv = Path(CURRENT.executable).parents[1] / "pyvenv.cfg" if pyvenv.exists(): - (folder / pyvenv.name).write_text(pyvenv.read_text()) + (folder / pyvenv.name).write_text(pyvenv.read_text(encoding="utf-8"), encoding="utf-8") inside_folder = str(tmp_path) base = CURRENT.discover_exe(session_app_data, inside_folder) found = base.executable @@ -51,6 +51,6 @@ def test_discover_ok(tmp_path, monkeypatch, suffix, impl, version, arch, into, c assert "get interpreter info via cmd: " in caplog.text dest.rename(dest.parent / (dest.name + "-1")) - CURRENT._cache_exe_discovery.clear() + CURRENT._cache_exe_discovery.clear() # noqa: SLF001 with pytest.raises(RuntimeError): CURRENT.discover_exe(session_app_data, inside_folder) diff --git a/tests/unit/discovery/test_discovery.py b/tests/unit/discovery/test_discovery.py index c04caea2b..680131e7d 100644 --- a/tests/unit/discovery/test_discovery.py +++ b/tests/unit/discovery/test_discovery.py @@ -1,9 +1,10 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import os import sys from argparse import Namespace +from pathlib import Path from uuid import uuid4 import pytest @@ -11,43 +12,62 @@ from virtualenv.discovery.builtin import Builtin, get_interpreter from virtualenv.discovery.py_info import PythonInfo from virtualenv.info import fs_supports_symlink -from virtualenv.util.path import Path -from virtualenv.util.six import ensure_text @pytest.mark.skipif(not fs_supports_symlink(), reason="symlink not supported") @pytest.mark.parametrize("case", ["mixed", "lower", "upper"]) -def test_discovery_via_path(monkeypatch, case, tmp_path, caplog, session_app_data): +@pytest.mark.parametrize("specificity", ["more", "less", "none"]) +def test_discovery_via_path(monkeypatch, case, specificity, tmp_path, caplog, session_app_data): # noqa: PLR0913 caplog.set_level(logging.DEBUG) current = PythonInfo.current_system(session_app_data) - core = "somethingVeryCryptic{}".format(".".join(str(i) for i in current.version_info[0:3])) name = "somethingVeryCryptic" if case == "lower": name = name.lower() elif case == "upper": name = name.upper() - exe_name = "{}{}{}".format(name, current.version_info.major, ".exe" if sys.platform == "win32" else "") + if specificity == "more": + # e.g. spec: python3, exe: /bin/python3.12 + core_ver = current.version_info.major + exe_ver = ".".join(str(i) for i in current.version_info[0:2]) + elif specificity == "less": + # e.g. spec: python3.12.1, exe: /bin/python3 + core_ver = ".".join(str(i) for i in current.version_info[0:3]) + exe_ver = current.version_info.major + elif specificity == "none": + # e.g. spec: python3.12.1, exe: /bin/python + core_ver = ".".join(str(i) for i in current.version_info[0:3]) + exe_ver = "" + core = "" if specificity == "none" else f"{name}{core_ver}" + exe_name = f"{name}{exe_ver}{'.exe' if sys.platform == 'win32' else ''}" target = tmp_path / current.install_path("scripts") target.mkdir(parents=True) executable = target / exe_name - os.symlink(sys.executable, ensure_text(str(executable))) + os.symlink(sys.executable, str(executable)) pyvenv_cfg = Path(sys.executable).parents[1] / "pyvenv.cfg" if pyvenv_cfg.exists(): (target / pyvenv_cfg.name).write_bytes(pyvenv_cfg.read_bytes()) - new_path = os.pathsep.join([str(target)] + os.environ.get(str("PATH"), str("")).split(os.pathsep)) - monkeypatch.setenv(str("PATH"), new_path) + new_path = os.pathsep.join([str(target), *os.environ.get("PATH", "").split(os.pathsep)]) + monkeypatch.setenv("PATH", new_path) interpreter = get_interpreter(core, []) assert interpreter is not None def test_discovery_via_path_not_found(tmp_path, monkeypatch): - monkeypatch.setenv(str("PATH"), str(tmp_path)) + monkeypatch.setenv("PATH", str(tmp_path)) interpreter = get_interpreter(uuid4().hex, []) assert interpreter is None -def test_relative_path(tmp_path, session_app_data, monkeypatch): +def test_discovery_via_path_in_nonbrowseable_directory(tmp_path, monkeypatch): + bad_perm = tmp_path / "bad_perm" + bad_perm.mkdir(mode=0o000) + monkeypatch.setenv("PATH", str(bad_perm / "bin")) + interpreter = get_interpreter(uuid4().hex, []) + assert interpreter is None + + +def test_relative_path(session_app_data, monkeypatch): sys_executable = Path(PythonInfo.current_system(app_data=session_app_data).system_executable) cwd = sys_executable.parents[1] monkeypatch.chdir(str(cwd)) @@ -59,7 +79,7 @@ def test_relative_path(tmp_path, session_app_data, monkeypatch): def test_discovery_fallback_fail(session_app_data, caplog): caplog.set_level(logging.DEBUG) builtin = Builtin( - Namespace(app_data=session_app_data, try_first_with=[], python=["magic-one", "magic-two"], env=os.environ) + Namespace(app_data=session_app_data, try_first_with=[], python=["magic-one", "magic-two"], env=os.environ), ) result = builtin.run() @@ -71,7 +91,7 @@ def test_discovery_fallback_fail(session_app_data, caplog): def test_discovery_fallback_ok(session_app_data, caplog): caplog.set_level(logging.DEBUG) builtin = Builtin( - Namespace(app_data=session_app_data, try_first_with=[], python=["magic-one", sys.executable], env=os.environ) + Namespace(app_data=session_app_data, try_first_with=[], python=["magic-one", sys.executable], env=os.environ), ) result = builtin.run() diff --git a/tests/unit/discovery/test_py_spec.py b/tests/unit/discovery/test_py_spec.py index 52154f14c..765686645 100644 --- a/tests/unit/discovery/test_py_spec.py +++ b/tests/unit/discovery/test_py_spec.py @@ -1,6 +1,5 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -import itertools import sys from copy import copy @@ -16,8 +15,8 @@ def test_bad_py_spec(): assert spec.str_spec == text assert spec.path == text content = vars(spec) - del content[str("str_spec")] - del content[str("path")] + del content["str_spec"] + del content["path"] assert all(v is None for v in content.values()) @@ -47,8 +46,8 @@ def test_spec_satisfies_arch(): @pytest.mark.parametrize( - "req, spec", - list(itertools.combinations(["py", "CPython", "python"], 2)) + [("jython", "jython")] + [("CPython", "cpython")], + ("req", "spec"), + [("py", "python"), ("jython", "jython"), ("CPython", "cpython")], ) def test_spec_satisfies_implementation_ok(req, spec): spec_1 = PythonSpec.from_string_spec(req) @@ -58,7 +57,7 @@ def test_spec_satisfies_implementation_ok(req, spec): def test_spec_satisfies_implementation_nok(): - spec_1 = PythonSpec.from_string_spec("python") + spec_1 = PythonSpec.from_string_spec("cpython") spec_2 = PythonSpec.from_string_spec("jython") assert spec_2.satisfies(spec_1) is False assert spec_1.satisfies(spec_2) is False @@ -77,10 +76,10 @@ def _version_satisfies_pairs(): return sorted(target) -@pytest.mark.parametrize("req, spec", _version_satisfies_pairs()) +@pytest.mark.parametrize(("req", "spec"), _version_satisfies_pairs()) def test_version_satisfies_ok(req, spec): - req_spec = PythonSpec.from_string_spec("python{}".format(req)) - sat_spec = PythonSpec.from_string_spec("python{}".format(spec)) + req_spec = PythonSpec.from_string_spec(f"python{req}") + sat_spec = PythonSpec.from_string_spec(f"python{spec}") assert sat_spec.satisfies(req_spec) is True @@ -102,10 +101,10 @@ def _version_not_satisfies_pairs(): return sorted(target) -@pytest.mark.parametrize("req, spec", _version_not_satisfies_pairs()) +@pytest.mark.parametrize(("req", "spec"), _version_not_satisfies_pairs()) def test_version_satisfies_nok(req, spec): - req_spec = PythonSpec.from_string_spec("python{}".format(req)) - sat_spec = PythonSpec.from_string_spec("python{}".format(spec)) + req_spec = PythonSpec.from_string_spec(f"python{req}") + sat_spec = PythonSpec.from_string_spec(f"python{spec}") assert sat_spec.satisfies(req_spec) is False diff --git a/tests/unit/discovery/windows/conftest.py b/tests/unit/discovery/windows/conftest.py new file mode 100644 index 000000000..21c16891c --- /dev/null +++ b/tests/unit/discovery/windows/conftest.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path + +import pytest + + +@pytest.fixture +def _mock_registry(mocker): # noqa: C901 + from virtualenv.discovery.windows.pep514 import winreg # noqa: PLC0415 + + loc, glob = {}, {} + mock_value_str = (Path(__file__).parent / "winreg-mock-values.py").read_text(encoding="utf-8") + exec(mock_value_str, glob, loc) # noqa: S102 + enum_collect = loc["enum_collect"] + value_collect = loc["value_collect"] + key_open = loc["key_open"] + hive_open = loc["hive_open"] + + def _enum_key(key, at): + key_id = key.value if isinstance(key, Key) else key + result = enum_collect[key_id][at] + if isinstance(result, OSError): + raise result + return result + + mocker.patch.object(winreg, "EnumKey", side_effect=_enum_key) + + def _query_value_ex(key, value_name): + key_id = key.value if isinstance(key, Key) else key + result = value_collect[key_id][value_name] + if isinstance(result, OSError): + raise result + return result + + mocker.patch.object(winreg, "QueryValueEx", side_effect=_query_value_ex) + + class Key: + def __init__(self, value) -> None: + self.value = value + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return None + + @contextmanager + def _open_key_ex(*args): + if len(args) == 2: + key, value = args + key_id = key.value if isinstance(key, Key) else key + result = Key(key_open[key_id][value]) # this needs to be something that can be with-ed, so let's wrap it + elif len(args) == 4: + result = hive_open[args] + else: + raise RuntimeError + value = result.value if isinstance(result, Key) else result + if isinstance(value, OSError): + raise value + yield result + + mocker.patch.object(winreg, "OpenKeyEx", side_effect=_open_key_ex) + mocker.patch("os.path.exists", return_value=True) + + +def _mock_pyinfo(major, minor, arch, exe): + """Return PythonInfo objects with essential metadata set for the given args""" + from virtualenv.discovery.py_info import PythonInfo, VersionInfo # noqa: PLC0415 + + info = PythonInfo() + info.base_prefix = str(Path(exe).parent) + info.executable = info.original_executable = info.system_executable = exe + info.implementation = "CPython" + info.architecture = arch + info.version_info = VersionInfo(major, minor, 0, "final", 0) + return info + + +@pytest.fixture +def _populate_pyinfo_cache(monkeypatch): + """Add metadata to virtualenv.discovery.cached_py_info._CACHE for all (mocked) registry entries""" + import virtualenv.discovery.cached_py_info # noqa: PLC0415 + + # Data matches _mock_registry fixture + interpreters = [ + ("ContinuumAnalytics", 3, 10, 32, "C:\\Users\\user\\Miniconda3\\python.exe", None), + ("ContinuumAnalytics", 3, 10, 64, "C:\\Users\\user\\Miniconda3-64\\python.exe", None), + ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None), + ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None), + ("PythonCore", 3, 5, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python35\\python.exe", None), + ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None), + ("PythonCore", 3, 7, 32, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python37-32\\python.exe", None), + ("PythonCore", 3, 12, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", None), + ("PythonCore", 2, 7, 64, "C:\\Python27\\python.exe", None), + ("PythonCore", 3, 4, 64, "C:\\Python34\\python.exe", None), + ("CompanyA", 3, 6, 64, "Z:\\CompanyA\\Python\\3.6\\python.exe", None), + ] + for _, major, minor, arch, exe, _ in interpreters: + info = _mock_pyinfo(major, minor, arch, exe) + monkeypatch.setitem(virtualenv.discovery.cached_py_info._CACHE, Path(info.executable), info) # noqa: SLF001 diff --git a/tests/unit/discovery/windows/test_windows.py b/tests/unit/discovery/windows/test_windows.py new file mode 100644 index 000000000..aca2afc14 --- /dev/null +++ b/tests/unit/discovery/windows/test_windows.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import sys + +import pytest + +from virtualenv.discovery.py_spec import PythonSpec + + +@pytest.mark.skipif(sys.platform != "win32", reason="no Windows registry") +@pytest.mark.usefixtures("_mock_registry") +@pytest.mark.usefixtures("_populate_pyinfo_cache") +@pytest.mark.parametrize( + ("string_spec", "expected_exe"), + [ + # 64-bit over 32-bit + ("python3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"), + ("cpython3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"), + # 1 installation of 3.9 available + ("python3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), + ("cpython3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), + # resolves to highest available version + ("python", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), + ("cpython", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), + # Non-standard org name + ("python3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"), + ("cpython3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"), + ], +) +def test_propose_interpreters(string_spec, expected_exe): + from virtualenv.discovery.windows import propose_interpreters # noqa: PLC0415 + + spec = PythonSpec.from_string_spec(string_spec) + interpreter = next(propose_interpreters(spec=spec, cache_dir=None, env=None)) + assert interpreter.executable == expected_exe diff --git a/tests/unit/discovery/windows/test_windows_pep514.py b/tests/unit/discovery/windows/test_windows_pep514.py index 70c85093e..3b1c46984 100644 --- a/tests/unit/discovery/windows/test_windows_pep514.py +++ b/tests/unit/discovery/windows/test_windows_pep514.py @@ -1,196 +1,65 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import sys import textwrap -from collections import defaultdict -from contextlib import contextmanager import pytest -import six - -from virtualenv.util.path import Path @pytest.mark.skipif(sys.platform != "win32", reason="no Windows registry") -def test_pep514(_mock_registry): - from virtualenv.discovery.windows.pep514 import discover_pythons +@pytest.mark.usefixtures("_mock_registry") +def test_pep514(): + from virtualenv.discovery.windows.pep514 import discover_pythons # noqa: PLC0415 interpreters = list(discover_pythons()) assert interpreters == [ - ("ContinuumAnalytics", 3, 7, 32, "C:\\Users\\user\\Miniconda3\\python.exe", None), - ("ContinuumAnalytics", 3, 7, 64, "C:\\Users\\user\\Miniconda3-64\\python.exe", None), - ("PythonCore", 3, 6, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None), - ("PythonCore", 3, 6, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None), - ("PythonCore", 3, 5, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python35\\python.exe", None), - ("PythonCore", 3, 6, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None), - ("PythonCore", 3, 7, 32, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python37-32\\python.exe", None), - ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", None), + ("ContinuumAnalytics", 3, 10, 32, "C:\\Users\\user\\Miniconda3\\python.exe", None), + ("ContinuumAnalytics", 3, 10, 64, "C:\\Users\\user\\Miniconda3-64\\python.exe", None), + ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None), + ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None), + ("PythonCore", 3, 8, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe", None), + ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None), + ("PythonCore", 3, 10, 32, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe", None), + ("PythonCore", 3, 12, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", None), + ("CompanyA", 3, 6, 64, "Z:\\CompanyA\\Python\\3.6\\python.exe", None), ("PythonCore", 2, 7, 64, "C:\\Python27\\python.exe", None), - ("PythonCore", 3, 4, 64, "C:\\Python34\\python.exe", None), + ("PythonCore", 3, 7, 64, "C:\\Python37\\python.exe", None), ] @pytest.mark.skipif(sys.platform != "win32", reason="no Windows registry") -def test_pep514_run(_mock_registry, capsys, caplog): - from virtualenv.discovery.windows import pep514 +@pytest.mark.usefixtures("_mock_registry") +def test_pep514_run(capsys, caplog): + from virtualenv.discovery.windows import pep514 # noqa: PLC0415 - pep514._run() + pep514._run() # noqa: SLF001 out, err = capsys.readouterr() expected = textwrap.dedent( r""" - ('ContinuumAnalytics', 3, 7, 32, 'C:\\Users\\user\\Miniconda3\\python.exe', None) - ('ContinuumAnalytics', 3, 7, 64, 'C:\\Users\\user\\Miniconda3-64\\python.exe', None) + ('CompanyA', 3, 6, 64, 'Z:\\CompanyA\\Python\\3.6\\python.exe', None) + ('ContinuumAnalytics', 3, 10, 32, 'C:\\Users\\user\\Miniconda3\\python.exe', None) + ('ContinuumAnalytics', 3, 10, 64, 'C:\\Users\\user\\Miniconda3-64\\python.exe', None) ('PythonCore', 2, 7, 64, 'C:\\Python27\\python.exe', None) - ('PythonCore', 3, 4, 64, 'C:\\Python34\\python.exe', None) - ('PythonCore', 3, 5, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python35\\python.exe', None) - ('PythonCore', 3, 6, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe', None) - ('PythonCore', 3, 6, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe', None) - ('PythonCore', 3, 6, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe', None) - ('PythonCore', 3, 7, 32, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python37-32\\python.exe', None) - ('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe', None) + ('PythonCore', 3, 10, 32, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe', None) + ('PythonCore', 3, 12, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe', None) + ('PythonCore', 3, 7, 64, 'C:\\Python37\\python.exe', None) + ('PythonCore', 3, 8, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', None) + ('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None) + ('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None) + ('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None) """, ).strip() assert out.strip() == expected assert not err prefix = "PEP-514 violation in Windows Registry at " expected_logs = [ - "{}HKEY_CURRENT_USER/PythonCore/3.1/SysArchitecture error: invalid format magic".format(prefix), - "{}HKEY_CURRENT_USER/PythonCore/3.2/SysArchitecture error: arch is not string: 100".format(prefix), - "{}HKEY_CURRENT_USER/PythonCore/3.3 error: no ExecutablePath or default for it".format(prefix), - "{}HKEY_CURRENT_USER/PythonCore/3.3 error: could not load exe with value None".format(prefix), - "{}HKEY_CURRENT_USER/PythonCore/3.8/InstallPath error: missing".format(prefix), - "{}HKEY_CURRENT_USER/PythonCore/3.9/SysVersion error: invalid format magic".format(prefix), - "{}HKEY_CURRENT_USER/PythonCore/3.X/SysVersion error: version is not string: 2778".format(prefix), - "{}HKEY_CURRENT_USER/PythonCore/3.X error: invalid format 3.X".format(prefix), + f"{prefix}HKEY_CURRENT_USER/PythonCore/3.1/SysArchitecture error: invalid format magic", + f"{prefix}HKEY_CURRENT_USER/PythonCore/3.2/SysArchitecture error: arch is not string: 100", + f"{prefix}HKEY_CURRENT_USER/PythonCore/3.3 error: no ExecutablePath or default for it", + f"{prefix}HKEY_CURRENT_USER/PythonCore/3.3 error: could not load exe with value None", + f"{prefix}HKEY_CURRENT_USER/PythonCore/3.11/InstallPath error: missing", + f"{prefix}HKEY_CURRENT_USER/PythonCore/3.12/SysVersion error: invalid format magic", + f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X/SysVersion error: version is not string: 2778", + f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X error: invalid format 3.X", ] assert caplog.messages == expected_logs - - -@pytest.fixture() -def _mock_registry(mocker): - from virtualenv.discovery.windows.pep514 import winreg - - loc, glob = {}, {} - mock_value_str = (Path(__file__).parent / "winreg-mock-values.py").read_text() - six.exec_(mock_value_str, glob, loc) - enum_collect = loc["enum_collect"] - value_collect = loc["value_collect"] - key_open = loc["key_open"] - hive_open = loc["hive_open"] - - def _e(key, at): - key_id = key.value if isinstance(key, Key) else key - result = enum_collect[key_id][at] - if isinstance(result, OSError): - raise result - return result - - mocker.patch.object(winreg, "EnumKey", side_effect=_e) - - def _v(key, value_name): - key_id = key.value if isinstance(key, Key) else key - result = value_collect[key_id][value_name] - if isinstance(result, OSError): - raise result - return result - - mocker.patch.object(winreg, "QueryValueEx", side_effect=_v) - - class Key(object): - def __init__(self, value): - self.value = value - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - return None - - @contextmanager - def _o(*args): - if len(args) == 2: - key, value = args - key_id = key.value if isinstance(key, Key) else key - result = Key(key_open[key_id][value]) # this needs to be something that can be with-ed, so let's wrap it - elif len(args) == 4: - result = hive_open[args] - else: - raise RuntimeError - value = result.value if isinstance(result, Key) else result - if isinstance(value, OSError): - raise value - yield result - - mocker.patch.object(winreg, "OpenKeyEx", side_effect=_o) - mocker.patch("os.path.exists", return_value=True) - - -@pytest.fixture() -def _collect_winreg_access(mocker): - if six.PY3: - # noinspection PyUnresolvedReferences - from winreg import EnumKey, OpenKeyEx, QueryValueEx - else: - # noinspection PyUnresolvedReferences - from _winreg import EnumKey, OpenKeyEx, QueryValueEx - from virtualenv.discovery.windows.pep514 import winreg - - hive_open = {} - key_open = defaultdict(dict) - - @contextmanager - def _c(*args): - res = None - key_id = id(args[0]) if len(args) == 2 else None - try: - with OpenKeyEx(*args) as c: - res = id(c) - yield c - except Exception as exception: - res = exception - raise exception - finally: - if len(args) == 4: - hive_open[args] = res - elif len(args) == 2: - key_open[key_id][args[1]] = res - - enum_collect = defaultdict(list) - - def _e(key, at): - result = None - key_id = id(key) - try: - result = EnumKey(key, at) - return result - except Exception as exception: - result = exception - raise result - finally: - enum_collect[key_id].append(result) - - value_collect = defaultdict(dict) - - def _v(key, value_name): - result = None - key_id = id(key) - try: - result = QueryValueEx(key, value_name) - return result - except Exception as exception: - result = exception - raise result - finally: - value_collect[key_id][value_name] = result - - mocker.patch.object(winreg, "EnumKey", side_effect=_e) - mocker.patch.object(winreg, "QueryValueEx", side_effect=_v) - mocker.patch.object(winreg, "OpenKeyEx", side_effect=_c) - - yield - - print("") - print("hive_open = {}".format(hive_open)) - print("key_open = {}".format(dict(key_open.items()))) - print("value_collect = {}".format(dict(value_collect.items()))) - print("enum_collect = {}".format(dict(enum_collect.items()))) diff --git a/tests/unit/discovery/windows/winreg-mock-values.py b/tests/unit/discovery/windows/winreg-mock-values.py index 4e5054965..da76c56b9 100644 --- a/tests/unit/discovery/windows/winreg-mock-values.py +++ b/tests/unit/discovery/windows/winreg-mock-values.py @@ -1,10 +1,6 @@ -import six +from __future__ import annotations -if six.PY3: - import winreg -else: - # noinspection PyUnresolvedReferences - import _winreg as winreg +import winreg hive_open = { (winreg.HKEY_CURRENT_USER, "Software\\Python", 0, winreg.KEY_READ): 78701856, @@ -16,12 +12,12 @@ } key_open = { 78701152: { - "Anaconda37-32\\InstallPath": 78703200, - "Anaconda37-32": 78703568, - "Anaconda37-64\\InstallPath": 78703520, - "Anaconda37-64": 78702368, + "Anaconda310-32\\InstallPath": 78703200, + "Anaconda310-32": 78703568, + "Anaconda310-64\\InstallPath": 78703520, + "Anaconda310-64": 78702368, }, - 78701856: {"ContinuumAnalytics": 78701152, "PythonCore": 78702656}, + 78701856: {"ContinuumAnalytics": 78701152, "PythonCore": 78702656, "CompanyA": 88800000}, 78702656: { "3.1\\InstallPath": 78701824, "3.1": 78700704, @@ -29,43 +25,47 @@ "3.2": 78704368, "3.3\\InstallPath": 78701936, "3.3": 78703024, - "3.5\\InstallPath": 78703792, - "3.5": 78701792, - "3.6\\InstallPath": 78701888, - "3.6": 78703424, - "3.7-32\\InstallPath": 78703600, - "3.7-32": 78704512, - "3.8\\InstallPath": OSError(2, "The system cannot find the file specified"), - "3.8": 78700656, - "3.9\\InstallPath": 78703632, - "3.9": 78702608, + "3.8\\InstallPath": 78703792, + "3.8": 78701792, + "3.9\\InstallPath": 78701888, + "3.9": 78703424, + "3.10-32\\InstallPath": 78703600, + "3.10-32": 78704512, + "3.11\\InstallPath": OSError(2, "The system cannot find the file specified"), + "3.11": 78700656, + "3.12\\InstallPath": 78703632, + "3.12": 78702608, "3.X": 78703088, }, - 78702960: {"2.7\\InstallPath": 78700912, "2.7": 78703136, "3.4\\InstallPath": 78703648, "3.4": 78704032}, + 78702960: {"2.7\\InstallPath": 78700912, "2.7": 78703136, "3.7\\InstallPath": 78703648, "3.7": 78704032}, 78701840: {"PythonCore": 78702960}, + 88800000: { + "3.6\\InstallPath": 88810000, + "3.6": 88820000, + }, } value_collect = { - 78703568: {"SysVersion": ("3.7", 1), "SysArchitecture": ("32bit", 1)}, + 78703568: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1)}, 78703200: { "ExecutablePath": ("C:\\Users\\user\\Miniconda3\\python.exe", 1), "ExecutableArguments": OSError(2, "The system cannot find the file specified"), }, - 78702368: {"SysVersion": ("3.7", 1), "SysArchitecture": ("64bit", 1)}, + 78702368: {"SysVersion": ("3.10", 1), "SysArchitecture": ("64bit", 1)}, 78703520: { "ExecutablePath": ("C:\\Users\\user\\Miniconda3-64\\python.exe", 1), "ExecutableArguments": OSError(2, "The system cannot find the file specified"), }, - 78700704: {"SysVersion": ("3.6", 1), "SysArchitecture": ("magic", 1)}, + 78700704: {"SysVersion": ("3.9", 1), "SysArchitecture": ("magic", 1)}, 78701824: { - "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", 1), + "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", 1), "ExecutableArguments": OSError(2, "The system cannot find the file specified"), }, - 78704368: {"SysVersion": ("3.6", 1), "SysArchitecture": (100, 4)}, + 78704368: {"SysVersion": ("3.9", 1), "SysArchitecture": (100, 4)}, 78704048: { - "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", 1), + "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", 1), "ExecutableArguments": OSError(2, "The system cannot find the file specified"), }, - 78703024: {"SysVersion": ("3.6", 1), "SysArchitecture": ("64bit", 1)}, + 78703024: {"SysVersion": ("3.9", 1), "SysArchitecture": ("64bit", 1)}, 78701936: { "ExecutablePath": OSError(2, "The system cannot find the file specified"), None: OSError(2, "The system cannot find the file specified"), @@ -75,17 +75,17 @@ "SysArchitecture": OSError(2, "The system cannot find the file specified"), }, 78703792: { - "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python35\\python.exe", 1), + "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe", 1), "ExecutableArguments": OSError(2, "The system cannot find the file specified"), }, - 78703424: {"SysVersion": ("3.6", 1), "SysArchitecture": ("64bit", 1)}, + 78703424: {"SysVersion": ("3.9", 1), "SysArchitecture": ("64bit", 1)}, 78701888: { - "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", 1), + "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", 1), "ExecutableArguments": OSError(2, "The system cannot find the file specified"), }, - 78704512: {"SysVersion": ("3.7", 1), "SysArchitecture": ("32bit", 1)}, + 78704512: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1)}, 78703600: { - "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python37-32\\python.exe", 1), + "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe", 1), "ExecutableArguments": OSError(2, "The system cannot find the file specified"), }, 78700656: { @@ -94,7 +94,7 @@ }, 78702608: {"SysVersion": ("magic", 1), "SysArchitecture": ("64bit", 1)}, 78703632: { - "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python36\\python.exe", 1), + "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", 1), "ExecutableArguments": OSError(2, "The system cannot find the file specified"), }, 78703088: {"SysVersion": (2778, 11)}, @@ -113,25 +113,36 @@ }, 78703648: { "ExecutablePath": OSError(2, "The system cannot find the file specified"), - None: ("C:\\Python34\\", 1), + None: ("C:\\Python37\\", 1), + "ExecutableArguments": OSError(2, "The system cannot find the file specified"), + }, + 88810000: { + "ExecutablePath": ("Z:\\CompanyA\\Python\\3.6\\python.exe", 1), "ExecutableArguments": OSError(2, "The system cannot find the file specified"), }, + 88820000: {"SysVersion": ("3.6", 1), "SysArchitecture": ("64bit", 1)}, } enum_collect = { - 78701856: ["ContinuumAnalytics", "PythonCore", OSError(22, "No more data is available", None, 259, None)], - 78701152: ["Anaconda37-32", "Anaconda37-64", OSError(22, "No more data is available", None, 259, None)], + 78701856: [ + "ContinuumAnalytics", + "PythonCore", + "CompanyA", + OSError(22, "No more data is available", None, 259, None), + ], + 78701152: ["Anaconda310-32", "Anaconda310-64", OSError(22, "No more data is available", None, 259, None)], 78702656: [ "3.1", "3.2", "3.3", - "3.5", - "3.6", - "3.7-32", "3.8", "3.9", + "3.10-32", + "3.11", + "3.12", "3.X", OSError(22, "No more data is available", None, 259, None), ], 78701840: ["PyLauncher", "PythonCore", OSError(22, "No more data is available", None, 259, None)], - 78702960: ["2.7", "3.4", OSError(22, "No more data is available", None, 259, None)], + 78702960: ["2.7", "3.7", OSError(22, "No more data is available", None, 259, None)], + 88800000: ["3.6", OSError(22, "No more data is available", None, 259, None)], } diff --git a/tests/unit/seed/embed/test_base_embed.py b/tests/unit/seed/embed/test_base_embed.py index 01c70ea5e..255e03177 100644 --- a/tests/unit/seed/embed/test_base_embed.py +++ b/tests/unit/seed/embed/test_base_embed.py @@ -1,12 +1,30 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + import pytest from virtualenv.run import session_via_cli +if TYPE_CHECKING: + from pathlib import Path + @pytest.mark.parametrize( - "args, download", + ("args", "download"), [([], False), (["--no-download"], False), (["--never-download"], False), (["--download"], True)], ) def test_download_cli_flag(args, download, tmp_path): - session = session_via_cli(args + [str(tmp_path)]) + session = session_via_cli([*args, str(tmp_path)]) assert session.seeder.download is download + + +def test_embed_wheel_versions(tmp_path: Path) -> None: + session = session_via_cli([str(tmp_path)]) + expected = ( + {"pip": "bundle"} + if sys.version_info[:2] >= (3, 12) + else {"pip": "bundle", "setuptools": "bundle", "wheel": "bundle"} + ) + assert session.seeder.distribution_to_versions() == expected diff --git a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py index fdbd4d6bc..4fd3d30c9 100644 --- a/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py +++ b/tests/unit/seed/embed/test_bootstrap_link_via_app_data.py @@ -1,11 +1,12 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import contextlib import os -import subprocess import sys from stat import S_IWGRP, S_IWOTH, S_IWUSR +from subprocess import Popen, check_call from threading import Thread +from typing import TYPE_CHECKING import pytest @@ -15,8 +16,11 @@ from virtualenv.run import cli_run from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, BUNDLE_SUPPORT from virtualenv.util.path import safe_delete -from virtualenv.util.six import ensure_text -from virtualenv.util.subprocess import Popen + +if TYPE_CHECKING: + from pathlib import Path + + from pytest_mock import MockerFixture @pytest.mark.slow @@ -25,12 +29,12 @@ def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies) current = PythonInfo.current_system() bundle_ver = BUNDLE_SUPPORT[current.version_release_str] create_cmd = [ - ensure_text(str(tmp_path / "en v")), # space in the name to ensure generated scripts work when path has space + str(tmp_path / "en v"), # space in the name to ensure generated scripts work when path has space "--no-periodic-update", "--seeder", "app-data", "--extra-search-dir", - ensure_text(str(BUNDLE_FOLDER)), + str(BUNDLE_FOLDER), "--download", "--pip", bundle_ver["pip"].split("-")[1], @@ -56,16 +60,16 @@ def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies) assert pip in files_post_first_create assert setuptools in files_post_first_create for pip_exe in [ - result.creator.script_dir / "pip{}{}".format(suffix, result.creator.exe.suffix) + result.creator.script_dir / f"pip{suffix}{result.creator.exe.suffix}" for suffix in ( "", - "{}".format(current.version_info.major), - "{}.{}".format(current.version_info.major, current.version_info.minor), - "-{}.{}".format(current.version_info.major, current.version_info.minor), + f"{current.version_info.major}", + f"{current.version_info.major}.{current.version_info.minor}", + f"-{current.version_info.major}.{current.version_info.minor}", ) ]: assert pip_exe.exists() - process = Popen([ensure_text(str(pip_exe)), "--version", "--disable-pip-version-check"]) + process = Popen([str(pip_exe), "--version", "--disable-pip-version-check"]) _, __ = process.communicate() assert not process.returncode @@ -87,7 +91,7 @@ def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies) assert setuptools not in files_post_first_uninstall # install a different setuptools to test that virtualenv removes this before installing new - version = "setuptools<{}".format(bundle_ver["setuptools"].split("-")[1]) + version = f"setuptools<{bundle_ver['setuptools'].split('-')[1]}" install_cmd = [str(result.creator.script("pip")), "--verbose", "--disable-pip-version-check", "install", version] process = Popen(install_cmd) process.communicate() @@ -106,20 +110,17 @@ def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies) # Windows does not allow removing a executable while running it, so when uninstalling pip we need to do it via # python -m pip remove_cmd = [str(result.creator.exe), "-m", "pip"] + remove_cmd[1:] - process = Popen(remove_cmd + ["pip", "wheel"]) + process = Popen([*remove_cmd, "pip", "wheel"]) _, __ = process.communicate() assert not process.returncode # pip is greedy here, removing all packages removes the site-package too if site_package.exists(): purelib = result.creator.purelib - patch_files = {purelib / "{}.{}".format("_virtualenv", i) for i in ("py", "pyc", "pth")} + patch_files = {purelib / f"{'_virtualenv'}.{i}" for i in ("py", "pyc", "pth")} patch_files.add(purelib / "__pycache__") post_run = set(site_package.iterdir()) - patch_files assert not post_run, "\n".join(str(i) for i in post_run) - if sys.version_info[0:2] == (3, 4) and os.environ.get(str("PIP_REQ_TRACKER")): - os.environ.pop(str("PIP_REQ_TRACKER")) - @contextlib.contextmanager def read_only_dir(d): @@ -127,19 +128,19 @@ def read_only_dir(d): for root, _, filenames in os.walk(str(d)): os.chmod(root, os.stat(root).st_mode & ~write) for filename in filenames: - filename = os.path.join(root, filename) - os.chmod(filename, os.stat(filename).st_mode & ~write) + name = os.path.join(root, filename) + os.chmod(name, os.stat(name).st_mode & ~write) try: yield finally: for root, _, filenames in os.walk(str(d)): os.chmod(root, os.stat(root).st_mode | write) for filename in filenames: - filename = os.path.join(root, filename) - os.chmod(filename, os.stat(filename).st_mode | write) + name = os.path.join(root, filename) + os.chmod(name, os.stat(name).st_mode | write) -@pytest.fixture() +@pytest.fixture def read_only_app_data(temp_app_data): temp_app_data.mkdir() with read_only_dir(temp_app_data): @@ -147,14 +148,15 @@ def read_only_app_data(temp_app_data): @pytest.mark.skipif(sys.platform == "win32", reason="Windows only applies R/O to files") -def test_base_bootstrap_link_via_app_data_not_writable(tmp_path, current_fastest, read_only_app_data, monkeypatch): +@pytest.mark.usefixtures("read_only_app_data") +def test_base_bootstrap_link_via_app_data_not_writable(tmp_path, current_fastest): dest = tmp_path / "venv" result = cli_run(["--seeder", "app-data", "--creator", current_fastest, "-vv", str(dest)]) assert result @pytest.mark.skipif(sys.platform == "win32", reason="Windows only applies R/O to files") -def test_populated_read_only_cache_and_symlinked_app_data(tmp_path, current_fastest, temp_app_data, monkeypatch): +def test_populated_read_only_cache_and_symlinked_app_data(tmp_path, current_fastest, temp_app_data): dest = tmp_path / "venv" cmd = [ "--seeder", @@ -167,19 +169,19 @@ def test_populated_read_only_cache_and_symlinked_app_data(tmp_path, current_fast ] assert cli_run(cmd) - subprocess.check_call((str(dest.joinpath("bin/python")), "-c", "import pip")) + check_call((str(dest.joinpath("bin/python")), "-c", "import pip")) - cached_py_info._CACHE.clear() # necessary to re-trigger py info discovery + cached_py_info._CACHE.clear() # necessary to re-trigger py info discovery # noqa: SLF001 safe_delete(dest) # should succeed with special flag when read-only with read_only_dir(temp_app_data): - assert cli_run(["--read-only-app-data"] + cmd) - subprocess.check_call((str(dest.joinpath("bin/python")), "-c", "import pip")) + assert cli_run(["--read-only-app-data", *cmd]) + check_call((str(dest.joinpath("bin/python")), "-c", "import pip")) @pytest.mark.skipif(sys.platform == "win32", reason="Windows only applies R/O to files") -def test_populated_read_only_cache_and_copied_app_data(tmp_path, current_fastest, temp_app_data, monkeypatch): +def test_populated_read_only_cache_and_copied_app_data(tmp_path, current_fastest, temp_app_data): dest = tmp_path / "venv" cmd = [ "--seeder", @@ -194,30 +196,33 @@ def test_populated_read_only_cache_and_copied_app_data(tmp_path, current_fastest assert cli_run(cmd) - cached_py_info._CACHE.clear() # necessary to re-trigger py info discovery + cached_py_info._CACHE.clear() # necessary to re-trigger py info discovery # noqa: SLF001 safe_delete(dest) # should succeed with special flag when read-only with read_only_dir(temp_app_data): - assert cli_run(["--read-only-app-data"] + cmd) + assert cli_run(["--read-only-app-data", *cmd]) @pytest.mark.slow @pytest.mark.parametrize("pkg", ["pip", "setuptools", "wheel"]) -def test_base_bootstrap_link_via_app_data_no(tmp_path, coverage_env, current_fastest, session_app_data, pkg): - create_cmd = [str(tmp_path), "--seeder", "app-data", "--no-{}".format(pkg)] +@pytest.mark.usefixtures("session_app_data", "current_fastest", "coverage_env") +def test_base_bootstrap_link_via_app_data_no(tmp_path, pkg): + create_cmd = [str(tmp_path), "--seeder", "app-data", f"--no-{pkg}", "--wheel", "bundle", "--setuptools", "bundle"] result = cli_run(create_cmd) assert not (result.creator.purelib / pkg).exists() for key in {"pip", "setuptools", "wheel"} - {pkg}: assert (result.creator.purelib / key).exists() -def test_app_data_parallel_ok(tmp_path, temp_app_data): +@pytest.mark.usefixtures("temp_app_data") +def test_app_data_parallel_ok(tmp_path): exceptions = _run_parallel_threads(tmp_path) assert not exceptions, "\n".join(exceptions) -def test_app_data_parallel_fail(tmp_path, temp_app_data, mocker): +@pytest.mark.usefixtures("temp_app_data") +def test_app_data_parallel_fail(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch("virtualenv.seed.embed.via_app_data.pip_install.base.PipInstall.build_image", side_effect=RuntimeError) exceptions = _run_parallel_threads(tmp_path) assert len(exceptions) == 2 @@ -231,12 +236,12 @@ def _run_parallel_threads(tmp_path): def _run(name): try: - cli_run(["--seeder", "app-data", str(tmp_path / name), "--no-pip", "--no-setuptools"]) - except Exception as exception: # noqa + cli_run(["--seeder", "app-data", str(tmp_path / name), "--no-pip", "--no-setuptools", "--wheel", "bundle"]) + except Exception as exception: # noqa: BLE001 as_str = str(exception) exceptions.append(as_str) - threads = [Thread(target=_run, args=("env{}".format(i),)) for i in range(1, 3)] + threads = [Thread(target=_run, args=(f"env{i}",)) for i in range(1, 3)] for thread in threads: thread.start() for thread in threads: diff --git a/tests/unit/seed/embed/test_pip_invoke.py b/tests/unit/seed/embed/test_pip_invoke.py index f2e7a33ef..d8c243e57 100644 --- a/tests/unit/seed/embed/test_pip_invoke.py +++ b/tests/unit/seed/embed/test_pip_invoke.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import itertools import sys @@ -14,18 +14,18 @@ @pytest.mark.slow @pytest.mark.parametrize("no", ["pip", "setuptools", "wheel", ""]) -def test_base_bootstrap_via_pip_invoke(tmp_path, coverage_env, mocker, current_fastest, no): +def test_base_bootstrap_via_pip_invoke(tmp_path, coverage_env, mocker, current_fastest, no): # noqa: C901 extra_search_dir = tmp_path / "extra" extra_search_dir.mkdir() - for_py_version = "{}.{}".format(*sys.version_info[0:2]) + for_py_version = f"{sys.version_info.major}.{sys.version_info.minor}" new = BUNDLE_SUPPORT[for_py_version] for wheel_filename in BUNDLE_SUPPORT[for_py_version].values(): copy2(str(BUNDLE_FOLDER / wheel_filename), str(extra_search_dir)) - def _load_embed_wheel(app_data, distribution, for_py_version, version): + def _load_embed_wheel(app_data, distribution, for_py_version, version): # noqa: ARG001 return load_embed_wheel(app_data, distribution, old_ver, version) - old_ver = "2.7" + old_ver = "3.8" old = BUNDLE_SUPPORT[old_ver] mocker.patch("virtualenv.seed.wheels.bundle.load_embed_wheel", side_effect=_load_embed_wheel) @@ -34,14 +34,12 @@ def _execute(cmd, env): for distribution, with_version in versions.items(): if distribution == no: continue - if with_version == "embed": - expected.add(BUNDLE_FOLDER) - elif old[distribution] == new[distribution]: + if with_version == "embed" or old[distribution] == new[distribution]: expected.add(BUNDLE_FOLDER) else: expected.add(extra_search_dir) expected_list = list( - itertools.chain.from_iterable(["--find-links", str(e)] for e in sorted(expected, key=lambda x: str(x))), + itertools.chain.from_iterable(["--find-links", str(e)] for e in sorted(expected, key=str)), ) found = cmd[-len(expected_list) :] if expected_list else [] assert "--no-index" not in cmd @@ -49,7 +47,7 @@ def _execute(cmd, env): assert found == expected_list return original(cmd, env) - original = PipInvoke._execute + original = PipInvoke._execute # noqa: SLF001 run = mocker.patch.object(PipInvoke, "_execute", side_effect=_execute) versions = {"pip": "embed", "setuptools": "bundle", "wheel": new["wheel"].split("-")[1]} @@ -66,9 +64,9 @@ def _execute(cmd, env): str(tmp_path / "app-data"), ] for dist, version in versions.items(): - create_cmd.extend(["--{}".format(dist), version]) + create_cmd.extend([f"--{dist}", version]) if no: - create_cmd.append("--no-{}".format(no)) + create_cmd.append(f"--no-{no}") result = cli_run(create_cmd) coverage_env() diff --git a/tests/unit/seed/wheels/test_acquire.py b/tests/unit/seed/wheels/test_acquire.py index dcce0f6ac..20979775b 100644 --- a/tests/unit/seed/wheels/test_acquire.py +++ b/tests/unit/seed/wheels/test_acquire.py @@ -1,23 +1,28 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os import sys -from datetime import datetime +from datetime import datetime, timezone +from pathlib import Path from subprocess import CalledProcessError +from typing import TYPE_CHECKING, Callable import pytest from virtualenv.app_data import AppDataDiskFolder -from virtualenv.info import IS_PYPY, PY2 from virtualenv.seed.wheels.acquire import download_wheel, get_wheel, pip_wheel_env_run from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, get_embed_wheel from virtualenv.seed.wheels.periodic_update import dump_datetime from virtualenv.seed.wheels.util import Wheel, discover_wheels -from virtualenv.util.path import Path + +if TYPE_CHECKING: + from unittest.mock import MagicMock + + from pytest_mock import MockerFixture @pytest.fixture(autouse=True) -def fake_release_date(mocker): +def _fake_release_date(mocker): mocker.patch("virtualenv.seed.wheels.periodic_update.release_date_for_wheel_path", return_value=None) @@ -41,7 +46,13 @@ def test_download_wheel_bad_output(mocker, for_py_version, session_app_data): as_path.iterdir.return_value = [i.path for i in available] result = download_wheel( - distribution, "=={}".format(embed.version), for_py_version, [], session_app_data, as_path, os.environ + distribution, + f"=={embed.version}", + for_py_version, + [], + session_app_data, + as_path, + os.environ, ) assert result.path == embed.path @@ -54,13 +65,10 @@ def test_download_fails(mocker, for_py_version, session_app_data): as_path = mocker.MagicMock() with pytest.raises(CalledProcessError) as context: - download_wheel("pip", "==1", for_py_version, [], session_app_data, as_path, os.environ), + download_wheel("pip", "==1", for_py_version, [], session_app_data, as_path, os.environ) exc = context.value - if sys.version_info < (3, 5): - assert exc.output == "outerr" - else: - assert exc.output == "out" - assert exc.stderr == "err" + assert exc.output == "out" + assert exc.stderr == "err" assert exc.returncode == 1 assert [ sys.executable, @@ -83,7 +91,7 @@ def test_download_fails(mocker, for_py_version, session_app_data): @pytest.fixture def downloaded_wheel(mocker): wheel = Wheel.from_path(Path("setuptools-0.0.0-py2.py3-none-any.whl")) - yield wheel, mocker.patch("virtualenv.seed.wheels.acquire.download_wheel", return_value=wheel) + return wheel, mocker.patch("virtualenv.seed.wheels.acquire.download_wheel", return_value=wheel) @pytest.mark.parametrize("version", ["bundle", "0.0.0"]) @@ -111,9 +119,15 @@ def test_get_wheel_download_not_called(mocker, for_py_version, session_app_data, assert write.call_count == 0 -@pytest.mark.skipif(IS_PYPY and PY2, reason="mocker.spy failing on PyPy 2.x") -def test_get_wheel_download_cached(tmp_path, freezer, mocker, for_py_version, downloaded_wheel): - from virtualenv.app_data.via_disk_folder import JSONStoreDisk +def test_get_wheel_download_cached( + tmp_path: Path, + mocker: MockerFixture, + for_py_version: str, + downloaded_wheel: tuple[Wheel, MagicMock], + time_freeze: Callable[[datetime], None], +) -> None: + time_freeze(datetime.now(tz=timezone.utc)) + from virtualenv.app_data.via_disk_folder import JSONStoreDisk # noqa: PLC0415 app_data = AppDataDiskFolder(folder=str(tmp_path)) expected = downloaded_wheel[0] @@ -139,7 +153,7 @@ def test_get_wheel_download_cached(tmp_path, freezer, mocker, for_py_version, do { "filename": expected.name, "release_date": None, - "found_date": dump_datetime(datetime.now()), + "found_date": dump_datetime(datetime.now(tz=timezone.utc)), "source": "download", }, ], diff --git a/tests/unit/seed/wheels/test_acquire_find_wheel.py b/tests/unit/seed/wheels/test_acquire_find_wheel.py index 18c46927b..7822849e5 100644 --- a/tests/unit/seed/wheels/test_acquire_find_wheel.py +++ b/tests/unit/seed/wheels/test_acquire_find_wheel.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import pytest @@ -6,25 +6,24 @@ from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, MAX, get_embed_wheel -def test_find_latest(for_py_version): +def test_find_latest_none(for_py_version): result = find_compatible_in_house("setuptools", None, for_py_version, BUNDLE_FOLDER) expected = get_embed_wheel("setuptools", for_py_version) assert result.path == expected.path -def test_find_exact(for_py_version): +def test_find_latest_string(for_py_version): + result = find_compatible_in_house("setuptools", "", for_py_version, BUNDLE_FOLDER) expected = get_embed_wheel("setuptools", for_py_version) - result = find_compatible_in_house("setuptools", "=={}".format(expected.version), for_py_version, BUNDLE_FOLDER) assert result.path == expected.path -def test_find_less_than(for_py_version): - latest = get_embed_wheel("setuptools", MAX) - result = find_compatible_in_house("setuptools", "<{}".format(latest.version), MAX, BUNDLE_FOLDER) - assert result is not None - assert result.path != latest.path +def test_find_exact(for_py_version): + expected = get_embed_wheel("setuptools", for_py_version) + result = find_compatible_in_house("setuptools", f"=={expected.version}", for_py_version, BUNDLE_FOLDER) + assert result.path == expected.path -def test_find_bad_spec(for_py_version): +def test_find_bad_spec(): with pytest.raises(ValueError, match="bad"): find_compatible_in_house("setuptools", "bad", MAX, BUNDLE_FOLDER) diff --git a/tests/unit/seed/wheels/test_bundle.py b/tests/unit/seed/wheels/test_bundle.py index 767a2b49f..d0e95cf31 100644 --- a/tests/unit/seed/wheels/test_bundle.py +++ b/tests/unit/seed/wheels/test_bundle.py @@ -1,7 +1,8 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import os -from datetime import datetime +from datetime import datetime, timezone +from pathlib import Path import pytest @@ -10,7 +11,6 @@ from virtualenv.seed.wheels.embed import get_embed_wheel from virtualenv.seed.wheels.periodic_update import dump_datetime from virtualenv.seed.wheels.util import Version, Wheel -from virtualenv.util.path import Path @pytest.fixture(scope="module") @@ -25,7 +25,7 @@ def next_pip_wheel(for_py_version): @pytest.fixture(scope="module") def app_data(tmp_path_factory, for_py_version, next_pip_wheel): temp_folder = tmp_path_factory.mktemp("module-app-data") - now = dump_datetime(datetime.now()) + now = dump_datetime(datetime.now(tz=timezone.utc)) app_data_ = AppDataDiskFolder(str(temp_folder)) app_data_.embed_update_log("pip", for_py_version).write( { @@ -38,11 +38,11 @@ def app_data(tmp_path_factory, for_py_version, next_pip_wheel): "found_date": "2000-01-01T00:00:00.000000Z", "release_date": "2000-01-01T00:00:00.000000Z", "source": "periodic", - } + }, ], - } + }, ) - yield app_data_ + return app_data_ def test_version_embed(app_data, for_py_version): diff --git a/tests/unit/seed/wheels/test_periodic_update.py b/tests/unit/seed/wheels/test_periodic_update.py index 7adb3593c..3172bc8e6 100644 --- a/tests/unit/seed/wheels/test_periodic_update.py +++ b/tests/unit/seed/wheels/test_periodic_update.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import json import os @@ -6,13 +6,14 @@ import sys from collections import defaultdict from contextlib import contextmanager -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from io import StringIO +from itertools import zip_longest +from pathlib import Path from textwrap import dedent +from urllib.error import URLError import pytest -from six import StringIO -from six.moves import zip_longest -from six.moves.urllib.error import URLError from virtualenv import cli_run from virtualenv.app_data import AppDataDiskFolder @@ -29,22 +30,33 @@ release_date_for_wheel_path, trigger_update, ) -from virtualenv.util.path import Path from virtualenv.util.subprocess import CREATE_NO_WINDOW @pytest.fixture(autouse=True) -def clear_pypi_info_cache(): - from virtualenv.seed.wheels.periodic_update import _PYPI_CACHE +def _clear_pypi_info_cache(): + from virtualenv.seed.wheels.periodic_update import _PYPI_CACHE # noqa: PLC0415 _PYPI_CACHE.clear() def test_manual_upgrade(session_app_data, caplog, mocker, for_py_version): wheel = get_embed_wheel("pip", for_py_version) - new_version = NewVersion(wheel.path, datetime.now(), datetime.now() - timedelta(days=20), "manual") + new_version = NewVersion( + wheel.path, + datetime.now(tz=timezone.utc), + datetime.now(tz=timezone.utc) - timedelta(days=20), + "manual", + ) - def _do_update(distribution, for_py_version, embed_filename, app_data, search_dirs, periodic): # noqa + def _do_update( # noqa: PLR0913 + distribution, + for_py_version, # noqa: ARG001 + embed_filename, # noqa: ARG001 + app_data, # noqa: ARG001 + search_dirs, # noqa: ARG001 + periodic, # noqa: ARG001 + ): if distribution == "pip": return [new_version] return [] @@ -58,37 +70,51 @@ def _do_update(distribution, for_py_version, embed_filename, app_data, search_di assert " new entries found:\n" in caplog.text assert "\tNewVersion(" in caplog.text packages = defaultdict(list) - for i in do_update_mock.call_args_list: - packages[i[1]["distribution"]].append(i[1]["for_py_version"]) + for args in do_update_mock.call_args_list: + packages[args[1]["distribution"]].append(args[1]["for_py_version"]) packages = {key: sorted(value) for key, value in packages.items()} - versions = list(sorted(BUNDLE_SUPPORT.keys())) + versions = sorted(BUNDLE_SUPPORT.keys()) expected = {"setuptools": versions, "wheel": versions, "pip": versions} assert packages == expected -def test_pick_periodic_update(tmp_path, session_app_data, mocker, for_py_version): - embed, current = get_embed_wheel("setuptools", "3.5"), get_embed_wheel("setuptools", for_py_version) +@pytest.mark.usefixtures("session_app_data") +def test_pick_periodic_update(tmp_path, mocker, for_py_version): + embed, current = get_embed_wheel("setuptools", "3.6"), get_embed_wheel("setuptools", for_py_version) mocker.patch("virtualenv.seed.wheels.bundle.load_embed_wheel", return_value=embed) - completed = datetime.now() - timedelta(days=29) + completed = datetime.now(tz=timezone.utc) - timedelta(days=29) u_log = UpdateLog( - started=datetime.now() - timedelta(days=30), + started=datetime.now(tz=timezone.utc) - timedelta(days=30), completed=completed, versions=[NewVersion(filename=current.path, found_date=completed, release_date=completed, source="periodic")], periodic=True, ) read_dict = mocker.patch("virtualenv.app_data.via_disk_folder.JSONStoreDisk.read", return_value=u_log.to_dict()) - result = cli_run([str(tmp_path), "--activators", "", "--no-periodic-update", "--no-wheel", "--no-pip"]) + result = cli_run( + [ + str(tmp_path), + "--activators", + "", + "--no-periodic-update", + "--no-wheel", + "--no-pip", + "--setuptools", + "bundle", + "--wheel", + "bundle", + ], + ) assert read_dict.call_count == 1 - installed = list(i.name for i in result.creator.purelib.iterdir() if i.suffix == ".dist-info") - assert "setuptools-{}.dist-info".format(current.version) in installed + installed = [i.name for i in result.creator.purelib.iterdir() if i.suffix == ".dist-info"] + assert f"setuptools-{current.version}.dist-info" in installed def test_periodic_update_stops_at_current(mocker, session_app_data, for_py_version): current = get_embed_wheel("setuptools", for_py_version) - now, completed = datetime.now(), datetime.now() - timedelta(days=29) + now, completed = datetime.now(tz=timezone.utc), datetime.now(tz=timezone.utc) - timedelta(days=29) u_log = UpdateLog( started=completed, completed=completed, @@ -108,7 +134,7 @@ def test_periodic_update_stops_at_current(mocker, session_app_data, for_py_versi def test_periodic_update_latest_per_patch(mocker, session_app_data, for_py_version): current = get_embed_wheel("setuptools", for_py_version) expected_path = wheel_path(current, (0, 1, 2)) - now = datetime.now() + now = datetime.now(tz=timezone.utc) completed = now - timedelta(hours=2) u_log = UpdateLog( started=completed, @@ -129,7 +155,7 @@ def test_periodic_update_latest_per_patch(mocker, session_app_data, for_py_versi def test_periodic_update_latest_per_patch_prev_is_manual(mocker, session_app_data, for_py_version): current = get_embed_wheel("setuptools", for_py_version) expected_path = wheel_path(current, (0, 1, 2)) - now = datetime.now() + now = datetime.now(tz=timezone.utc) completed = now - timedelta(hours=2) u_log = UpdateLog( started=completed, @@ -151,7 +177,7 @@ def test_periodic_update_latest_per_patch_prev_is_manual(mocker, session_app_dat def test_manual_update_honored(mocker, session_app_data, for_py_version): current = get_embed_wheel("setuptools", for_py_version) expected_path = wheel_path(current, (0, 1, 1)) - now = datetime.now() + now = datetime.now(tz=timezone.utc) completed = now u_log = UpdateLog( started=completed, @@ -176,7 +202,7 @@ def wheel_path(wheel, of, pre_release=""): return str(wheel.path.parent / new_name) -_UP_NOW = datetime.now() +_UP_NOW = datetime.now(tz=timezone.utc) _UPDATE_SKIP = { "started_just_now_no_complete": UpdateLog(started=_UP_NOW, completed=None, versions=[], periodic=True), "started_1_hour_no_complete": UpdateLog( @@ -207,8 +233,8 @@ def wheel_path(wheel, of, pre_release=""): @pytest.mark.parametrize("u_log", list(_UPDATE_SKIP.values()), ids=list(_UPDATE_SKIP.keys())) -def test_periodic_update_skip(u_log, mocker, for_py_version, session_app_data, freezer): - freezer.move_to(_UP_NOW) +def test_periodic_update_skip(u_log, mocker, for_py_version, session_app_data, time_freeze): + time_freeze(_UP_NOW) mocker.patch("virtualenv.app_data.via_disk_folder.JSONStoreDisk.read", return_value=u_log.to_dict()) mocker.patch("virtualenv.seed.wheels.periodic_update.trigger_update", side_effect=RuntimeError) @@ -234,8 +260,8 @@ def test_periodic_update_skip(u_log, mocker, for_py_version, session_app_data, f @pytest.mark.parametrize("u_log", list(_UPDATE_YES.values()), ids=list(_UPDATE_YES.keys())) -def test_periodic_update_trigger(u_log, mocker, for_py_version, session_app_data, freezer): - freezer.move_to(_UP_NOW) +def test_periodic_update_trigger(u_log, mocker, for_py_version, session_app_data, time_freeze): + time_freeze(_UP_NOW) mocker.patch("virtualenv.app_data.via_disk_folder.JSONStoreDisk.read", return_value=u_log.to_dict()) write = mocker.patch("virtualenv.app_data.via_disk_folder.JSONStoreDisk.write") trigger_update_ = mocker.patch("virtualenv.seed.wheels.periodic_update.trigger_update") @@ -251,14 +277,20 @@ def test_periodic_update_trigger(u_log, mocker, for_py_version, session_app_data def test_trigger_update_no_debug(for_py_version, session_app_data, tmp_path, mocker, monkeypatch): - monkeypatch.delenv(str("_VIRTUALENV_PERIODIC_UPDATE_INLINE"), raising=False) + monkeypatch.delenv("_VIRTUALENV_PERIODIC_UPDATE_INLINE", raising=False) current = get_embed_wheel("setuptools", for_py_version) process = mocker.MagicMock() process.communicate.return_value = None, None - Popen = mocker.patch("virtualenv.seed.wheels.periodic_update.Popen", return_value=process) + Popen = mocker.patch("virtualenv.seed.wheels.periodic_update.Popen", return_value=process) # noqa: N806 trigger_update( - "setuptools", for_py_version, current, [tmp_path / "a", tmp_path / "b"], session_app_data, os.environ, True + "setuptools", + for_py_version, + current, + [tmp_path / "a", tmp_path / "b"], + session_app_data, + os.environ, + True, ) assert Popen.call_count == 1 @@ -284,7 +316,7 @@ def test_trigger_update_no_debug(for_py_version, session_app_data, tmp_path, moc ) assert args == ([sys.executable, "-c", cmd],) - expected = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE} + expected = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} if sys.platform == "win32": expected["creationflags"] = CREATE_NO_WINDOW assert kwargs == expected @@ -292,15 +324,21 @@ def test_trigger_update_no_debug(for_py_version, session_app_data, tmp_path, moc def test_trigger_update_debug(for_py_version, session_app_data, tmp_path, mocker, monkeypatch): - monkeypatch.setenv(str("_VIRTUALENV_PERIODIC_UPDATE_INLINE"), str("1")) + monkeypatch.setenv("_VIRTUALENV_PERIODIC_UPDATE_INLINE", "1") current = get_embed_wheel("pip", for_py_version) process = mocker.MagicMock() process.communicate.return_value = None, None - Popen = mocker.patch("virtualenv.seed.wheels.periodic_update.Popen", return_value=process) + Popen = mocker.patch("virtualenv.seed.wheels.periodic_update.Popen", return_value=process) # noqa: N806 trigger_update( - "pip", for_py_version, current, [tmp_path / "a", tmp_path / "b"], session_app_data, os.environ, False + "pip", + for_py_version, + current, + [tmp_path / "a", tmp_path / "b"], + session_app_data, + os.environ, + False, ) assert Popen.call_count == 1 @@ -330,8 +368,8 @@ def test_trigger_update_debug(for_py_version, session_app_data, tmp_path, mocker assert process.communicate.call_count == 1 -def test_do_update_first(tmp_path, mocker, freezer): - freezer.move_to(_UP_NOW) +def test_do_update_first(tmp_path, mocker, time_freeze): + time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) extra = tmp_path / "extra" @@ -346,7 +384,15 @@ def test_do_update_first(tmp_path, mocker, freezer): ] download_wheels = (Wheel(Path(i[0])) for i in pip_version_remote) - def _download_wheel(distribution, version_spec, for_py_version, search_dirs, app_data, to_folder, env): + def _download_wheel( # noqa: PLR0913 + distribution, + version_spec, # noqa: ARG001 + for_py_version, + search_dirs, + app_data, + to_folder, + env, # noqa: ARG001 + ): assert distribution == "pip" assert for_py_version == "3.9" assert [str(i) for i in search_dirs] == [str(extra)] @@ -400,14 +446,22 @@ def _release(of, context): } -def test_do_update_skip_already_done(tmp_path, mocker, freezer): - freezer.move_to(_UP_NOW + timedelta(hours=1)) +def test_do_update_skip_already_done(tmp_path, mocker, time_freeze): + time_freeze(_UP_NOW + timedelta(hours=1)) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) extra = tmp_path / "extra" extra.mkdir() - def _download_wheel(distribution, version_spec, for_py_version, search_dirs, app_data, to_folder, env): # noqa + def _download_wheel( # noqa: PLR0913 + distribution, # noqa: ARG001 + version_spec, # noqa: ARG001 + for_py_version, # noqa: ARG001 + search_dirs, # noqa: ARG001 + app_data, # noqa: ARG001 + to_folder, # noqa: ARG001 + env, # noqa: ARG001 + ): return wheel.path download_wheel = mocker.patch("virtualenv.seed.wheels.acquire.download_wheel", side_effect=_download_wheel) @@ -448,13 +502,17 @@ def _download_wheel(distribution, version_spec, for_py_version, search_dirs, app def test_new_version_eq(): - value = NewVersion("a", datetime.now(), datetime.now(), "periodic") - assert value == value + now = datetime.now(tz=timezone.utc) + value = NewVersion("a", now, now, "periodic") + assert value == NewVersion("a", now, now, "periodic") def test_new_version_ne(): - assert NewVersion("a", datetime.now(), datetime.now(), "periodic") != NewVersion( - "a", datetime.now(), datetime.now() + timedelta(hours=1), "manual" + assert NewVersion("a", datetime.now(tz=timezone.utc), datetime.now(tz=timezone.utc), "periodic") != NewVersion( + "a", + datetime.now(tz=timezone.utc), + datetime.now(tz=timezone.utc) + timedelta(hours=1), + "manual", ) @@ -463,7 +521,8 @@ def test_get_release_unsecure(mocker, caplog): def _release(of, context): assert of == "https://pypi.org/pypi/pip/json" if context is None: - raise URLError("insecure") + msg = "insecure" + raise URLError(msg) assert context yield StringIO(json.dumps({"releases": {"20.1": [{"upload_time": "2020-12-22T12:12:12"}]}})) @@ -471,7 +530,7 @@ def _release(of, context): result = release_date_for_wheel_path(Path("pip-20.1.whl")) - assert result == datetime(year=2020, month=12, day=22, hour=12, minute=12, second=12) + assert result == datetime(year=2020, month=12, day=22, hour=12, minute=12, second=12, tzinfo=timezone.utc) assert url_o.call_count == 2 assert "insecure" in caplog.text assert " failed " in caplog.text @@ -497,11 +556,14 @@ def download(): yield Wheel(Path(path)) do = download() - return mocker.patch("virtualenv.seed.wheels.acquire.download_wheel", side_effect=lambda *a, **k: next(do)) + return mocker.patch( + "virtualenv.seed.wheels.acquire.download_wheel", + side_effect=lambda *a, **k: next(do), # noqa: ARG005 + ) -def test_download_stop_with_embed(tmp_path, mocker, freezer): - freezer.move_to(_UP_NOW) +def test_download_stop_with_embed(tmp_path, mocker, time_freeze): + time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) pip_version_remote = [wheel_path(wheel, (0, 0, 2)), wheel_path(wheel, (0, 0, 1)), wheel_path(wheel, (-1, 0, 0))] @@ -523,8 +585,8 @@ def test_download_stop_with_embed(tmp_path, mocker, freezer): assert write.call_count == 1 -def test_download_manual_stop_after_one_download(tmp_path, mocker, freezer): - freezer.move_to(_UP_NOW) +def test_download_manual_stop_after_one_download(tmp_path, mocker, time_freeze): + time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) pip_version_remote = [wheel_path(wheel, (0, 1, 1))] @@ -545,8 +607,8 @@ def test_download_manual_stop_after_one_download(tmp_path, mocker, freezer): assert write.call_count == 1 -def test_download_manual_ignores_pre_release(tmp_path, mocker, freezer): - freezer.move_to(_UP_NOW) +def test_download_manual_ignores_pre_release(tmp_path, mocker, time_freeze): + time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) pip_version_remote = [wheel_path(wheel, (0, 0, 1))] @@ -578,8 +640,8 @@ def test_download_manual_ignores_pre_release(tmp_path, mocker, freezer): ] -def test_download_periodic_stop_at_first_usable(tmp_path, mocker, freezer): - freezer.move_to(_UP_NOW) +def test_download_periodic_stop_at_first_usable(tmp_path, mocker, time_freeze): + time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) pip_version_remote = [wheel_path(wheel, (0, 1, 1)), wheel_path(wheel, (0, 1, 0))] @@ -590,7 +652,7 @@ def test_download_periodic_stop_at_first_usable(tmp_path, mocker, freezer): rel_date_gen = iter(rel_date_remote) release_date = mocker.patch( "virtualenv.seed.wheels.periodic_update.release_date_for_wheel_path", - side_effect=lambda *a, **k: next(rel_date_gen), + side_effect=lambda *a, **k: next(rel_date_gen), # noqa: ARG005 ) last_update = _UP_NOW - timedelta(days=14) @@ -606,8 +668,8 @@ def test_download_periodic_stop_at_first_usable(tmp_path, mocker, freezer): assert write.call_count == 1 -def test_download_periodic_stop_at_first_usable_with_previous_minor(tmp_path, mocker, freezer): - freezer.move_to(_UP_NOW) +def test_download_periodic_stop_at_first_usable_with_previous_minor(tmp_path, mocker, time_freeze): + time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) pip_version_remote = [wheel_path(wheel, (0, 1, 1)), wheel_path(wheel, (0, 1, 0)), wheel_path(wheel, (0, -1, 0))] @@ -622,7 +684,7 @@ def test_download_periodic_stop_at_first_usable_with_previous_minor(tmp_path, mo rel_date_gen = iter(rel_date_remote) release_date = mocker.patch( "virtualenv.seed.wheels.periodic_update.release_date_for_wheel_path", - side_effect=lambda *a, **k: next(rel_date_gen), + side_effect=lambda *a, **k: next(rel_date_gen), # noqa: ARG005 ) last_update = _UP_NOW - timedelta(days=14) diff --git a/tests/unit/seed/wheels/test_wheels_util.py b/tests/unit/seed/wheels/test_wheels_util.py index e487797bc..87edab2d4 100644 --- a/tests/unit/seed/wheels/test_wheels_util.py +++ b/tests/unit/seed/wheels/test_wheels_util.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import pytest @@ -10,7 +10,7 @@ def test_wheel_support_no_python_requires(mocker): wheel = get_embed_wheel("setuptools", for_py_version=None) zip_mock = mocker.MagicMock() mocker.patch("virtualenv.seed.wheels.util.ZipFile", new=zip_mock) - zip_mock.return_value.__enter__.return_value.read = lambda name: b"" + zip_mock.return_value.__enter__.return_value.read = lambda name: b"" # noqa: ARG005 supports = wheel.support_py("3.8") assert supports is True diff --git a/tests/unit/test_run.py b/tests/unit/test_run.py index 53849f59a..b61731d57 100644 --- a/tests/unit/test_run.py +++ b/tests/unit/test_run.py @@ -1,9 +1,8 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations import logging import pytest -import six from virtualenv import __version__ from virtualenv.run import cli_run, session_via_cli @@ -24,13 +23,11 @@ def test_version(capsys): cli_run(args=["--version"]) assert context.value.code == 0 - out, err = capsys.readouterr() - extra = out if six.PY2 else err - content = out if six.PY3 else err - assert not extra + content, err = capsys.readouterr() + assert not err assert __version__ in content - import virtualenv + import virtualenv # noqa: PLC0415 assert virtualenv.__file__ in content diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index ca3f31f18..b83c247a2 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -1,11 +1,11 @@ -from __future__ import absolute_import, unicode_literals +from __future__ import annotations -import subprocess -import sys +import concurrent.futures +import traceback import pytest -from virtualenv.info import IS_WIN, PY2 +from virtualenv.util.lock import ReentrantFileLock from virtualenv.util.subprocess import run_cmd @@ -16,12 +16,21 @@ def test_run_fail(tmp_path): assert code -@pytest.mark.skipif(not (PY2 and IS_WIN), reason="subprocess patch only applied on Windows python2") -def test_windows_py2_cwd_works(tmp_path): - cwd = str(tmp_path) - result = subprocess.check_output( - [sys.executable, "-c", "import os; print(os.getcwd())"], - cwd=cwd, - universal_newlines=True, - ) - assert result == "{}\n".format(cwd) +def test_reentrant_file_lock_is_thread_safe(tmp_path): + lock = ReentrantFileLock(tmp_path) + target_file = tmp_path / "target" + target_file.touch() + + def recreate_target_file(): + with lock.lock_for_key("target"): + target_file.unlink() + target_file.touch() + + with concurrent.futures.ThreadPoolExecutor() as executor: + tasks = [executor.submit(recreate_target_file) for _ in range(4)] + concurrent.futures.wait(tasks) + for task in tasks: + try: + task.result() + except Exception: # noqa: BLE001, PERF203 + pytest.fail(traceback.format_exc()) diff --git a/tox.ini b/tox.ini index e2aa8e95c..e7e944189 100644 --- a/tox.ini +++ b/tox.ini @@ -1,167 +1,109 @@ [tox] -envlist = - fix_lint - py39 - py38 - py37 - py36 - py35 - py27 +requires = + tox>=4.2 +env_list = + fix pypy3 - pypy2 + 3.13 + 3.12 + 3.11 + 3.10 + 3.9 + 3.8 coverage readme docs -isolated_build = true skip_missing_interpreters = true -minversion = 3.14 [testenv] description = run tests with {basepython} -passenv = +package = wheel +wheel_build_env = .pkg +extras = + test +pass_env = CI_RUN - HOME - PIP_* PYTEST_* TERM -setenv = +set_env = COVERAGE_FILE = {toxworkdir}/.coverage.{envname} - COVERAGE_PROCESS_START = {toxinidir}/.coveragerc - PYTHONIOENCODING = utf-8 + COVERAGE_PROCESS_START = {toxinidir}/pyproject.toml + PYTHONWARNDEFAULTENCODING = 1 _COVERAGE_SRC = {envsitepackagesdir}/virtualenv - {py27,pypy2, upgrade}: PYTHONWARNINGS = ignore:DEPRECATION::pip._internal.cli.base_command -extras = - testing commands = - python -m coverage erase - python -m coverage run -m pytest {tty:--color=yes} \ - --junitxml {toxworkdir}/junit.{envname}.xml \ - {posargs:tests --int --timeout 600} - python -m coverage combine - python -m coverage report --skip-covered --show-missing - python -m coverage xml -o {toxworkdir}/coverage.{envname}.xml - python -m coverage html -d {envtmpdir}/htmlcov --show-contexts \ - --title virtualenv-{envname}-coverage -install_command = python -m pip install {opts} {packages} --disable-pip-version-check -package = wheel -wheel_build_env = .pkg + coverage erase + coverage run -m pytest {posargs:--junitxml "{toxworkdir}/junit.{envname}.xml" tests --int} + coverage combine + coverage report --skip-covered --show-missing + coverage xml -o "{toxworkdir}/coverage.{envname}.xml" + coverage html -d {envtmpdir}/htmlcov --show-contexts --title virtualenv-{envname}-coverage +uv_seed = true -[testenv:fix_lint] +[testenv:fix] description = format the code base to adhere to our styles, and complain about what we cannot do automatically -passenv = - * -basepython = python3.10 skip_install = true deps = - pre-commit>=2 + pre-commit-uv>=4.1.1 commands = pre-commit run --all-files --show-diff-on-failure - python -c 'import pathlib; print("hint: run \{\} install to add checks as pre-commit hook".format(pathlib.Path(r"{envdir}") / "bin" / "pre-commit"))' - -[testenv:coverage] -description = [run locally after tests]: combine coverage data and create report; - generates a diff coverage against origin/main (can be changed by setting DIFF_AGAINST env var) -passenv = - DIFF_AGAINST -setenv = - COVERAGE_FILE = {toxworkdir}/.coverage -skip_install = true -deps = - coverage>=5.0.1 - diff_cover>=3 -extras = -parallel_show_output = true -commands = - python -m coverage combine - python -m coverage report --skip-covered --show-missing - python -m coverage xml -o {toxworkdir}/coverage.xml - python -m coverage html -d {toxworkdir}/htmlcov - python -m diff_cover.diff_cover_tool --compare-branch {env:DIFF_AGAINST:origin/main} {toxworkdir}/coverage.xml -depends = - py39 - py38 - py37 - py36 - py35 - py27 - pypy - pypy3 [testenv:readme] -description = check that the long description is valid (need for PyPI) +description = check that the long description is valid skip_install = true deps = - build>=0.0.4 - twine>=3 -extras = + check-wheel-contents>=0.6 + twine>=5.1.1 + uv>=0.4.10 commands = - python -m build -o {envtmpdir} --wheel --sdist . - twine check {envtmpdir}/* + uv build --sdist --wheel --out-dir {envtmpdir} . + twine check {envtmpdir}{/}* + check-wheel-contents --no-config {envtmpdir} [testenv:docs] description = build documentation -basepython = python3.10 extras = docs commands = - python -c 'import glob; import subprocess; subprocess.call(["proselint"] + glob.glob("docs/*.rst") + glob.glob("docs/**/*.rst"))' - sphinx-build -d "{envtmpdir}/doctree" docs "{toxworkdir}/docs_out" --color -b html {posargs} + sphinx-build -d "{envtmpdir}/doctree" docs "{toxworkdir}/docs_out" --color -b html {posargs:-W} python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))' [testenv:upgrade] description = upgrade pip/wheels/setuptools to latest -passenv = - UPGRADE_ADVISORY skip_install = true deps = - black -changedir = {toxinidir}/tasks + ruff>=0.6.5 +pass_env = + UPGRADE_ADVISORY +change_dir = {toxinidir}/tasks commands = - python upgrade_wheels.py + - python upgrade_wheels.py +uv_seed = true [testenv:release] description = do a release, required posarg of the version number -passenv = - * -basepython = python3.10 deps = - gitpython>=3.1.24 - packaging>=21.3 - towncrier>=21.3 -changedir = {toxinidir}/tasks + gitpython>=3.1.43 + packaging>=24.1 + towncrier>=24.8 +change_dir = {toxinidir}/tasks commands = python release.py --version {posargs} [testenv:dev] description = generate a DEV environment -usedevelop = true -deps = - {[testenv:release]deps} - setuptools_scm[toml]>=3.4 +package = editable extras = docs - testing + test commands = - python -m pip list --format=columns + uv pip tree python -c 'import sys; print(sys.executable)' [testenv:zipapp] description = generate a zipapp skip_install = true deps = - packaging>=21.3 + packaging>=24.1 commands = python tasks/make_zipapp.py - -[isort] -profile = black -line_length = 120 -known_first_party = virtualenv - -[flake8] -max-complexity = 22 -max-line-length = 120 -ignore = E203, W503, C901, E402 - -[pep8] -max-line-length = 120 +uv_seed = true