diff --git a/.github/FUNDING.yml b/.github/FUNDING.yaml similarity index 100% rename from .github/FUNDING.yml rename to .github/FUNDING.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yaml similarity index 76% rename from .github/dependabot.yml rename to .github/dependabot.yaml index 123014908..5e4251f20 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yaml @@ -4,3 +4,5 @@ updates: directory: "/" schedule: interval: "daily" + cooldown: + default-days: 7 diff --git a/.github/release.yml b/.github/release.yaml similarity index 100% rename from .github/release.yml rename to .github/release.yaml diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index c6e6ba93b..bab7c6f92 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -17,6 +17,8 @@ jobs: if: github.event_name != 'schedule' || github.repository_owner == 'pypa' runs-on: ${{ matrix.os }} timeout-minutes: 40 + permissions: + contents: read strategy: fail-fast: false matrix: @@ -35,11 +37,15 @@ jobs: - pypy-3.9 - pypy-3.8 - graalpy-24.1 + - rp os: - ubuntu-24.04 - macos-15 - windows-2025 include: + - { os: macos-15, py: "brew@3.14" } + - { os: macos-15, py: "brew@3.13" } + - { os: macos-15, py: "brew@3.12" } - { os: macos-15, py: "brew@3.11" } - { os: macos-15, py: "brew@3.10" } - { os: macos-15, py: "brew@3.9" } @@ -50,34 +56,58 @@ jobs: - { os: windows-2025, py: "pypy-3.8" } steps: - name: 🚀 Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - name: 📥 Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + persist-credentials: false - name: 🏷️ Fetch upstream tags for versioning shell: bash run: | git fetch --force --tags https://github.com/pypa/virtualenv.git - name: 🐍 Setup Python for tox - uses: actions/setup-python@v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.14" - name: 📦 Install tox with this virtualenv shell: bash run: | - if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --no-managed-python --python 3.14 "tox>=4.32" --with . + if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" || "${{ matrix.py }}" == "rp" ]]; then + uv tool install --no-managed-python --python 3.14 "tox>=4.45" --with . else - uv tool install --no-managed-python --python 3.14 "tox>=4.32" --with tox-uv --with . + uv tool install --no-managed-python --python 3.14 "tox>=4.45" --with tox-uv --with . fi - name: 🐍 Setup Python for test ${{ matrix.py }} - uses: actions/setup-python@v5 - if: ${{ !startsWith(matrix.py, 'brew@') }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + if: ${{ !startsWith(matrix.py, 'brew@') && matrix.py != 'rp' }} with: python-version: ${{ matrix.py }} + - name: 🦀 Setup RustPython + if: matrix.py == 'rp' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + tag=$(gh release list --repo RustPython/RustPython --limit 1 --json tagName -q '.[0].tagName') + if [ "${{ runner.os }}" = "Linux" ]; then + asset="rustpython-release-Linux-x86_64-unknown-linux-gnu" + elif [ "${{ runner.os }}" = "macOS" ]; then + asset="rustpython-release-macOS-aarch64-apple-darwin" + elif [ "${{ runner.os }}" = "Windows" ]; then + asset="rustpython-release-Windows-x86_64-pc-windows-msvc.exe" + fi + gh release download "$tag" --repo RustPython/RustPython --pattern "$asset" --dir "$RUNNER_TEMP" + if [ "${{ runner.os }}" = "Windows" ]; then + cp "$RUNNER_TEMP/$asset" "/c/ProgramData/chocolatey/bin/rustpython.exe" + else + chmod +x "$RUNNER_TEMP/$asset" + sudo mv "$RUNNER_TEMP/$asset" /usr/local/bin/rustpython + fi - name: 🛠️ Install OS dependencies shell: bash + env: + GH_TOKEN: ${{ github.token }} run: | if [ "${{ runner.os }}" = "Linux" ]; then sudo apt-get install -y software-properties-common @@ -95,7 +125,10 @@ jobs: fi brew install fish tcsh nushell || brew upgrade fish tcsh nushell elif [ "${{ runner.os }}" = "Windows" ]; then - choco install nushell + nu_version=$(gh release view --repo nushell/nushell --json tagName -q .tagName) + gh release download "$nu_version" --repo nushell/nushell --pattern "*-x86_64-pc-windows-msvc.zip" --dir "$RUNNER_TEMP" + 7z x "$RUNNER_TEMP/nu-${nu_version#v}-x86_64-pc-windows-msvc.zip" -o"$RUNNER_TEMP/nushell" -y + cp "$RUNNER_TEMP/nushell/nu.exe" "/c/ProgramData/chocolatey/bin/nu.exe" fi - name: 🧬 Pick environment to run shell: bash @@ -103,7 +136,7 @@ jobs: py="${{ matrix.py }}" if [[ "$py" == brew@* ]]; then brew_version="${py#brew@}" - echo "TOX_DISCOVER=/opt/homebrew/bin/python${brew_version}" >> "$GITHUB_ENV" + echo "UV_PYTHON=/opt/homebrew/bin/python${brew_version}" >> "$GITHUB_ENV" py="$brew_version" fi [[ "$py" == graalpy-* ]] && py="graalpy" @@ -122,6 +155,8 @@ jobs: name: 🔎 check ${{ matrix.tox_env }} - ${{ matrix.os }} if: github.event_name != 'schedule' || github.repository_owner == 'pypa' runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: fail-fast: false matrix: @@ -132,20 +167,25 @@ jobs: - dev - docs - readme + - type + - type-3.8 - upgrade - zipapp exclude: - - { os: windows-2025, tox_env: readme } - { os: windows-2025, tox_env: docs } + - { os: windows-2025, tox_env: readme } + - { os: windows-2025, tox_env: type } + - { os: windows-2025, tox_env: type-3.8 } steps: - name: 🚀 Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - name: 📦 Install tox - run: uv tool install --python-preference only-managed --python 3.14 "tox>=4.32" --with tox-uv + run: uv tool install --python-preference only-managed --python 3.14 "tox>=4.45" --with tox-uv - name: 📥 Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 + persist-credentials: false - name: 🏷️ Fetch upstream tags for versioning shell: bash run: | @@ -154,3 +194,5 @@ jobs: run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }} - name: 🏃 Run check for ${{ matrix.tox_env }} run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} + env: + UPGRADE_ADVISORY: ${{ matrix.tox_env == 'upgrade' && '1' || '' }} diff --git a/.github/workflows/pre-release.yaml b/.github/workflows/pre-release.yaml new file mode 100644 index 000000000..20c08fa34 --- /dev/null +++ b/.github/workflows/pre-release.yaml @@ -0,0 +1,45 @@ +name: Pre-release +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump type" + required: true + type: choice + options: + - auto + - major + - minor + - patch + default: auto + +jobs: + pre-release: + runs-on: ubuntu-24.04 + environment: release + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + token: ${{ secrets.GH_RELEASE_TOKEN }} + persist-credentials: false + - name: Install the latest version of uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Python + run: uv python install 3.14 + - name: Configure git identity from token owner + env: + GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + run: | + user_info=$(gh api /user) + git config user.name "$(echo "$user_info" | jq -r '.name // .login')" + git config user.email "$(echo "$user_info" | jq -r '.id')+$(echo "$user_info" | jq -r '.login')@users.noreply.github.com" + - name: Generate changelog, commit, tag, and push + run: uv tool run --with tox-uv tox r -e release -- --version "${{ inputs.bump }}" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index def30f5d9..d5eb557a4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,7 +1,7 @@ -name: Release to PyPI +name: Release on: push: - tags: ["*"] + tags: ["*.*.*"] env: dists-artifact-name: python-package-distributions @@ -9,41 +9,114 @@ env: jobs: build: runs-on: ubuntu-24.04 + permissions: + contents: read steps: - - name: 📥 Checkout code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - name: 🚀 Install the latest version of uv - uses: astral-sh/setup-uv@v4 + persist-credentials: false + - name: Install the latest version of uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: - enable-cache: true + enable-cache: false cache-dependency-glob: "pyproject.toml" github-token: ${{ secrets.GITHUB_TOKEN }} - - name: 📦 Build package + - name: Set up Python + run: uv python install 3.14 + - name: Build sdist and wheel run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist - - name: 📦 Store the distribution packages - uses: actions/upload-artifact@v4 + - name: Build zipapp + run: uv tool run --with tox-uv tox r -e zipapp + - name: Store the distribution packages + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: ${{ env.dists-artifact-name }} path: dist/* + - name: Store the zipapp + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: virtualenv-zipapp + path: virtualenv.pyz - release: - needs: - - build + publish: + needs: build runs-on: ubuntu-24.04 environment: name: release url: https://pypi.org/project/virtualenv/${{ github.ref_name }} permissions: + contents: write id-token: write steps: - - name: 📥 Download all the dists - uses: actions/download-artifact@v4 + - name: Download all the dists + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ env.dists-artifact-name }} path: dist/ - - name: 🚀 Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.13.0 + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: attestations: true + - name: Download the zipapp + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: virtualenv-zipapp + - name: Create GitHub Release + run: gh release create "$GITHUB_REF_NAME" virtualenv.pyz --generate-notes + env: + GH_TOKEN: ${{ github.token }} + - name: Update get-virtualenv + env: + GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + run: | + git clone https://x-access-token:${GH_TOKEN}@github.com/pypa/get-virtualenv.git + cp virtualenv.pyz get-virtualenv/public/virtualenv.pyz + echo -n "${GITHUB_REF_NAME}" > get-virtualenv/public/version.txt + cd get-virtualenv + user_info=$(gh api /user) + git config user.name "$(echo "$user_info" | jq -r '.name // .login')" + git config user.email "$(echo "$user_info" | jq -r '.id')+$(echo "$user_info" | jq -r '.login')@users.noreply.github.com" + git add public/virtualenv.pyz public/version.txt + git commit -m "update virtualenv to ${GITHUB_REF_NAME}" + git push origin main + + rollback: + if: ${{ always() && needs.build.result == 'success' && needs.publish.result == 'failure' }} + needs: + - build + - publish + runs-on: ubuntu-24.04 + environment: release + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + token: ${{ secrets.GH_RELEASE_TOKEN }} # zizmor: ignore[artipacked] + - name: Delete GitHub Release if created + env: + GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + run: gh release delete "${GITHUB_REF_NAME}" --yes --cleanup-tag || true + - name: Delete remote tag + run: git push origin --delete "${GITHUB_REF_NAME}" || true + - name: Reset release commit on main + run: | + git checkout main + git reset --hard HEAD~1 + git push origin main --force + - name: Rollback get-virtualenv if updated + env: + GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + run: | + git clone https://x-access-token:${GH_TOKEN}@github.com/pypa/get-virtualenv.git + cd get-virtualenv + current_version=$(cat public/version.txt) + if [ "$current_version" = "${GITHUB_REF_NAME}" ]; then + gh release delete "$current_version" --yes --cleanup-tag || true + git reset --hard HEAD~1 + git push origin main --force + fi diff --git a/.github/workflows/upgrade.yaml b/.github/workflows/upgrade.yaml new file mode 100644 index 000000000..29c49ade2 --- /dev/null +++ b/.github/workflows/upgrade.yaml @@ -0,0 +1,97 @@ +name: Upgrade embedded dependencies +on: + schedule: + - cron: "0 10 * * *" # daily at 10:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + upgrade: + name: Upgrade embedded pip/setuptools/wheel + if: github.repository_owner == 'pypa' + runs-on: ubuntu-24.04 + environment: upgrade + permissions: + contents: write + pull-requests: write + steps: + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + - name: Install tox + run: uv tool install --python-preference only-managed --python 3.14 "tox>=4.32" --with tox-uv + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + ssh-key: ${{ secrets.DEPLOY_KEY }} # zizmor: ignore[artipacked] + - name: Fetch upstream tags for versioning + run: git fetch --force --tags https://github.com/pypa/virtualenv.git + - name: Run upgrade + id: upgrade + env: + UPGRADE_ADVISORY: "1" + run: | + tox run -e upgrade + if [ -n "$(git diff --name-only)" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "### Embedded dependency changes" >> "$GITHUB_STEP_SUMMARY" + git diff --stat >> "$GITHUB_STEP_SUMMARY" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "### All embedded dependencies are up to date" >> "$GITHUB_STEP_SUMMARY" + fi + - name: Check PyPI release age + if: steps.upgrade.outputs.changed == 'true' + id: age-check + run: | + cutoff=$(( $(date +%s) - 7 * 24 * 3600 )) + too_new=false + for whl in $(git diff --name-only -- 'src/virtualenv/seed/wheels/embed/*.whl'); do + pkg=$(basename "$whl" | cut -d- -f1) + version=$(basename "$whl" | cut -d- -f2) + upload_time=$(curl -sf "https://pypi.org/pypi/${pkg}/${version}/json" | jq -r '.urls[0].upload_time_iso_8601 // empty') + if [ -z "$upload_time" ]; then + echo "::warning::Could not fetch release date for ${pkg}==${version}" + continue + fi + release_epoch=$(date -d "$upload_time" +%s) + if [ "$release_epoch" -gt "$cutoff" ]; then + echo "::notice::${pkg}==${version} released less than 7 days ago (${upload_time})" + too_new=true + fi + done + if [ "$too_new" = "true" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "### Skipped — some packages released less than 7 days ago" >> "$GITHUB_STEP_SUMMARY" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Create Pull Request + if: steps.upgrade.outputs.changed == 'true' && steps.age-check.outputs.skip != 'true' + id: cpr + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 + with: + commit-message: "Upgrade embedded dependencies" + branch: auto/upgrade-embedded-deps + delete-branch: true + title: "Upgrade embedded pip/setuptools/wheel" + body: | + Automated upgrade of embedded pip, setuptools, and wheel dependencies. + + This PR was created automatically by the [upgrade workflow](https://github.com/${{ github.repository }}/actions/workflows/upgrade.yaml). + labels: | + dependencies + - name: Rename changelog with PR number + if: steps.cpr.outputs.pull-request-number + run: | + pr_number="${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" + git fetch origin auto/upgrade-embedded-deps + git checkout auto/upgrade-embedded-deps + mv docs/changelog/u.bugfix.rst "docs/changelog/${pr_number}.bugfix.rst" + git add docs/changelog/ + git commit -m "Rename changelog to ${pr_number}.bugfix.rst" + git push + env: + STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7fa5d3484..41d5512fd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,26 +5,25 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.36.1 + rev: 0.37.1 hooks: - id: check-github-workflows args: ["--verbose"] - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell args: ["--write-changes"] - - repo: https://github.com/tox-dev/tox-ini-fmt - rev: "1.7.1" + - repo: https://github.com/tox-dev/tox-toml-fmt + rev: "v1.9.1" hooks: - - id: tox-ini-fmt - args: ["-p", "fix"] + - id: tox-toml-fmt - repo: https://github.com/tox-dev/pyproject-fmt - rev: "v2.12.1" + rev: "v2.20.0" hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.14.14" + rev: "v0.15.8" hooks: - id: ruff-format - id: ruff @@ -36,6 +35,16 @@ repos: additional_dependencies: - prettier@3.3.3 - "@prettier/plugin-xml@3.4.1" + - repo: https://github.com/LilSpazJoekp/docstrfmt + rev: v2.0.2 + hooks: + - id: docstrfmt + args: ["-l", "120"] + additional_dependencies: ["sphinx>=9.1"] + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.23.1 + hooks: + - id: zizmor - repo: meta hooks: - id: check-hooks-apply diff --git a/.readthedocs.yml b/.readthedocs.yml index ab3011302..60e9a43d0 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,15 +1,8 @@ version: 2 build: - os: ubuntu-22.04 - tools: - python: "3" -python: - install: - - method: pip - path: . - extra_requirements: - - docs -sphinx: - builder: html - configuration: docs/conf.py - fail_on_warning: true + os: ubuntu-lts-latest + tools: {} + commands: + - curl -LsSf https://astral.sh/uv/install.sh | sh + - ~/.local/bin/uv tool install tox --with tox-uv -p 3.14 --managed-python + - ~/.local/bin/tox run -e docs -- diff --git a/docs/_static/rtd-search.js b/docs/_static/rtd-search.js new file mode 100644 index 000000000..133e1e31c --- /dev/null +++ b/docs/_static/rtd-search.js @@ -0,0 +1,11 @@ +document.addEventListener("DOMContentLoaded", () => { + const searchInput = document.querySelector( + ".sidebar-search input[type='search']", + ); + if (searchInput) { + searchInput.addEventListener("focus", () => { + document.dispatchEvent(new CustomEvent("readthedocs-search-show")); + searchInput.blur(); + }); + } +}); diff --git a/docs/_static/virtualenv.png b/docs/_static/virtualenv.png new file mode 100644 index 000000000..1f729837d Binary files /dev/null and b/docs/_static/virtualenv.png differ diff --git a/docs/_static/virtualenv.svg b/docs/_static/virtualenv.svg new file mode 100644 index 000000000..090396002 --- /dev/null +++ b/docs/_static/virtualenv.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + virtualenv + + + + + + + + diff --git a/docs/changelog.rst b/docs/changelog.rst index d2d3f109b..67737dbab 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,1077 +1,1422 @@ -Release History -=============== +################# + Release History +################# -.. include:: _draft.rst +.. towncrier-draft-entries:: [UNRELEASED DRAFT] .. towncrier release notes start -v20.36.1 (2026-01-09) ---------------------- +********************** + v21.2.0 (2026-03-09) +********************** + +Features - 21.2.0 +================= + +- Update embed wheel generator (``tasks/upgrade_wheels.py``) to include type annotations in generated output - by + :user:`rahuldevikar`. (:issue:`3075`) + +Bugfixes - 21.2.0 +================= + +- Pass ``--without-scm-ignore-files`` to subprocess venv on Python 3.13+ so virtualenv controls ``.gitignore`` creation, + fixing flaky ``test_create_no_seed`` and ``--no-vcs-ignore`` being ignored in subprocess path - by + :user:`gaborbernat`. (:issue:`3089`) +- Use ``BASH_SOURCE[0]`` instead of ``$0`` in the bash activate script relocation fallback, fixing incorrect ``PATH`` + when sourcing the activate script from a different directory - by :user:`gaborbernat`. (:issue:`3090`) + +********************** + v21.1.0 (2026-02-27) +********************** + +Features - 21.1.0 +================= + +- Add comprehensive type annotations across the entire codebase and ship a PEP 561 ``py.typed`` marker so downstream + consumers and type checkers recognize virtualenv as an inline-typed package - by :user:`rahuldevikar`. (:issue:`3075`) + +********************** + v21.0.0 (2026-02-25) +********************** + +Deprecations and Removals - 21.0.0 +================================== + +- The Python discovery logic has been extracted into a standalone ``python-discovery`` package on PyPI (`documentation + `_) and is now consumed as a dependency. If you previously imported + discovery internals directly (e.g. ``from virtualenv.discovery.py_info import PythonInfo``), switch to ``from + python_discovery import PythonInfo``. Backward-compatibility re-export shims are provided at + ``virtualenv.discovery.py_info``, ``virtualenv.discovery.py_spec``, and ``virtualenv.discovery.cached_py_info``, + however these are considered unsupported and may be removed in a future release - by :user:`gaborbernat`. + (:issue:`3070`) + +*********************** + v20.39.1 (2026-02-25) +*********************** + +Features - 20.39.1 +================== + +- Add support for creating virtual environments with RustPython - by :user:`elmjag`. (:issue:`3010`) + +*********************** + v20.39.0 (2026-02-23) +*********************** + +Features - 20.39.0 +================== + +- Automatically resolve version manager shims (pyenv, mise, asdf) to the real Python binary during discovery, preventing + incorrect interpreter selection when shims are on ``PATH`` - by :user:`gaborbernat`. (:issue:`3049`) +- Add architecture (ISA) awareness to Python discovery — users can now specify a CPU architecture suffix in the + ``--python`` spec string (e.g. ``cpython3.12-64-arm64``) to distinguish between interpreters that share the same + version and bitness but target different architectures. Uses ``sysconfig.get_platform()`` as the data source, with + cross-platform normalization (``amd64`` ↔ ``x86_64``, ``aarch64`` ↔ ``arm64``). Omitting the suffix preserves existing + behavior - by :user:`rahuldevikar`. (:issue:`3059`) + +*********************** + v20.38.0 (2026-02-19) +*********************** + +Features - 20.38.0 +================== + +- Store app data (pip/setuptools/wheel caches) under the OS cache directory (``platformdirs.user_cache_dir``) instead of + the data directory (``platformdirs.user_data_dir``). Existing app data at the old location is automatically migrated + on first use. This ensures cached files that can be redownloaded are placed in the standard cache location (e.g. + ``~/.cache`` on Linux, ``~/Library/Caches`` on macOS) where they are excluded from backups and can be cleaned by + system tools - by :user:`rahuldevikar`. (:issue:`1884`) (:issue:`1884`) +- Add ``PKG_CONFIG_PATH`` environment variable support to all activation scripts (Bash, Batch, PowerShell, Fish, C + Shell, Nushell, and Python). The virtualenv's ``lib/pkgconfig`` directory is now automatically prepended to + ``PKG_CONFIG_PATH`` on activation and restored on deactivation, enabling packages that use ``pkg-config`` during + build/install to find their configuration files - by :user:`rahuldevikar`. (:issue:`2637`) +- Upgrade embedded pip to ``26.0.1`` from ``25.3`` and setuptools to ``82.0.0``, ``75.3.4`` from ``75.3.2``, ``80.9.0`` + - by :user:`rahuldevikar`. (:issue:`3027`) +- Replace ``ty: ignore`` comments with proper type narrowing using assertions and explicit None checks - by + :user:`rahuldevikar`. (:issue:`3029`) + +Bugfixes - 20.38.0 +================== + +- Exclude pywin32 DLLs (``pywintypes*.dll``, ``pythoncom*.dll``) from being copied to the Scripts directory during + virtualenv creation on Windows. This fixes compatibility issues with pywin32, which expects its DLLs to be installed + in ``site-packages/pywin32_system32`` by its own post-install script - by :user:`rahuldevikar`. (:issue:`2662`) +- Preserve symlinks in ``pyvenv.cfg`` paths to match ``venv`` behavior. Use ``os.path.abspath()`` instead of + ``os.path.realpath()`` to normalize paths without resolving symlinks, fixing issues with Python installations accessed + via symlinked directories (common in network-mounted filesystems) - by :user:`rahuldevikar`. Fixes :issue:`2770`. + (:issue:`2770`) +- Fix Windows activation scripts to properly quote ``python.exe`` path, preventing failures when Python is installed in + a path with spaces (e.g., ``C:\Program Files``) and a file named ``C:\Program`` exists on the filesystem - by + :user:`rahuldevikar`. (:issue:`2985`) +- Fix ``bash -u`` (``set -o nounset``) compatibility in bash activation script by using ``${PKG_CONFIG_PATH:-}`` and + ``${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}`` to handle unset ``PKG_CONFIG_PATH`` - by :user:`Fridayai700`. + (:issue:`3044`) +- Gracefully handle corrupted on-disk cache and invalid JSON from Python interrogation subprocess instead of crashing + with unhandled ``JSONDecodeError`` or ``KeyError`` - by :user:`gaborbernat`. (:issue:`3054`) + +*********************** + v20.36.1 (2026-01-09) +*********************** Bugfixes - 20.36.1 -~~~~~~~~~~~~~~~~~~ -- Fix TOCTOU vulnerabilities in app_data and lock directory creation that could be exploited via symlink attacks - reported by :user:`tsigouris007`, fixed by :user:`gaborbernat`. (:issue:`3013`) +================== -v20.36.0 (2026-01-07) ---------------------- +- Fix TOCTOU vulnerabilities in app_data and lock directory creation that could be exploited via symlink attacks - + reported by :user:`tsigouris007`, fixed by :user:`gaborbernat`. (:issue:`3013`) + +*********************** + v20.36.0 (2026-01-07) +*********************** Features - 20.36.0 -~~~~~~~~~~~~~~~~~~ -- Add support for PEP 440 version specifiers in the ``--python`` flag. Users can now specify Python versions using operators like ``>=``, ``<=``, ``~=``, etc. For example: ``virtualenv --python=">=3.12" myenv`` `. (:issue:`2994`) +================== + +- Add support for PEP 440 version specifiers in the ``--python`` flag. Users can now specify Python versions using + operators like ``>=``, ``<=``, ``~=``, etc. For example: ``virtualenv --python=">=3.12" myenv`` `. (:issue:`2994`) -v20.35.4 (2025-10-28) ---------------------- +*********************** + v20.35.4 (2025-10-28) +*********************** Bugfixes - 20.35.4 -~~~~~~~~~~~~~~~~~~ -- Fix race condition in ``_virtualenv.py`` when file is overwritten during import, preventing ``NameError`` when ``_DISTUTILS_PATCH`` is accessed - by :user:`gracetyy`. (:issue:`2969`) +================== + +- Fix race condition in ``_virtualenv.py`` when file is overwritten during import, preventing ``NameError`` when + ``_DISTUTILS_PATCH`` is accessed - by :user:`gracetyy`. (:issue:`2969`) - Upgrade embedded wheels: - * pip to ``25.3`` from ``25.2`` (:issue:`2989`) + - pip to ``25.3`` from ``25.2`` (:issue:`2989`) -v20.35.3 (2025-10-10) ---------------------- +*********************** + v20.35.3 (2025-10-10) +*********************** Bugfixes - 20.35.3 -~~~~~~~~~~~~~~~~~~ +================== + - Accept RuntimeError in `test_too_many_open_files`, by :user:`esafak` (:issue:`2935`) -v20.35.2 (2025-10-10) ---------------------- +*********************** + v20.35.2 (2025-10-10) +*********************** Bugfixes - 20.35.2 -~~~~~~~~~~~~~~~~~~ +================== + - Revert out changes related to the extraction of the discovery module - by :user:`gaborbernat`. (:issue:`2978`) -v20.35.1 (2025-10-09) ---------------------- +*********************** + v20.35.1 (2025-10-09) +*********************** Bugfixes - 20.35.1 -~~~~~~~~~~~~~~~~~~ +================== + - Patch get_interpreter to handle missing cache and app_data - by :user:`esafak` (:issue:`2972`) - Fix backwards incompatible changes to ``PythonInfo`` - by :user:`gaborbernat`. (:issue:`2975`) -v20.35.0 (2025-10-08) ---------------------- +*********************** + v20.35.0 (2025-10-08) +*********************** Features - 20.35.0 -~~~~~~~~~~~~~~~~~~ +================== + - Add AppData and Cache protocols to discovery for decoupling - by :user:`esafak`. (:issue:`2074`) - Ensure python3.exe and python3 on Windows for Python 3 - by :user:`esafak`. (:issue:`2774`) Bugfixes - 20.35.0 -~~~~~~~~~~~~~~~~~~ +================== + - Replaced direct references to tcl/tk library paths with getattr - by :user:`esafak` (:issue:`2944`) - Restore absolute import of fs_is_case_sensitive - by :user:`esafak`. (:issue:`2955`) -v20.34.0 (2025-08-13) ---------------------- +*********************** + v20.34.0 (2025-08-13) +*********************** Features - 20.34.0 -~~~~~~~~~~~~~~~~~~ -- Abstract out caching in discovery - by :user:`esafak`. - Decouple `FileCache` from `py_info` (discovery) - by :user:`esafak`. - Remove references to py_info in FileCache - by :user:`esafak`. - Decouple discovery from creator plugins - by :user:`esafak`. - Decouple discovery by duplicating info utils - by :user:`esafak`. (:issue:`2074`) +================== + +- Abstract out caching in discovery - by :user:`esafak`. Decouple `FileCache` from `py_info` (discovery) - by + :user:`esafak`. Remove references to py_info in FileCache - by :user:`esafak`. Decouple discovery from creator plugins + - by :user:`esafak`. Decouple discovery by duplicating info utils - by :user:`esafak`. (:issue:`2074`) - Add PyPy 3.11 support. Contributed by :user:`esafak`. (:issue:`2932`) Bugfixes - 20.34.0 -~~~~~~~~~~~~~~~~~~ +================== + - Upgrade embedded wheel pip to ``25.2`` from ``25.1.1`` - by :user:`gaborbernat`. (:issue:`2333`) - Accept RuntimeError in `test_too_many_open_files`, by :user:`esafak` (:issue:`2935`) - Python in PATH takes precedence over uv-managed python. Contributed by :user:`edgarrmondragon`. (:issue:`2952`) -v20.33.1 (2025-08-05) ---------------------- +*********************** + v20.33.1 (2025-08-05) +*********************** Bugfixes - 20.33.1 -~~~~~~~~~~~~~~~~~~ -- Correctly unpack _get_tcl_tk_libs() response in PythonInfo. - Contributed by :user:`esafak`. (:issue:`2930`) -- Restore `py_info.py` timestamp in `test_py_info_cache_invalidation_on_py_info_change` - Contributed by :user:`esafak`. (:issue:`2933`) +================== + +- Correctly unpack _get_tcl_tk_libs() response in PythonInfo. Contributed by :user:`esafak`. (:issue:`2930`) +- Restore `py_info.py` timestamp in `test_py_info_cache_invalidation_on_py_info_change` Contributed by :user:`esafak`. + (:issue:`2933`) -v20.33.0 (2025-08-03) ---------------------- +*********************** + v20.33.0 (2025-08-03) +*********************** Features - 20.33.0 -~~~~~~~~~~~~~~~~~~ -- Added support for Tcl and Tkinter. You're welcome. - Contributed by :user:`esafak`. (:issue:`425`) +================== + +- Added support for Tcl and Tkinter. You're welcome. Contributed by :user:`esafak`. (:issue:`425`) Bugfixes - 20.33.0 -~~~~~~~~~~~~~~~~~~ -- Prevent logging setup when --help is passed, fixing a flaky test. - Contributed by :user:`esafak`. (:issue:`u`) -- Fix cache invalidation for PythonInfo by hashing `py_info.py`. - Contributed by :user:`esafak`. (:issue:`2467`) -- When no discovery plugins are found, the application would crash with a StopIteration. - This change catches the StopIteration and raises a RuntimeError with a more informative message. - Contributed by :user:`esafak`. (:issue:`2493`) -- Stop `--try-first-with` overriding absolute `--python` paths. - Contributed by :user:`esafak`. (:issue:`2659`) -- Force UTF-8 encoding for pip download - Contributed by :user:`esafak`. (:issue:`2780`) -- Creating a virtual environment on a filesystem without symlink-support would fail even with `--copies` - Make `fs_supports_symlink` perform a real symlink creation check on all platforms. - Contributed by :user:`esafak`. (:issue:`2786`) +================== + +- Prevent logging setup when --help is passed, fixing a flaky test. Contributed by :user:`esafak`. (:issue:`u`) +- Fix cache invalidation for PythonInfo by hashing `py_info.py`. Contributed by :user:`esafak`. (:issue:`2467`) +- When no discovery plugins are found, the application would crash with a StopIteration. This change catches the + StopIteration and raises a RuntimeError with a more informative message. Contributed by :user:`esafak`. + (:issue:`2493`) +- Stop `--try-first-with` overriding absolute `--python` paths. Contributed by :user:`esafak`. (:issue:`2659`) +- Force UTF-8 encoding for pip download Contributed by :user:`esafak`. (:issue:`2780`) +- Creating a virtual environment on a filesystem without symlink-support would fail even with `--copies` Make + `fs_supports_symlink` perform a real symlink creation check on all platforms. Contributed by :user:`esafak`. + (:issue:`2786`) - Add a note to the user guide recommending the use of a specific Python version when creating virtual environments. Contributed by :user:`esafak`. (:issue:`2808`) -- Fix 'Too many open files' error due to a file descriptor leak in virtualenv's locking mechanism. - Contributed by :user:`esafak`. (:issue:`2834`) -- Support renamed Windows venv redirector (`venvlauncher.exe` and `venvwlauncher.exe`) on Python 3.13 - Contributed by :user:`esafak`. (:issue:`2851`) +- Fix 'Too many open files' error due to a file descriptor leak in virtualenv's locking mechanism. Contributed by + :user:`esafak`. (:issue:`2834`) +- Support renamed Windows venv redirector (`venvlauncher.exe` and `venvwlauncher.exe`) on Python 3.13 Contributed by + :user:`esafak`. (:issue:`2851`) - Resolve Nushell activation script deprecation warnings by dynamically selecting the ``--optional`` flag for Nushell - ``get`` command on version 0.106.0 and newer, while retaining the deprecated ``-i`` flag for older versions to maintain - compatibility. Contributed by :user:`gaborbernat`. (:issue:`2910`) + ``get`` command on version 0.106.0 and newer, while retaining the deprecated ``-i`` flag for older versions to + maintain compatibility. Contributed by :user:`gaborbernat`. (:issue:`2910`) -v20.32.0 (2025-07-20) ---------------------- +*********************** + v20.32.0 (2025-07-20) +*********************** Features - 20.32.0 -~~~~~~~~~~~~~~~~~~ +================== + - Warn on incorrect invocation of Nushell activation script - by :user:`esafak`. (:issue:`nushell_activation`) - Discover uv-managed Python installations (:issue:`2901`) Bugfixes - 20.32.0 -~~~~~~~~~~~~~~~~~~ +================== + - Ignore missing absolute paths for python discovery - by :user:`esafak` (:issue:`2870`) - Upgrade embedded setuptools to ``80.9.0`` from ``80.3.1`` - by :user:`gaborbernat`. (:issue:`2900`) -v20.31.2 (2025-05-08) ---------------------- +*********************** + v20.31.2 (2025-05-08) +*********************** No significant changes. - -v20.31.1 (2025-05-05) ---------------------- +*********************** + v20.31.1 (2025-05-05) +*********************** Bugfixes - 20.31.1 -~~~~~~~~~~~~~~~~~~ +================== + - Upgrade embedded wheels: - * pip to ``25.1.1`` from ``25.1`` - * setuptools to ``80.3.1`` from ``78.1.0`` (:issue:`2880`) + - pip to ``25.1.1`` from ``25.1`` + - setuptools to ``80.3.1`` from ``78.1.0`` (:issue:`2880`) -v20.31.0 (2025-05-05) ---------------------- +*********************** + v20.31.0 (2025-05-05) +*********************** Features - 20.31.0 -~~~~~~~~~~~~~~~~~~ -- No longer bundle ``wheel`` wheels (except on Python 3.8), ``setuptools`` includes native ``bdist_wheel`` support. Update ``pip`` to ``25.1``. (:issue:`2868`) +================== + +- No longer bundle ``wheel`` wheels (except on Python 3.8), ``setuptools`` includes native ``bdist_wheel`` support. + Update ``pip`` to ``25.1``. (:issue:`2868`) Bugfixes - 20.31.0 -~~~~~~~~~~~~~~~~~~ -- ``get_embed_wheel()`` no longer fails with a :exc:`TypeError` when it is - called with an unknown *distribution*. (:issue:`2877`) +================== + +- ``get_embed_wheel()`` no longer fails with a :exc:`TypeError` when it is called with an unknown *distribution*. + (:issue:`2877`) - Fix ``HelpFormatter`` error with Python 3.14.0b1. (:issue:`2878`) -v20.30.0 (2025-03-31) ---------------------- +*********************** + v20.30.0 (2025-03-31) +*********************** Features - 20.30.0 -~~~~~~~~~~~~~~~~~~ +================== + - Add support for `GraalPy `_. (:issue:`2832`) Bugfixes - 20.30.0 -~~~~~~~~~~~~~~~~~~ +================== + - Upgrade embedded wheels: - * setuptools to ``78.1.0`` from ``75.3.2`` (:issue:`2863`) + - setuptools to ``78.1.0`` from ``75.3.2`` (:issue:`2863`) -v20.29.3 (2025-03-06) ---------------------- +*********************** + v20.29.3 (2025-03-06) +*********************** Bugfixes - 20.29.3 -~~~~~~~~~~~~~~~~~~ +================== + - Ignore unreadable directories in ``PATH``. (:issue:`2794`) -v20.29.2 (2025-02-10) ---------------------- +*********************** + v20.29.2 (2025-02-10) +*********************** Bugfixes - 20.29.2 -~~~~~~~~~~~~~~~~~~ +================== + - Remove old virtualenv wheel from the source distribution - by :user:`gaborbernat`. (:issue:`2841`) - Upgrade embedded wheel pip to ``25.0.1`` from ``24.3.1`` - by :user:`gaborbernat`. (:issue:`2843`) -v20.29.1 (2025-01-17) ---------------------- +*********************** + v20.29.1 (2025-01-17) +*********************** Bugfixes - 20.29.1 -~~~~~~~~~~~~~~~~~~ +================== + - Fix PyInfo cache incompatibility warnings - by :user:`robsdedude`. (:issue:`2827`) -v20.29.0 (2025-01-15) ---------------------- +*********************** + v20.29.0 (2025-01-15) +*********************** Features - 20.29.0 -~~~~~~~~~~~~~~~~~~ +================== + - Add support for selecting free-threaded Python interpreters, e.g., `python3.13t`. (:issue:`2809`) Bugfixes - 20.29.0 -~~~~~~~~~~~~~~~~~~ +================== + - Upgrade embedded wheels: - * setuptools to ``75.8.0`` from ``75.6.0`` (:issue:`2823`) + - setuptools to ``75.8.0`` from ``75.6.0`` (:issue:`2823`) -v20.28.1 (2025-01-02) ---------------------- +*********************** + v20.28.1 (2025-01-02) +*********************** Bugfixes - 20.28.1 -~~~~~~~~~~~~~~~~~~ +================== + - Skip tcsh tests on broken tcsh versions - by :user:`gaborbernat`. (:issue:`2814`) -v20.28.0 (2024-11-25) ---------------------- +*********************** + 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) ---------------------- +*********************** + v20.27.2 (2024-11-25) +*********************** Bugfixes - 20.27.2 -~~~~~~~~~~~~~~~~~~ +================== + - Upgrade embedded wheels: - * setuptools to ``75.3.0`` from ``75.2.0`` (:issue:`2798`) + - 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`) + - 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`) + - 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) ---------------------- +*********************** + v20.27.1 (2024-10-28) +*********************** Bugfixes - 20.27.1 -~~~~~~~~~~~~~~~~~~ +================== + - Upgrade embedded wheels: - * pip to ``24.3.1`` from ``24.2`` (:issue:`2789`) + - pip to ``24.3.1`` from ``24.2`` (:issue:`2789`) -v20.27.0 (2024-10-17) ---------------------- +*********************** + 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`` +- 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) ---------------------- +*********************** + 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`) +================== + +- Properly quote string placeholders in activation script templates to mitigate potential command injection - by + :user:`y5c4l3`. (:issue:`2768`) -v20.26.5 (2024-09-17) ---------------------- +*********************** + 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) ---------------------- +*********************** + 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`) + - 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) ---------------------- +*********************** + 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`) + - setuptools to ``70.1.0`` from ``69.5.1`` + - pip to ``24.1`` from ``24.0`` (:issue:`2741`) -v20.26.2 (2024-05-13) ---------------------- +*********************** + 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`) +================== + +- ``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) ---------------------- +*********************** + 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) ---------------------- +*********************** + 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) ---------------------- +- 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) ---------------------- +*********************** + 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) ---------------------- +*********************** + 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`) + - 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) ---------------------- +*********************** + 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`) + - 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`) + - 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) ---------------------- +*********************** + 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`) - + - 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) ---------------------- +*********************** + 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`) - + - setuptools to ``68.2.0`` from ``68.1.2`` (:issue:`2642`) -v20.24.4 (2023-08-30) ---------------------- +*********************** + 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`) +- 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) ---------------------- +*********************** + 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`) + - wheel to ``0.41.1`` from ``0.41.0`` (:issue:`2622`) Misc - 20.24.3 -~~~~~~~~~~~~~~ -- :issue:`2610` +============== +- :issue:`2610` -v20.24.2 (2023-07-24) ---------------------- +*********************** + 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`) +- 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) ---------------------- +*********************** + 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`) +- Upgrade embedded wheels: + - pip to ``23.2`` from ``23.1.2`` - by :user:`arielkirkwood` (:issue:`2611`) -v20.24.0 (2023-07-14) ---------------------- +*********************** + 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`) +================== + +- 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`) - + - setuptools to ``68.0.0`` from ``67.8.0`` (:issue:`2607`) -v20.23.1 (2023-06-16) ---------------------- +*********************** + 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`) +- 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) ---------------------- +*********************** + 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`) +================== +- 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) ---------------------- +*********************** + 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) ---------------------- +*********************** + 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`) +- 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`) + - 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) ---------------------- +*********************** + 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`) +================== +- 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) ---------------------- +*********************** + 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`) +================== + +- 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`) +================== +- 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) ---------------------- +*********************** + v20.19.0 (2023-02-07) +*********************** Features - 20.19.0 -~~~~~~~~~~~~~~~~~~ -- Allow platformdirs version ``3`` - by :user:`cdce8p`. (:issue:`2499`) +================== +- Allow platformdirs version ``3`` - by :user:`cdce8p`. (:issue:`2499`) -v20.18.0 (2023-02-06) ---------------------- +*********************** + 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) ---------------------- +*********************** + 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 `_) +================== +- 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) ---------------------- +*********************** + 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 `_) +================== + +- 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 `_) +================== +- 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) ---------------------- +*********************** + 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 `_) +================== +- 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) ---------------------- +*********************** + 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 `_) +================== +- 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) ---------------------- +*********************** + 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 `_) +================== +- 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) ---------------------- +*********************** + v20.16.4 (2022-08-29) +*********************** Bugfixes - 20.16.4 -~~~~~~~~~~~~~~~~~~ -- Bump embed setuptools to ``65.3`` - by :user:`gaborbernat`. (`#2405 `_) +================== +- Bump embed setuptools to ``65.3`` - by :user:`gaborbernat`. (`#2405 + `_) -v20.16.3 (2022-08-04) ---------------------- +*********************** + 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 `_) +================== +- 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) ---------------------- +*********************** + v20.16.2 (2022-07-27) +*********************** Bugfixes - 20.16.2 -~~~~~~~~~~~~~~~~~~ -- Bump embedded pip from ``22.2`` to ``22.2.1`` - by :user:`gaborbernat`. (`#2391 `_) +================== +- Bump embedded pip from ``22.2`` to ``22.2.1`` - by :user:`gaborbernat`. (`#2391 + `_) -v20.16.1 (2022-07-26) ---------------------- +*********************** + v20.16.1 (2022-07-26) +*********************** Features - 20.16.1 -~~~~~~~~~~~~~~~~~~ -- Update Nushell activation scripts to version 0.67 - by :user:`kubouch`. (`#2386 `_) +================== +- Update Nushell activation scripts to version 0.67 - by :user:`kubouch`. (`#2386 + `_) -v20.16.0 (2022-07-25) ---------------------- +*********************** + 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 `_) +================== +- 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) ---------------------- +*********************** + 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 `_) +================== +- 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) ---------------------- +*********************** + 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 `_) +================== + +- 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 `_) +================== +- 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) ---------------------- +*********************** + v20.14.1 (2022-04-11) +*********************** Features - 20.14.1 -~~~~~~~~~~~~~~~~~~ -- Support for creating a virtual environment from a Python 2.7 framework on macOS 12 - by :user:`nickhutchinson`. (`#2284 `_) +================== + +- Support for creating a virtual environment from a Python 2.7 framework on macOS 12 - by :user:`nickhutchinson`. + (`#2284 `_) Bugfixes - 20.14.1 -~~~~~~~~~~~~~~~~~~ -- Upgrade embedded setuptools to ``62.1.0`` from ``61.0.0`` - by :user:`gaborbernat`. (`#2327 `_) +================== +- Upgrade embedded setuptools to ``62.1.0`` from ``61.0.0`` - by :user:`gaborbernat`. (`#2327 + `_) -v20.14.0 (2022-03-25) ---------------------- +*********************** + v20.14.0 (2022-03-25) +*********************** Features - 20.14.0 -~~~~~~~~~~~~~~~~~~ -- Support Nushell activation scripts with nu version ``0.60`` - by :user:`kubouch`. (`#2321 `_) +================== + +- Support Nushell activation scripts with nu version ``0.60`` - by :user:`kubouch`. (`#2321 + `_) Bugfixes - 20.14.0 -~~~~~~~~~~~~~~~~~~ -- Upgrade embedded setuptools to ``61.0.0`` from ``60.10.0`` - by :user:`gaborbernat`. (`#2322 `_) +================== -v20.13.4 (2022-03-18) ---------------------- +- Upgrade embedded setuptools to ``61.0.0`` from ``60.10.0`` - by :user:`gaborbernat`. (`#2322 + `_) + +*********************** + v20.13.4 (2022-03-18) +*********************** 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 `_) +================== + +- 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 + `_) -v20.13.3 (2022-03-07) ---------------------- +*********************** + v20.13.3 (2022-03-07) +*********************** Bugfixes - 20.13.3 -~~~~~~~~~~~~~~~~~~ -- Avoid symlinking the contents of ``/usr`` into PyPy3.8+ virtualenvs - by :user:`stefanor`. (`#2310 `_) -- Bump embed pip from ``22.0.3`` to ``22.0.4`` - by :user:`gaborbernat`. (`#2311 `_) +================== +- Avoid symlinking the contents of ``/usr`` into PyPy3.8+ virtualenvs - by :user:`stefanor`. (`#2310 + `_) +- Bump embed pip from ``22.0.3`` to ``22.0.4`` - by :user:`gaborbernat`. (`#2311 + `_) -v20.13.2 (2022-02-24) ---------------------- +*********************** + v20.13.2 (2022-02-24) +*********************** Bugfixes - 20.13.2 -~~~~~~~~~~~~~~~~~~ -- Upgrade embedded setuptools to ``60.9.3`` from ``60.6.0`` - by :user:`gaborbernat`. (`#2306 `_) +================== +- Upgrade embedded setuptools to ``60.9.3`` from ``60.6.0`` - by :user:`gaborbernat`. (`#2306 + `_) -v20.13.1 (2022-02-05) ---------------------- +*********************** + v20.13.1 (2022-02-05) +*********************** Bugfixes - 20.13.1 -~~~~~~~~~~~~~~~~~~ -- fix "execv() arg 2 must contain only strings" error on M1 MacOS (`#2282 `_) -- 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 `_) +================== +- fix "execv() arg 2 must contain only strings" error on M1 MacOS (`#2282 + `_) +- 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 + `_) -v20.13.0 (2022-01-02) ---------------------- +*********************** + v20.13.0 (2022-01-02) +*********************** Features - 20.13.0 -~~~~~~~~~~~~~~~~~~ -- Add downloaded wheel information in the relevant JSON embed file to - prevent additional downloads of the same wheel. - by :user:`mayeut`. (`#2268 `_) +================== + +- Add downloaded wheel information in the relevant JSON embed file to prevent additional downloads of the same wheel. - + by :user:`mayeut`. (`#2268 `_) Bugfixes - 20.13.0 -~~~~~~~~~~~~~~~~~~ -- Fix ``AttributeError: 'bool' object has no attribute 'error'`` when creating a - Python 2.x virtualenv on macOS - by ``moreati``. (`#2269 `_) -- Fix ``PermissionError: [Errno 1] Operation not permitted`` when creating a - Python 2.x virtualenv on macOS/arm64 - by ``moreati``. (`#2271 `_) +================== +- Fix ``AttributeError: 'bool' object has no attribute 'error'`` when creating a Python 2.x virtualenv on macOS - by + ``moreati``. (`#2269 `_) +- Fix ``PermissionError: [Errno 1] Operation not permitted`` when creating a Python 2.x virtualenv on macOS/arm64 - by + ``moreati``. (`#2271 `_) -v20.12.1 (2022-01-01) ---------------------- +*********************** + v20.12.1 (2022-01-01) +*********************** Bugfixes - 20.12.1 -~~~~~~~~~~~~~~~~~~ -- Try using previous updates of ``pip``, ``setuptools`` & ``wheel`` - when inside an update grace period rather than always falling back - to embedded wheels - by :user:`mayeut`. (`#2265 `_) -- New patch versions of ``pip``, ``setuptools`` & ``wheel`` are now - returned in the expected timeframe. - by :user:`mayeut`. (`#2266 `_) -- Manual upgrades of ``pip``, ``setuptools`` & ``wheel`` are - not discarded by a periodic update - by :user:`mayeut`. (`#2267 `_) +================== +- Try using previous updates of ``pip``, ``setuptools`` & ``wheel`` when inside an update grace period rather than + always falling back to embedded wheels - by :user:`mayeut`. (`#2265 + `_) +- New patch versions of ``pip``, ``setuptools`` & ``wheel`` are now returned in the expected timeframe. - by + :user:`mayeut`. (`#2266 `_) +- Manual upgrades of ``pip``, ``setuptools`` & ``wheel`` are not discarded by a periodic update - by :user:`mayeut`. + (`#2267 `_) -v20.12.0 (2021-12-31) ---------------------- +*********************** + v20.12.0 (2021-12-31) +*********************** Features - 20.12.0 -~~~~~~~~~~~~~~~~~~ -- Sign the python2 exe on Darwin arm64 - by :user:`tmspicer`. (`#2233 `_) +================== + +- Sign the python2 exe on Darwin arm64 - by :user:`tmspicer`. (`#2233 + `_) Bugfixes - 20.12.0 -~~~~~~~~~~~~~~~~~~ -- Fix ``--download`` option - by :user:`mayeut`. (`#2120 `_) -- Upgrade embedded setuptools to ``60.2.0`` from ``60.1.1`` - by :user:`gaborbernat`. (`#2263 `_) +================== +- Fix ``--download`` option - by :user:`mayeut`. (`#2120 `_) +- Upgrade embedded setuptools to ``60.2.0`` from ``60.1.1`` - by :user:`gaborbernat`. (`#2263 + `_) -v20.11.2 (2021-12-29) ---------------------- +*********************** + v20.11.2 (2021-12-29) +*********************** Bugfixes - 20.11.2 -~~~~~~~~~~~~~~~~~~ -- Fix installation of pinned versions of ``pip``, ``setuptools`` & ``wheel`` - by :user:`mayeut`. (`#2203 `_) +================== +- Fix installation of pinned versions of ``pip``, ``setuptools`` & ``wheel`` - by :user:`mayeut`. (`#2203 + `_) -v20.11.1 (2021-12-29) ---------------------- +*********************** + v20.11.1 (2021-12-29) +*********************** Bugfixes - 20.11.1 -~~~~~~~~~~~~~~~~~~ -- Bump embed setuptools to ``60.1.1`` from ``60.1.0`` - by :user:`gaborbernat`. (`#2258 `_) +================== +- Bump embed setuptools to ``60.1.1`` from ``60.1.0`` - by :user:`gaborbernat`. (`#2258 + `_) -v20.11.0 (2021-12-28) ---------------------- +*********************** + v20.11.0 (2021-12-28) +*********************** 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:`d3dave`. (`#2248 `_) +================== +- 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:`d3dave`. (`#2248 + `_) -v20.10.0 (2021-11-01) ---------------------- +*********************** + v20.10.0 (2021-11-01) +*********************** Features - 20.10.0 -~~~~~~~~~~~~~~~~~~ +================== + - If a ``"venv"`` install scheme exists in ``sysconfig``, virtualenv now uses it to create new virtual environments. - This allows Python distributors, such as Fedora, to patch/replace the default install scheme without affecting - the paths in new virtual environments. - 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 - behavior on par with venv. (`#2224 `_) + This allows Python distributors, such as Fedora, to patch/replace the default install scheme without affecting the + paths in new virtual environments. 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 behavior on par with venv. (`#2224 + `_) Bugfixes - 20.10.0 -~~~~~~~~~~~~~~~~~~ -- Fix broken prompt set up by activate.bat - by :user:`SiggyBar`. (`#2225 `_) +================== +- Fix broken prompt set up by activate.bat - by :user:`SiggyBar`. (`#2225 + `_) -v20.9.0 (2021-10-23) --------------------- +********************** + v20.9.0 (2021-10-23) +********************** Features - 20.9.0 -~~~~~~~~~~~~~~~~~ -- Special-case ``--prompt .`` to the name of the current directory - by :user:`rkm`. (`#2220 `_) +================= + +- Special-case ``--prompt .`` to the name of the current directory - by :user:`rkm`. (`#2220 + `_) - Add libffi-8.dll to pypy windows `#2218 `_ - by :user:`mattip` Bugfixes - 20.9.0 -~~~~~~~~~~~~~~~~~ -- Fixed path collision that could lead to a PermissionError or writing to system - directories when using PyPy3.8 - by :user:`mgorny`. (`#2182 `_) +================= + +- Fixed path collision that could lead to a PermissionError or writing to system directories when using PyPy3.8 - by + :user:`mgorny`. (`#2182 `_) - Upgrade embedded setuptools to ``58.3.0`` from ``58.1.0`` and pip to ``21.3.1`` from ``21.2.4`` - by :user:`gaborbernat`. (`#2205 `_) -- Remove stray closing parenthesis in activate.bat - by :user:`SiggyBar`. (`#2221 `_) - +- Remove stray closing parenthesis in activate.bat - by :user:`SiggyBar`. (`#2221 + `_) -v20.8.1 (2021-09-24) --------------------- +********************** + v20.8.1 (2021-09-24) +********************** Bugfixes - 20.8.1 -~~~~~~~~~~~~~~~~~ -- Fixed a bug where while creating a venv on top of an existing one, without cleaning, when seeded - wheel version mismatch occurred, multiple ``.dist-info`` directories may be present, confounding entrypoint - discovery - by :user:`arcivanov` (`#2185 `_) -- Bump embed setuptools from ``58.0.4`` to ``58.1.0`` - by :user:`gaborbernat`. (`#2195 `_) +================= + +- Fixed a bug where while creating a venv on top of an existing one, without cleaning, when seeded wheel version + mismatch occurred, multiple ``.dist-info`` directories may be present, confounding entrypoint discovery - by + :user:`arcivanov` (`#2185 `_) +- Bump embed setuptools from ``58.0.4`` to ``58.1.0`` - by :user:`gaborbernat`. (`#2195 + `_) Misc - 20.8.1 -~~~~~~~~~~~~~ -- `#2189 `_ +============= +- `#2189 `_ -v20.8.0 (2021-09-16) --------------------- +********************** + v20.8.0 (2021-09-16) +********************** -* upgrade embedded setuptools to ``58.0.4`` from ``57.4.0`` and pip to ``21.2.4`` from ``21.2.3`` -* Add nushell activation script +- upgrade embedded setuptools to ``58.0.4`` from ``57.4.0`` and pip to ``21.2.4`` from ``21.2.3`` +- Add nushell activation script -v20.7.2 (2021-08-10) --------------------- +********************** + v20.7.2 (2021-08-10) +********************** Bugfixes - 20.7.2 -~~~~~~~~~~~~~~~~~ -- Upgrade embedded pip to ``21.2.3`` from ``21.2.2`` and wheel to ``0.37.0`` from ``0.36.2`` - by :user:`gaborbernat`. (`#2168 `_) +================= +- Upgrade embedded pip to ``21.2.3`` from ``21.2.2`` and wheel to ``0.37.0`` from ``0.36.2`` - by :user:`gaborbernat`. + (`#2168 `_) -v20.7.1 (2021-08-09) --------------------- +********************** + v20.7.1 (2021-08-09) +********************** Bugfixes - 20.7.1 -~~~~~~~~~~~~~~~~~ -- Fix unpacking dictionary items in PythonInfo.install_path (`#2165 `_) +================= +- Fix unpacking dictionary items in PythonInfo.install_path (`#2165 `_) -v20.7.0 (2021-07-31) --------------------- +********************** + v20.7.0 (2021-07-31) +********************** Bugfixes - 20.7.0 -~~~~~~~~~~~~~~~~~ -- upgrade embedded pip to ``21.2.2`` from ``21.1.3`` and setuptools to ``57.4.0`` from ``57.1.0`` - by :user:`gaborbernat` (`#2159 `_) +================= + +- upgrade embedded pip to ``21.2.2`` from ``21.1.3`` and setuptools to ``57.4.0`` from ``57.1.0`` - by + :user:`gaborbernat` (`#2159 `_) Deprecations and Removals - 20.7.0 -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- Removed ``xonsh`` activator due to this breaking fairly often the CI and lack of support from those packages - maintainers, upstream is encouraged to continue supporting the project as a - `plugin `_ - by :user:`gaborbernat`. (`#2160 `_) +================================== +- Removed ``xonsh`` activator due to this breaking fairly often the CI and lack of support from those packages + maintainers, upstream is encouraged to continue supporting the project as a `plugin + `_ - by :user:`gaborbernat`. (`#2160 + `_) -v20.6.0 (2021-07-14) --------------------- +********************** + v20.6.0 (2021-07-14) +********************** Features - 20.6.0 -~~~~~~~~~~~~~~~~~ -- Support Python interpreters without ``distutils`` (fallback to ``syconfig`` in these cases) - by :user:`gaborbernat`. (`#1910 `_) +================= +- Support Python interpreters without ``distutils`` (fallback to ``syconfig`` in these cases) - by :user:`gaborbernat`. + (`#1910 `_) -v20.5.0 (2021-07-13) --------------------- +********************** + v20.5.0 (2021-07-13) +********************** Features - 20.5.0 -~~~~~~~~~~~~~~~~~ -- Plugins now use 'selectable' entry points - by :user:`jaraco`. (`#2093 `_) +================= + +- Plugins now use 'selectable' entry points - by :user:`jaraco`. (`#2093 + `_) - add libffi-7.dll to the hard-coded list of dlls for PyPy (`#2141 `_) -- Use the better maintained ``platformdirs`` instead of ``appdirs`` - by :user:`gaborbernat`. (`#2142 `_) +- Use the better maintained ``platformdirs`` instead of ``appdirs`` - by :user:`gaborbernat`. (`#2142 + `_) Bugfixes - 20.5.0 -~~~~~~~~~~~~~~~~~ -- Bump pip the embedded pip ``21.1.3`` and setuptools to ``57.1.0`` - by :user:`gaborbernat`. (`#2135 `_) +================= + +- Bump pip the embedded pip ``21.1.3`` and setuptools to ``57.1.0`` - by :user:`gaborbernat`. (`#2135 + `_) Deprecations and Removals - 20.5.0 -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- Drop python ``3.4`` support as it has been over 2 years since EOL - by :user:`gaborbernat`. (`#2141 `_) +================================== +- Drop python ``3.4`` support as it has been over 2 years since EOL - by :user:`gaborbernat`. (`#2141 + `_) -v20.4.7 (2021-05-24) --------------------- +********************** + v20.4.7 (2021-05-24) +********************** Bugfixes - 20.4.7 -~~~~~~~~~~~~~~~~~ -- Upgrade embedded pip to ``21.1.2`` and setuptools to ``57.0.0`` - by :user:`gaborbernat`. (`#2123 `_) +================= +- Upgrade embedded pip to ``21.1.2`` and setuptools to ``57.0.0`` - by :user:`gaborbernat`. (`#2123 + `_) -v20.4.6 (2021-05-05) --------------------- +********************** + v20.4.6 (2021-05-05) +********************** Bugfixes - 20.4.6 -~~~~~~~~~~~~~~~~~ -- Fix ``site.getsitepackages()`` broken on python2 on debian - by :user:`freundTech`. (`#2105 `_) +================= +- Fix ``site.getsitepackages()`` broken on python2 on debian - by :user:`freundTech`. (`#2105 + `_) -v20.4.5 (2021-05-05) --------------------- +********************** + v20.4.5 (2021-05-05) +********************** Bugfixes - 20.4.5 -~~~~~~~~~~~~~~~~~ -- Bump pip to ``21.1.1`` from ``21.0.1`` - by :user:`gaborbernat`. (`#2104 `_) -- Fix ``site.getsitepackages()`` ignoring ``--system-site-packages`` on python2 - by :user:`freundTech`. (`#2106 `_) +================= +- Bump pip to ``21.1.1`` from ``21.0.1`` - by :user:`gaborbernat`. (`#2104 + `_) +- Fix ``site.getsitepackages()`` ignoring ``--system-site-packages`` on python2 - by :user:`freundTech`. (`#2106 + `_) -v20.4.4 (2021-04-20) --------------------- +********************** + v20.4.4 (2021-04-20) +********************** Bugfixes - 20.4.4 -~~~~~~~~~~~~~~~~~ -- Built in discovery class is always preferred over plugin supplied classes. (`#2087 `_) -- Upgrade embedded setuptools to ``56.0.0`` by :user:`gaborbernat`. (`#2094 `_) +================= +- Built in discovery class is always preferred over plugin supplied classes. (`#2087 + `_) +- Upgrade embedded setuptools to ``56.0.0`` by :user:`gaborbernat`. (`#2094 + `_) -v20.4.3 (2021-03-16) --------------------- +********************** + v20.4.3 (2021-03-16) +********************** Bugfixes - 20.4.3 -~~~~~~~~~~~~~~~~~ -- Bump embedded setuptools from ``52.0.0`` to ``54.1.2`` - by :user:`gaborbernat` (`#2069 `_) -- Fix PyPy3 stdlib on Windows is incorrect - by :user:`gaborbernat`. (`#2071 `_) +================= +- Bump embedded setuptools from ``52.0.0`` to ``54.1.2`` - by :user:`gaborbernat` (`#2069 + `_) +- Fix PyPy3 stdlib on Windows is incorrect - by :user:`gaborbernat`. (`#2071 + `_) -v20.4.2 (2021-02-01) --------------------- +********************** + v20.4.2 (2021-02-01) +********************** Bugfixes - 20.4.2 -~~~~~~~~~~~~~~~~~ -- Running virtualenv ``--upgrade-embed-wheels`` crashes - by :user:`gaborbernat`. (`#2058 `_) +================= +- Running virtualenv ``--upgrade-embed-wheels`` crashes - by :user:`gaborbernat`. (`#2058 + `_) -v20.4.1 (2021-01-31) --------------------- +********************** + v20.4.1 (2021-01-31) +********************** Bugfixes - 20.4.1 -~~~~~~~~~~~~~~~~~ -- Bump embedded pip and setuptools packages to latest upstream supported (``21.0.1`` and ``52.0.0``) - by :user:`gaborbernat`. (`#2060 `_) +================= +- Bump embedded pip and setuptools packages to latest upstream supported (``21.0.1`` and ``52.0.0``) - by + :user:`gaborbernat`. (`#2060 `_) -v20.4.0 (2021-01-19) --------------------- +********************** + v20.4.0 (2021-01-19) +********************** Features - 20.4.0 -~~~~~~~~~~~~~~~~~ +================= + - On the programmatic API allow passing in the environment variable dictionary to use, defaults to ``os.environ`` if not specified - by :user:`gaborbernat`. (`#2054 `_) Bugfixes - 20.4.0 -~~~~~~~~~~~~~~~~~ -- Upgrade embedded setuptools to ``51.3.3`` from ``51.1.2`` - by :user:`gaborbernat`. (`#2055 `_) +================= +- Upgrade embedded setuptools to ``51.3.3`` from ``51.1.2`` - by :user:`gaborbernat`. (`#2055 + `_) -v20.3.1 (2021-01-13) --------------------- +********************** + v20.3.1 (2021-01-13) +********************** Bugfixes - 20.3.1 -~~~~~~~~~~~~~~~~~ -- Bump embed pip to ``20.3.3``, setuptools to ``51.1.1`` and wheel to ``0.36.2`` - by :user:`gaborbernat`. (`#2036 `_) -- Allow unfunctioning of pydoc to fail freely so that virtualenvs can be - activated under Zsh with set -e (since otherwise ``unset -f`` and - ``unfunction`` exit with 1 if the function does not exist in Zsh) - by - :user:`d125q`. (`#2049 `_) -- Drop cached python information if the system executable is no longer present (for example when the executable is a - shim and the mapped executable is replaced - such is the case with pyenv) - by :user:`gaborbernat`. (`#2050 `_) +================= +- Bump embed pip to ``20.3.3``, setuptools to ``51.1.1`` and wheel to ``0.36.2`` - by :user:`gaborbernat`. (`#2036 + `_) +- Allow unfunctioning of pydoc to fail freely so that virtualenvs can be activated under Zsh with set -e (since + otherwise ``unset -f`` and ``unfunction`` exit with 1 if the function does not exist in Zsh) - by :user:`d125q`. + (`#2049 `_) +- Drop cached python information if the system executable is no longer present (for example when the executable is a + shim and the mapped executable is replaced - such is the case with pyenv) - by :user:`gaborbernat`. (`#2050 + `_) -v20.3.0 (2021-01-10) --------------------- +********************** + v20.3.0 (2021-01-10) +********************** Features - 20.3.0 -~~~~~~~~~~~~~~~~~ +================= + - The builtin discovery takes now a ``--try-first-with`` argument and is first attempted as valid interpreters. One can - use this to force discovery of a given python executable when the discovery order/mechanism raises errors - - by :user:`gaborbernat`. (`#2046 `_) + use this to force discovery of a given python executable when the discovery order/mechanism raises errors - by + :user:`gaborbernat`. (`#2046 `_) Bugfixes - 20.3.0 -~~~~~~~~~~~~~~~~~ -- On Windows python ``3.7+`` distributions where the exe shim is missing fallback to the old ways - by :user:`gaborbernat`. (`#1986 `_) -- When discovering interpreters on Windows, via the PEP-514, prefer ``PythonCore`` releases over other ones. virtualenv - is used via pip mostly by this distribution, so prefer it over other such as conda - by :user:`gaborbernat`. (`#2046 `_) +================= +- On Windows python ``3.7+`` distributions where the exe shim is missing fallback to the old ways - by + :user:`gaborbernat`. (`#1986 `_) +- When discovering interpreters on Windows, via the PEP-514, prefer ``PythonCore`` releases over other ones. virtualenv + is used via pip mostly by this distribution, so prefer it over other such as conda - by :user:`gaborbernat`. (`#2046 + `_) -v20.2.2 (2020-12-07) --------------------- +********************** + v20.2.2 (2020-12-07) +********************** Bugfixes - 20.2.2 -~~~~~~~~~~~~~~~~~ -- Bump pip to ``20.3.1``, setuptools to ``51.0.0`` and wheel to ``0.36.1`` - by :user:`gaborbernat`. (`#2029 `_) +================= +- Bump pip to ``20.3.1``, setuptools to ``51.0.0`` and wheel to ``0.36.1`` - by :user:`gaborbernat`. (`#2029 + `_) -v20.2.1 (2020-11-23) --------------------- +********************** + v20.2.1 (2020-11-23) +********************** No significant changes. - -v20.2.0 (2020-11-21) --------------------- +********************** + v20.2.0 (2020-11-21) +********************** Features - 20.2.0 -~~~~~~~~~~~~~~~~~ -- Optionally skip VCS ignore directive for entire virtualenv directory, using option :option:`no-vcs-ignore`, by default ``False``. (`#2003 `_) -- Add ``--read-only-app-data`` option to allow for creation based on an existing - app data cache which is non-writable. This may be useful (for example) to - produce a docker image where the app-data is pre-populated. +================= + +- Optionally skip VCS ignore directive for entire virtualenv directory, using option :option:`no-vcs-ignore`, by default + ``False``. (`#2003 `_) +- Add ``--read-only-app-data`` option to allow for creation based on an existing app data cache which is non-writable. + This may be useful (for example) to produce a docker image where the app-data is pre-populated. .. code-block:: dockerfile @@ -1087,114 +1432,147 @@ Features - 20.2.0 Patch by :user:`asottile`. (`#2009 `_) Bugfixes - 20.2.0 -~~~~~~~~~~~~~~~~~ -- Fix processing of the ``VIRTUALENV_PYTHON`` environment variable and make it - multi-value as well (separated by comma) - by :user:`pneff`. (`#1998 `_) +================= +- Fix processing of the ``VIRTUALENV_PYTHON`` environment variable and make it multi-value as well (separated by comma) + - by :user:`pneff`. (`#1998 `_) -v20.1.0 (2020-10-25) --------------------- +********************** + v20.1.0 (2020-10-25) +********************** Features - 20.1.0 -~~~~~~~~~~~~~~~~~ +================= + - The python specification can now take one or more values, first found is used to create the virtual environment - by :user:`gaborbernat`. (`#1995 `_) - -v20.0.35 (2020-10-15) ---------------------- +*********************** + v20.0.35 (2020-10-15) +*********************** Bugfixes - 20.0.35 -~~~~~~~~~~~~~~~~~~ -- Bump embedded setuptools from ``50.3.0`` to ``50.3.1`` - by :user:`gaborbernat`. (`#1982 `_) -- After importing virtualenv passing cwd to a subprocess calls breaks with ``invalid directory`` - by :user:`gaborbernat`. (`#1983 `_) +================== +- Bump embedded setuptools from ``50.3.0`` to ``50.3.1`` - by :user:`gaborbernat`. (`#1982 + `_) +- After importing virtualenv passing cwd to a subprocess calls breaks with ``invalid directory`` - by + :user:`gaborbernat`. (`#1983 `_) -v20.0.34 (2020-10-12) ---------------------- +*********************** + v20.0.34 (2020-10-12) +*********************** Bugfixes - 20.0.34 -~~~~~~~~~~~~~~~~~~ -- Align with venv module when creating virtual environments with builtin creator on Windows 3.7 and later - - by :user:`gaborbernat`. (`#1782 `_) -- Handle Cygwin path conversion in the activation script - by :user:`davidcoghlan`. (`#1969 `_) +================== +- Align with venv module when creating virtual environments with builtin creator on Windows 3.7 and later - by + :user:`gaborbernat`. (`#1782 `_) +- Handle Cygwin path conversion in the activation script - by :user:`davidcoghlan`. (`#1969 + `_) -v20.0.33 (2020-10-04) ---------------------- +*********************** + v20.0.33 (2020-10-04) +*********************** Bugfixes - 20.0.33 -~~~~~~~~~~~~~~~~~~ -- Fix ``None`` type error in cygwin if POSIX path in dest - by :user:`danyeaw`. (`#1962 `_) -- Fix Python 3.4 incompatibilities (added back to the CI) - by :user:`gaborbernat`. (`#1963 `_) +================== +- Fix ``None`` type error in cygwin if POSIX path in dest - by :user:`danyeaw`. (`#1962 + `_) +- Fix Python 3.4 incompatibilities (added back to the CI) - by :user:`gaborbernat`. (`#1963 + `_) -v20.0.32 (2020-10-01) ---------------------- +*********************** + v20.0.32 (2020-10-01) +*********************** Bugfixes - 20.0.32 -~~~~~~~~~~~~~~~~~~ -- For activation scripts always use UNIX line endings (unless it's BATCH shell related) - by :user:`saytosid`. (`#1818 `_) -- Upgrade embedded pip to ``20.2.1`` and setuptools to ``49.4.0`` - by :user:`gaborbernat`. (`#1918 `_) -- Avoid spawning new windows when doing seed package upgrades in the background on Windows - by :user:`gaborbernat`. (`#1928 `_) -- Fix a bug that reading and writing on the same file may cause race on multiple processes. (`#1938 `_) -- Upgrade embedded setuptools to ``50.2.0`` and pip to ``20.2.3`` - by :user:`gaborbernat`. (`#1939 `_) -- Provide correct path for bash activator in cygwin or msys2 - by :user:`danyeaw`. (`#1940 `_) -- Relax importlib requirement to allow version<3 - by :user:`usamasadiq` (`#1953 `_) -- pth files were not processed on CPython2 if $PYTHONPATH was pointing to site-packages/ - by :user:`navytux`. (`#1959 `_) (`#1960 `_) - - -v20.0.31 (2020-08-17) ---------------------- +================== + +- For activation scripts always use UNIX line endings (unless it's BATCH shell related) - by :user:`saytosid`. (`#1818 + `_) +- Upgrade embedded pip to ``20.2.1`` and setuptools to ``49.4.0`` - by :user:`gaborbernat`. (`#1918 + `_) +- Avoid spawning new windows when doing seed package upgrades in the background on Windows - by :user:`gaborbernat`. + (`#1928 `_) +- Fix a bug that reading and writing on the same file may cause race on multiple processes. (`#1938 + `_) +- Upgrade embedded setuptools to ``50.2.0`` and pip to ``20.2.3`` - by :user:`gaborbernat`. (`#1939 + `_) +- Provide correct path for bash activator in cygwin or msys2 - by :user:`danyeaw`. (`#1940 + `_) +- Relax importlib requirement to allow version<3 - by :user:`usamasadiq` (`#1953 + `_) +- pth files were not processed on CPython2 if $PYTHONPATH was pointing to site-packages/ - by :user:`navytux`. (`#1959 + `_) (`#1960 `_) + +*********************** + v20.0.31 (2020-08-17) +*********************** Bugfixes - 20.0.31 -~~~~~~~~~~~~~~~~~~ -- Upgrade embedded pip to ``20.2.1``, setuptools to ``49.6.0`` and wheel to ``0.35.1`` - by :user:`gaborbernat`. (`#1918 `_) +================== +- Upgrade embedded pip to ``20.2.1``, setuptools to ``49.6.0`` and wheel to ``0.35.1`` - by :user:`gaborbernat`. (`#1918 + `_) -v20.0.30 (2020-08-04) ---------------------- +*********************** + v20.0.30 (2020-08-04) +*********************** Bugfixes - 20.0.30 -~~~~~~~~~~~~~~~~~~ -- Upgrade pip to ``20.2.1`` and setuptools to ``49.2.1`` - by :user:`gaborbernat`. (`#1915 `_) +================== +- Upgrade pip to ``20.2.1`` and setuptools to ``49.2.1`` - by :user:`gaborbernat`. (`#1915 + `_) -v20.0.29 (2020-07-31) ---------------------- +*********************** + v20.0.29 (2020-07-31) +*********************** Bugfixes - 20.0.29 -~~~~~~~~~~~~~~~~~~ -- Upgrade embedded pip from version ``20.1.2`` to ``20.2`` - by :user:`gaborbernat`. (`#1909 `_) +================== +- Upgrade embedded pip from version ``20.1.2`` to ``20.2`` - by :user:`gaborbernat`. (`#1909 + `_) -v20.0.28 (2020-07-24) ---------------------- +*********************** + v20.0.28 (2020-07-24) +*********************** Bugfixes - 20.0.28 -~~~~~~~~~~~~~~~~~~ -- Fix test suite failing if run from system Python - by :user:`gaborbernat`. (`#1882 `_) +================== + +- Fix test suite failing if run from system Python - by :user:`gaborbernat`. (`#1882 + `_) - Provide ``setup_logging`` flag to python API so that users can bypass logging handling if their application already performs this - by :user:`gaborbernat`. (`#1896 `_) - Use ``\n`` instead if ``\r\n`` as line separator for report (because Python already performs this transformation - automatically upon write to the logging pipe) - by :user:`gaborbernat`. (`#1905 `_) + automatically upon write to the logging pipe) - by :user:`gaborbernat`. (`#1905 + `_) - -v20.0.27 (2020-07-15) ---------------------- +*********************** + v20.0.27 (2020-07-15) +*********************** Bugfixes - 20.0.27 -~~~~~~~~~~~~~~~~~~ -- No longer preimport threading to fix support for `gpython `_ and `gevent `_ - by :user:`navytux`. (`#1897 `_) -- Upgrade setuptools from ``49.2.0`` on ``Python 3.5+`` - by :user:`gaborbernat`. (`#1898 `_) +================== +- No longer preimport threading to fix support for `gpython `_ and `gevent + `_ - by :user:`navytux`. (`#1897 `_) +- Upgrade setuptools from ``49.2.0`` on ``Python 3.5+`` - by :user:`gaborbernat`. (`#1898 + `_) -v20.0.26 (2020-07-07) ---------------------- +*********************** + v20.0.26 (2020-07-07) +*********************** Bugfixes - 20.0.26 -~~~~~~~~~~~~~~~~~~ -- Bump dependency ``distutils >= 0.3.1`` - by :user:`gaborbernat`. (`#1880 `_) +================== + +- Bump dependency ``distutils >= 0.3.1`` - by :user:`gaborbernat`. (`#1880 + `_) - Improve periodic update handling: - better logging output while running and enable logging on background process call ( @@ -1203,36 +1581,45 @@ Bugfixes - 20.0.26 - stop downloading wheels once we reach the embedded version, by :user:`gaborbernat`. (`#1883 `_) -- Do not print error message if the application exists with ``SystemExit(0)`` - by :user:`gaborbernat`. (`#1885 `_) -- Upgrade embedded setuptools from ``47.3.1`` to ``49.1.0`` for Python ``3.5+`` - by :user:`gaborbernat`. (`#1887 `_) +- Do not print error message if the application exists with ``SystemExit(0)`` - by :user:`gaborbernat`. (`#1885 + `_) +- Upgrade embedded setuptools from ``47.3.1`` to ``49.1.0`` for Python ``3.5+`` - by :user:`gaborbernat`. (`#1887 + `_) -v20.0.25 (2020-06-23) ---------------------- +*********************** + v20.0.25 (2020-06-23) +*********************** Bugfixes - 20.0.25 -~~~~~~~~~~~~~~~~~~ -- Fix that when the ``app-data`` seeders image creation fails the exception is silently ignored. Avoid two virtual environment creations to step on each others toes by using a lock while creating the base images. By :user:`gaborbernat`. (`#1869 `_) +================== +- Fix that when the ``app-data`` seeders image creation fails the exception is silently ignored. Avoid two virtual + environment creations to step on each others toes by using a lock while creating the base images. By + :user:`gaborbernat`. (`#1869 `_) -v20.0.24 (2020-06-22) ---------------------- +*********************** + v20.0.24 (2020-06-22) +*********************** Features - 20.0.24 -~~~~~~~~~~~~~~~~~~ +================== + - Ensure that the seeded packages do not get too much out of date: - add a CLI flag that triggers upgrade of embedded wheels under :option:`upgrade-embed-wheels` - - periodically (once every 14 days) upgrade the embedded wheels in a background process, and use them if they have been - released for more than 28 days (can be disabled via :option:`no-periodic-update`) + - periodically (once every 14 days) upgrade the embedded wheels in a background process, and use them if they have + been released for more than 28 days (can be disabled via :option:`no-periodic-update`) More details under :ref:`wheels` - by :user:`gaborbernat`. (`#1821 `_) + - Upgrade embed wheel content: - ship wheels for Python ``3.9`` and ``3.10`` - upgrade setuptools for Python ``3.5+`` from ``47.1.1`` to ``47.3.1`` by :user:`gaborbernat`. (`#1841 `_) + - Display the installed seed package versions in the final summary output, for example: .. code-block:: console @@ -1245,249 +1632,317 @@ Features - 20.0.24 by :user:`gaborbernat`. (`#1864 `_) Bugfixes - 20.0.24 -~~~~~~~~~~~~~~~~~~ -- Do not generate/overwrite ``.gitignore`` if it already exists at destination path - by :user:`gaborbernat`. (`#1862 `_) -- Improve error message for no ``.dist-info`` inside the ``app-data`` copy seeder - by :user:`gaborbernat`. (`#1867 `_) +================== + +- Do not generate/overwrite ``.gitignore`` if it already exists at destination path - by :user:`gaborbernat`. (`#1862 + `_) +- Improve error message for no ``.dist-info`` inside the ``app-data`` copy seeder - by :user:`gaborbernat`. (`#1867 + `_) Improved Documentation - 20.0.24 -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- How seeding mechanisms discover (and automatically keep it up to date) wheels at :ref:`wheels` - by :user:`gaborbernat`. (`#1821 `_) -- How distributions should handle shipping their own embedded wheels at :ref:`distribution_wheels` - by :user:`gaborbernat`. (`#1840 `_) +================================ +- How seeding mechanisms discover (and automatically keep it up to date) wheels at :ref:`wheels` - by + :user:`gaborbernat`. (`#1821 `_) +- How distributions should handle shipping their own embedded wheels at :ref:`distribution_wheels` - by + :user:`gaborbernat`. (`#1840 `_) -v20.0.23 (2020-06-12) ---------------------- +*********************** + v20.0.23 (2020-06-12) +*********************** Bugfixes - 20.0.23 -~~~~~~~~~~~~~~~~~~ -- Fix typo in ``setup.cfg`` - by :user:`RowdyHowell`. (`#1857 `_) +================== +- Fix typo in ``setup.cfg`` - by :user:`RowdyHowell`. (`#1857 `_) -v20.0.22 (2020-06-12) ---------------------- +*********************** + v20.0.22 (2020-06-12) +*********************** Bugfixes - 20.0.22 -~~~~~~~~~~~~~~~~~~ -- Relax ``importlib.resources`` requirement to also allow version 2 - by :user:`asottile`. (`#1846 `_) -- Upgrade embedded setuptools to ``44.1.1`` for python 2 and ``47.1.1`` for python3.5+ - by :user:`gaborbernat`. (`#1855 `_) +================== +- Relax ``importlib.resources`` requirement to also allow version 2 - by :user:`asottile`. (`#1846 + `_) +- Upgrade embedded setuptools to ``44.1.1`` for python 2 and ``47.1.1`` for python3.5+ - by :user:`gaborbernat`. (`#1855 + `_) -v20.0.21 (2020-05-20) ---------------------- +*********************** + v20.0.21 (2020-05-20) +*********************** Features - 20.0.21 -~~~~~~~~~~~~~~~~~~ +================== + - Generate ignore file for version control systems to avoid tracking virtual environments by default. Users should - remove these files if still want to track. For now we support only **git** by :user:`gaborbernat`. (`#1806 `_) + remove these files if still want to track. For now we support only **git** by :user:`gaborbernat`. (`#1806 + `_) Bugfixes - 20.0.21 -~~~~~~~~~~~~~~~~~~ +================== + - Fix virtualenv fails sometimes when run concurrently, ``--clear-app-data`` conflicts with :option:`clear` flag when abbreviation is turned on. To bypass this while allowing abbreviated flags on the command line we had to move it to :option:`reset-app-data` - by :user:`gaborbernat`. (`#1824 `_) -- Upgrade embedded ``setuptools`` to ``46.4.0`` from ``46.1.3`` on Python ``3.5+``, and ``pip`` from ``20.1`` to ``20.1.1`` - by :user:`gaborbernat`. (`#1827 `_) -- Seeder pip now correctly handles ``--extra-search-dir`` - by :user:`frenzymadness`. (`#1834 `_) - +- Upgrade embedded ``setuptools`` to ``46.4.0`` from ``46.1.3`` on Python ``3.5+``, and ``pip`` from ``20.1`` to + ``20.1.1`` - by :user:`gaborbernat`. (`#1827 `_) +- Seeder pip now correctly handles ``--extra-search-dir`` - by :user:`frenzymadness`. (`#1834 + `_) -v20.0.20 (2020-05-04) ---------------------- +*********************** + v20.0.20 (2020-05-04) +*********************** Bugfixes - 20.0.20 -~~~~~~~~~~~~~~~~~~ -- Fix download fails with python 3.4 - by :user:`gaborbernat`. (`#1809 `_) -- Fixes older CPython2 versions use ``_get_makefile_filename`` instead of ``get_makefile_filename`` on ``sysconfig`` - by :user:`ianw`. (`#1810 `_) -- Fix download is ``True`` by default - by :user:`gaborbernat`. (`#1813 `_) -- Fail ``app-data`` seed operation when wheel download fails and better error message - by :user:`gaborbernat`. (`#1814 `_) +================== +- Fix download fails with python 3.4 - by :user:`gaborbernat`. (`#1809 + `_) +- Fixes older CPython2 versions use ``_get_makefile_filename`` instead of ``get_makefile_filename`` on ``sysconfig`` - + by :user:`ianw`. (`#1810 `_) +- Fix download is ``True`` by default - by :user:`gaborbernat`. (`#1813 + `_) +- Fail ``app-data`` seed operation when wheel download fails and better error message - by :user:`gaborbernat`. (`#1814 + `_) -v20.0.19 (2020-05-03) ---------------------- +*********************** + v20.0.19 (2020-05-03) +*********************** Bugfixes - 20.0.19 -~~~~~~~~~~~~~~~~~~ -- Fix generating a Python 2 environment from Python 3 creates invalid python activator - by :user:`gaborbernat`. (`#1776 `_) -- Fix pinning seed packages via ``app-data`` seeder raised ``Invalid Requirement`` - by :user:`gaborbernat`. (`#1779 `_) -- Do not stop interpreter discovery if we fail to find the system interpreter for a executable during discovery - - by :user:`gaborbernat`. (`#1781 `_) -- On CPython2 POSIX platforms ensure ``syconfig.get_makefile_filename`` exists within the virtual environment (this is used by some c-extension based libraries - e.g. numpy - for building) - by :user:`gaborbernat`. (`#1783 `_) -- Better handling of options :option:`copies` and :option:`symlinks`. Introduce priority of where the option is set - to follow the order: CLI, env var, file, hardcoded. If both set at same level prefers copy over symlink. - by +================== + +- Fix generating a Python 2 environment from Python 3 creates invalid python activator - by :user:`gaborbernat`. (`#1776 + `_) +- Fix pinning seed packages via ``app-data`` seeder raised ``Invalid Requirement`` - by :user:`gaborbernat`. (`#1779 + `_) +- Do not stop interpreter discovery if we fail to find the system interpreter for a executable during discovery - by + :user:`gaborbernat`. (`#1781 `_) +- On CPython2 POSIX platforms ensure ``syconfig.get_makefile_filename`` exists within the virtual environment (this is + used by some c-extension based libraries - e.g. numpy - for building) - by :user:`gaborbernat`. (`#1783 + `_) +- Better handling of options :option:`copies` and :option:`symlinks`. Introduce priority of where the option is set to + follow the order: CLI, env var, file, hardcoded. If both set at same level prefers copy over symlink. - by :user:`gaborbernat`. (`#1784 `_) -- Upgrade pip for Python ``2.7`` and ``3.5+`` from ``20.0.2`` to ``20.1`` - by :user:`gaborbernat`. (`#1793 `_) -- Fix CPython is not discovered from Windows registry, and discover pythons from Windows registry in decreasing order - by version - by :user:`gaborbernat`. (`#1796 `_) +- Upgrade pip for Python ``2.7`` and ``3.5+`` from ``20.0.2`` to ``20.1`` - by :user:`gaborbernat`. (`#1793 + `_) +- Fix CPython is not discovered from Windows registry, and discover pythons from Windows registry in decreasing order by + version - by :user:`gaborbernat`. (`#1796 `_) - Fix symlink detection for creators - by :user:`asottile` (`#1803 `_) - -v20.0.18 (2020-04-16) ---------------------- +*********************** + v20.0.18 (2020-04-16) +*********************** Bugfixes - 20.0.18 -~~~~~~~~~~~~~~~~~~ +================== + - Importing setuptools before cli_run could cause our python information query to fail due to setuptools patching ``distutils.dist.Distribution`` - by :user:`gaborbernat`. (`#1771 `_) - -v20.0.17 (2020-04-09) ---------------------- +*********************** + v20.0.17 (2020-04-09) +*********************** Features - 20.0.17 -~~~~~~~~~~~~~~~~~~ -- Extend environment variables checked for configuration to also check aliases (e.g. setting either - ``VIRTUALENV_COPIES`` or ``VIRTUALENV_ALWAYS_COPY`` will work) - by :user:`gaborbernat`. (`#1763 `_) +================== +- Extend environment variables checked for configuration to also check aliases (e.g. setting either + ``VIRTUALENV_COPIES`` or ``VIRTUALENV_ALWAYS_COPY`` will work) - by :user:`gaborbernat`. (`#1763 + `_) -v20.0.16 (2020-04-04) ---------------------- +*********************** + v20.0.16 (2020-04-04) +*********************** Bugfixes - 20.0.16 -~~~~~~~~~~~~~~~~~~ -- Allow seed wheel files inside the :option:`extra-search-dir` folders that do not have ``Requires-Python`` - metadata specified, these are considered compatible with all python versions - by :user:`gaborbernat`. (`#1757 `_) +================== +- Allow seed wheel files inside the :option:`extra-search-dir` folders that do not have ``Requires-Python`` metadata + specified, these are considered compatible with all python versions - by :user:`gaborbernat`. (`#1757 + `_) -v20.0.15 (2020-03-27) ---------------------- +*********************** + v20.0.15 (2020-03-27) +*********************** Features - 20.0.15 -~~~~~~~~~~~~~~~~~~ -- Upgrade embedded setuptools to ``46.1.3`` from ``46.1.1`` - by :user:`gaborbernat`. (`#1752 `_) +================== +- Upgrade embedded setuptools to ``46.1.3`` from ``46.1.1`` - by :user:`gaborbernat`. (`#1752 + `_) -v20.0.14 (2020-03-25) ---------------------- +*********************** + v20.0.14 (2020-03-25) +*********************** Features - 20.0.14 -~~~~~~~~~~~~~~~~~~ +================== + - Remove ``__PYVENV_LAUNCHER__`` on macOs for Python ``3.7.(<8)`` and ``3.8.(<3)`` on interpreter startup via ``pth`` - file, this pulls in the `upstream patch `_ - by :user:`gaborbernat`. (`#1704 `_) -- Upgrade embedded setuptools for Python ``3.5+`` to ``46.1.1``, for Python ``2.7`` to ``44.1.0`` - by :user:`gaborbernat`. (`#1745 `_) + file, this pulls in the `upstream patch `_ - by :user:`gaborbernat`. + (`#1704 `_) +- Upgrade embedded setuptools for Python ``3.5+`` to ``46.1.1``, for Python ``2.7`` to ``44.1.0`` - by + :user:`gaborbernat`. (`#1745 `_) Bugfixes - 20.0.14 -~~~~~~~~~~~~~~~~~~ -- Fix discovery of interpreter by name from ``PATH`` that does not match a spec format - by :user:`gaborbernat`. (`#1746 `_) +================== +- Fix discovery of interpreter by name from ``PATH`` that does not match a spec format - by :user:`gaborbernat`. (`#1746 + `_) -v20.0.13 (2020-03-19) ---------------------- +*********************** + v20.0.13 (2020-03-19) +*********************** Bugfixes - 20.0.13 -~~~~~~~~~~~~~~~~~~ -- Do not fail when the pyc files is missing for the host Python 2 - by :user:`gaborbernat`. (`#1738 `_) -- Support broken Packaging pythons that put the include headers under distutils pattern rather than sysconfig one - - by :user:`gaborbernat`. (`#1739 `_) +================== +- Do not fail when the pyc files is missing for the host Python 2 - by :user:`gaborbernat`. (`#1738 + `_) +- Support broken Packaging pythons that put the include headers under distutils pattern rather than sysconfig one - by + :user:`gaborbernat`. (`#1739 `_) -v20.0.12 (2020-03-19) ---------------------- +*********************** + v20.0.12 (2020-03-19) +*********************** Bugfixes - 20.0.12 -~~~~~~~~~~~~~~~~~~ -- Fix relative path discovery of interpreters - by :user:`gaborbernat`. (`#1734 `_) +================== +- Fix relative path discovery of interpreters - by :user:`gaborbernat`. (`#1734 + `_) -v20.0.11 (2020-03-18) ---------------------- +*********************** + v20.0.11 (2020-03-18) +*********************** Features - 20.0.11 -~~~~~~~~~~~~~~~~~~ +================== + - Improve error message when the host python does not satisfy invariants needed to create virtual environments (now we - print which host files are incompatible/missing and for which creators when no supported creator can be matched, however - we found creators that can describe the given Python interpreter - will still print no supported creator for Jython, - however print exactly what host files do not allow creation of virtual environments in case of CPython/PyPy) - - by :user:`gaborbernat`. (`#1716 `_) + print which host files are incompatible/missing and for which creators when no supported creator can be matched, + however we found creators that can describe the given Python interpreter - will still print no supported creator for + Jython, however print exactly what host files do not allow creation of virtual environments in case of CPython/PyPy) - + by :user:`gaborbernat`. (`#1716 `_) Bugfixes - 20.0.11 -~~~~~~~~~~~~~~~~~~ -- Support Python 3 Framework distributed via XCode in macOs Catalina and before - by :user:`gaborbernat`. (`#1663 `_) -- Fix Windows Store Python support, do not allow creation via symlink as that's not going to work by design - - by :user:`gaborbernat`. (`#1709 `_) +================== + +- Support Python 3 Framework distributed via XCode in macOs Catalina and before - by :user:`gaborbernat`. (`#1663 + `_) +- Fix Windows Store Python support, do not allow creation via symlink as that's not going to work by design - by + :user:`gaborbernat`. (`#1709 `_) - Fix ``activate_this.py`` throws ``AttributeError`` on Windows when virtual environment was created via cross python mechanism - by :user:`gaborbernat`. (`#1710 `_) -- Fix ``--no-pip``, ``--no-setuptools``, ``--no-wheel`` not being respected - by :user:`gaborbernat`. (`#1712 `_) -- Allow missing ``.py`` files if a compiled ``.pyc`` version is available - by :user:`tucked`. (`#1714 `_) -- Do not fail if the distutils/setuptools patch happens on a C-extension loader (such as ``zipimporter`` on Python 3.7 or - earlier) - by :user:`gaborbernat`. (`#1715 `_) +- Fix ``--no-pip``, ``--no-setuptools``, ``--no-wheel`` not being respected - by :user:`gaborbernat`. (`#1712 + `_) +- Allow missing ``.py`` files if a compiled ``.pyc`` version is available - by :user:`tucked`. (`#1714 + `_) +- Do not fail if the distutils/setuptools patch happens on a C-extension loader (such as ``zipimporter`` on Python 3.7 + or earlier) - by :user:`gaborbernat`. (`#1715 `_) - Support Python 2 implementations that require the landmark files and ``site.py`` to be in platform standard library - instead of the standard library path of the virtual environment (notably some RHEL ones, such as the Docker - image ``amazonlinux:1``) - by :user:`gaborbernat`. (`#1719 `_) + instead of the standard library path of the virtual environment (notably some RHEL ones, such as the Docker image + ``amazonlinux:1``) - by :user:`gaborbernat`. (`#1719 `_) - Allow the test suite to pass even when called with the system Python - to help repackaging of the tool for Linux - distributions - by :user:`gaborbernat`. (`#1721 `_) -- Also generate ``pipx.y`` console script beside ``pip-x.y`` to be compatible with how pip installs itself - - by :user:`gaborbernat`. (`#1723 `_) -- Automatically create the application data folder if it does not exists - by :user:`gaborbernat`. (`#1728 `_) + distributions - by :user:`gaborbernat`. (`#1721 `_) +- Also generate ``pipx.y`` console script beside ``pip-x.y`` to be compatible with how pip installs itself - by + :user:`gaborbernat`. (`#1723 `_) +- Automatically create the application data folder if it does not exists - by :user:`gaborbernat`. (`#1728 + `_) Improved Documentation - 20.0.11 -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- :ref:`supports ` details now explicitly what Python installations we support - - by :user:`gaborbernat`. (`#1714 `_) +================================ +- :ref:`supports ` details now explicitly what Python installations we support - by + :user:`gaborbernat`. (`#1714 `_) -v20.0.10 (2020-03-10) ---------------------- +*********************** + v20.0.10 (2020-03-10) +*********************** Bugfixes - 20.0.10 -~~~~~~~~~~~~~~~~~~ +================== + - Fix acquiring python information might be altered by distutils configuration files generating incorrect layout virtual environments - by :user:`gaborbernat`. (`#1663 `_) -- Upgrade embedded setuptools to ``46.0.0`` from ``45.3.0`` on Python ``3.5+`` - by :user:`gaborbernat`. (`#1702 `_) +- Upgrade embedded setuptools to ``46.0.0`` from ``45.3.0`` on Python ``3.5+`` - by :user:`gaborbernat`. (`#1702 + `_) Improved Documentation - 20.0.10 -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +================================ + - Document requirements (pip + index server) when installing via pip under the installation section - by :user:`gaborbernat`. (`#1618 `_) -- Document installing from non PEP-518 systems - :user:`gaborbernat`. (`#1619 `_) -- Document installing latest unreleased version from Github - :user:`gaborbernat`. (`#1620 `_) +- Document installing from non PEP-518 systems - :user:`gaborbernat`. (`#1619 + `_) +- Document installing latest unreleased version from Github - :user:`gaborbernat`. (`#1620 + `_) - -v20.0.9 (2020-03-08) --------------------- +********************** + v20.0.9 (2020-03-08) +********************** Bugfixes - 20.0.9 -~~~~~~~~~~~~~~~~~ -- ``pythonw.exe`` works as ``python.exe`` on Windows - by :user:`gaborbernat`. (`#1686 `_) -- Handle legacy loaders for virtualenv import hooks used to patch distutils configuration load - by :user:`gaborbernat`. (`#1690 `_) +================= + +- ``pythonw.exe`` works as ``python.exe`` on Windows - by :user:`gaborbernat`. (`#1686 + `_) +- Handle legacy loaders for virtualenv import hooks used to patch distutils configuration load - by :user:`gaborbernat`. + (`#1690 `_) - Support for python 2 platforms that store landmark files in ``platstdlib`` over ``stdlib`` (e.g. RHEL) - by :user:`gaborbernat`. (`#1694 `_) -- Upgrade embedded setuptools to ``45.3.0`` from ``45.2.0`` for Python ``3.5+`` - by :user:`gaborbernat`. (`#1699 `_) - +- Upgrade embedded setuptools to ``45.3.0`` from ``45.2.0`` for Python ``3.5+`` - by :user:`gaborbernat`. (`#1699 + `_) -v20.0.8 (2020-03-04) --------------------- +********************** + v20.0.8 (2020-03-04) +********************** Bugfixes - 20.0.8 -~~~~~~~~~~~~~~~~~ -- Having `distutils configuration `_ - files that set ``prefix`` and ``install_scripts`` cause installation of packages in the wrong location - - by :user:`gaborbernat`. (`#1663 `_) -- Fix ``PYTHONPATH`` being overridden on Python 2 — by :user:`jd`. (`#1673 `_) -- Fix list configuration value parsing from config file or environment variable - by :user:`gaborbernat`. (`#1674 `_) -- Fix Batch activation script shell prompt to display environment name by default - by :user:`spetafree`. (`#1679 `_) -- Fix startup on Python 2 is slower for virtualenv - this was due to setuptools calculating it's working set distribution - - by :user:`gaborbernat`. (`#1682 `_) +================= + +- Having `distutils configuration `_ files + that set ``prefix`` and ``install_scripts`` cause installation of packages in the wrong location - by + :user:`gaborbernat`. (`#1663 `_) +- Fix ``PYTHONPATH`` being overridden on Python 2 — by :user:`jd`. (`#1673 + `_) +- Fix list configuration value parsing from config file or environment variable - by :user:`gaborbernat`. (`#1674 + `_) +- Fix Batch activation script shell prompt to display environment name by default - by :user:`spetafree`. (`#1679 + `_) +- Fix startup on Python 2 is slower for virtualenv - this was due to setuptools calculating it's working set + distribution - by :user:`gaborbernat`. (`#1682 `_) - Fix entry points are not populated for editable installs on Python 2 due to setuptools working set being calculated before ``easy_install.pth`` runs - by :user:`gaborbernat`. (`#1684 `_) -- Fix ``attr:`` import fails for setuptools - by :user:`gaborbernat`. (`#1685 `_) +- Fix ``attr:`` import fails for setuptools - by :user:`gaborbernat`. (`#1685 + `_) - -v20.0.7 (2020-02-26) --------------------- +********************** + v20.0.7 (2020-02-26) +********************** Bugfixes - 20.0.7 -~~~~~~~~~~~~~~~~~ +================= + - Disable distutils fixup for python 3 until `pypa/pip #7778 `_ is fixed and released - by :user:`gaborbernat`. (`#1669 `_) - -v20.0.6 (2020-02-26) --------------------- +********************** + v20.0.6 (2020-02-26) +********************** Bugfixes - 20.0.6 -~~~~~~~~~~~~~~~~~ -- Fix global site package always being added with bundled macOs python framework builds - by :user:`gaborbernat`. (`#1561 `_) -- Fix generated scripts use host version info rather than target - by :user:`gaborbernat`. (`#1600 `_) -- Fix circular prefix reference with single elements (accept these as if they were system executables, print a info about - them referencing themselves) - by :user:`gaborbernat`. (`#1632 `_) +================= + +- Fix global site package always being added with bundled macOs python framework builds - by :user:`gaborbernat`. + (`#1561 `_) +- Fix generated scripts use host version info rather than target - by :user:`gaborbernat`. (`#1600 + `_) +- Fix circular prefix reference with single elements (accept these as if they were system executables, print a info + about them referencing themselves) - by :user:`gaborbernat`. (`#1632 + `_) - Handle the case when the application data folder is read-only: - the application data folder is now controllable via :option:`app-data`, @@ -1497,75 +1952,103 @@ Bugfixes - 20.0.6 - :option:`symlink-app-data` is always ``False`` when the application data is temporary by :user:`gaborbernat`. (`#1640 `_) -- Fix PyPy 2 builtin modules are imported from standard library, rather than from builtin - by :user:`gaborbernat`. (`#1652 `_) -- Fix creation of entry points when path contains spaces - by :user:`nsoranzo`. (`#1660 `_) -- Fix relative paths for the zipapp (for python ``3.7+``) - by :user:`gaborbernat`. (`#1666 `_) -v20.0.5 (2020-02-21) --------------------- +- Fix PyPy 2 builtin modules are imported from standard library, rather than from builtin - by :user:`gaborbernat`. + (`#1652 `_) +- Fix creation of entry points when path contains spaces - by :user:`nsoranzo`. (`#1660 + `_) +- Fix relative paths for the zipapp (for python ``3.7+``) - by :user:`gaborbernat`. (`#1666 + `_) + +********************** + v20.0.5 (2020-02-21) +********************** Features - 20.0.5 -~~~~~~~~~~~~~~~~~ -- Also create ``pythonX.X`` executables when creating pypy virtualenvs - by :user:`asottile` (`#1612 `_) -- Fail with better error message if trying to install source with unsupported ``setuptools``, allow ``setuptools-scm >= 2`` - and move to legacy ``setuptools-scm`` format to support better older platforms (``CentOS 7`` and such) - by :user:`gaborbernat`. (`#1621 `_) -- Report of the created virtual environment is now split across four short lines rather than one long - by :user:`gaborbernat` (`#1641 `_) +================= + +- Also create ``pythonX.X`` executables when creating pypy virtualenvs - by :user:`asottile` (`#1612 + `_) +- Fail with better error message if trying to install source with unsupported ``setuptools``, allow ``setuptools-scm >= + 2`` and move to legacy ``setuptools-scm`` format to support better older platforms (``CentOS 7`` and such) - by + :user:`gaborbernat`. (`#1621 `_) +- Report of the created virtual environment is now split across four short lines rather than one long - by + :user:`gaborbernat` (`#1641 `_) Bugfixes - 20.0.5 -~~~~~~~~~~~~~~~~~ -- Add macOs Python 2 Framework support (now we test it with the CI via brew) - by :user:`gaborbernat` (`#1561 `_) -- Fix losing of libpypy-c.so when the pypy executable is a symlink - by :user:`asottile` (`#1614 `_) -- Discover python interpreter in a case insensitive manner - by :user:`PrajwalM2212` (`#1624 `_) -- Fix cross interpreter support when the host python sets ``sys.base_executable`` based on ``__PYVENV_LAUNCHER__`` - - by :user:`cjolowicz` (`#1643 `_) +================= +- Add macOs Python 2 Framework support (now we test it with the CI via brew) - by :user:`gaborbernat` (`#1561 + `_) +- Fix losing of libpypy-c.so when the pypy executable is a symlink - by :user:`asottile` (`#1614 + `_) +- Discover python interpreter in a case insensitive manner - by :user:`PrajwalM2212` (`#1624 + `_) +- Fix cross interpreter support when the host python sets ``sys.base_executable`` based on ``__PYVENV_LAUNCHER__`` - by + :user:`cjolowicz` (`#1643 `_) -v20.0.4 (2020-02-14) --------------------- +********************** + v20.0.4 (2020-02-14) +********************** Features - 20.0.4 -~~~~~~~~~~~~~~~~~ -- When aliasing interpreters, use relative symlinks - by :user:`asottile`. (`#1596 `_) +================= + +- When aliasing interpreters, use relative symlinks - by :user:`asottile`. (`#1596 + `_) Bugfixes - 20.0.4 -~~~~~~~~~~~~~~~~~ -- Allow the use of ``/`` as pathname component separator on Windows - by ``vphilippon`` (`#1582 `_) -- Lower minimal version of six required to 1.9 - by ``ssbarnea`` (`#1606 `_) +================= +- Allow the use of ``/`` as pathname component separator on Windows - by ``vphilippon`` (`#1582 + `_) +- Lower minimal version of six required to 1.9 - by ``ssbarnea`` (`#1606 + `_) -v20.0.3 (2020-02-12) --------------------- +********************** + v20.0.3 (2020-02-12) +********************** Bugfixes - 20.0.3 -~~~~~~~~~~~~~~~~~ +================= + - On Python 2 with Apple Framework builds the global site package is no longer added when the - :option:`system-site-packages` is not specified - by :user:`gaborbernat`. (`#1561 `_) + :option:`system-site-packages` is not specified - by :user:`gaborbernat`. (`#1561 + `_) - Fix system python discovery mechanism when prefixes contain relative parts (e.g. ``..``) by resolving paths within the python information query - by :user:`gaborbernat`. (`#1583 `_) -- Expose a programmatic API as ``from virtualenv import cli_run`` - by :user:`gaborbernat`. (`#1585 `_) +- Expose a programmatic API as ``from virtualenv import cli_run`` - by :user:`gaborbernat`. (`#1585 + `_) - Fix ``app-data`` :option:`seeder` injects a extra ``.dist-info.virtualenv`` path that breaks ``importlib.metadata``, - now we inject an extra ``.virtualenv`` - by :user:`gaborbernat`. (`#1589 `_) + now we inject an extra ``.virtualenv`` - by :user:`gaborbernat`. (`#1589 + `_) Improved Documentation - 20.0.3 -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- Document a programmatic API as ``from virtualenv import cli_run`` under :ref:`programmatic_api` - - by :user:`gaborbernat`. (`#1585 `_) +=============================== +- Document a programmatic API as ``from virtualenv import cli_run`` under :ref:`programmatic_api` - by + :user:`gaborbernat`. (`#1585 `_) -v20.0.2 (2020-02-11) --------------------- +********************** + 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 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 `_) + 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 - by :user:`gaborbernat`. (`#1572 `_) Bugfixes - 20.0.2 -~~~~~~~~~~~~~~~~~ +================= + - Support long path names for generated virtual environment console entry points (such as ``pip``) when using the ``app-data`` :option:`seeder` - by :user:`gaborbernat`. (`#997 `_) - Improve python discovery mechanism: @@ -1574,71 +2057,88 @@ Bugfixes - 20.0.2 - beside the prefix folder also try with the platform dependent binary folder within that, by :user:`gaborbernat`. (`#1545 `_) + - When copying (either files or trees) do not copy the permission bits, last access time, last modification time, and - flags as access to these might be forbidden (for example in case of the macOs Framework Python) and these are not needed - for the user to use the virtual environment - by :user:`gaborbernat`. (`#1561 `_) + flags as access to these might be forbidden (for example in case of the macOs Framework Python) and these are not + needed for the user to use the virtual environment - by :user:`gaborbernat`. (`#1561 + `_) - While discovering a python executables interpreters that cannot be queried are now displayed with info level rather than warning, so now they're no longer shown by default (these can be just executables to which we don't have access - or that are broken, don't warn if it's not the target Python we want) - by :user:`gaborbernat`. (`#1574 `_) + or that are broken, don't warn if it's not the target Python we want) - by :user:`gaborbernat`. (`#1574 + `_) - The ``app-data`` :option:`seeder` no longer symlinks the packages on UNIX and copies on Windows. Instead by default - always copies, however now has the :option:`symlink-app-data` flag allowing users to request this less robust but faster - method - by :user:`gaborbernat`. (`#1575 `_) + always copies, however now has the :option:`symlink-app-data` flag allowing users to request this less robust but + faster method - by :user:`gaborbernat`. (`#1575 `_) Improved Documentation - 20.0.2 -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- Add link to the `legacy documentation `_ for the changelog by :user:`jezdez`. (`#1547 `_) -- Fine tune the documentation layout: default width of theme, allow tables to wrap around, soft corners for code snippets - - by :user:`pradyunsg`. (`#1548 `_) +=============================== +- Add link to the `legacy documentation `_ for the changelog by :user:`jezdez`. + (`#1547 `_) +- Fine tune the documentation layout: default width of theme, allow tables to wrap around, soft corners for code + snippets - by :user:`pradyunsg`. (`#1548 `_) -v20.0.1 (2020-02-10) --------------------- +********************** + v20.0.1 (2020-02-10) +********************** Features - 20.0.1 -~~~~~~~~~~~~~~~~~ -- upgrade embedded setuptools to ``45.2.0`` from ``45.1.0`` for Python ``3.4+`` - by :user:`gaborbernat`. (`#1554 `_) +================= + +- upgrade embedded setuptools to ``45.2.0`` from ``45.1.0`` for Python ``3.4+`` - by :user:`gaborbernat`. (`#1554 + `_) Bugfixes - 20.0.1 -~~~~~~~~~~~~~~~~~ -- Virtual environments created via relative path on Windows creates bad console executables - by :user:`gaborbernat`. (`#1552 `_) -- Seems sometimes venvs created set their base executable to themselves; we accept these without question, so we handle - virtual environments as system pythons causing issues - by :user:`gaborbernat`. (`#1553 `_) +================= +- Virtual environments created via relative path on Windows creates bad console executables - by :user:`gaborbernat`. + (`#1552 `_) +- Seems sometimes venvs created set their base executable to themselves; we accept these without question, so we handle + virtual environments as system pythons causing issues - by :user:`gaborbernat`. (`#1553 + `_) -v20.0.0. (2020-02-10) ---------------------- +*********************** + v20.0.0. (2020-02-10) +*********************** Improved Documentation - 20.0.0. -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +================================ + - Fixes typos, repeated words and inconsistent heading spacing. Rephrase parts of the development documentation and CLI - documentation. Expands shorthands like ``env var`` and ``config`` to their full forms. Uses descriptions from respective - documentation, for projects listed in ``related links`` - by :user:`pradyunsg`. (`#1540 `_) + documentation. Expands shorthands like ``env var`` and ``config`` to their full forms. Uses descriptions from + respective documentation, for projects listed in ``related links`` - by :user:`pradyunsg`. (`#1540 + `_) -v20.0.0b2 (2020-02-04) ----------------------- +************************ + v20.0.0b2 (2020-02-04) +************************ Features - 20.0.0b2 -~~~~~~~~~~~~~~~~~~~ +=================== + - Improve base executable discovery mechanism: - print at debug level why we refuse some candidates, - when no candidates match exactly, instead of hard failing fallback to the closest match where the priority of - matching attributes is: python implementation, major version, minor version, architecture, patch version, - release level and serial (this is to facilitate things to still work when the OS upgrade replace/upgrades the system - python with a never version, than what the virtualenv host python was created with), + matching attributes is: python implementation, major version, minor version, architecture, patch version, release + level and serial (this is to facilitate things to still work when the OS upgrade replace/upgrades the system python + with a never version, than what the virtualenv host python was created with), - always resolve system_executable information during the interpreter discovery, and the discovered environment is the - system interpreter instead of the venv/virtualenv (this happened before lazily the first time we accessed, and caused - reporting that the created virtual environment is of type of the virtualenv host python version, instead of the - system pythons version - these two can differ if the OS upgraded the system python underneath and the virtualenv + system interpreter instead of the venv/virtualenv (this happened before lazily the first time we accessed, and + caused reporting that the created virtual environment is of type of the virtualenv host python version, instead of + the system pythons version - these two can differ if the OS upgraded the system python underneath and the virtualenv host was created via copy), by :user:`gaborbernat`. (`#1515 `_) -- Generate ``bash`` and ``fish`` activators on Windows too (as these can be available with git bash, cygwin or mysys2) - - by :user:`gaborbernat`. (`#1527 `_) -- Upgrade the bundled ``wheel`` package from ``0.34.0`` to ``0.34.2`` - by :user:`gaborbernat`. (`#1531 `_) + +- Generate ``bash`` and ``fish`` activators on Windows too (as these can be available with git bash, cygwin or mysys2) - + by :user:`gaborbernat`. (`#1527 `_) +- Upgrade the bundled ``wheel`` package from ``0.34.0`` to ``0.34.2`` - by :user:`gaborbernat`. (`#1531 + `_) Bugfixes - 20.0.0b2 -~~~~~~~~~~~~~~~~~~~ +=================== + - Bash activation script should have no extensions instead of ``.sh`` (this fixes the :pypi:`virtualenvwrapper` integration) - by :user:`gaborbernat`. (`#1508 `_) - Show less information when we run with a single verbosity (``-v``): @@ -1648,32 +2148,42 @@ Bugfixes - 20.0.0b2 - for the ``app-data`` seeder do not show the type of lock, only the path to the app data directory, By :user:`gaborbernat`. (`#1510 `_) + - Fixed cannot discover a python interpreter that has already been discovered under a different path (such is the case - when we have multiple symlinks to the same interpreter) - by :user:`gaborbernat`. (`#1512 `_) -- Support relative paths for ``-p`` - by :user:`gaborbernat`. (`#1514 `_) -- Creating virtual environments in parallel fail with cannot acquire lock within app data - by :user:`gaborbernat`. (`#1516 `_) -- pth files were not processed under Debian CPython2 interpreters - by :user:`gaborbernat`. (`#1517 `_) + when we have multiple symlinks to the same interpreter) - by :user:`gaborbernat`. (`#1512 + `_) +- Support relative paths for ``-p`` - by :user:`gaborbernat`. (`#1514 + `_) +- Creating virtual environments in parallel fail with cannot acquire lock within app data - by :user:`gaborbernat`. + (`#1516 `_) +- pth files were not processed under Debian CPython2 interpreters - by :user:`gaborbernat`. (`#1517 + `_) - Fix prompt not displayed correctly with upcoming fish 3.10 due to us not preserving ``$pipestatus`` - by :user:`krobelus`. (`#1530 `_) - Stable order within ``pyenv.cfg`` and add ``include-system-site-packages`` only for creators that reference a global Python - by user:`gaborbernat`. (`#1535 `_) Improved Documentation - 20.0.0b2 -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- Create the first iteration of the new documentation - by :user:`gaborbernat`. (`#1465 `_) -- Project readme is now of type MarkDown instead of reStructuredText - by :user:`gaborbernat`. (`#1531 `_) +================================= +- Create the first iteration of the new documentation - by :user:`gaborbernat`. (`#1465 + `_) +- Project readme is now of type MarkDown instead of reStructuredText - by :user:`gaborbernat`. (`#1531 + `_) -v20.0.0b1 (2020-01-28) ----------------------- +************************ + 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. +- 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:: - The current virtualenv is the second iteration of implementation. From version ``0.8`` all the way to ``16.7.9`` - we numbered the first iteration. Version ``20.0.0b1`` is a complete rewrite of the package, and as such this release - history starts from there. The old changelog is still available in the - `legacy branch documentation `_. + The current virtualenv is the second iteration of implementation. From version ``0.8`` all the way to ``16.7.9`` we + numbered the first iteration. Version ``20.0.0b1`` is a complete rewrite of the package, and as such this release + history starts from there. The old changelog is still available in the `legacy branch documentation + `_. diff --git a/docs/changelog/2662.bugfix.rst b/docs/changelog/2662.bugfix.rst deleted file mode 100644 index c9e072cdd..000000000 --- a/docs/changelog/2662.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Exclude pywin32 DLLs (``pywintypes*.dll``, ``pythoncom*.dll``) from being copied to the Scripts directory during -virtualenv creation on Windows. This fixes compatibility issues with pywin32, which expects its DLLs to be installed -in ``site-packages/pywin32_system32`` by its own post-install script - by :user:`rahuldevikar`. diff --git a/docs/changelog/2770.bugfix.rst b/docs/changelog/2770.bugfix.rst deleted file mode 100644 index b67738777..000000000 --- a/docs/changelog/2770.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Preserve symlinks in ``pyvenv.cfg`` paths to match ``venv`` behavior. Use ``os.path.abspath()`` instead of ``os.path.realpath()`` to normalize paths without resolving symlinks, fixing issues with Python installations accessed via symlinked directories (common in network-mounted filesystems) - by :user:`rahuldevikar`. Fixes :issue:`2770`. diff --git a/docs/changelog/2985.bugfix.rst b/docs/changelog/2985.bugfix.rst deleted file mode 100644 index ae2fe2aca..000000000 --- a/docs/changelog/2985.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix Windows activation scripts to properly quote ``python.exe`` path, preventing failures when Python is installed in a path with spaces (e.g., ``C:\Program Files``) and a file named ``C:\Program`` exists on the filesystem - by :user:`rahuldevikar`. diff --git a/docs/changelog/3093.bugfix.rst b/docs/changelog/3093.bugfix.rst new file mode 100644 index 000000000..c691a5fee --- /dev/null +++ b/docs/changelog/3093.bugfix.rst @@ -0,0 +1,3 @@ +Upgrade embedded wheels: + +- setuptools to ``82.0.1`` from ``82.0.0`` diff --git a/docs/changelog/examples.rst b/docs/changelog/examples.rst index eda79eb70..86257006a 100644 --- a/docs/changelog/examples.rst +++ b/docs/changelog/examples.rst @@ -1,15 +1,20 @@ .. examples for changelog entries adding to your Pull Requests -file ``544.doc.rst``:: +file ``544.doc.rst``: + +:: explain everything much better - by :user:`passionate_technicalwriter`. -file ``544.feature.rst``:: +file ``544.feature.rst``: + +:: ``tox --version`` now shows information about all registered plugins - by :user:`obestwalter`. +file ``571.bugfix.rst``: -file ``571.bugfix.rst``:: +:: ``skip_install`` overrides ``usedevelop`` (``usedevelop`` is an option to choose the installation type if the package is installed and ``skip_install`` determines if it should be diff --git a/docs/changelog/template.jinja2 b/docs/changelog/template.jinja2 index bb88fa2c2..9de49f34a 100644 --- a/docs/changelog/template.jinja2 +++ b/docs/changelog/template.jinja2 @@ -1,18 +1,18 @@ -{% set top_underline = underlines[0] %} {% if versiondata.name %} -v{{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}} +{% set version_title = "v" + versiondata.version + " (" + versiondata.date + ")" %} {% else %} -{{ versiondata.version }} ({{ versiondata.date }}) -{{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} +{% set version_title = versiondata.version + " (" + versiondata.date + ")" %} {% endif %} +{{ top_underline * (version_title|length + 2) }} + {{ version_title }} +{{ top_underline * (version_title|length + 2) }} {% for section, _ in sections.items() %} -{% set underline = underlines[1] %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} {{ definitions[category]['name'] }} - {{ versiondata.version }} -{{ underline * ((definitions[category]['name'] + versiondata.version)|length + 3)}} +{{ underlines[0] * ((definitions[category]['name'] + versiondata.version)|length + 3) }} + {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} - {{ text }} ({{ values|join(', ') }}) diff --git a/docs/cli_interface.rst b/docs/cli_interface.rst deleted file mode 100644 index fd3543b35..000000000 --- a/docs/cli_interface.rst +++ /dev/null @@ -1,138 +0,0 @@ -CLI interface -============= - -.. _cli_flags: - -CLI flags -~~~~~~~~~ - -``virtualenv`` is primarily a command line application. - -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 -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 -with environment variables or the configuration file). - -The options that can be passed to virtualenv, along with their default values and a short description are listed below. - -:command:`virtualenv [OPTIONS]` - -.. table_cli:: - :module: virtualenv.run - :func: build_parser_only - -Discovery options -~~~~~~~~~~~~~~~~~ - -Understanding Interpreter Discovery: ``--python`` vs. ``--try-first-with`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -You can control which Python interpreter ``virtualenv`` selects using the ``--python`` and ``--try-first-with`` flags. -To avoid confusion, it's best to think of them as the "rule" and the "hint". - -**``--python ``: The Rule** - -This flag sets the mandatory requirements for the interpreter. The ```` can be: - -- **A version string** (e.g., ``python3.8``, ``pypy3``). ``virtualenv`` will search for any interpreter that matches this version. -- **A version specifier** using PEP 440 operators (e.g., ``>=3.12``, ``~=3.11.0``, ``python>=3.10``). ``virtualenv`` will search for any interpreter that satisfies the version constraint. You can also specify the implementation: ``cpython>=3.12``. -- **An absolute path** (e.g., ``/usr/bin/python3.8``). This is a *strict* requirement. Only the interpreter at this exact path will be used. If it does not exist or is not a valid interpreter, creation will fail. - -**``--try-first-with ``: The Hint** - -This flag provides a path to a Python executable to check *before* ``virtualenv`` performs its standard search. This can speed up discovery or help select a specific interpreter when multiple versions exist on your system. - -**How They Work Together** - -``virtualenv`` will only use an interpreter from ``--try-first-with`` if it **satisfies the rule** from the ``--python`` flag. The ``--python`` rule always wins. - -**Examples:** - -1. **Hint does not match the rule:** - - .. code-block:: bash - - virtualenv --python python3.8 --try-first-with /usr/bin/python3.10 my-env - - - **Result:** ``virtualenv`` first inspects ``/usr/bin/python3.10``. It sees this does not match the ``python3.8`` rule and **rejects it**. It then proceeds with its normal search to find a ``python3.8`` interpreter elsewhere. - -2. **Hint does not match a strict path rule:** - - .. code-block:: bash - - virtualenv --python /usr/bin/python3.8 --try-first-with /usr/bin/python3.10 my-env - - - **Result:** The rule is strictly ``/usr/bin/python3.8``. ``virtualenv`` checks the ``/usr/bin/python3.10`` hint, sees the path doesn't match, and **rejects it**. It then moves on to test ``/usr/bin/python3.8`` and successfully creates the environment. - -This approach ensures that the behavior is predictable and that ``--python`` remains the definitive source of truth for the user's intent. - - -Defaults -~~~~~~~~ - -.. _conf_file: - -Configuration file -^^^^^^^^^^^^^^^^^^ - -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``). - -For example, :option:`--python ` would be specified as: - -.. code-block:: ini - - [virtualenv] - python = /opt/python-3.8/bin/python - -Options that take multiple values, like :option:`extra-search-dir` can be specified as: - -.. code-block:: ini - - [virtualenv] - extra_search_dir = - /path/to/dists - /path/to/other/dists - -.. _env_vars: - -Environment Variables -^^^^^^^^^^^^^^^^^^^^^ - -Default values may be also specified via environment variables. The keys of the settings are derived from the -command line option (left strip the ``-`` characters, and replace ``-`` with ``_``, finally capitalize the name). Where -multiple flags are available first found wins (where order is as it shows up under the ``--help``). - -For example, to use a custom Python binary, instead of the one virtualenv is run with, you can set the environment -variable ``VIRTUALENV_PYTHON`` like: - -.. code-block:: console - - env VIRTUALENV_PYTHON=/opt/python-3.8/bin/python virtualenv - -Where the option accepts multiple values, for example for :option:`python` or -:option:`extra-search-dir`, the values can be separated either by literal -newlines or commas. Newlines and commas can not be mixed and if both are -present only the newline is used for separating values. Examples for multiple -values: - - -.. code-block:: console - - env VIRTUALENV_PYTHON=/opt/python-3.8/bin/python,python3.8 virtualenv - env VIRTUALENV_EXTRA_SEARCH_DIR=/path/to/dists\n/path/to/other/dists virtualenv - -The equivalent CLI-flags based invocation for the above examples would be: - -.. code-block:: console - - virtualenv --python=/opt/python-3.8/bin/python --python=python3.8 - virtualenv --extra-search-dir=/path/to/dists --extra-search-dir=/path/to/other/dists diff --git a/docs/conf.py b/docs/conf.py index f02c8a7cb..065d48e00 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,5 @@ from __future__ import annotations -import subprocess import sys from datetime import datetime, timezone from pathlib import Path @@ -17,8 +16,22 @@ "sphinx.ext.autodoc", "sphinx.ext.autosectionlabel", "sphinx.ext.extlinks", + "sphinx.ext.intersphinx", + "sphinx_autodoc_typehints", + "sphinx_copybutton", + "sphinx_inline_tabs", + "sphinxcontrib.mermaid", + "sphinxcontrib.towncrier.ext", ] +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} + +towncrier_draft_autoversion_mode = "draft" +towncrier_draft_include_empty = True +towncrier_draft_working_directory = Path(__file__).parent.parent + templates_path = [] unused_docs = [] source_suffix = ".rst" @@ -33,7 +46,16 @@ 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"] +html_static_path = ["_static"] +html_css_files = ["custom.css"] +html_js_files = ["rtd-search.js"] +html_favicon = "_static/virtualenv.svg" +html_theme_options = { + "light_logo": "virtualenv.png", + "dark_logo": "virtualenv.png", + "sidebar_hide_name": True, +} +html_show_sourcelink = False autoclass_content = "both" # Include __init__ in class documentation autodoc_member_order = "bysource" @@ -47,25 +69,17 @@ } -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 +def setup(app) -> None: doc_tree = Path(app.doctreedir) - cli_interface_doctree = doc_tree / "cli_interface.doctree" - if cli_interface_doctree.exists(): - cli_interface_doctree.unlink() + for name in ("cli_interface", "reference/cli"): + doctree = doc_tree / f"{name}.doctree" + if doctree.exists(): + doctree.unlink() 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 # noqa: PLC0415 app.add_css_file("custom.css") diff --git a/docs/development.rst b/docs/development.rst index 868974179..6c167c287 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -1,38 +1,38 @@ -Development -=========== - -Getting started ---------------- +############# + Development +############# +***************** + Getting started +***************** ``virtualenv`` is a volunteer maintained open source project and we welcome contributions of all forms. The sections below will help you get started with development, testing, and documentation. We’re pleased that you are interested in working on virtualenv. This document is meant to get you setup to work on virtualenv and to act as a guide and reference -to the development setup. If you face any issues during this process, please -`open an issue `_ about it on -the issue tracker. +to the development setup. If you face any issues during this process, please `open an issue +`_ about it on the issue +tracker. Setup -~~~~~ +===== virtualenv is a command line application written in Python. To work on it, you'll need: - **Source code**: available on `GitHub `_. You can use ``git`` to clone the - repository: + repository: .. code-block:: console git clone https://github.com/pypa/virtualenv cd virtualenv -- **Python interpreter**: We recommend using ``CPython``. You can use - `this guide `_ to set it up. - +- **Python interpreter**: We recommend using ``CPython``. You can use `this guide + `_ to set it up. - :pypi:`tox`: to automatically get the projects development dependencies and run the test suite. We recommend installing it using `pipx `_. Running from source tree -~~~~~~~~~~~~~~~~~~~~~~~~ +======================== The easiest way to do this is to generate the development tox environment, and then invoke virtualenv from under the ``.tox/dev`` folder @@ -44,10 +44,10 @@ The easiest way to do this is to generate the development tox environment, and t .tox/dev/Scripts/virtualenv # on Windows Running tests -~~~~~~~~~~~~~ +============= -virtualenv's tests are written using the :pypi:`pytest` test framework. :pypi:`tox` is used to automate the setup -and execution of virtualenv's tests. +virtualenv's tests are written using the :pypi:`pytest` test framework. :pypi:`tox` is used to automate the setup and +execution of virtualenv's tests. To run tests locally execute: @@ -58,10 +58,9 @@ To run tests locally execute: This will run the test suite for the same Python version as under which ``tox`` is installed. Alternatively you can specify a specific version of python by using the ``pyNN`` format, such as: ``py38``, ``pypy3``, etc. -``tox`` has been configured to forward any additional arguments it is given to ``pytest``. -This enables the use of pytest's -`rich CLI `_. As an example, you can -select tests using the various ways that pytest provides: +``tox`` has been configured to forward any additional arguments it is given to ``pytest``. This enables the use of +pytest's `rich CLI `_. As an example, you +can select tests using the various ways that pytest provides: .. code-block:: console @@ -71,11 +70,11 @@ select tests using the various ways that pytest provides: tox -e py -- -k "test_extra" Some tests require additional dependencies to be run, such is the various shell activators (``bash``, ``fish``, -``powershell``, etc). These tests will automatically be skipped if these are not present, note however that in CI -all tests are run; so even if all tests succeed locally for you, they may still fail in the CI. +``powershell``, etc). These tests will automatically be skipped if these are not present, note however that in CI all +tests are run; so even if all tests succeed locally for you, they may still fail in the CI. Running linters -~~~~~~~~~~~~~~~ +=============== virtualenv uses :pypi:`pre-commit` for managing linting of the codebase. ``pre-commit`` performs various checks on all files in virtualenv and uses tools that help follow a consistent code style within the codebase. To use linters locally, @@ -90,8 +89,42 @@ run: Avoid using ``# noqa`` comments to suppress linter warnings - wherever possible, warnings should be fixed instead. ``# noqa`` comments are reserved for rare cases where the recommended style causes severe readability problems. +Type checking +============= + +virtualenv ships a :PEP:`561` ``py.typed`` marker and has comprehensive type annotations across the entire codebase. +This means downstream consumers and type checkers automatically recognize virtualenv as an inline-typed package. + +All new code **must** include complete type annotations for function parameters and return types. To verify annotations +locally, run: + +.. code-block:: console + + tox -e type + +This uses `ty `_ (Astral's Rust-based type checker) to validate annotations against Python +3.14. A second environment checks compatibility with the minimum supported version: + +.. code-block:: console + + tox -e type-3.8 + +Both environments validate that annotations are consistent and correct. + +Annotation guidelines +--------------------- + +- Use ``from __future__ import annotations`` at the top of every module (enforced by ruff's ``required-imports`` + setting). +- Place imports that are only needed for type checking inside an ``if TYPE_CHECKING:`` block to avoid runtime overhead. +- Ruff's ``ANN`` rules are enabled. ``ANN401`` (``typing.Any``) is suppressed on a case-by-case basis with inline ``# + noqa: ANN401`` comments where ``Any`` is genuinely required (e.g. serialization, dynamic dispatch). +- Prefer concrete types over ``Any``. Use ``Union`` / ``|`` for nullable or multi-type parameters. +- When a type error is genuinely unfixable (e.g. third-party library limitations), suppress it with an inline ``# ty: + ignore[rule-name]`` comment and a brief justification. + Building documentation -~~~~~~~~~~~~~~~~~~~~~~ +====================== virtualenv's documentation is built using :pypi:`Sphinx`. The documentation is written in reStructuredText. To build it locally, run: @@ -104,23 +137,57 @@ The built documentation can be found in the ``.tox/docs_out`` folder and may be that folder. Release -~~~~~~~ +======= + +virtualenv's release schedule is tied to ``pip`` and ``setuptools``. We bundle the latest version of these libraries so +each time there's a new version of any of these, there will be a new virtualenv release shortly afterwards (we usually +wait just a few days to avoid pulling in any broken releases). -virtualenv's release schedule is tied to ``pip`` and ``setuptools``. We bundle the latest version of these -libraries so each time there's a new version of any of these, there will be a new virtualenv release shortly afterwards -(we usually wait just a few days to avoid pulling in any broken releases). +Performing a release +-------------------- -Contributing -------------- +A full release publishes to `PyPI `_, creates a `GitHub Release +`_ with the zipapp attached, and updates `get-virtualenv +`_ so that ``https://bootstrap.pypa.io/virtualenv.pyz`` serves the new version. + +Version bumping +^^^^^^^^^^^^^^^ + +The ``--version`` argument to ``tox r -e release`` controls the version. It defaults to ``auto``, which inspects the +``docs/changelog`` directory: if any ``*.feature.rst`` or ``*.removal.rst`` fragments exist, the minor version is +bumped, otherwise the patch version is bumped. You can also pass ``major``, ``minor``, or ``patch`` explicitly. + +Both methods produce identical results: a release commit and tag on ``main``. Pushing the tag triggers the `Release +workflow `_ which builds the sdist, wheel, and +zipapp, publishes to PyPI via trusted publisher, creates a `GitHub Release +`_ with the zipapp attached, and updates `get-virtualenv +`_. If publish fails, a rollback job automatically reverts everything. + +**Via GitHub Actions (recommended)** + +1. Go to the `Pre-release workflow `_ on GitHub. +2. Click **Run workflow** and select the bump type (``auto``, ``major``, ``minor``, or ``patch``). + +**Locally** + +.. code-block:: console + + tox r -e release + +Pass ``--version `` to override the default ``auto`` behavior (e.g. ``--version minor``). + +************** + Contributing +************** Submitting pull requests -~~~~~~~~~~~~~~~~~~~~~~~~ +======================== Submit pull requests against the ``main`` branch, providing a good description of what you're doing and why. You must have legal permission to distribute any code you contribute to virtualenv and it must be available under the MIT -License. Provide tests that cover your changes and run the tests locally first. virtualenv -:ref:`supports ` multiple Python versions and operating systems. Any pull request must -consider and work on all these platforms. +License. Provide tests that cover your changes and run the tests locally first. virtualenv :ref:`supports +` multiple Python versions and operating systems. Any pull request must consider and work on +all these platforms. Pull Requests should be small to facilitate review. Keep them self-contained, and limited in scope. `Studies have shown `_ that review quality falls off as patch size @@ -133,24 +200,23 @@ PR more difficult. Examples include re-flowing text in comments or documentation or whitespace within lines. Such changes can be made separately, as a "formatting cleanup" PR, if needed. Automated testing -~~~~~~~~~~~~~~~~~ +================= -All pull requests and merges to 'main' branch are tested using -`GitHub Actions `_ (configured by -``.github/workflows/check.yaml`` file at the root of the repository). You can find the status and results to the CI runs for your -PR on GitHub's Web UI for the pull request. You can also find links to the CI services' pages for the specific builds in -the form of "Details" links, in case the CI run fails and you wish to view the output. +All pull requests and merges to 'main' branch are tested using `GitHub Actions `_ +(configured by ``.github/workflows/check.yaml`` file at the root of the repository). You can find the status and results +to the CI runs for your PR on GitHub's Web UI for the pull request. You can also find links to the CI services' pages +for the specific builds in the form of "Details" links, in case the CI run fails and you wish to view the output. To trigger CI to run again for a pull request, you can close and open the pull request or submit another change to the pull request. If needed, project maintainers can manually trigger a restart of a job/build. NEWS entries -~~~~~~~~~~~~ +============ The ``changelog.rst`` file is managed using :pypi:`towncrier` and all non trivial changes must be accompanied by a news -entry. To add an entry to the news file, first you need to have created an issue describing the change you want to -make. A Pull Request itself *may* function as such, but it is preferred to have a dedicated issue (for example, in case -the PR ends up rejected due to code quality reasons). +entry. To add an entry to the news file, first you need to have created an issue describing the change you want to make. +A Pull Request itself *may* function as such, but it is preferred to have a dedicated issue (for example, in case the PR +ends up rejected due to code quality reasons). Once you have an issue or pull request, you take the number and you create a file inside of the ``docs/changelog`` directory named after that issue number with an extension of: @@ -168,29 +234,28 @@ added a feature and deprecated/removed the old feature at the same time, you wou you may create a file for each of them with the same contents and :pypi:`towncrier` will deduplicate them. Contents of a NEWS entry -^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------ The contents of this file are reStructuredText formatted text that will be used as the content of the news file entry. -You do not need to reference the issue or PR numbers here as towncrier will automatically add a reference to all of -the affected issues when rendering the news file. +You do not need to reference the issue or PR numbers here as towncrier will automatically add a reference to all of the +affected issues when rendering the news file. In order to maintain a consistent style in the ``changelog.rst`` file, it is preferred to keep the news entry to the point, in sentence case, shorter than 120 characters and in an imperative tone -- an entry should complete the sentence ``This change will …``. In rare cases, where one line is not enough, use a summary line in an imperative tone followed -by a blank line separating it from a description of the feature/change in one or more paragraphs, each wrapped -at 120 characters. Remember that a news entry is meant for end users and should only contain details relevant to an end -user. +by a blank line separating it from a description of the feature/change in one or more paragraphs, each wrapped at 120 +characters. Remember that a news entry is meant for end users and should only contain details relevant to an end user. Choosing the type of NEWS entry -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------------- A trivial change is anything that does not warrant an entry in the news file. Some examples are: code refactors that -don't change anything as far as the public is concerned, typo fixes, white space modification, etc. To mark a PR -as trivial a contributor simply needs to add a randomly named, empty file to the ``news/`` directory with the extension -of ``.trivial``. +don't change anything as far as the public is concerned, typo fixes, white space modification, etc. To mark a PR as +trivial a contributor simply needs to add a randomly named, empty file to the ``news/`` directory with the extension of +``.trivial``. Becoming a maintainer -~~~~~~~~~~~~~~~~~~~~~ +===================== If you want to become an official maintainer, start by helping out. As a first step, we welcome you to triage issues on virtualenv's issue tracker. virtualenv maintainers provide triage abilities to contributors once they have been around @@ -200,10 +265,25 @@ initiate a vote among the existing maintainers. .. note:: - Upon becoming a maintainer, a person should be given access to various virtualenv-related tooling across - multiple platforms. These are noted here for future reference by the maintainers: + Upon becoming a maintainer, a person should be given access to various virtualenv-related tooling across multiple + platforms. These are noted here for future reference by the maintainers: - GitHub Push Access - PyPI Publishing Access - CI Administration capabilities - ReadTheDocs Administration capabilities + +.. _current-maintainers: + +Current maintainers +------------------- + +- :user:`Bernát Gábor ` +- :user:`Rahul Devikar ` + +Previous maintainers +-------------------- + +- :user:`Paul Moore ` +- :user:`Ian Bicking ` +- :user:`Donald Stufft ` diff --git a/docs/explanation.rst b/docs/explanation.rst new file mode 100644 index 000000000..86229a103 --- /dev/null +++ b/docs/explanation.rst @@ -0,0 +1,512 @@ +############# + Explanation +############# + +This page explains the design decisions and concepts behind virtualenv. It focuses on understanding why things work the +way they do. + +************************** + virtualenv vs venv vs uv +************************** + +Since Python 3.3, the standard library includes the ``venv`` module, which provides basic virtual environment creation +following `PEP 405 `_. `uv `_ is +a newer, Rust-based tool that also creates virtual environments via ``uv venv``. + +virtualenv occupies a middle ground: faster and more featureful than ``venv``, while remaining a pure Python solution +with a plugin system for extensibility. + +.. list-table:: + :header-rows: 1 + :widths: 20 27 27 26 + + - - + - ``venv`` + - ``virtualenv`` + - `uv `_ + - - Performance + - Slowest (60s+); spawns `pip `_ as a subprocess to seed. + - Fast; caches pre-built install images, subsequent creation < 1 second. + - Fastest; Rust implementation, milliseconds. Does not seed pip/setuptools by default. + - - Extensibility + - No plugin system. + - Plugin system for discovery, creation, seeding, and activation. + - No plugin system. + - - Cross-version + - Only the Python version it runs under. + - Any installed Python via auto-discovery (registry, uv-managed, PATH). + - Any installed or uv-managed Python. + - - Upgradeability + - Tied to Python releases. + - Independent via `PyPI `_. + - Independent via its own release cycle. + - - Programmatic API + - Basic ``create()`` function only. + - Full Python API; can describe environments without creating them. Used by `tox `_, + `poetry `_, `pipx `_, etc. + - Command line only. + - - Type annotations + - No ``py.typed`` marker; limited annotations. + - Fully typed with :PEP:`561` ``py.typed`` marker; checked by `ty `_. + - Not applicable (Rust binary). + - - Best for + - Zero dependencies, basic needs. + - Plugin extensibility, programmatic API, tool compatibility (`tox `_, + `virtualenvwrapper `_). + - Maximum speed, already using ``uv`` for package management. + +.. mermaid:: + + flowchart TD + A{Need plugins or programmatic API?} -->|Yes| V[virtualenv] + A -->|No| B{Already using uv?} + B -->|Yes| U[uv venv] + B -->|No| C{Can install external tools?} + C -->|Yes| D{Speed matters?} + C -->|No| VENV[venv] + D -->|Yes| U + D -->|No| V + + style A fill:#d97706,stroke:#b45309,color:#fff + style B fill:#d97706,stroke:#b45309,color:#fff + style C fill:#d97706,stroke:#b45309,color:#fff + style D fill:#d97706,stroke:#b45309,color:#fff + style V fill:#16a34a,stroke:#15803d,color:#fff + style U fill:#2563eb,stroke:#1d4ed8,color:#fff + style VENV fill:#7c3aed,stroke:#6d28d9,color:#fff + +********************** + How virtualenv works +********************** + +Python packaging often faces a fundamental problem: different applications require different versions of the same +library. If Application A needs ``requests==2.25.1`` but Application B needs ``requests==2.28.0``, installing both into +the global site-packages directory creates a conflict. Only one version can exist in a given location. + +virtualenv solves this by creating isolated Python environments. Each environment has its own installation directories +and can maintain its own set of installed packages, independent of other environments and the system Python. + +virtualenv operates in two distinct phases: + +.. mermaid:: + + flowchart TD + Start([virtualenv command]) --> Phase1[Phase 1: Python Discovery] + Phase1 --> Discover{Find Python interpreter} + Discover -->|Default| SameVersion[Use virtualenv's own Python] + Discover -->|--python flag| CustomVersion[Use specified Python] + CustomVersion --> Phase2[Phase 2: Environment Creation] + SameVersion --> Phase2 + Phase2 --> CreatePython[Create Python matching target interpreter] + CreatePython --> SeedPackages[Install seed packages: pip, setuptools, wheel] + SeedPackages --> ActivationScripts[Install activation scripts] + ActivationScripts --> VCSIgnore[Create VCS ignore files] + VCSIgnore --> Complete([Virtual environment ready]) + + style Start fill:#2563eb,stroke:#1d4ed8,color:#fff + style Phase1 fill:#6366f1,stroke:#4f46e5,color:#fff + style Phase2 fill:#6366f1,stroke:#4f46e5,color:#fff + style Complete fill:#16a34a,stroke:#15803d,color:#fff + style Discover fill:#d97706,stroke:#b45309,color:#fff + +**Phase 1: Discover a Python interpreter** + virtualenv first identifies which Python interpreter to use as the template for the virtual environment. By default, + it uses the same Python version that virtualenv itself is running on. You can override this with the ``--python`` + flag to specify a different interpreter. + +**Phase 2: Create the virtual environment** + Once the target interpreter is identified, virtualenv creates the environment in four steps: + + 1. Create a Python executable matching the target interpreter + 2. Install seed packages (pip, setuptools, wheel) to enable package installation + 3. Install activation scripts for various shells + 4. Create VCS ignore files (currently Git's ``.gitignore``, skip with ``--no-vcs-ignore``) + +An important design principle: virtual environments are not self-contained. A complete Python installation consists of +thousands of files, and copying all of them into every virtual environment would be wasteful. Instead, virtual +environments are lightweight shells that borrow most content from the system Python. They contain only what's needed to +redirect Python's behavior. + +This design has two implications: + +- Environment creation is fast because only a small number of files need to be created. +- Upgrading the system Python might affect existing virtual environments, since they reference the system Python's + standard library and binary extensions. + +The Python executable in a virtual environment is effectively isolated from the one used to create it, but the +supporting files are shared. + +.. warning:: + + If you upgrade your system Python, existing virtual environments will still report the old version (the version + number is embedded in the Python executable itself), but they will use the new version's standard library and binary + extensions. This normally works without issues, but be aware that the environment is effectively running a hybrid of + old and new Python versions. + +****************** + Python discovery +****************** + +Before creating a virtual environment, virtualenv must locate a Python interpreter. The interpreter determines the +virtual environment's Python version, implementation (CPython, PyPy, etc.), and architecture (32-bit or 64-bit). + +The ``--python`` flag accepts several specifier formats: + +**Path specifier** + An absolute or relative path to a Python executable, such as ``/usr/bin/python3.8`` or ``./python``. + +**Version specifier** + A string following the format ``{implementation}{version}{architecture}{machine}`` where: + + - Implementation is alphabetic characters (``python`` means any implementation; if omitted, defaults to ``python``). + - Version is dot-separated numbers, optionally followed by ``t`` for free-threading builds. + - Architecture is ``-64`` or ``-32`` (if omitted, means any architecture). + - Machine is the CPU instruction set architecture, e.g. ``-arm64``, ``-x86_64``, ``-aarch64`` (if omitted, means any + machine). Cross-platform aliases are normalized automatically (``amd64`` ↔ ``x86_64``, ``aarch64`` ↔ ``arm64``). + + Examples: + + - ``python3.8.1`` - Any Python implementation with version 3.8.1 + - ``3`` - Any Python implementation with major version 3 + - ``3.13t`` - Any Python implementation version 3.13 with free-threading enabled + - ``cpython3`` - CPython implementation with major version 3 + - ``pypy2`` - PyPy implementation with major version 2 + - ``cpython3.12-64-arm64`` - CPython 3.12, 64-bit, ARM64 architecture + - ``3.11-64-x86_64`` - Any implementation, version 3.11, 64-bit, x86_64 architecture + - ``rustpython`` - RustPython implementation + +**PEP 440 version specifier** + Version constraints using PEP 440 operators: + + - ``>=3.12`` - Any Python 3.12 or later + - ``~=3.11.0`` - Compatible with Python 3.11.0 + - ``cpython>=3.10`` - CPython 3.10 or later + +When you provide a specifier, virtualenv searches for matching interpreters using this strategy: + +.. mermaid:: + + flowchart TD + Start([Python specifier provided]) --> Windows{Running on Windows?} + Windows -->|Yes| Registry[Check Windows Registry per PEP-514] + Windows -->|No| UVManaged + Registry --> RegistryMatch{Match found?} + RegistryMatch -->|Yes| Found([Use matched Python]) + RegistryMatch -->|No| UVManaged[Check uv-managed Python installations] + UVManaged --> UVMatch{Match found?} + UVMatch -->|Yes| Found + UVMatch -->|No| PATH[Search PATH for matching executable] + PATH --> PATHMatch{Match found?} + PATHMatch -->|Yes| Found + PATHMatch -->|No| NotFound([Discovery fails]) + + style Start fill:#2563eb,stroke:#1d4ed8,color:#fff + style Found fill:#16a34a,stroke:#15803d,color:#fff + style NotFound fill:#dc2626,stroke:#b91c1c,color:#fff + style Windows fill:#d97706,stroke:#b45309,color:#fff + style RegistryMatch fill:#d97706,stroke:#b45309,color:#fff + style UVMatch fill:#d97706,stroke:#b45309,color:#fff + style PATHMatch fill:#d97706,stroke:#b45309,color:#fff + +1. **Windows Registry** (Windows only): Check registered Python installations per `PEP 514 + `_. +2. **uv-managed installations**: Check the ``UV_PYTHON_INSTALL_DIR`` environment variable or platform-specific uv Python + directories for managed Python installations. +3. **PATH search**: Search for executables on the ``PATH`` environment variable with names matching the specification. + +Version manager shim resolution +=============================== + +Version managers like `pyenv `_, `mise `_, and `asdf +`_ place lightweight shim scripts on ``PATH`` that delegate to the real Python binary. When +virtualenv discovers a Python interpreter by running it as a subprocess, shims may resolve to the wrong Python version +(typically the system Python) because the shim's resolution logic depends on shell environment state that doesn't fully +propagate to child processes. + +virtualenv detects shims by checking whether the candidate executable lives in a known shim directory +(``$PYENV_ROOT/shims``, ``$MISE_DATA_DIR/shims``, or ``$ASDF_DATA_DIR/shims``). When a shim is detected, virtualenv +bypasses it and locates the real binary directly under the version manager's ``versions`` directory, using the active +version from: + +1. The ``PYENV_VERSION`` environment variable (colon-separated for multiple versions). +2. A ``.python-version`` file in the current directory or any parent directory. +3. The global version file at ``$PYENV_ROOT/version``. + +This convention is shared across pyenv, mise, and asdf, so the same resolution logic works for all three. + +.. warning:: + + Virtual environments typically reference the system Python's standard library. If you upgrade the system Python, the + virtual environment will report the old version (embedded in its Python executable) but will actually use the new + version's standard library content. This can cause confusion when debugging version-specific behavior. + + If you use a virtual environment's Python as the target for creating another virtual environment, virtualenv will + detect the system Python version and create an environment matching the actual (upgraded) version, not the version + reported by the virtual environment. + +********** + Creators +********** + +Creators are responsible for constructing the virtual environment structure. virtualenv supports two types of creators: + +**venv creator** + This creator delegates the entire creation process to the standard library's ``venv`` module, following `PEP 405 + `_. The venv creator has two limitations: + + - It only works with Python 3.5 or later. + - It requires spawning a subprocess to invoke the venv module, unless virtualenv is installed in the system Python. + + The subprocess overhead can be significant, especially on Windows where process creation is expensive. + +**builtin creator** + This creator means virtualenv performs the creation itself by knowing exactly which files to create and which system + files to reference. The builtin creator is actually a family of specialized creators for different combinations of + Python implementation (CPython, PyPy, GraalPy, RustPython) and platform (Windows, POSIX). The name ``builtin`` is an + alias that selects the first available builtin creator for the target environment. + + Because builtin creators don't require subprocess invocation, they're generally faster than the venv creator. + +.. mermaid:: + + flowchart TD + Start([Select creator]) --> Builtin{Builtin creator available?} + Builtin -->|Yes| UseBuiltin([Use builtin creator - faster, no subprocess]) + Builtin -->|No| UseVenv([Use venv creator - delegates to stdlib]) + + style Start fill:#2563eb,stroke:#1d4ed8,color:#fff + style UseBuiltin fill:#16a34a,stroke:#15803d,color:#fff + style UseVenv fill:#7c3aed,stroke:#6d28d9,color:#fff + style Builtin fill:#d97706,stroke:#b45309,color:#fff + +virtualenv defaults to using the builtin creator if one is available for the target environment, falling back to the +venv creator otherwise. + +********* + Seeders +********* + +After creating the virtual environment structure, virtualenv installs seed packages that enable package management +within the environment. The seed packages are: + +- ``pip`` - The package installer for Python (always installed). +- ``setuptools`` - Package development and installation library (disabled by default on Python 3.12+). +- ``wheel`` - Support for the wheel binary package format (only installed by default on Python 3.8). + +virtualenv supports two seeding methods with dramatically different performance characteristics: + +**pip seeder** + This method uses the bundled pip wheel to install seed packages by spawning a child pip process. The subprocess + performs a full installation, including unpacking wheels and generating metadata. This method is reliable but slow, + typically consuming 98% of the total virtual environment creation time. + +**app-data seeder** + This method creates reusable install images in a user application data directory. The first time you create an + environment with specific seed package versions, the app-data seeder builds complete install images and stores them + in the cache. Subsequent environment creations simply link or copy these pre-built images into the virtual + environment's ``site-packages`` directory. + + Performance comparison for creating virtual environments: + + .. mermaid:: + + xychart-beta horizontal + title "Seeding time (seconds, lower is better)" + x-axis ["pip seeder (70s)", "app-data copy Win (8s)", "app-data symlink Win (0.8s)", "app-data symlink Linux/macOS (0.1s)"] + y-axis "Seconds" 0 --> 80 + bar [70, 8, 0.8, 0.1] + + On platforms that support symlinks efficiently (Linux, macOS), the app-data seeder provides nearly instant seeding. + + You can override the cache location using the ``VIRTUALENV_OVERRIDE_APP_DATA`` environment variable. + +.. _wheels: + +Wheel acquisition +================= + +Both seeding methods require wheel files for the seed packages. virtualenv acquires wheels using a priority system: + +.. mermaid:: + + flowchart TD + Start([Need wheel file]) --> Embedded{Found in embedded wheels?} + Embedded -->|Yes| UseEmbedded([Use embedded wheel]) + Embedded -->|No| Upgraded{Found in upgraded wheels?} + Upgraded -->|Yes| UseUpgraded([Use upgraded wheel]) + Upgraded -->|No| Extra{Found in extra-search-dir?} + Extra -->|Yes| UseExtra([Use extra wheel]) + Extra -->|No| Download{Download enabled?} + Download -->|Yes| DownloadPyPI([Download from PyPI]) + Download -->|No| Fail([Seeding fails]) + + style Start fill:#2563eb,stroke:#1d4ed8,color:#fff + style UseEmbedded fill:#16a34a,stroke:#15803d,color:#fff + style UseUpgraded fill:#16a34a,stroke:#15803d,color:#fff + style UseExtra fill:#16a34a,stroke:#15803d,color:#fff + style DownloadPyPI fill:#16a34a,stroke:#15803d,color:#fff + style Fail fill:#dc2626,stroke:#b91c1c,color:#fff + style Embedded fill:#d97706,stroke:#b45309,color:#fff + style Upgraded fill:#d97706,stroke:#b45309,color:#fff + style Extra fill:#d97706,stroke:#b45309,color:#fff + style Download fill:#d97706,stroke:#b45309,color:#fff + +**Embedded wheels** + virtualenv ships with a set of wheels bundled directly into the package. These are tested with the virtualenv + release and provide a baseline set of seed packages. Different Python versions require different package versions, + so virtualenv bundles multiple wheels to support its wide Python version range. + +**Upgraded embedded wheels** + Users can manually upgrade the embedded wheels by running virtualenv with the ``--upgrade-embed-wheels`` flag. This + fetches newer versions of seed packages from PyPI and stores them in the user application data directory. Subsequent + virtualenv invocations will use these upgraded wheels instead of the embedded ones. + + virtualenv can also perform periodic automatic upgrades (see below). + +**Extra search directories** + Users can specify additional directories containing wheels using the ``--extra-search-dir`` flag. This is useful in + air-gapped environments or when using custom package builds. + +**PyPI download** + If no suitable wheel is found in the above locations, or if the ``--download`` flag is set, virtualenv will use pip + to download the latest compatible version from PyPI. + +Periodic update mechanism +========================= + +To keep the seed packages reasonably current without requiring users to manually upgrade virtualenv or run +``--upgrade-embed-wheels``, virtualenv implements a periodic automatic update system: + +.. mermaid:: + + timeline + title Periodic update safety gates + section PyPI release + Package published : New wheel available on PyPI + section 28-day wait + Day 1-28 : Wheel is too new, ignored by virtualenv + section Check interval + Every 14 days : virtualenv checks for eligible wheels + section 1-hour hold + After download : Wheel downloaded but not yet used + +1 hour : Wheel becomes active for new environments + +The 28-day waiting period protects users from automatically adopting newly released packages that might contain bugs. +The 1-hour delay after download ensures continuous integration systems don't start using different package versions +mid-run, which could cause confusing test failures. + +You can disable the periodic update mechanism with the ``--no-periodic-update`` flag. + +.. _distribution_wheels: + +Distribution maintainer patching +================================ + +Operating system distributions and package managers sometimes need to customize which seed package versions virtualenv +uses. They want to align virtualenv's bundled packages with system package versions. + +Distributions can patch the ``virtualenv.seed.wheels.embed`` module, replacing the ``get_embed_wheel`` function with +their own implementation that returns distribution-provided wheels. If they want to use virtualenv's test suite for +validation, they should also provide the ``BUNDLE_FOLDER``, ``BUNDLE_SUPPORT``, and ``MAX`` variables. + +Distributions should also consider patching ``virtualenv.seed.embed.base_embed.PERIODIC_UPDATE_ON_BY_DEFAULT`` to +``False``, allowing the system package manager to control seed package updates rather than virtualenv's periodic update +mechanism. Users can still manually request upgrades via ``--upgrade-embed-wheels``, but automatic updates won't +interfere with system-managed packages. + +************ + Activators +************ + +Activation scripts modify the current shell environment to prioritize the virtual environment's executables. This is +purely a convenience mechanism - you can always use absolute paths to virtual environment executables without +activating. + +What activation does: + +.. mermaid:: + + flowchart TD + Before([Before activation]) --> ModifyPATH[Prepend venv/bin to PATH] + ModifyPATH --> SetVENV[Set VIRTUAL_ENV variable] + SetVENV --> SetPROMPT[Set VIRTUAL_ENV_PROMPT variable] + SetPROMPT --> SetPKG[Set PKG_CONFIG_PATH] + SetPKG --> ModifyPrompt[Modify shell prompt] + ModifyPrompt --> After([After activation]) + + style Before fill:#2563eb,stroke:#1d4ed8,color:#fff + style After fill:#16a34a,stroke:#15803d,color:#fff + style ModifyPATH fill:#6366f1,stroke:#4f46e5,color:#fff + style SetVENV fill:#6366f1,stroke:#4f46e5,color:#fff + style SetPROMPT fill:#6366f1,stroke:#4f46e5,color:#fff + style SetPKG fill:#6366f1,stroke:#4f46e5,color:#fff + style ModifyPrompt fill:#6366f1,stroke:#4f46e5,color:#fff + +**PATH modification** + The activation script prepends the virtual environment's ``bin`` directory (``Scripts`` on Windows) to the ``PATH`` + environment variable. This ensures that when you run ``python``, ``pip``, or other executables, the shell finds the + virtual environment's versions first. + +**Environment variables** + Activation sets several environment variables: + + - ``VIRTUAL_ENV`` - Absolute path to the virtual environment directory. + - ``VIRTUAL_ENV_PROMPT`` - The prompt prefix (the environment name or custom value from ``--prompt``). + - ``PKG_CONFIG_PATH`` - Modified to include the virtual environment's ``lib/pkgconfig`` directory. + +**Prompt modification** + By default, activation prepends the environment name to your shell prompt, typically shown as ``(venv)`` before the + regular prompt. This visual indicator helps you remember which environment is active. You can customize this with + the ``--prompt`` flag when creating the environment, or disable it entirely by setting the + ``VIRTUAL_ENV_DISABLE_PROMPT`` environment variable. + +**Deactivation** + Activation scripts also provide a ``deactivate`` command that reverses the changes, restoring your original PATH and + removing the environment variables and prompt modifications. + +virtualenv provides activation scripts for multiple shells: + +- `Bash `_ (``activate``) +- `Fish `_ (``activate.fish``) +- `Csh/Tcsh `_ (``activate.csh``) +- `PowerShell `_ (``activate.ps1``) +- `Windows Batch `_ + (``activate.bat``) +- `Nushell `_ (``activate.nu``) +- Python (``activate_this.py``) -- for programmatic activation from within a running Python process, see + :ref:`how-to/usage:Programmatic activation` + +.. note:: + + On Windows 7 and later, PowerShell's default execution policy is ``Restricted``, which prevents running the + ``activate.ps1`` script. You can allow locally-generated scripts to run by changing the execution policy: + + .. code-block:: powershell + + Set-ExecutionPolicy RemoteSigned + + Since virtualenv generates ``activate.ps1`` locally for each environment, PowerShell considers it a local script + rather than a remote one and allows execution under the ``RemoteSigned`` policy. + +Remember: activation is optional. The following commands are equivalent: + +.. code-block:: console + + # With activation + source venv/bin/activate + python script.py + deactivate + + # Without activation + venv/bin/python script.py + +For a deeper dive into how activation works under the hood, see Allison Kaptur's blog post `There's no magic: virtualenv +edition `_, which explains how virtualenv uses +``PATH`` and ``PYTHONHOME`` to isolate virtual environments. + +********** + See also +********** + +- :doc:`how-to/usage` - Practical guides for common virtualenv tasks. +- :doc:`reference/cli` - Complete CLI reference documentation. diff --git a/docs/extend.rst b/docs/extend.rst deleted file mode 100644 index 6dc107e3b..000000000 --- a/docs/extend.rst +++ /dev/null @@ -1,88 +0,0 @@ -Extend functionality -==================== - -``virtualenv`` allows one to extend the builtin functionality via a plugin system. To add a plugin you need to: - -- write a python file containing the plugin code which follows our expected interface, -- package it as a python library, -- install it alongside the virtual environment. - -Python discovery ----------------- - -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 -`pyenv `_ project to discover, and if not present install the requested Python -interpreter. Python discovery mechanisms must be registered under key ``virtualenv.discovery``, and the plugin must -implement :class:`virtualenv.discovery.discover.Discover`: - -.. code-block:: ini - - virtualenv.discovery = - pyenv = virtualenv_pyenv.discovery:PyEnvDiscovery - - -.. currentmodule:: virtualenv.discovery.discover - -.. autoclass:: Discover - :undoc-members: - :members: - - -Creators --------- -Creators are what actually perform the creation of a virtual environment. The builtin virtual environment creators -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.create`` , and the class must implement :class:`virtualenv.create.creator.Creator`: - -.. code-block:: ini - - virtualenv.create = - cpython3-posix = virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Posix - -.. currentmodule:: virtualenv.create.creator - -.. autoclass:: Creator - :undoc-members: - :members: - :exclude-members: run, set_pyenv_cfg, debug_script, debug_script, validate_dest, debug - - -Seed mechanism --------------- - -Seeders are what given a virtual environment will install somehow some seed packages into it. They must be registered -under and entry point with key ``virtualenv.seed`` , and the class must implement -:class:`virtualenv.seed.seeder.Seeder`: - -.. code-block:: ini - - virtualenv.seed = - db = virtualenv.seed.fromDb:InstallFromDb - -.. currentmodule:: virtualenv.seed.seeder - -.. autoclass:: Seeder - :undoc-members: - :members: - -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 an entry point with key ``virtualenv.activate`` , and the class must implement -:class:`virtualenv.activation.activator.Activator`: - -.. code-block:: ini - - virtualenv.activate = - bash = virtualenv.activation.bash:BashActivator - -.. currentmodule:: virtualenv.activation.activator - -.. autoclass:: Activator - :undoc-members: - :members: diff --git a/docs/how-to/install.rst b/docs/how-to/install.rst new file mode 100644 index 000000000..78ad552c5 --- /dev/null +++ b/docs/how-to/install.rst @@ -0,0 +1,103 @@ +#################### + Install virtualenv +#################### + +virtualenv is a command-line tool, so it should be installed in an isolated environment rather than into your system +Python. Pick the method that fits your setup: + +- `uv `_ -- fast, modern Python package manager. Use this if you already have ``uv`` or are + starting fresh. +- `pipx `_ -- installs Python CLI tools in isolated environments. Use this if you already + have ``pipx`` set up. +- `pip `_ -- the standard Python package installer. Use ``--user`` to avoid modifying + system packages. May not work on distributions with externally-managed Python environments. +- `zipapp `_ -- a self-contained executable requiring no installation. + Use this in CI or environments where you cannot install packages. + +.. mermaid:: + + flowchart TD + A{Can you install packages?} -->|Yes| B{Have uv?} + A -->|No| Z[zipapp] + B -->|Yes| U[uv tool install] + B -->|No| C{Have pipx?} + C -->|Yes| P[pipx install] + C -->|No| D[pip install --user] + + style A fill:#d97706,stroke:#b45309,color:#fff + style B fill:#d97706,stroke:#b45309,color:#fff + style C fill:#d97706,stroke:#b45309,color:#fff + style U fill:#16a34a,stroke:#15803d,color:#fff + style P fill:#16a34a,stroke:#15803d,color:#fff + style D fill:#7c3aed,stroke:#6d28d9,color:#fff + style Z fill:#7c3aed,stroke:#6d28d9,color:#fff + +.. tab:: uv + + Install virtualenv as a `uv tool `_: + + .. code-block:: console + + $ uv tool install virtualenv + + Install the development version: + + .. code-block:: console + + $ uv tool install git+https://github.com/pypa/virtualenv.git@main + +.. tab:: pipx + + Install virtualenv using `pipx `_: + + .. code-block:: console + + $ pipx install virtualenv + + Install the development version: + + .. code-block:: console + + $ pipx install git+https://github.com/pypa/virtualenv.git@main + +.. tab:: pip + + Install virtualenv using `pip `_: + + .. code-block:: console + + $ python -m pip install --user virtualenv + + Install the development version: + + .. code-block:: console + + $ python -m pip install git+https://github.com/pypa/virtualenv.git@main + + .. warning:: + + Some Linux distributions use system-managed Python environments. If you encounter errors about externally-managed + environments, use ``uv tool`` or ``pipx`` instead. + +.. tab:: zipapp + + Download the zipapp file and run it directly: + + .. code-block:: console + + $ python virtualenv.pyz --help + + Download the latest version from https://bootstrap.pypa.io/virtualenv.pyz or a specific version from + ``https://bootstrap.pypa.io/virtualenv/x.y/virtualenv.pyz``. + +********************* + Verify installation +********************* + +Check the installed version: + +.. code-block:: console + + $ virtualenv --version + +See :doc:`../reference/compatibility` for supported Python versions. diff --git a/docs/how-to/usage.rst b/docs/how-to/usage.rst new file mode 100644 index 000000000..4969daa17 --- /dev/null +++ b/docs/how-to/usage.rst @@ -0,0 +1,355 @@ +################ + Use virtualenv +################ + +************************* + Select a Python version +************************* + +By default, virtualenv uses the same Python version it runs under. Override this with ``--python`` or ``-p``. + +Using version specifiers +======================== + +Specify a Python version by name or version number: + +.. code-block:: console + + $ virtualenv -p python3.8 venv + $ virtualenv -p 3.10 venv + $ virtualenv -p pypy3 venv + $ virtualenv -p rustpython venv + +Using PEP 440 specifiers +======================== + +Use `PEP 440 `_ version specifiers to match Python versions: + +.. code-block:: console + + $ virtualenv --python ">=3.12" venv + $ virtualenv --python "~=3.11.0" venv + $ virtualenv --python "cpython>=3.10" venv + +- ``>=3.12`` -- any Python 3.12 or later. +- ``~=3.11.0`` -- compatible release, equivalent to ``>=3.11.0, <3.12.0`` (any 3.11.x patch). +- ``cpython>=3.10`` -- restrict to CPython implementation, 3.10 or later. + +Using free-threading Python +=========================== + +Create an environment with `free-threading Python `_: + +.. code-block:: console + + $ virtualenv -p 3.13t venv + +Targeting a specific CPU architecture +===================================== + +On machines that support multiple architectures — such as Apple Silicon (arm64 + x86_64 via Rosetta) or Windows on ARM — +you can request a specific CPU architecture by appending it to the spec string: + +.. code-block:: console + + $ virtualenv -p cpython3.12-64-arm64 venv + $ virtualenv -p 3.11-64-x86_64 venv + +Cross-platform aliases are normalized automatically, so ``amd64`` and ``x86_64`` are treated as equivalent, as are +``aarch64`` and ``arm64``. If omitted, any architecture matches (preserving existing behavior). + +Using absolute paths +==================== + +Specify the full path to a Python interpreter: + +.. code-block:: console + + $ virtualenv -p /usr/bin/python3.9 venv + +Using ``--try-first-with`` +========================== + +Use ``--try-first-with`` to provide a hint about which Python to check first. Unlike ``--python``, this is a hint rather +than a rule. The interpreter at this path is checked first, but only used if it matches the ``--python`` constraint. + +.. code-block:: console + + $ virtualenv --python ">=3.10" --try-first-with /usr/bin/python3.9 venv + +In this example, /usr/bin/python3.9 is checked first but rejected because it does not satisfy the >=3.10 constraint. + +Using version managers (pyenv, mise, asdf) +========================================== + +virtualenv automatically resolves shims from `pyenv `_, `mise `_, +and `asdf `_ to the real Python binary. Set the active Python version using any of the standard +mechanisms and virtualenv will discover it: + +.. code-block:: console + + $ pyenv local 3.12.0 + $ virtualenv venv # uses pyenv's 3.12.0, not the system Python + + $ PYENV_VERSION=3.11.0 virtualenv venv # uses 3.11.0 + +This also works with mise and asdf: + +.. code-block:: console + + $ mise use python@3.12 + $ virtualenv venv + +No additional configuration is required. See :doc:`../explanation` for details on how shim resolution works. + +******************************** + Activate a virtual environment +******************************** + +Activate the environment to modify your shell's PATH and environment variables. + +.. tab:: Bash/Zsh + + .. code-block:: console + + $ source venv/bin/activate + +.. tab:: Fish + + .. code-block:: console + + $ source venv/bin/activate.fish + +.. tab:: PowerShell + + .. code-block:: console + + PS> .\venv\Scripts\Activate.ps1 + + .. note:: + + If you encounter an execution policy error, run ``Set-ExecutionPolicy RemoteSigned`` to allow local scripts. + +.. tab:: CMD + + .. code-block:: console + + > .\venv\Scripts\activate.bat + +.. tab:: Nushell + + .. code-block:: console + + $ overlay use venv/bin/activate.nu + +Deactivate the environment +========================== + +Exit the virtual environment: + +.. code-block:: console + + $ deactivate + +Use without activation +====================== + +Use the environment without activating it by calling executables with their full paths: + +.. code-block:: console + + $ venv/bin/python script.py + $ venv/bin/pip install package + +Customize prompt +================ + +Set a custom prompt prefix: + +.. code-block:: console + + $ virtualenv --prompt myproject venv + +Disable the prompt modification by setting the ``VIRTUAL_ENV_DISABLE_PROMPT`` environment variable. + +Access the prompt string via the ``VIRTUAL_ENV_PROMPT`` environment variable. + +Programmatic activation +======================= + +Activate the environment from within a running Python process using ``activate_this.py``. This modifies ``sys.path`` and +environment variables in the current process so that subsequent imports resolve from the virtual environment. + +.. code-block:: python + + import runpy + + runpy.run_path("venv/bin/activate_this.py") + +A common use case is web applications served by a system-wide WSGI server (such as mod_wsgi or uWSGI) that need to load +packages from a virtual environment: + +.. code-block:: python + + import runpy + from pathlib import Path + + runpy.run_path(str(Path("/var/www/myapp/venv/bin/activate_this.py"))) + + from myapp import create_app # noqa: E402 + + application = create_app() + +******************** + Configure defaults +******************** + +Use a configuration file to set default options for virtualenv. + +Configuration file location +=========================== + +The configuration file is named ``virtualenv.ini`` and located in the platformdirs app config directory. Run +``virtualenv --help`` to see the exact location for your system. + +Override the location with the ``VIRTUALENV_CONFIG_FILE`` environment variable. + +Configuration format +==================== + +Derive configuration keys from command-line options by stripping leading ``-`` and replacing remaining ``-`` with ``_``: + +.. code-block:: ini + + [virtualenv] + python = /opt/python-3.8/bin/python + +Multi-value options +=================== + +Specify multiple values on separate lines: + +.. code-block:: ini + + [virtualenv] + extra_search_dir = + /path/to/dists + /path/to/other/dists + +Environment variables +===================== + +Set options using environment variables with the ``VIRTUALENV_`` prefix and uppercase key names: + +.. code-block:: console + + $ export VIRTUALENV_PYTHON=/opt/python-3.8/bin/python + +For multi-value options, separate values with commas or newlines. + +Override app-data location +========================== + +Set the ``VIRTUALENV_OVERRIDE_APP_DATA`` environment variable to override the default app-data cache directory location. + +Configuration priority +====================== + +Options are resolved in this order (highest to lowest priority): + +.. mermaid:: + + block-beta + columns 1 + A["Command-line arguments (highest)"] + B["Environment variables"] + C["Configuration file"] + D["Default values (lowest)"] + + style A fill:#16a34a,stroke:#15803d,color:#fff + style B fill:#2563eb,stroke:#1d4ed8,color:#fff + style C fill:#d97706,stroke:#b45309,color:#fff + style D fill:#6366f1,stroke:#4f46e5,color:#fff + +*********************** + Control seed packages +*********************** + +Upgrade embedded wheels +======================= + +Update the embedded wheel files to the latest versions: + +.. code-block:: console + + $ virtualenv --upgrade-embed-wheels + +Provide custom wheels +===================== + +Use custom wheel files from a local directory: + +.. code-block:: console + + $ virtualenv --extra-search-dir /path/to/wheels venv + +Download latest from PyPI +========================= + +Download the latest versions of seed packages from PyPI: + +.. code-block:: console + + $ virtualenv --download venv + +Disable periodic updates +======================== + +Disable automatic periodic updates of seed packages: + +.. code-block:: console + + $ virtualenv --no-periodic-update venv + +For distribution maintainers +============================ + +Patch the ``virtualenv.seed.wheels.embed`` module and set ``PERIODIC_UPDATE_ON_BY_DEFAULT`` to ``False`` to disable +periodic updates by default. See :doc:`../explanation` for implementation details. + +********************** + Use from Python code +********************** + +Call virtualenv from Python code using the ``cli_run`` function: + +.. code-block:: python + + from virtualenv import cli_run + + cli_run(["venv"]) + +Pass options as list elements: + +.. code-block:: python + + cli_run(["-p", "python3.8", "--without-pip", "myenv"]) + +Use the returned session object to access environment details: + +.. code-block:: python + + result = cli_run(["venv"]) + print(result.creator.dest) # path to created environment + print(result.creator.exe) # path to python executable + +Use ``session_via_cli`` to describe the environment without creating it: + +.. code-block:: python + + from virtualenv import session_via_cli + + session = session_via_cli(["venv"]) + # inspect session.creator, session.seeder, session.activators + +See :doc:`../reference/api` for complete API documentation. diff --git a/docs/index.rst b/docs/index.rst index 52484a9ae..050eed9f5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,122 +1,137 @@ -virtualenv -========== +############ + virtualenv +############ .. image:: https://img.shields.io/pypi/v/virtualenv?style=flat-square - :target: https://pypi.org/project/virtualenv/#history - :alt: Latest version on PyPI + :target: https://pypi.org/project/virtualenv/#history + :alt: Latest version on PyPI + .. image:: https://img.shields.io/pypi/implementation/virtualenv?style=flat-square - :alt: PyPI - Implementation + :alt: PyPI - Implementation + .. image:: https://img.shields.io/pypi/pyversions/virtualenv?style=flat-square - :alt: PyPI - Python Version + :alt: PyPI - Python Version + .. image:: https://readthedocs.org/projects/virtualenv/badge/?version=latest&style=flat-square - :target: https://virtualenv.pypa.io - :alt: Documentation status + :target: https://virtualenv.pypa.io + :alt: Documentation status + .. image:: https://img.shields.io/discord/803025117553754132 - :target: https://discord.gg/pypa - :alt: Discord + :target: https://discord.gg/pypa + :alt: Discord + .. image:: https://img.shields.io/pypi/dm/virtualenv?style=flat-square - :target: https://pypistats.org/packages/virtualenv - :alt: PyPI - Downloads + :target: https://pypistats.org/packages/virtualenv + :alt: PyPI - Downloads + .. image:: https://img.shields.io/pypi/l/virtualenv?style=flat-square - :target: https://opensource.org/licenses/MIT - :alt: PyPI - License + :target: https://opensource.org/licenses/MIT + :alt: PyPI - License + .. image:: https://img.shields.io/github/issues/pypa/virtualenv?style=flat-square - :target: https://github.com/pypa/virtualenv/issues - :alt: Open issues + :target: https://github.com/pypa/virtualenv/issues + :alt: Open issues + .. image:: https://img.shields.io/github/issues-pr/pypa/virtualenv?style=flat-square - :target: https://github.com/pypa/virtualenv/pulls - :alt: Open pull requests + :target: https://github.com/pypa/virtualenv/pulls + :alt: Open pull requests + .. image:: https://img.shields.io/github/stars/pypa/virtualenv?style=flat-square - :target: https://pypistats.org/packages/virtualenv - :alt: Package popularity + :target: https://pypistats.org/packages/virtualenv + :alt: Package popularity -``virtualenv`` is a tool to create isolated Python environments. +``virtualenv`` is a tool to create isolated Python environments. Since Python 3.3, a subset of it has been integrated +into the standard library under the ``venv`` module. For how ``virtualenv`` compares to the stdlib ``venv`` module, see +:doc:`explanation`. -virtualenv vs venv ------------------- +****************** + Quick navigation +****************** -Since Python ``3.3``, a subset of it has been -integrated into the standard library under the `venv module `_. The -``venv`` module does not offer all features of this library, to name just a few more prominent: +**Tutorials** - Learn by doing -- is slower (by not having the ``app-data`` seed method), -- is not as extendable, -- cannot create virtual environments for arbitrarily installed python versions (and automatically discover these), -- is not upgrade-able via `pip `_, -- does not have as rich programmatic API (describe virtual environments without creating them). +- :doc:`tutorial/getting-started` — Create your first virtual environment and learn the basic workflow -Concept and purpose of virtualenv ---------------------------------- +**How-to guides** - Solve specific problems -The basic problem being addressed is one of dependencies and versions, and indirectly permissions. -Imagine you have an application that needs version ``1`` of ``LibFoo``, but another application requires version -``2``. How can you use both these libraries? If you install everything into your host python (e.g. ``python3.8``) -it's easy to end up in a situation where two packages have conflicting requirements. +- :doc:`how-to/install` — Install virtualenv on your system +- :doc:`how-to/usage` — Select Python versions, activate environments, configure defaults, and use from Python code -Or more generally, what if you want to install an application *and leave it be*? If an application works, any change -in its libraries or the versions of those libraries can break the application. Also, what if you can't install packages -into the global ``site-packages`` directory, due to not having permissions to change the host python environment? +**Reference** - Technical information -In all these cases, ``virtualenv`` can help you. It creates an environment that has its own installation directories, -that doesn't share libraries with other virtualenv environments (and optionally doesn't access the globally installed -libraries either). +- :doc:`reference/compatibility` — Supported Python versions and operating systems +- :doc:`reference/cli` — Command line options and flags +- :doc:`reference/api` — Programmatic Python API reference +**Explanation** - Understand the concepts -Compatibility -------------- +- :doc:`explanation` — How virtualenv works under the hood and why it exists -With the release of virtualenv 20.22, April 2023, (`release note `__) target interpreters are now limited to Python v. 3.7+. +**Extensions** -Trying to use an earlier version will normally result in the target interpreter raising a syntax error. This virtualenv tool will then print some details about the exception and abort, ie no explicit warning about trying to use an outdated/incompatible version. It may look like this: +- :doc:`plugin/index` — Extend virtualenv with custom creators, seeders, and activators -.. code-block:: console +****************** + Related projects +****************** - $ virtualenv --discovery pyenv -p python3.6 foo - RuntimeError: failed to query /home/velle/.pyenv/versions/3.6.15/bin/python3.6 with code 1 err: ' File "/home/velle/.virtualenvs/toxrunner/lib/python3.12/site-packages/virtualenv/discovery/py_info.py", line 7 - from __future__ import annotations - ^ - SyntaxError: future feature annotations is not defined +Several tools build on virtualenv to provide higher-level workflows: +- `virtualenvwrapper `_ — Shell wrapper for creating and managing + multiple virtualenvs +- `pew `_ — Python Env Wrapper, a set of commands to manage multiple virtual + environments +- `tox `_ — Automate testing across multiple Python versions +- `nox `_ — Flexible test automation in Python -In tox, even if the interpreter is installed and available, the message is (somewhat misleading): +******************** + External resources +******************** -.. code-block:: console +Learn more about virtualenv from these community resources: - py36: skipped because could not find python interpreter with spec(s): py36 +- `Corey Schafer's virtualenv tutorial `_ — Video walkthrough for beginners +- `Bernat Gabor's status quo `_ — Talk about the current state of Python + packaging +- `Carl Meyer's reverse-engineering `_ — + Deep dive into how virtualenv works internally +.. toctree:: + :hidden: + :caption: Tutorial + tutorial/getting-started -Useful links ------------- +.. toctree:: + :hidden: + :caption: How-to guides -**Related projects, that build abstractions on top of virtualenv** + how-to/install + how-to/usage -* :pypi:`virtualenvwrapper` - a useful set of scripts for creating and deleting virtual environments -* :pypi:`pew` - provides a set of commands to manage multiple virtual environments -* :pypi:`tox` - a generic virtualenv management and test automation command line tool, driven by a ``tox.ini`` - configuration file -* :pypi:`nox` - a tool that automates testing in multiple Python environments, similar to tox, - driven by a ``noxfile.py`` configuration file +.. toctree:: + :hidden: + :caption: Reference -**Tutorials** + reference/compatibility + reference/cli + reference/api -* `Corey Schafer tutorial `_ on how to use it -* `Using virtualenv with mod_wsgi `_ +.. toctree:: + :hidden: + :caption: Explanation -**Presenting how the package works from within** + explanation -* `Bernat Gabor: status quo of virtual environments `_ -* `Carl Meyer: Reverse-engineering Ian Bicking's brain: inside pip and virtualenv - `_ +.. toctree:: + :hidden: + :caption: Extend -.. comment: split here + plugin/index .. toctree:: - :hidden: - - installation - user_guide - cli_interface - extend - development - changelog + :hidden: + :caption: Project + + development + changelog diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index b82d1dd19..000000000 --- a/docs/installation.rst +++ /dev/null @@ -1,124 +0,0 @@ -Installation -============ - -via pipx --------- - -: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. - -.. code-block:: console - - pipx install virtualenv - virtualenv --help - -via pip -------- - -Alternatively you can install it within the global Python interpreter itself (perhaps as a user package via the -``--user`` flag). Be cautious if you are using a python install that is managed by your operating system or -another package manager. ``pip`` might not coordinate with those tools, and may leave your system in an -inconsistent state. Note, if you go down this path you need to ensure pip is new enough per the subsections below: - -.. code-block:: console - - python -m pip install --user virtualenv - python -m virtualenv --help - -wheel -~~~~~ -Installing virtualenv via a wheel (default with pip) requires an installer that can understand the ``python-requires`` -tag (see `PEP-503 `_), with pip this is version ``9.0.0`` (released 2016 -November). Furthermore, in case you're not installing it via the PyPi you need to be using a mirror that correctly -forwards the ``python-requires`` tag (notably the OpenStack mirrors don't do this, or older -`devpi `_ versions - added with version ``4.7.0``). - -.. _sdist: - -sdist -~~~~~ -When installing via a source distribution you need an installer that handles the -`PEP-517 `_ specification. In case of ``pip`` this is version ``18.0.0`` or -later (released on 2018 July). If you cannot upgrade your pip to support this you need to ensure that the build -requirements from `pyproject.toml `_ are satisfied -before triggering the install. - -via zipapp ----------- - -You can use virtualenv without installing it too. We publish a Python -`zipapp `_, you can just download this from -`https://bootstrap.pypa.io/virtualenv.pyz `_ and invoke this package -with a python interpreter: - -.. code-block:: console - - python virtualenv.pyz --help - -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 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: - -.. code-block:: console - - https://github.com/pypa/get-virtualenv/blob//public//virtualenv.pyz?raw=true - -latest unreleased ------------------ -Installing an unreleased version is discouraged and should be only done for testing purposes. If you do so you'll need -a pip version of at least ``18.0.0`` and use the following command: - - -.. code-block:: console - - pip install git+https://github.com/pypa/virtualenv.git@main - -.. _compatibility-requirements: - -Python and OS Compatibility ---------------------------- - -virtualenv works with the following Python interpreter implementations: - -- `CPython `_: ``3.13 >= 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. - -CPython is shipped in multiple forms, and each OS repackages it, often applying some customization along the way. -Therefore we cannot say universally that we support all platforms, but rather specify some we test against. In case -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 `_ -- Ubuntu 16.04+ (both upstream and `deadsnakes `_ builds) -- Fedora -- RHEL and CentOS -- OpenSuse -- Arch Linux - -macOS -~~~~~ -In case of macOS we support: - -- 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.8+ `_ diff --git a/docs/plugin/api.rst b/docs/plugin/api.rst new file mode 100644 index 000000000..dee75e40e --- /dev/null +++ b/docs/plugin/api.rst @@ -0,0 +1,79 @@ +###################### + Plugin API reference +###################### + +This page documents the interfaces that plugins must implement. + +*********** + Discovery +*********** + +Discovery plugins locate Python interpreters for creating virtual environments. + +.. currentmodule:: virtualenv.discovery.discover + +.. autoclass:: Discover + :undoc-members: + :members: + +PythonInfo +========== + +Discovery plugins return a ``PythonInfo`` object describing the located interpreter. + +.. currentmodule:: virtualenv.discovery.py_info + +.. autoclass:: PythonInfo + :undoc-members: + :members: + +********** + App data +********** + +The application data interface used by plugins for caching. + +.. currentmodule:: virtualenv.app_data.base + +.. autoclass:: AppData + :members: + +********** + Creators +********** + +Creator plugins build the virtual environment directory structure and install the Python interpreter. + +.. currentmodule:: virtualenv.create.creator + +.. autoclass:: CreatorMeta + :members: + +.. autoclass:: Creator + :undoc-members: + :members: + :exclude-members: run, set_pyenv_cfg, debug_script, validate_dest, debug + +********* + Seeders +********* + +Seeder plugins install initial packages (like pip, setuptools, wheel) into the virtual environment. + +.. currentmodule:: virtualenv.seed.seeder + +.. autoclass:: Seeder + :undoc-members: + :members: + +************ + Activators +************ + +Activator plugins generate shell-specific activation scripts. + +.. currentmodule:: virtualenv.activation.activator + +.. autoclass:: Activator + :undoc-members: + :members: diff --git a/docs/plugin/architecture.rst b/docs/plugin/architecture.rst new file mode 100644 index 000000000..4ce911622 --- /dev/null +++ b/docs/plugin/architecture.rst @@ -0,0 +1,125 @@ +##################### + Plugin architecture +##################### + +This page explains how virtualenv's plugin system works internally. + +************** + Entry points +************** + +virtualenv uses Python entry points (``setuptools`` / ``importlib.metadata``) to discover plugins. Each plugin registers +under one of four entry point groups: + +- ``virtualenv.discovery`` +- ``virtualenv.create`` +- ``virtualenv.seed`` +- ``virtualenv.activate`` + +At startup, virtualenv loads all registered entry points from these groups and makes them available as CLI options. +Built-in implementations are registered in virtualenv's own ``pyproject.toml``, while third-party plugins register their +entry points in their own package metadata. + +When a package with virtualenv plugins is installed in the same environment as virtualenv, the plugins become +immediately available without additional configuration. + +****************** + Plugin lifecycle +****************** + +The following diagram shows how plugins are discovered and executed: + +.. mermaid:: + + sequenceDiagram + participant User + participant CLI + participant EntryPoints + participant Discovery + participant Creator + participant Seeder + participant Activator + + rect rgba(37, 99, 235, 0.15) + User->>CLI: virtualenv myenv + CLI->>EntryPoints: Load plugins from all groups + EntryPoints-->>CLI: Available plugins + CLI->>CLI: Build argument parser with plugin options + CLI->>User: Parse CLI arguments + User-->>CLI: Selected options + end + + rect rgba(22, 163, 106, 0.15) + CLI->>Discovery: Run selected discovery plugin + Discovery-->>CLI: PythonInfo + CLI->>Creator: Create environment with PythonInfo + Creator-->>CLI: Created environment + CLI->>Seeder: Seed packages into environment + Seeder-->>CLI: Seeded environment + CLI->>Activator: Generate activation scripts + Activator-->>CLI: Complete environment + end + +The lifecycle follows these stages: + +1. virtualenv starts and discovers all entry points from the four plugin groups +2. The CLI parser is built dynamically, incorporating options from all discovered plugins +3. User arguments are parsed to select which discovery, creator, seeder, and activator plugins to use +4. Selected plugins execute in sequence: discover → create → seed → activate +5. Each stage passes its output to the next stage + +************************ + Extension point design +************************ + +Each extension point follows a consistent pattern: + +Base abstract class + Each extension point defines a base abstract class (``Discover``, ``Creator``, ``Seeder``, ``Activator``) that + specifies the interface plugins must implement. + +Built-in implementations + virtualenv includes built-in implementations registered as entry points in its own ``pyproject.toml``. For example, + the built-in CPython creator is registered as ``cpython3-posix``. + +Third-party plugins + External packages implement the base interface and register their own entry points under the same group. When + installed, they appear alongside built-in options. + +CLI selection + Command-line flags (``--discovery``, ``--creator``, ``--seeder``, ``--activators``) allow users to select which + implementation to use. Multiple activators can be selected simultaneously. + +Parser integration + Each plugin can contribute CLI arguments through the ``add_parser_arguments`` classmethod. These arguments appear in + ``virtualenv --help`` and are available when the plugin is selected. + +********************** + How plugins interact +********************** + +Plugins execute in a pipeline where each stage depends on the previous one: + +Discovery → Creator + The discovery plugin produces a ``PythonInfo`` object describing the source Python interpreter. This object contains + metadata about the Python version, platform, paths, and capabilities. The creator plugin receives this + ``PythonInfo`` and uses it to determine how to build the virtual environment structure. + +Creator → Seeder + The creator plugin produces a ``Creator`` object representing the newly created virtual environment. This includes + paths to the environment's ``bin`` directory, site-packages, and Python executable. The seeder plugin uses these + paths to install packages. + +Seeder → Activator + After seeding completes, activator plugins use the ``Creator`` object to generate shell activation scripts. These + scripts reference the environment's bin directory and other paths to configure the shell environment. + +This pipeline ensures that each plugin has the information it needs from previous stages. The ``PythonInfo`` flows from +discovery to creator, and the ``Creator`` object flows from creator to both seeder and activators. + +Plugin isolation +================ + +Plugins within the same extension point do not interact with each other. Only one discovery and one creator plugin can +run per invocation, though multiple activators can run simultaneously. This isolation keeps plugins simple and focused +on their specific task. diff --git a/docs/plugin/how-to.rst b/docs/plugin/how-to.rst new file mode 100644 index 000000000..7c11a91f8 --- /dev/null +++ b/docs/plugin/how-to.rst @@ -0,0 +1,210 @@ +###################### + Plugin how-to guides +###################### + +This page provides task-oriented guides for creating each type of virtualenv plugin. + +*************************** + Create a discovery plugin +*************************** + +Discovery plugins locate Python interpreters. Register your plugin under the ``virtualenv.discovery`` entry point group. + +Implement the ``Discover`` interface: + +.. code-block:: python + + from virtualenv.discovery.discover import Discover + from virtualenv.discovery.py_info import PythonInfo + + + class CustomDiscovery(Discover): + @classmethod + def add_parser_arguments(cls, parser): + parser.add_argument("--custom-opt", help="custom discovery option") + + def __init__(self, options): + super().__init__(options) + self.custom_opt = options.custom_opt + + def run(self): + # Locate Python interpreter and return PythonInfo + python_exe = self._find_python() + return PythonInfo.from_exe(str(python_exe)) + + def _find_python(self): + # Implementation-specific logic + pass + +Register the entry point: + +.. code-block:: ini + + [virtualenv.discovery] + custom = your_package.discovery:CustomDiscovery + +************************* + Create a creator plugin +************************* + +Creator plugins build the virtual environment structure. Register under ``virtualenv.create``. + +Implement the ``Creator`` interface: + +.. code-block:: python + + from virtualenv.create.creator import Creator + + + class CustomCreator(Creator): + @classmethod + def add_parser_arguments(cls, parser, interpreter): + parser.add_argument("--custom-creator-opt", help="custom creator option") + + def __init__(self, options, interpreter): + super().__init__(options, interpreter) + self.custom_opt = options.custom_creator_opt + + def create(self): + # Create directory structure + self.bin_dir.mkdir(parents=True, exist_ok=True) + # Copy or symlink Python executable + self.install_python() + # Set up site-packages + self.install_site_packages() + # Write pyvenv.cfg + self.set_pyenv_cfg() + +Register the entry point using a naming pattern that matches platform and Python version: + +.. code-block:: ini + + [virtualenv.create] + cpython3-posix = virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Posix + cpython3-win = virtualenv.create.via_global_ref.builtin.cpython.cpython3:CPython3Windows + +************************ + Create a seeder plugin +************************ + +Seeder plugins install initial packages into the virtual environment. Register under ``virtualenv.seed``. + +Implement the ``Seeder`` interface: + +.. code-block:: python + + from virtualenv.seed.seeder import Seeder + + + class CustomSeeder(Seeder): + @classmethod + def add_parser_arguments(cls, parser, interpreter, app_data): + parser.add_argument("--custom-seed-opt", help="custom seeder option") + + def __init__(self, options, enabled, app_data): + super().__init__(options, enabled, app_data) + self.custom_opt = options.custom_seed_opt + + def run(self, creator): + # Install packages into creator.bin_dir / creator.script("pip") + self._install_packages(creator) + + def _install_packages(self, creator): + # Implementation-specific logic + pass + +Register the entry point: + +.. code-block:: ini + + [virtualenv.seed] + custom = your_package.seed:CustomSeeder + +**************************** + Create an activator plugin +**************************** + +Activator plugins generate shell activation scripts. Register under ``virtualenv.activate``. + +Implement the ``Activator`` interface: + +.. code-block:: python + + from virtualenv.activation.activator import Activator + + + class CustomShellActivator(Activator): + def generate(self, creator): + # Generate activation script content + script_content = self._render_template(creator) + # Write to activation directory + dest = creator.bin_dir / self.script_name + dest.write_text(script_content) + + def _render_template(self, creator): + # Return activation script content + return f""" + # Custom shell activation script + export VIRTUAL_ENV="{creator.dest}" + export PATH="{creator.bin_dir}:$PATH" + """ + + @property + def script_name(self): + return "activate.custom" + +Register the entry point: + +.. code-block:: ini + + [virtualenv.activate] + bash = virtualenv.activation.bash:BashActivator + fish = virtualenv.activation.fish:FishActivator + custom = your_package.activation:CustomShellActivator + +********************************* + Package and distribute a plugin +********************************* + +Use ``pyproject.toml`` to declare entry points: + +.. code-block:: toml + + [project] + name = "virtualenv-custom-plugin" + version = "1.0.0" + dependencies = ["virtualenv>=20.0.0"] + + [project.entry-points."virtualenv.discovery"] + custom = "virtualenv_custom.discovery:CustomDiscovery" + + [project.entry-points."virtualenv.create"] + custom-posix = "virtualenv_custom.creator:CustomCreator" + + [project.entry-points."virtualenv.seed"] + custom = "virtualenv_custom.seeder:CustomSeeder" + + [project.entry-points."virtualenv.activate"] + custom = "virtualenv_custom.activator:CustomActivator" + + [build-system] + requires = ["setuptools>=61"] + build-backend = "setuptools.build_meta" + +Install your plugin alongside virtualenv: + +.. code-block:: console + + $ pip install virtualenv-custom-plugin + +Or in development mode: + +.. code-block:: console + + $ pip install -e /path/to/virtualenv-custom-plugin + +Test your plugin by creating a virtual environment: + +.. code-block:: console + + $ virtualenv --discovery=custom --creator=custom-posix --seeder=custom --activators=custom test-env diff --git a/docs/plugin/index.rst b/docs/plugin/index.rst new file mode 100644 index 000000000..79b39f952 --- /dev/null +++ b/docs/plugin/index.rst @@ -0,0 +1,39 @@ +######### + Plugins +######### + +virtualenv can be extended via plugins using Python entry points. Plugins are automatically discovered from the Python +environment where virtualenv is installed, allowing you to customize how virtual environments are created, seeded, and +activated. + +****************** + Extension points +****************** + +virtualenv provides four extension points through entry point groups: + +``virtualenv.discovery`` + Python interpreter discovery plugins. These plugins locate and identify Python interpreters that will be used as the + base for creating virtual environments. + +``virtualenv.create`` + Virtual environment creator plugins. These plugins handle the actual creation of the virtual environment structure, + including copying or symlinking the Python interpreter and standard library. + +``virtualenv.seed`` + Seed package installer plugins. These plugins install initial packages (like pip, setuptools, wheel) into newly + created virtual environments. + +``virtualenv.activate`` + Shell activation script plugins. These plugins generate shell-specific activation scripts that modify the + environment to use the virtual environment. + +All extension points follow a common pattern: virtualenv discovers registered entry points, builds CLI options from +them, and executes the selected implementations during environment creation. + +.. toctree:: + + tutorial + how-to + api + architecture diff --git a/docs/plugin/tutorial.rst b/docs/plugin/tutorial.rst new file mode 100644 index 000000000..abe20488d --- /dev/null +++ b/docs/plugin/tutorial.rst @@ -0,0 +1,116 @@ +################### + Your first plugin +################### + +This tutorial walks through creating a simple discovery plugin that locates Python interpreters managed by pyenv. + +****************************** + Create the package structure +****************************** + +Set up a new Python package with the following structure: + +.. code-block:: text + + virtualenv-pyenv/ + ├── pyproject.toml + └── src/ + └── virtualenv_pyenv/ + └── __init__.py + +*************************** + Configure the entry point +*************************** + +In ``pyproject.toml``, declare your plugin as an entry point under the ``virtualenv.discovery`` group: + +.. code-block:: toml + + [project] + name = "virtualenv-pyenv" + version = "0.1.0" + dependencies = ["virtualenv>=20"] + + [project.entry-points."virtualenv.discovery"] + pyenv = "virtualenv_pyenv:PyEnvDiscovery" + + [build-system] + requires = ["setuptools>=61"] + build-backend = "setuptools.build_meta" + +********************** + Implement the plugin +********************** + +In ``src/virtualenv_pyenv/__init__.py``, implement the discovery plugin by subclassing ``Discover``: + +.. code-block:: python + + from __future__ import annotations + + import subprocess + from pathlib import Path + + from virtualenv.discovery.discover import Discover + from virtualenv.discovery.py_info import PythonInfo + + + class PyEnvDiscovery(Discover): + def __init__(self, options): + super().__init__(options) + self.python_spec = options.python if options.python else "python" + + @classmethod + def add_parser_arguments(cls, parser): + parser.add_argument( + "--python", + dest="python", + metavar="py", + type=str, + default=None, + help="pyenv Python version to use (e.g., 3.11.0)", + ) + + def run(self): + try: + result = subprocess.run( + ["pyenv", "which", "python"], + capture_output=True, + text=True, + check=True, + ) + python_path = Path(result.stdout.strip()) + return PythonInfo.from_exe(str(python_path)) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + raise RuntimeError(f"Failed to locate pyenv Python: {e}") + +******************** + Install the plugin +******************** + +Install your plugin in development mode alongside virtualenv: + +.. code-block:: console + + $ pip install -e virtualenv-pyenv/ + +******************* + Verify the plugin +******************* + +Check that virtualenv recognizes your plugin by running: + +.. code-block:: console + + $ virtualenv --discovery help + +The output should list ``pyenv`` as an available discovery mechanism. You can now use it: + +.. code-block:: console + + $ virtualenv --discovery=pyenv myenv + created virtual environment CPython3.11.0.final.0-64 in 234ms + creator CPython3Posix(dest=/path/to/myenv, clear=False, no_vcs_ignore=False, global=False) + seeder FromAppData(download=False, pip=bundle, setuptools=bundle, wheel=bundle, via=copy, app_data_dir=/path) + added seed packages: pip==23.0, setuptools==65.5.0, wheel==0.38.4 + activators BashActivator,CShellActivator,FishActivator,NushellActivator,PowerShellActivator,PythonActivator diff --git a/docs/reference/api.rst b/docs/reference/api.rst new file mode 100644 index 000000000..30a5f6cf9 --- /dev/null +++ b/docs/reference/api.rst @@ -0,0 +1,40 @@ +.. _programmatic_api: + +######## + Python +######## + +The primary interface to ``virtualenv`` is the command line application. However, it can also be used programmatically +via the ``virtualenv.cli_run`` function and the ``Session`` class. + +See :doc:`../how-to/usage` for usage examples. + +******************* + virtualenv module +******************* + +.. automodule:: virtualenv + :members: + +*************** + Session class +*************** + +The ``Session`` class represents a virtualenv creation session and provides access to the created environment's +properties. + +.. currentmodule:: virtualenv.run.session + +.. autoclass:: Session + :members: + +******************* + VirtualEnvOptions +******************* + +Options namespace passed to plugin constructors, populated from the CLI, environment variables, and configuration files. + +.. currentmodule:: virtualenv.config.cli.parser + +.. autoclass:: VirtualEnvOptions + :members: diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst new file mode 100644 index 000000000..bd7f889b5 --- /dev/null +++ b/docs/reference/cli.rst @@ -0,0 +1,18 @@ +############## + Command line +############## + +``virtualenv`` is primarily a command line application. All options have sensible defaults, and there is one required +argument: the name or path of the virtual environment to create. + +See :doc:`../how-to/usage` for how to select Python versions, configure defaults, and use environment variables. + +********************** + Command line options +********************** + +:command:`virtualenv [OPTIONS]` + +.. table_cli:: + :module: virtualenv.run + :func: build_parser_only diff --git a/docs/reference/compatibility.rst b/docs/reference/compatibility.rst new file mode 100644 index 000000000..f7d60fe6d --- /dev/null +++ b/docs/reference/compatibility.rst @@ -0,0 +1,97 @@ +.. _compatibility-requirements: + +############### + Compatibility +############### + +********************************** + Supported Python implementations +********************************** + +``virtualenv`` works with the following Python interpreter implementations. Only the latest patch version of each minor +version is fully supported; previous patch versions work on a best effort basis. + +CPython +======= + +``3.14 >= python_version >= 3.8`` + +PyPy +==== + +``3.11 >= python_version >= 3.8`` + +GraalPy +======= + +``24.1`` and later (Linux and macOS only). + +RustPython +========== + +Experimental support (Linux, macOS, and Windows). `RustPython `_ implements +Python 3.14. + +**************** + Support policy +**************** + +- **New versions** are added close to their release date, typically during the beta phase. +- **Old versions** are dropped 18 months after `CPython EOL `_, giving users + plenty of time to migrate. + +************************** + Version support timeline +************************** + +Major version support changes: + +- **20.27.0** (2024-10-17): dropped support for running under Python 3.7 and earlier. +- **20.22.0** (2023-04-19): dropped support for creating environments for Python 3.6 and earlier. +- **20.18.0** (2023-02-06): dropped support for running under Python 3.6 and earlier. + +***************************** + Supported operating systems +***************************** + +CPython is shipped in multiple forms, and each OS repackages it, often applying some customization. The platforms listed +below are tested. Unlisted platforms may work but are not explicitly supported. If you encounter issues on unlisted +platforms, please open a feature request. + +Cross-platform +============== + +These Python distributions work on Linux, macOS, and Windows: + +- Installations from `python.org `_ +- `python-build-standalone `_ builds (used by `uv + `_ and `mise `_) +- Python versions managed by `pyenv `_, `mise `_, or `asdf + `_ (shims are automatically resolved to the real binary) + +Linux +===== + +- Ubuntu 16.04 and later (both upstream and `deadsnakes `_ + builds) +- Fedora +- RHEL and CentOS +- OpenSuse +- Arch Linux + +macOS +===== + +- Python versions installed via `Homebrew `_ (works, but `not recommended + `_ -- Homebrew may upgrade or remove Python versions + without warning, breaking existing virtual environments) +- Python 3 part of XCode (Python framework builds at ``/Library/Frameworks/Python3.framework/``) + +.. note:: + + Framework builds do not support copy-based virtual environments. Use symlink or hardlink creation methods instead. + +Windows +======= + +- `Windows Store `_ Python 3.8 and later diff --git a/docs/tutorial/getting-started.rst b/docs/tutorial/getting-started.rst new file mode 100644 index 000000000..9a1611d10 --- /dev/null +++ b/docs/tutorial/getting-started.rst @@ -0,0 +1,244 @@ +################# + Getting started +################# + +This tutorial will teach you the basics of virtualenv through hands-on practice. You'll create your first virtual +environment, install packages, and learn how to manage project dependencies. + +*************** + Prerequisites +*************** + +Before starting this tutorial, you need: + +- Python 3.8 or later installed on your system. If you use a version manager like `pyenv + `_, `mise `_, or `asdf `_, virtualenv + will automatically discover the Python version they manage. +- virtualenv installed (see :doc:`../how-to/install`). + +*************************************** + Create your first virtual environment +*************************************** + +Let's create a virtual environment called ``myproject``: + +.. code-block:: console + + $ virtualenv myproject + created virtual environment CPython3.13.2.final.0-64 in 200ms + creator CPython3Posix(dest=/home/user/myproject, clear=False, no_vcs_ignore=False, global=False) + seeder FromAppData(download=False, pip=bundle, setuptools=bundle, via=copy, app_data_dir=/home/user/.cache/virtualenv) + activators BashActivator,CShellActivator,FishActivator,NushellActivator,PowerShellActivator,PythonActivator + +This creates a new directory called ``myproject`` containing a complete, isolated Python environment with its own copy +of Python, pip, and other tools. + +************************** + Activate the environment +************************** + +To use your virtual environment, you can activate it. The activation command differs by platform: + +.. tab:: Linux/macOS + + .. code-block:: console + + $ source myproject/bin/activate + +.. tab:: Windows (PowerShell) + + .. code-block:: console + + PS> .\myproject\Scripts\Activate.ps1 + +.. tab:: Windows (CMD) + + .. code-block:: console + + C:\> .\myproject\Scripts\activate.bat + +After activation, your prompt changes to show the active environment: + +.. code-block:: console + + (myproject) $ + +You can verify that Python is now running from inside the virtual environment: + +.. tab:: Linux/macOS + + .. code-block:: console + + (myproject) $ which python + /home/user/myproject/bin/python + +.. tab:: Windows (PowerShell) + + .. code-block:: console + + (myproject) PS> where.exe python + C:\Users\user\myproject\Scripts\python.exe + +.. tab:: Windows (CMD) + + .. code-block:: console + + (myproject) C:\> where.exe python + C:\Users\user\myproject\Scripts\python.exe + +******************* + Install a package +******************* + +With the environment activated, install a package using pip: + +.. code-block:: console + + (myproject) $ pip install requests + Collecting requests + Using cached requests-2.32.3-py3-none-any.whl (64 kB) + Installing collected packages: requests + Successfully installed requests-2.32.3 + +Verify that the package is installed only inside your virtual environment: + +.. code-block:: console + + (myproject) $ python -c "import requests; print(requests.__file__)" + /home/user/myproject/lib/python3.13/site-packages/requests/__init__.py + +The path shows that ``requests`` is installed in the virtual environment, not in your system Python. + +************ + Deactivate +************ + +When you're done working in the virtual environment, deactivate it: + +.. code-block:: console + + (myproject) $ deactivate + $ + +The prompt returns to normal, and Python commands now use your system Python again. + +************************ + Use without activation +************************ + +Activation is a convenience, not a requirement. You can run any executable from the virtual environment directly by +using its full path: + +.. tab:: Linux/macOS + + .. code-block:: console + + $ myproject/bin/python -c "import sys; print(sys.prefix)" + /home/user/myproject + + $ myproject/bin/pip install httpx + +.. tab:: Windows (PowerShell) + + .. code-block:: console + + PS> .\myproject\Scripts\python.exe -c "import sys; print(sys.prefix)" + C:\Users\user\myproject + + PS> .\myproject\Scripts\pip.exe install httpx + +.. tab:: Windows (CMD) + + .. code-block:: console + + C:\> .\myproject\Scripts\python.exe -c "import sys; print(sys.prefix)" + C:\Users\user\myproject + + C:\> .\myproject\Scripts\pip.exe install httpx + +This is especially useful in scripts, CI pipelines, and automation where modifying the shell environment is unnecessary. + +*********************** + Set up a real project +*********************** + +Now let's apply what you've learned to a real project workflow: + +.. code-block:: console + + $ mkdir myapp && cd myapp + $ virtualenv venv + $ source venv/bin/activate # or use the appropriate command for your platform + (venv) $ pip install flask requests + (venv) $ pip freeze > requirements.txt + +The ``requirements.txt`` file now contains your project's dependencies: + +.. code-block:: text + + blinker==1.9.0 + certifi==2025.1.31 + charset-normalizer==3.4.1 + click==8.1.8 + flask==3.1.0 + idna==3.10 + itsdangerous==2.2.0 + Jinja2==3.1.5 + MarkupSafe==3.0.2 + requests==2.32.3 + urllib3==2.3.0 + werkzeug==3.1.3 + +This file lets you recreate the exact environment later. Let's test this: + +.. code-block:: console + + (venv) $ deactivate + $ rm -rf venv + $ virtualenv venv + $ source venv/bin/activate + (venv) $ pip install -r requirements.txt + +All packages are reinstalled exactly as before. Here's the complete workflow: + +.. mermaid:: + + graph TD + A[Create virtual environment] --> B[Activate] + B --> C[Install packages] + C --> D[Freeze to requirements.txt] + D --> E[Deactivate & clean up] + E --> F[Recreate virtual environment] + F --> G[Install from requirements.txt] + G --> H[Ready to work] + + style A fill:#2563eb,stroke:#1d4ed8,color:#fff + style B fill:#6366f1,stroke:#4f46e5,color:#fff + style C fill:#6366f1,stroke:#4f46e5,color:#fff + style D fill:#6366f1,stroke:#4f46e5,color:#fff + style E fill:#d97706,stroke:#b45309,color:#fff + style F fill:#6366f1,stroke:#4f46e5,color:#fff + style G fill:#6366f1,stroke:#4f46e5,color:#fff + style H fill:#16a34a,stroke:#15803d,color:#fff + +****************** + What you learned +****************** + +In this tutorial, you learned how to: + +- Create a virtual environment with ``virtualenv``. +- Activate and deactivate virtual environments on different platforms. +- Install packages in isolation from your system Python. +- Save project dependencies with ``pip freeze``. +- Reproduce environments using ``requirements.txt``. + +************ + Next steps +************ + +Now that you understand the basics, explore these topics: + +- :doc:`../how-to/usage` for selecting specific Python versions, configuring defaults, and advanced usage patterns. +- :doc:`../explanation` for understanding how virtualenv works under the hood and how it compares to ``venv``. +- :doc:`../reference/cli` for all available command line options and flags. diff --git a/docs/user_guide.rst b/docs/user_guide.rst deleted file mode 100644 index 82cc235b1..000000000 --- a/docs/user_guide.rst +++ /dev/null @@ -1,311 +0,0 @@ -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 ------------- - -Virtualenv has one basic command: - -.. code-block:: console - - virtualenv venv - -.. note:: - - When creating a virtual environment, it's recommended to use a specific Python version, for example, by invoking - virtualenv with ``python3.10 -m virtualenv venv``. If you use a generic command like ``python3 -m virtualenv venv``, - the created environment will be linked to ``/usr/bin/python3``. This can be problematic because when a new Python - version is installed on the system, the ``/usr/bin/python3`` symlink will likely be updated to point to the new - version. This will cause the virtual environment to inadvertently use the new Python version, which is often not the - desired behavior. Using a specific version ensures that the virtual environment is tied to that exact version, - providing stability and predictability. - -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 behavior, for a -full list make sure to check out :ref:`cli_flags`. - -The tool works in two phases: - -- **Phase 1** discovers a python interpreter to create a virtual environment from (by default this is the same python - as the one ``virtualenv`` is running from, however we can change this via the :option:`p` option). -- **Phase 2** creates a virtual environment at the specified destination (:option:`dest`), this can be broken down into - four further sub-steps: - - - create a python that matches the target python interpreter from phase 1, - - install (bootstrap) seed packages (one or more of :pypi:`pip`, :pypi:`setuptools`, :pypi:`wheel`) in the created - virtual environment, - - install activation scripts into the binary directory of the virtual environment (these will allow end users to - *activate* the virtual environment from various shells). - - create files that mark the virtual environment as to be ignored by version control systems (currently we support - Git only, as Mercurial, Bazaar or SVN do not support ignore files in subdirectories). This step can be skipped - with the :option:`no-vcs-ignore` option. - - -The python in your new virtualenv is effectively isolated from the python that was used to create it. - -Python discovery ----------------- - -The first thing we need to be able to create a virtual environment is a python interpreter. This will describe to the -tool what type of virtual environment you would like to create, think of it as: version, architecture, implementation. - -``virtualenv`` being a python application has always at least one such available, the one ``virtualenv`` itself is -using, and as such this is the default discovered element. This means that if you install ``virtualenv`` under -python ``3.8``, virtualenv will by default create virtual environments that are also of version ``3.8``. - -Created python virtual environments are usually not self-contained. A complete python packaging is usually made up of -thousands of files, so it's not efficient to install the entire python again into a new folder. Instead virtual -environments are mere shells, that contain little within themselves, and borrow most from the system python (this is what -you installed, when you installed python itself). This does mean that if you upgrade your system python your virtual -environments *might* break, so watch out. The upside of this, referring to the system python, is that creating virtual -environments can be fast. - -Here we'll describe the built-in mechanism (note this can be extended though by plugins). The CLI flag :option:`p` or -:option:`python` allows you to specify a python specifier for what type of virtual environment you would like, the -format is either: - -- a relative/absolute path to a Python interpreter, - -- a specifier identifying the Python implementation, version, architecture in the following format: - - .. code-block:: - - {python implementation name}{version}{architecture} - - We have the following restrictions: - - - the python implementation is all alphabetic characters (``python`` means any implementation, and if is missing it - defaults to ``python``), - - the version is a dot separated version number optionally followed by ``t`` for free-threading, - - the architecture is either ``-64`` or ``-32`` (missing means ``any``). - - For example: - - - ``python3.8.1`` means any python implementation having the version ``3.8.1``, - - ``3`` means any python implementation having the major version ``3``, - - ``3.13t`` means any python implementation having the version ``3.13`` with free threading, - - ``cpython3`` means a ``CPython`` implementation having the version ``3``, - - ``pypy2`` means a python interpreter with the ``PyPy`` implementation and major version ``2``. - - Given the specifier ``virtualenv`` will apply the following strategy to discover/find the system executable: - - - If we're on Windows look into the Windows registry, and check if we see any registered Python implementations that - match the specification. This is in line with expectation laid out inside - `PEP-514 `_ - - If `uv-managed `_ Python installations are available, use the - first one that matches the specification. - - Try to discover a matching python executable within the folders enumerated on the ``PATH`` environment variable. - In this case we'll try to find an executable that has a name roughly similar to the specification (for exact logic, - please see the implementation code). - -.. warning:: - - As detailed above, virtual environments usually just borrow things from the system Python, they don't actually contain - all the data from the system Python. The version of the python executable is hardcoded within the python exe itself. - Therefore, if you upgrade your system Python, your virtual environment will still report the version before the - upgrade, even though now other than the executable all additional content (standard library, binary libs, etc) are - of the new version. - - Barring any major incompatibilities (rarely the case) the virtual environment will continue working, but other than - the content embedded within the python executable it will behave like the upgraded version. If such a virtual - environment python is specified as the target python interpreter, we will create virtual environments that match the - new system Python version, not the version reported by the virtual environment. - -Creators --------- - -These are what actually setup the virtual environment, usually as a reference against the system python. virtualenv -at the moment has two types of virtual environments: - -- ``venv`` - this delegates the creation process towards the ``venv`` module, as described in - `PEP 405 `_. This is only available on Python interpreters having version - ``3.5`` or later, and also has the downside that virtualenv **must** create a process to invoke that module (unless - virtualenv is installed in the system python), which can be an expensive operation (especially true on Windows). - -- ``builtin`` - this means ``virtualenv`` is able to do the creation operation itself (by knowing exactly what files to - create and what system files need to be referenced). The creator with name ``builtin`` is an alias on the first - creator that's of this type (we provide creators for various target environments, that all differ in actual create - operations, such as CPython 2 on Windows, PyPy2 on Windows, CPython3 on Posix, PyPy3 on Posix, and so on; for a full - list see :option:`creator`). - -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). Installing -:pypi:`setuptools` is disabled by default on Python 3.12+ environments. :pypi:`wheel` is only installed on Python 3.8, by default. 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). -- ``app-data`` - this method uses the user application data directory to create install images. These images are needed - to be created only once, and subsequent virtual environments can just link/copy those images into their pure python - library path (the ``site-packages`` folder). This allows all but the first virtual environment creation to be blazing - fast (a ``pip`` mechanism takes usually 98% of the virtualenv creation time, so by creating this install image that - we can just link into the virtual environments install directory we can achieve speedups of shaving the initial - 1 minute and 10 seconds down to just 8 seconds in case of a copy, or ``0.8`` seconds in case symlinks are available - - this is on Windows, Linux/macOS with symlinks this can be as low as ``100ms`` from 3+ seconds). - To override the filesystem location of the seed cache, one can use the - ``VIRTUALENV_OVERRIDE_APP_DATA`` environment variable. - -.. _wheels: - -Wheels -~~~~~~ - -To install a seed package via either ``pip`` or ``app-data`` method virtualenv needs to acquire a wheel of the target -package. These wheels may be acquired from multiple locations as follows: - -- ``virtualenv`` ships out of box with a set of embed ``wheels`` for all three seed packages (:pypi:`pip`, - :pypi:`setuptools`, :pypi:`wheel`). These are packaged together with the virtualenv source files, and only change upon - upgrading virtualenv. Different Python versions require different versions of these, and because virtualenv supports a - wide range of Python versions, the number of embedded wheels out of box is greater than 3. Whenever newer versions of - these embedded packages are released upstream ``virtualenv`` project upgrades them, and does a new release. Therefore, - upgrading virtualenv periodically will also upgrade the version of the seed packages. -- However, end users might not be able to upgrade virtualenv at the same speed as we do new releases. Therefore, a user - might request to upgrade the list of embedded wheels by invoking virtualenv with the :option:`upgrade-embed-wheels` - flag. If the operation is triggered in such a manual way subsequent runs of virtualenv will always use the upgraded - embed wheels. - - The operation can trigger automatically too, as a background process upon invocation of virtualenv, if no such upgrade - has been performed in the last 14 days. It will only start using automatically upgraded wheel if they have been - released for more than 28 days, and the automatic upgrade finished at least an hour ago: - - - the 28 days period should guarantee end users are not pulling in automatically releases that have known bugs within, - - the one hour period after the automatic upgrade finished is implemented so that continuous integration services do - not start using a new embedded versions half way through. - - - 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), - - save the date the version was first discovered, and wait until 28 days passed. -- Users can specify a set of local paths containing additional wheels by using the :option:`extra-search-dir` command - line argument flag. - -When searching for a wheel to use virtualenv performs lookup in the following order: - -- embedded wheels, -- upgraded embedded wheels, -- extra search dir. - -Bundled wheels are all three above together. If neither of the locations contain the requested wheel version or -:option:`download` option is set will use ``pip`` download to load the latest version available from the index server. - -.. _distribution_wheels: - -Embed wheels for distributions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Custom distributions often want to use their own set of wheel versions to distribute instead of the one virtualenv -releases on PyPi. The reason for this is trying to keep the system versions of those packages in sync with what -virtualenv uses. In such cases they should patch the module `virtualenv.seed.wheels.embed -`_, making sure to provide the function -``get_embed_wheel`` (which returns the wheel to use given a distribution/python version). The ``BUNDLE_FOLDER``, -``BUNDLE_SUPPORT`` and ``MAX`` variables are needed if they want to use virtualenv's test suite to validate. - -Furthermore, they might want to disable the periodic update by patching the -`virtualenv.seed.embed.base_embed.PERIODIC_UPDATE_ON_BY_DEFAULT -`_ -to ``False``, and letting the system update mechanism to handle this. Note in this case the user might still request an -upgrade of the embedded wheels by invoking virtualenv via :option:`upgrade-embed-wheels`, but no longer happens -automatically, and will not alter the OS provided wheels. - -Activators ----------- -These are activation scripts that will mangle with your shell's settings to ensure that commands from within the python -virtual environment take priority over your system paths. For example, if invoking ``pip`` from your shell returned the -system python's pip before activation, once you do the activation this should refer to the virtual environments ``pip``. -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. 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 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. - -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. - -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 - `execution policies `_ on the system. By default, Windows - 7 and later, the system's execution policy is set to ``Restricted``, meaning no scripts like the ``activate`` script - are allowed to be executed. - - However, that can't stop us from changing that slightly to allow it to be executed. You may relax the system - execution policy to allow running of local scripts without verifying the code signature using the following: - - .. code-block:: powershell - - Set-ExecutionPolicy RemoteSigned - - Since the ``activate.ps1`` script is generated locally for each virtualenv, it is not considered a remote script and - can then be executed. - -A longer explanation of this can be found within Allison Kaptur's 2013 blog post: `There's no magic: virtualenv -edition `_ explains how virtualenv uses bash and -Python and ``PATH`` and ``PYTHONHOME`` to isolate virtual environments' paths. - -.. _programmatic_api: - -Programmatic API ----------------- - -At the moment ``virtualenv`` offers only CLI level interface. If you want to trigger invocation of Python environments -from within Python you should be using the ``virtualenv.cli_run`` method; this takes an ``args`` argument where you can -pass the options the same way you would from the command line. The run will return a session object containing data -about the created virtual environment. - -.. code-block:: python - - from virtualenv import cli_run - - cli_run(["venv"]) - -.. automodule:: virtualenv - :members: - -.. currentmodule:: virtualenv.run.session - -.. autoclass:: Session - :members: diff --git a/pyproject.toml b/pyproject.toml index 7f151a419..471684f3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,8 @@ [build-system] build-backend = "hatchling.build" requires = [ - "hatch-vcs>=0.3", - "hatchling>=1.17.1", + "hatch-vcs>=0.4", + "hatchling>=1.27", ] [project] @@ -17,6 +17,7 @@ keywords = [ license = "MIT" maintainers = [ { name = "Bernat Gabor", email = "gaborjbernat@gmail.com" }, + { name = "Rahul Devikar", email = "rahuldevikar5512@gmail.com" }, ] requires-python = ">=3.8" classifiers = [ @@ -45,35 +46,13 @@ dynamic = [ ] dependencies = [ "distlib>=0.3.7,<1", - "filelock>=3.16.1,<4; python_version<'3.10'", - "filelock>=3.20.1,<4; python_version>='3.10'", + "filelock>=3.16.1,<=3.19.1; python_version<'3.10'", + "filelock>=3.24.2,<4; python_version>='3.10'", "importlib-metadata>=6.6; python_version<'3.8'", "platformdirs>=3.9.1,<5", + "python-discovery>=1", "typing-extensions>=4.13.2; python_version<'3.11'", ] -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=='GraalVM' 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" @@ -94,18 +73,74 @@ entry-points."virtualenv.create".graalpy-posix = "virtualenv.create.via_global_r entry-points."virtualenv.create".graalpy-win = "virtualenv.create.via_global_ref.builtin.graalpy:GraalPyWindows" 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".rustpython-posix = "virtualenv.create.via_global_ref.builtin.rustpython:RustPythonPosix" +entry-points."virtualenv.create".rustpython-win = "virtualenv.create.via_global_ref.builtin.rustpython:RustPythonWindows" 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" +[dependency-groups] +dev = [ + { include-group = "docs" }, + { include-group = "lint" }, + { include-group = "pkg-meta" }, + { include-group = "test" }, + { include-group = "type" }, +] +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=='GraalVM' or \ + platform_python_implementation=='RustPython' 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", + "pytest-xdist>=3.5", + "setuptools>=68", + "time-machine>=2.10; platform_python_implementation=='CPython'", +] +type = [ + "ty>=0.0.19", + { include-group = "test" }, +] +docs = [ + "furo>=2023.7.26", + "pre-commit-uv>=4.2", + "proselint>=0.13", + "sphinx>=7.1.2,!=7.3", + "sphinx-argparse>=0.4", + "sphinx-autodoc-typehints>=3.6.2", + "sphinx-copybutton>=0.5.2", + "sphinx-inline-tabs>=2025.12.21.14", + "sphinxcontrib-mermaid>=2", + "sphinxcontrib-towncrier>=0.2.1a0", + "towncrier>=23.6", +] +lint = [ + "pre-commit-uv>=4.2", +] +pkg-meta = [ + "check-wheel-contents>=0.6.3", + "twine>=6.2", + "uv>=0.10.2", +] + [tool.hatch] build.hooks.vcs.version-file = "src/virtualenv/version.py" build.targets.sdist.include = [ "/src", "/tests", "/tasks", - "/tox.ini", + "/tox.toml", ] version.source = "vcs" @@ -118,31 +153,40 @@ 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 + "D200", # fits on one line - conflicts with docstrfmt "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible + "D205", # 1 blank line required between summary and description - conflicts with docstrfmt + "D209", # multi-line docstring closing quotes - conflicts with docstrfmt "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible + "D213", # multi-line-summary-second-line - conflicts with docstrfmt + "D301", # use r if any backslashes - conflicts with docstrfmt "DOC", # no restructuredtext support + "E501", # line too long - handled by ruff format and docstrfmt + "FBT001", # boolean positional args - pre-existing signatures, changing would break public API "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 + "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 "RUF067", # `__init__` module should only contain docstrings and re-exports "S104", # Possible binding to all interfaces - - "S404", # Using subprocess is alright - "S603", # subprocess calls are fine + "S404", # Using subprocess is alright + "S603", # subprocess calls are fine +] +lint.per-file-ignores."docs/**/*.py" = [ + "ANN", # don't require type annotations in docs scripts ] lint.per-file-ignores."src/virtualenv/activation/python/activate_this.py" = [ "F821", # ignore undefined template string placeholders ] lint.per-file-ignores."tests/**/*.py" = [ + "ANN", # don't require type annotations in tests (pytest fixtures make this impractical) "D", # don't care about documentation in tests "FBT", # don't care about booleans as positional arguments in tests "INP001", # no implicit namespace @@ -150,6 +194,7 @@ lint.per-file-ignores."tests/**/*.py" = [ "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 + "S607", # partial executable path is fine in tests ] lint.isort = { known-first-party = [ "virtualenv", @@ -165,46 +210,51 @@ count = true [tool.pyproject-fmt] max_supported_python = "3.14" -[tool.pytest.ini_options] -markers = [ +[tool.ty] +rules.unused-ignore-comment = "ignore" # some ignores are platform/version-specific (e.g., ctypes.windll on Linux) + +[tool.pytest] +ini_options.markers = [ "slow", + "graalpy: minimal test suite for GraalPy validation", ] -timeout = 600 -addopts = "--showlocals --no-success-flaky-report" -env = [ +ini_options.timeout = 120 +ini_options.addopts = "--showlocals --no-success-flaky-report" +ini_options.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/**", +run.dynamic_context = "test_function" +run.parallel = true +run.plugins = [ + "covdefaults", +] +run.relative_files = true +run.source = [ + "${_COVERAGE_SRC}", + "tests", ] 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", +report.omit = [ + "**/src/virtualenv/activation/python/activate_this.py", + "**/src/virtualenv/create/via_global_ref/_virtualenv.py", + # 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/seed/wheels/embed/pip-*.whl/pip/**", ] -run.relative_files = true +html.show_contexts = true +html.skip_covered = false [tool.towncrier] -name = "tox" +name = "virtualenv" filename = "docs/changelog.rst" directory = "docs/changelog" title_format = false issue_format = ":issue:`{issue}`" template = "docs/changelog/template.jinja2" +underlines = [ "*", "=", "-" ] diff --git a/src/virtualenv/__main__.py b/src/virtualenv/__main__.py index 49f59da38..1cfffe266 100644 --- a/src/virtualenv/__main__.py +++ b/src/virtualenv/__main__.py @@ -5,11 +5,20 @@ import os import sys from timeit import default_timer +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import MutableMapping + + from virtualenv.config.cli.parser import VirtualEnvOptions + from virtualenv.run.session import Session LOGGER = logging.getLogger(__name__) -def run(args=None, options=None, env=None): +def run( + args: list[str] | None = None, options: VirtualEnvOptions | None = None, env: MutableMapping[str, str] | None = None +) -> None: env = os.environ if env is None else env start = default_timer() from virtualenv.run import cli_run # noqa: PLC0415 @@ -18,7 +27,7 @@ def run(args=None, options=None, env=None): if args is None: args = sys.argv[1:] try: - session = cli_run(args, options, env) + session = cli_run(args, options, env=env) LOGGER.warning(LogSession(session, start)) except ProcessCallFailedError as exception: print(f"subprocess call failed for {exception.cmd} with code {exception.code}") # noqa: T201 @@ -37,7 +46,7 @@ def run(args=None, options=None, env=None): class LogSession: - def __init__(self, session, start) -> None: + def __init__(self, session: Session, start: float) -> None: self.session = session self.start = start @@ -59,7 +68,7 @@ def __str__(self) -> str: return "\n".join(lines) -def run_with_catch(args=None, env=None): +def run_with_catch(args: list[str] | None = None, env: MutableMapping[str, str] | None = None) -> None: from virtualenv.config.cli.parser import VirtualEnvOptions # noqa: PLC0415 env = os.environ if env is None else env diff --git a/src/virtualenv/activation/activator.py b/src/virtualenv/activation/activator.py index dd404b47c..159a3e99f 100644 --- a/src/virtualenv/activation/activator.py +++ b/src/virtualenv/activation/activator.py @@ -2,45 +2,56 @@ import os from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from argparse import ArgumentParser + from pathlib import Path + + from python_discovery import PythonInfo + + from virtualenv.config.cli.parser import VirtualEnvOptions + from virtualenv.create.creator import Creator class Activator(ABC): """Generates activate script for the virtual environment.""" - def __init__(self, options) -> None: - """ - Create a new activator generator. + def __init__(self, options: VirtualEnvOptions) -> 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): # noqa: ARG003 - """ - Check if the activation script is supported in the given interpreter. + def supports(cls, interpreter: PythonInfo) -> bool: # 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 + + :returns: ``True`` if supported, ``False`` otherwise + """ return True @classmethod # noqa: B027 - def add_parser_arguments(cls, parser, interpreter): - """ - Add CLI arguments for this activation script. + def add_parser_arguments(cls, parser: ArgumentParser, interpreter: PythonInfo) -> None: + """Add CLI arguments for this activation script. :param parser: the CLI parser :param interpreter: the interpreter this virtual environment is based of + """ @abstractmethod - def generate(self, creator): - """ - Generate activate script for the given creator. + def generate(self, creator: Creator) -> list[Path]: + """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 - :param creator: the creator (based of :class:`virtualenv.create.creator.Creator`) we used to create this \ - virtual environment """ raise NotImplementedError diff --git a/src/virtualenv/activation/bash/__init__.py b/src/virtualenv/activation/bash/__init__.py index 4f160744f..e083e9585 100644 --- a/src/virtualenv/activation/bash/__init__.py +++ b/src/virtualenv/activation/bash/__init__.py @@ -1,19 +1,25 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING from virtualenv.activation.via_template import ViaTemplateActivator +if TYPE_CHECKING: + from collections.abc import Iterator + + from virtualenv.create.creator import Creator + class BashActivator(ViaTemplateActivator): - def templates(self): + def templates(self) -> Iterator[str]: yield "activate.sh" - def as_name(self, template): + def as_name(self, template: str) -> str: return Path(template).stem - def replacements(self, creator, dest): - data = super().replacements(creator, dest) + def replacements(self, creator: Creator, dest_folder: Path) -> dict[str, str]: + data = super().replacements(creator, dest_folder) data.update({ "__TCL_LIBRARY__": getattr(creator.interpreter, "tcl_lib", None) or "", "__TK_LIBRARY__": getattr(creator.interpreter, "tk_lib", None) or "", diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh index de2d2422c..b418101d4 100644 --- a/src/virtualenv/activation/bash/activate.sh +++ b/src/virtualenv/activation/bash/activate.sh @@ -11,35 +11,39 @@ deactivate () { unset -f pydoc >/dev/null 2>&1 || true # reset old environment variables - # ! [ -z ${VAR+_} ] returns true if VAR is declared at all - if ! [ -z "${_OLD_VIRTUAL_PATH:+_}" ] ; then + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then PATH="$_OLD_VIRTUAL_PATH" export PATH unset _OLD_VIRTUAL_PATH fi - if ! [ -z "${_OLD_VIRTUAL_PYTHONHOME+_}" ] ; then + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then PYTHONHOME="$_OLD_VIRTUAL_PYTHONHOME" export PYTHONHOME unset _OLD_VIRTUAL_PYTHONHOME fi - if ! [ -z "${_OLD_VIRTUAL_TCL_LIBRARY+_}" ]; then + if [ -n "${_OLD_VIRTUAL_TCL_LIBRARY:-}" ]; then TCL_LIBRARY="$_OLD_VIRTUAL_TCL_LIBRARY" export TCL_LIBRARY unset _OLD_VIRTUAL_TCL_LIBRARY fi - if ! [ -z "${_OLD_VIRTUAL_TK_LIBRARY+_}" ]; then + if [ -n "${_OLD_VIRTUAL_TK_LIBRARY:-}" ]; then TK_LIBRARY="$_OLD_VIRTUAL_TK_LIBRARY" export TK_LIBRARY unset _OLD_VIRTUAL_TK_LIBRARY fi + if [ -n "${_OLD_PKG_CONFIG_PATH:-}" ]; then + PKG_CONFIG_PATH="$_OLD_PKG_CONFIG_PATH" + export PKG_CONFIG_PATH + unset _OLD_PKG_CONFIG_PATH + fi # 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 - if ! [ -z "${_OLD_VIRTUAL_PS1+_}" ] ; then + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then PS1="$_OLD_VIRTUAL_PS1" export PS1 unset _OLD_VIRTUAL_PS1 @@ -58,22 +62,28 @@ deactivate nondestructive if [ ! -d __VIRTUAL_ENV__ ]; then echo "Virtual environment directory __VIRTUAL_ENV__ does not exist!" >&2 - CURRENT_PATH=$(realpath "${0}") + CURRENT_PATH=$(realpath "${BASH_SOURCE[0]}") CURRENT_DIR=$(dirname "${CURRENT_PATH}") VIRTUAL_ENV="$(realpath "${CURRENT_DIR}/../")" else VIRTUAL_ENV=__VIRTUAL_ENV__ fi -if ([ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ]) && $(command -v cygpath &> /dev/null) ; then - VIRTUAL_ENV=$(cygpath -u "$VIRTUAL_ENV") -fi +case "$(uname)" in + CYGWIN*|MSYS*|MINGW*) + VIRTUAL_ENV=$(cygpath "$VIRTUAL_ENV") + ;; +esac export VIRTUAL_ENV _OLD_VIRTUAL_PATH="$PATH" PATH="$VIRTUAL_ENV/"__BIN_NAME__":$PATH" export PATH +_OLD_PKG_CONFIG_PATH="${PKG_CONFIG_PATH:-}" +PKG_CONFIG_PATH="${VIRTUAL_ENV}/lib/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}" +export PKG_CONFIG_PATH + if [ "x"__VIRTUAL_PROMPT__ != x ] ; then VIRTUAL_ENV_PROMPT=__VIRTUAL_PROMPT__ else @@ -82,13 +92,13 @@ fi export VIRTUAL_ENV_PROMPT # unset PYTHONHOME if set -if ! [ -z "${PYTHONHOME+_}" ] ; then +if [ -n "${PYTHONHOME:-}" ] ; then _OLD_VIRTUAL_PYTHONHOME="$PYTHONHOME" unset PYTHONHOME fi if [ __TCL_LIBRARY__ != "" ]; then - if ! [ -z "${TCL_LIBRARY+_}" ] ; then + if [ -n "${TCL_LIBRARY:-}" ] ; then _OLD_VIRTUAL_TCL_LIBRARY="$TCL_LIBRARY" fi TCL_LIBRARY=__TCL_LIBRARY__ @@ -96,7 +106,7 @@ if [ __TCL_LIBRARY__ != "" ]; then fi if [ __TK_LIBRARY__ != "" ]; then - if ! [ -z "${TK_LIBRARY+_}" ] ; then + if [ -n "${TK_LIBRARY:-}" ] ; then _OLD_VIRTUAL_TK_LIBRARY="$TK_LIBRARY" fi TK_LIBRARY=__TK_LIBRARY__ diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 3d74ba835..f54a1d3c8 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -1,25 +1,33 @@ from __future__ import annotations import os +from typing import TYPE_CHECKING from virtualenv.activation.via_template import ViaTemplateActivator +if TYPE_CHECKING: + from collections.abc import Iterator + + from python_discovery import PythonInfo + + from virtualenv.create.creator import Creator + class BatchActivator(ViaTemplateActivator): @classmethod - def supports(cls, interpreter): + def supports(cls, interpreter: PythonInfo) -> bool: return interpreter.os == "nt" - def templates(self): + def templates(self) -> Iterator[str]: yield "activate.bat" yield "deactivate.bat" yield "pydoc.bat" @staticmethod - def quote(string): + def quote(string: str) -> str: return string - def instantiate_template(self, replacements, template, creator): + def instantiate_template(self, replacements: dict[str, str], template: str, creator: Creator) -> str: # ensure the text has all newlines as \r\n - required by batch base = super().instantiate_template(replacements, template, creator) return base.replace(os.linesep, "\n").replace("\n", os.linesep) diff --git a/src/virtualenv/activation/batch/activate.bat b/src/virtualenv/activation/batch/activate.bat index 62f393c80..06192921f 100644 --- a/src/virtualenv/activation/batch/activate.bat +++ b/src/virtualenv/activation/batch/activate.bat @@ -39,6 +39,9 @@ @if defined TK_LIBRARY @set "_OLD_VIRTUAL_TK_LIBRARY=%TK_LIBRARY%" @if NOT "__TK_LIBRARY__"=="" @set "TK_LIBRARY=__TK_LIBRARY__" +@if defined PKG_CONFIG_PATH @set "_OLD_PKG_CONFIG_PATH=%PKG_CONFIG_PATH%" +@set "PKG_CONFIG_PATH=%VIRTUAL_ENV%\lib\pkgconfig;%PKG_CONFIG_PATH%" + @REM if defined _OLD_VIRTUAL_PATH ( @if not defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH1 @set "PATH=%_OLD_VIRTUAL_PATH%" diff --git a/src/virtualenv/activation/batch/deactivate.bat b/src/virtualenv/activation/batch/deactivate.bat index 7a12d47ed..d486958ad 100644 --- a/src/virtualenv/activation/batch/deactivate.bat +++ b/src/virtualenv/activation/batch/deactivate.bat @@ -20,6 +20,10 @@ @if not defined _OLD_VIRTUAL_TK_LIBRARY @set TK_LIBRARY= @set _OLD_VIRTUAL_TK_LIBRARY= +@if defined _OLD_PKG_CONFIG_PATH @set "PKG_CONFIG_PATH=%_OLD_PKG_CONFIG_PATH%" +@if not defined _OLD_PKG_CONFIG_PATH @set PKG_CONFIG_PATH= +@set _OLD_PKG_CONFIG_PATH= + @if not defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH @set "PATH=%_OLD_VIRTUAL_PATH%" @set _OLD_VIRTUAL_PATH= diff --git a/src/virtualenv/activation/cshell/__init__.py b/src/virtualenv/activation/cshell/__init__.py index 7001f999a..11f48a671 100644 --- a/src/virtualenv/activation/cshell/__init__.py +++ b/src/virtualenv/activation/cshell/__init__.py @@ -1,14 +1,21 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from virtualenv.activation.via_template import ViaTemplateActivator +if TYPE_CHECKING: + from collections.abc import Iterator + + from python_discovery import PythonInfo + class CShellActivator(ViaTemplateActivator): @classmethod - def supports(cls, interpreter): + def supports(cls, interpreter: PythonInfo) -> bool: return interpreter.os != "nt" - def templates(self): + def templates(self) -> Iterator[str]: yield "activate.csh" diff --git a/src/virtualenv/activation/cshell/activate.csh b/src/virtualenv/activation/cshell/activate.csh index 5c02616d7..ae6fbedd8 100644 --- a/src/virtualenv/activation/cshell/activate.csh +++ b/src/virtualenv/activation/cshell/activate.csh @@ -5,7 +5,7 @@ set newline='\ ' -alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH:q" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_TCL_LIBRARY != 0 && setenv TCL_LIBRARY "$_OLD_VIRTUAL_TCL_LIBRARY:q" && unset _OLD_VIRTUAL_TCL_LIBRARY || unsetenv TCL_LIBRARY; test $?_OLD_VIRTUAL_TK_LIBRARY != 0 && setenv TK_LIBRARY "$_OLD_VIRTUAL_TK_LIBRARY:q" && unset _OLD_VIRTUAL_TK_LIBRARY || unsetenv TK_LIBRARY; 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' +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH:q" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_TCL_LIBRARY != 0 && setenv TCL_LIBRARY "$_OLD_VIRTUAL_TCL_LIBRARY:q" && unset _OLD_VIRTUAL_TCL_LIBRARY || unsetenv TCL_LIBRARY; test $?_OLD_VIRTUAL_TK_LIBRARY != 0 && setenv TK_LIBRARY "$_OLD_VIRTUAL_TK_LIBRARY:q" && unset _OLD_VIRTUAL_TK_LIBRARY || unsetenv TK_LIBRARY; test $?_OLD_PKG_CONFIG_PATH != 0 && setenv PKG_CONFIG_PATH "$_OLD_PKG_CONFIG_PATH:q" && unset _OLD_PKG_CONFIG_PATH || unsetenv PKG_CONFIG_PATH; 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 @@ -15,6 +15,13 @@ setenv VIRTUAL_ENV __VIRTUAL_ENV__ set _OLD_VIRTUAL_PATH="$PATH:q" setenv PATH "$VIRTUAL_ENV:q/"__BIN_NAME__":$PATH:q" +if ($?PKG_CONFIG_PATH) then + set _OLD_PKG_CONFIG_PATH="$PKG_CONFIG_PATH" + setenv PKG_CONFIG_PATH "${VIRTUAL_ENV}/lib/pkgconfig:${PKG_CONFIG_PATH}" +else + setenv PKG_CONFIG_PATH "${VIRTUAL_ENV}/lib/pkgconfig" +endif + if (__TCL_LIBRARY__ != "") then if ($?TCL_LIBRARY) then set _OLD_VIRTUAL_TCL_LIBRARY="$TCL_LIBRARY" diff --git a/src/virtualenv/activation/fish/__init__.py b/src/virtualenv/activation/fish/__init__.py index 26263566e..74300db75 100644 --- a/src/virtualenv/activation/fish/__init__.py +++ b/src/virtualenv/activation/fish/__init__.py @@ -1,14 +1,22 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from virtualenv.activation.via_template import ViaTemplateActivator +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + from virtualenv.create.creator import Creator + class FishActivator(ViaTemplateActivator): - def templates(self): + def templates(self) -> Iterator[str]: yield "activate.fish" - def replacements(self, creator, dest): - data = super().replacements(creator, dest) + def replacements(self, creator: Creator, dest_folder: Path) -> dict[str, str]: + data = super().replacements(creator, dest_folder) data.update({ "__TCL_LIBRARY__": getattr(creator.interpreter, "tcl_lib", None) or "", "__TK_LIBRARY__": getattr(creator.interpreter, "tk_lib", None) or "", diff --git a/src/virtualenv/activation/fish/activate.fish b/src/virtualenv/activation/fish/activate.fish index c9d174997..33a8c7fc4 100644 --- a/src/virtualenv/activation/fish/activate.fish +++ b/src/virtualenv/activation/fish/activate.fish @@ -1,28 +1,10 @@ # This file must be used using `source bin/activate.fish` *within a running fish ( http://fishshell.com ) session*. # Do not run it directly. -function _bashify_path -d "Converts a fish path to something bash can recognize" - set fishy_path $argv - set bashy_path $fishy_path[1] - for path_part in $fishy_path[2..-1] - set bashy_path "$bashy_path:$path_part" - end - echo $bashy_path -end - -function _fishify_path -d "Converts a bash path to something fish can recognize" - echo $argv | tr ':' '\n' -end - function deactivate -d 'Exit virtualenv mode and return to the normal environment.' # reset old environment variables if test -n "$_OLD_VIRTUAL_PATH" - # https://github.com/fish-shell/fish-shell/issues/436 altered PATH handling - if test (string sub -s 1 -l 1 $FISH_VERSION) -lt 3 - set -gx PATH (_fishify_path "$_OLD_VIRTUAL_PATH") - else - set -gx PATH $_OLD_VIRTUAL_PATH - end + set -gx PATH $_OLD_VIRTUAL_PATH set -e _OLD_VIRTUAL_PATH end @@ -43,6 +25,11 @@ function deactivate -d 'Exit virtualenv mode and return to the normal environmen end end + if test -n "$_OLD_PKG_CONFIG_PATH" + set -gx PKG_CONFIG_PATH "$_OLD_PKG_CONFIG_PATH" + set -e _OLD_PKG_CONFIG_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" set -gx PYTHONHOME "$_OLD_VIRTUAL_PYTHONHOME" set -e _OLD_VIRTUAL_PYTHONHOME @@ -67,8 +54,6 @@ function deactivate -d 'Exit virtualenv mode and return to the normal environmen # Self-destruct! functions -e pydoc functions -e deactivate - functions -e _bashify_path - functions -e _fishify_path end end @@ -77,12 +62,10 @@ deactivate nondestructive set -gx VIRTUAL_ENV __VIRTUAL_ENV__ -# https://github.com/fish-shell/fish-shell/issues/436 altered PATH handling -if test (string sub -s 1 -l 1 $FISH_VERSION) -lt 3 - set -gx _OLD_VIRTUAL_PATH (_bashify_path $PATH) -else - set -gx _OLD_VIRTUAL_PATH $PATH -end +set -gx _OLD_PKG_CONFIG_PATH "$PKG_CONFIG_PATH" +set -gx PKG_CONFIG_PATH "$VIRTUAL_ENV/lib/pkgconfig:$PKG_CONFIG_PATH" + +set -gx _OLD_VIRTUAL_PATH $PATH set -gx PATH "$VIRTUAL_ENV"'/'__BIN_NAME__ $PATH if test -n __TCL_LIBRARY__ @@ -121,12 +104,14 @@ if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" functions -c fish_prompt _old_fish_prompt function fish_prompt + set -l old_status $status # Run the user's prompt first; it might depend on (pipe)status. set -l prompt (_old_fish_prompt) printf '(%s) ' $VIRTUAL_ENV_PROMPT string join -- \n $prompt # handle multi-line prompts + echo "exit $old_status" | . end set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" diff --git a/src/virtualenv/activation/nushell/__init__.py b/src/virtualenv/activation/nushell/__init__.py index d3b312497..60a0d78be 100644 --- a/src/virtualenv/activation/nushell/__init__.py +++ b/src/virtualenv/activation/nushell/__init__.py @@ -1,21 +1,28 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from virtualenv.activation.via_template import ViaTemplateActivator +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + from virtualenv.create.creator import Creator + class NushellActivator(ViaTemplateActivator): - def templates(self): + def templates(self) -> Iterator[str]: yield "activate.nu" @staticmethod - def quote(string): - """ - Nushell supports raw strings like: r###'this is a string'###. + def quote(string: str) -> str: + """Nushell supports raw strings like: r###'this is a string'###. https://github.com/nushell/nushell.github.io/blob/main/book/working_with_strings.md - This method finds the maximum continuous sharps in the string and then - quote it with an extra sharp. + This method finds the maximum continuous sharps in the string and then quote it with an extra sharp. + """ max_sharps = 0 current_sharps = 0 @@ -28,7 +35,7 @@ def quote(string): wrapping = "#" * (max_sharps + 1) return f"r{wrapping}'{string}'{wrapping}" - def replacements(self, creator, dest_folder): # noqa: ARG002 + def replacements(self, creator: Creator, dest_folder: Path) -> dict[str, str]: # noqa: ARG002 return { "__VIRTUAL_PROMPT__": "" if self.flag_prompt is None else self.flag_prompt, "__VIRTUAL_ENV__": str(creator.dest), diff --git a/src/virtualenv/activation/nushell/activate.nu b/src/virtualenv/activation/nushell/activate.nu index b48fdd03f..7046c5880 100644 --- a/src/virtualenv/activation/nushell/activate.nu +++ b/src/virtualenv/activation/nushell/activate.nu @@ -57,7 +57,9 @@ export-env { } else { __VIRTUAL_PROMPT__ } - let new_env = { $path_name: $new_path VIRTUAL_ENV: $virtual_env VIRTUAL_ENV_PROMPT: $virtual_env_prompt } + let old_pkg_config_path = if (has-env 'PKG_CONFIG_PATH') { $env.PKG_CONFIG_PATH } else { '' } + let new_pkg_config_path = $'($virtual_env)/lib/pkgconfig:($old_pkg_config_path)' + let new_env = { $path_name: $new_path VIRTUAL_ENV: $virtual_env VIRTUAL_ENV_PROMPT: $virtual_env_prompt PKG_CONFIG_PATH: $new_pkg_config_path } if (has-env 'TCL_LIBRARY') { let $new_env = $new_env | insert TCL_LIBRARY __TCL_LIBRARY__ } diff --git a/src/virtualenv/activation/powershell/__init__.py b/src/virtualenv/activation/powershell/__init__.py index 8489656cc..7efe907c6 100644 --- a/src/virtualenv/activation/powershell/__init__.py +++ b/src/virtualenv/activation/powershell/__init__.py @@ -1,21 +1,26 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from virtualenv.activation.via_template import ViaTemplateActivator +if TYPE_CHECKING: + from collections.abc import Iterator + class PowerShellActivator(ViaTemplateActivator): - def templates(self): + def templates(self) -> Iterator[str]: 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]. + def quote(string: str) -> str: + """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 + [2]: + https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_parsing#passing-arguments-that-contain-quote-characters + + """ string = string.replace("'", "''") return f"'{string}'" diff --git a/src/virtualenv/activation/powershell/activate.ps1 b/src/virtualenv/activation/powershell/activate.ps1 index 052a8298d..e56633a5b 100644 --- a/src/virtualenv/activation/powershell/activate.ps1 +++ b/src/virtualenv/activation/powershell/activate.ps1 @@ -1,5 +1,92 @@ -$script:THIS_PATH = $myinvocation.mycommand.path -$script:BASE_DIR = Split-Path (Resolve-Path "$THIS_PATH/..") -Parent +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +activate.ps1 +Activates the Python virtual environment that contains the activate.ps1 script. + +.Example +activate.ps1 -Verbose +Activates the Python virtual environment that contains the activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + Write-Verbose "File exists, parse ``key = value`` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} function global:deactivate([switch] $NonDestructive) { if (Test-Path variable:_OLD_VIRTUAL_PATH) { @@ -25,6 +112,11 @@ function global:deactivate([switch] $NonDestructive) { } } + if (Test-Path variable:_OLD_PKG_CONFIG_PATH) { + $env:PKG_CONFIG_PATH = $variable:_OLD_PKG_CONFIG_PATH + Remove-Variable "_OLD_PKG_CONFIG_PATH" -Scope global + } + if (Test-Path function:_old_virtual_prompt) { $function:prompt = $function:_old_virtual_prompt Remove-Item function:\_old_virtual_prompt @@ -38,8 +130,11 @@ function global:deactivate([switch] $NonDestructive) { Remove-Item env:VIRTUAL_ENV_PROMPT -ErrorAction SilentlyContinue } + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + if (!$NonDestructive) { - # Self destruct! Remove-Item function:deactivate Remove-Item function:pydoc } @@ -49,19 +144,38 @@ function global:pydoc { & python -m pydoc $args } -# unset irrelevant variables -deactivate -nondestructive +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath -$VIRTUAL_ENV = $BASE_DIR -$env:VIRTUAL_ENV = $VIRTUAL_ENV +Write-Verbose "Activation script is located in path: '$VenvExecPath'" -if (__VIRTUAL_PROMPT__ -ne "") { - $env:VIRTUAL_ENV_PROMPT = __VIRTUAL_PROMPT__ +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} else { + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" } -else { - $env:VIRTUAL_ENV_PROMPT = $( Split-Path $env:VIRTUAL_ENV -Leaf ) + +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} else { + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose "Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt'] + } elseif (__VIRTUAL_PROMPT__ -ne "") { + $Prompt = __VIRTUAL_PROMPT__ + } else { + $Prompt = Split-Path -Path $VenvDir -Leaf + } } +deactivate -nondestructive + +$env:VIRTUAL_ENV = $VenvDir +$env:VIRTUAL_ENV_PROMPT = $Prompt + if (__TCL_LIBRARY__ -ne "") { if (Test-Path env:TCL_LIBRARY) { New-Variable -Scope global -Name _OLD_VIRTUAL_TCL_LIBRARY -Value $env:TCL_LIBRARY @@ -78,16 +192,22 @@ if (__TK_LIBRARY__ -ne "") { New-Variable -Scope global -Name _OLD_VIRTUAL_PATH -Value $env:PATH +if (Test-Path env:PKG_CONFIG_PATH) { + New-Variable -Scope global -Name _OLD_PKG_CONFIG_PATH -Value $env:PKG_CONFIG_PATH +} +$env:PKG_CONFIG_PATH = "$env:VIRTUAL_ENV\lib\pkgconfig;$env:PKG_CONFIG_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 + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt 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) + Write-Host -NoNewline -ForegroundColor Green "($($_PYTHON_VENV_PROMPT_PREFIX)) " + _old_virtual_prompt } } diff --git a/src/virtualenv/activation/python/__init__.py b/src/virtualenv/activation/python/__init__.py index e900f7ec9..ef59448b4 100644 --- a/src/virtualenv/activation/python/__init__.py +++ b/src/virtualenv/activation/python/__init__.py @@ -2,19 +2,26 @@ import os from collections import OrderedDict +from typing import TYPE_CHECKING from virtualenv.activation.via_template import ViaTemplateActivator +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + from virtualenv.create.creator import Creator + class PythonActivator(ViaTemplateActivator): - def templates(self): + def templates(self) -> Iterator[str]: yield "activate_this.py" @staticmethod - def quote(string): + def quote(string: str) -> str: return repr(string) - def replacements(self, creator, dest_folder): + def replacements(self, creator: Creator, dest_folder: Path) -> dict[str, str]: replacements = super().replacements(creator, dest_folder) lib_folders = OrderedDict((os.path.relpath(str(i), str(dest_folder)), None) for i in creator.libs) lib_folders = os.pathsep.join(lib_folders.keys()) diff --git a/src/virtualenv/activation/python/activate_this.py b/src/virtualenv/activation/python/activate_this.py index 9cc816fab..69a4b67c8 100644 --- a/src/virtualenv/activation/python/activate_this.py +++ b/src/virtualenv/activation/python/activate_this.py @@ -1,10 +1,9 @@ -""" -Activate virtualenv for current interpreter: +"""Activate virtualenv for current interpreter: -import runpy -runpy.run_path(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 @@ -20,19 +19,27 @@ 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] # ty: ignore[unresolved-reference] # 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["VIRTUAL_ENV"] = base # virtual env is right above bin directory -os.environ["VIRTUAL_ENV_PROMPT"] = __VIRTUAL_PROMPT__ or os.path.basename(base) +os.environ["VIRTUAL_ENV_PROMPT"] = __VIRTUAL_PROMPT__ or os.path.basename(base) # ty: ignore[unresolved-reference] + +# Set PKG_CONFIG_PATH to include the virtualenv's pkgconfig directory +pkg_config_path = os.path.join(base, "lib", "pkgconfig") +existing_pkg_config_path = os.environ.get("PKG_CONFIG_PATH", "") +if existing_pkg_config_path: + os.environ["PKG_CONFIG_PATH"] = os.pathsep.join([pkg_config_path, existing_pkg_config_path]) +else: + os.environ["PKG_CONFIG_PATH"] = pkg_config_path # 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): # ty: ignore[unresolved-reference] 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) # ty: ignore[unresolved-reference,unresolved-attribute] sys.path[:] = sys.path[prev_length:] + sys.path[0:prev_length] -sys.real_prefix = sys.prefix +sys.real_prefix = sys.prefix # ty: ignore[unresolved-attribute] sys.prefix = base diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activation/via_template.py index 85f932605..926c2b0be 100644 --- a/src/virtualenv/activation/via_template.py +++ b/src/virtualenv/activation/via_template.py @@ -4,9 +4,16 @@ import shlex import sys from abc import ABC, abstractmethod +from typing import TYPE_CHECKING from .activator import Activator +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + from pathlib import Path + + from virtualenv.create.creator import Creator + if sys.version_info >= (3, 10): from importlib.resources import files @@ -19,20 +26,21 @@ def read_binary(module_name: str, filename: str) -> bytes: class ViaTemplateActivator(Activator, ABC): @abstractmethod - def templates(self): + def templates(self) -> Iterator[str]: raise NotImplementedError @staticmethod - def quote(string): - """ - Quote strings in the activation script. + def quote(string: str) -> str: + """Quote strings in the activation script. :param string: the string to quote - :return: quoted string that works in the activation script + + :returns: quoted string that works in the activation script + """ return shlex.quote(string) - def generate(self, creator): + def generate(self, creator: Creator) -> list[Path]: dest_folder = creator.bin_dir replacements = self.replacements(creator, dest_folder) generated = self._generate(replacements, self.templates(), dest_folder, creator) @@ -40,7 +48,7 @@ def generate(self, creator): creator.pyenv_cfg["prompt"] = self.flag_prompt return generated - def replacements(self, creator, dest_folder): # noqa: ARG002 + def replacements(self, creator: Creator, dest_folder: Path) -> dict[str, str]: # noqa: ARG002 return { "__VIRTUAL_PROMPT__": "" if self.flag_prompt is None else self.flag_prompt, "__VIRTUAL_ENV__": str(creator.dest), @@ -51,7 +59,9 @@ def replacements(self, creator, dest_folder): # noqa: ARG002 "__TK_LIBRARY__": getattr(creator.interpreter, "tk_lib", None) or "", } - def _generate(self, replacements, templates, to_folder, creator): + def _generate( + self, replacements: dict[str, str], templates: Iterable[str], to_folder: Path, creator: Creator + ) -> list[Path]: generated = [] for template in templates: text = self.instantiate_template(replacements, template, creator) @@ -67,10 +77,10 @@ def _generate(self, replacements, templates, to_folder, creator): generated.append(dest) return generated - def as_name(self, template): + def as_name(self, template: str) -> str: return template - def instantiate_template(self, replacements, template, creator): + def instantiate_template(self, replacements: dict[str, str], template: str, creator: Creator) -> str: # read content as binary to avoid platform specific line normalization (\n -> \r\n) binary = read_binary(self.__module__, template) text = binary.decode("utf-8", errors="strict") @@ -80,7 +90,7 @@ def instantiate_template(self, replacements, template, creator): return text @staticmethod - def _repr_unicode(creator, value): # noqa: ARG004 + def _repr_unicode(creator: Creator, value: str) -> str: # noqa: ARG004 return value # by default, we just let it be unicode diff --git a/src/virtualenv/app_data/__init__.py b/src/virtualenv/app_data/__init__.py index 7a9d38e92..576b56651 100644 --- a/src/virtualenv/app_data/__init__.py +++ b/src/virtualenv/app_data/__init__.py @@ -4,25 +4,49 @@ import logging import os +import shutil +from typing import TYPE_CHECKING, Any -from platformdirs import user_data_dir +from platformdirs import user_cache_dir, user_data_dir from .na import AppDataDisabled from .read_only import ReadOnlyAppData from .via_disk_folder import AppDataDiskFolder from .via_tempdir import TempAppData +if TYPE_CHECKING: + from collections.abc import Mapping + + from .base import AppData + LOGGER = logging.getLogger(__name__) -def _default_app_data_dir(env): +def _default_app_data_dir(env: Mapping[str, str]) -> str: key = "VIRTUALENV_OVERRIDE_APP_DATA" if key in env: return env[key] - return user_data_dir(appname="virtualenv", appauthor="pypa") + return _cache_dir_with_migration() + + +def _cache_dir_with_migration() -> str: + new_dir = user_cache_dir(appname="virtualenv", appauthor="pypa") + old_dir = user_data_dir(appname="virtualenv", appauthor="pypa") + if new_dir == old_dir: + return new_dir + if os.path.isdir(old_dir) and not os.path.isdir(new_dir): + LOGGER.info("migrating app data from %s to %s", old_dir, new_dir) + try: + shutil.move(old_dir, new_dir) + except OSError as exception: + LOGGER.warning( + "could not migrate app data from %s to %s: %r, using old location", old_dir, new_dir, exception + ) + return old_dir + return new_dir -def make_app_data(folder, **kwargs): +def make_app_data(folder: str | None, **kwargs: Any) -> AppData: # noqa: ANN401 is_read_only = kwargs.pop("read_only") env = kwargs.pop("env") if kwargs: # py3+ kwonly diff --git a/src/virtualenv/app_data/base.py b/src/virtualenv/app_data/base.py index 2077deebd..b18b96b00 100644 --- a/src/virtualenv/app_data/base.py +++ b/src/virtualenv/app_data/base.py @@ -4,52 +4,92 @@ from abc import ABC, abstractmethod from contextlib import contextmanager +from typing import TYPE_CHECKING from virtualenv.info import IS_ZIPAPP +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + from typing import Any + class AppData(ABC): """Abstract storage interface for the virtualenv application.""" @abstractmethod - def close(self): + def close(self) -> None: """Called before virtualenv exits.""" @abstractmethod - def reset(self): + def reset(self) -> None: """Called when the user passes in the reset app data.""" @abstractmethod - def py_info(self, path): + def py_info(self, path: Path) -> ContentStore: + """Return a content store for cached interpreter information at the given path. + + :param path: the interpreter executable path + + :returns: a content store for the cached data + + """ raise NotImplementedError @abstractmethod - def py_info_clear(self): + def py_info_clear(self) -> None: + """Clear all cached interpreter information.""" raise NotImplementedError @property - def can_update(self): + def can_update(self) -> bool: + """``True`` if this app data store supports updating cached content.""" raise NotImplementedError @abstractmethod - def embed_update_log(self, distribution, for_py_version): + def embed_update_log(self, distribution: str, for_py_version: str) -> ContentStore: + """Return a content store for the embed update log of a distribution. + + :param distribution: the package name (e.g. ``pip``) + :param for_py_version: the target Python version string + + :returns: a content store for the update log + + """ raise NotImplementedError @property - def house(self): + def house(self) -> Path: + """The root directory of the application data store.""" raise NotImplementedError @property - def transient(self): + def transient(self) -> bool: + """``True`` if this app data store is transient and does not persist across runs.""" raise NotImplementedError @abstractmethod - def wheel_image(self, for_py_version, name): + def wheel_image(self, for_py_version: str, name: str) -> Path: + """Return the path to a cached wheel image. + + :param for_py_version: the target Python version string + :param name: the package name + + :returns: the path to the cached wheel + + """ raise NotImplementedError @contextmanager - def ensure_extracted(self, path, to_folder=None): - """Some paths might be within the zipapp, unzip these to a path on the disk.""" + def ensure_extracted(self, path: Path, to_folder: Path | None = None) -> Generator[Path]: + """Ensure a path is available on disk, extracting from zipapp if needed. + + :param path: the path to ensure is available + :param to_folder: optional target directory for extraction + + :returns: yields the usable path on disk + + """ if IS_ZIPAPP: with self.extract(path, to_folder) as result: yield result @@ -58,36 +98,67 @@ def ensure_extracted(self, path, to_folder=None): @abstractmethod @contextmanager - def extract(self, path, to_folder): + def extract(self, path: Path, to_folder: Path | None) -> Generator[Path]: + """Extract a path from the zipapp to a location on disk. + + :param path: the path to extract + :param to_folder: optional target directory + + :returns: yields the extracted path + + """ raise NotImplementedError @abstractmethod @contextmanager - def locked(self, path): + def locked(self, path: Path) -> Generator[None]: + """Acquire an exclusive lock on the given path. + + :param path: the path to lock + + """ raise NotImplementedError class ContentStore(ABC): + """A store for reading and writing cached content.""" + @abstractmethod - def exists(self): + def exists(self) -> bool: + """Check if the stored content exists. + + :returns: ``True`` if content exists + + """ raise NotImplementedError @abstractmethod - def read(self): + def read(self) -> Any: # noqa: ANN401 + """Read the stored content. + + :returns: the stored content + + """ raise NotImplementedError @abstractmethod - def write(self, content): + def write(self, content: Any) -> None: # noqa: ANN401 + """Write content to the store. + + :param content: the content to write + + """ raise NotImplementedError @abstractmethod - def remove(self): + def remove(self) -> None: + """Remove the stored content.""" raise NotImplementedError @abstractmethod @contextmanager - def locked(self): - pass + def locked(self) -> Generator[None]: + """Acquire an exclusive lock on this content store.""" __all__ = [ diff --git a/src/virtualenv/app_data/na.py b/src/virtualenv/app_data/na.py index 921e83a81..e6170f879 100644 --- a/src/virtualenv/app_data/na.py +++ b/src/virtualenv/app_data/na.py @@ -1,9 +1,15 @@ from __future__ import annotations from contextlib import contextmanager +from typing import TYPE_CHECKING from .base import AppData, ContentStore +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + from typing import Any, NoReturn + class AppDataDisabled(AppData): """No application cache available (most likely as we don't have write permissions).""" @@ -16,53 +22,53 @@ def __init__(self) -> None: error = RuntimeError("no app data folder available, probably no write access to the folder") - def close(self): + def close(self) -> None: """Do nothing.""" - def reset(self): + def reset(self) -> None: """Do nothing.""" - def py_info(self, path): # noqa: ARG002 + def py_info(self, path: Path) -> ContentStoreNA: # noqa: ARG002 return ContentStoreNA() - def embed_update_log(self, distribution, for_py_version): # noqa: ARG002 + def embed_update_log(self, distribution: str, for_py_version: str) -> ContentStoreNA: # noqa: ARG002 return ContentStoreNA() - def extract(self, path, to_folder): # noqa: ARG002 + def extract(self, path: Path, to_folder: Path | None) -> NoReturn: # noqa: ARG002 raise self.error @contextmanager - def locked(self, path): # noqa: ARG002 + def locked(self, path: Path) -> Generator[None]: # noqa: ARG002 """Do nothing.""" yield @property - def house(self): + def house(self) -> NoReturn: raise self.error - def wheel_image(self, for_py_version, name): # noqa: ARG002 + def wheel_image(self, for_py_version: str, name: str) -> NoReturn: # noqa: ARG002 raise self.error - def py_info_clear(self): + def py_info_clear(self) -> None: """Nothing to clear.""" class ContentStoreNA(ContentStore): - def exists(self): + def exists(self) -> bool: return False - def read(self): + def read(self) -> None: """Nothing to read.""" return - def write(self, content): + def write(self, content: Any) -> None: # noqa: ANN401 """Nothing to write.""" - def remove(self): + def remove(self) -> None: """Nothing to remove.""" @contextmanager - def locked(self): + def locked(self) -> Generator[None]: yield diff --git a/src/virtualenv/app_data/read_only.py b/src/virtualenv/app_data/read_only.py index 952dbad5a..5bd62c7f5 100644 --- a/src/virtualenv/app_data/read_only.py +++ b/src/virtualenv/app_data/read_only.py @@ -1,11 +1,16 @@ from __future__ import annotations import os.path +from typing import TYPE_CHECKING from virtualenv.util.lock import NoOpFileLock from .via_disk_folder import AppDataDiskFolder, PyInfoStoreDisk +if TYPE_CHECKING: + from pathlib import Path + from typing import NoReturn + class ReadOnlyAppData(AppDataDiskFolder): can_update = False @@ -24,15 +29,15 @@ def reset(self) -> None: def py_info_clear(self) -> None: raise NotImplementedError - def py_info(self, path): + def py_info(self, path: Path) -> _PyInfoStoreDiskReadOnly: return _PyInfoStoreDiskReadOnly(self.py_info_at, path) - def embed_update_log(self, distribution, for_py_version): + def embed_update_log(self, distribution: str, for_py_version: str) -> NoReturn: raise NotImplementedError class _PyInfoStoreDiskReadOnly(PyInfoStoreDisk): - def write(self, content): # noqa: ARG002 + def write(self, content: str) -> NoReturn: # noqa: ARG002 msg = "read-only app data python info cannot be updated" raise RuntimeError(msg) diff --git a/src/virtualenv/app_data/via_disk_folder.py b/src/virtualenv/app_data/via_disk_folder.py index 9ebe91c2e..5d7500203 100644 --- a/src/virtualenv/app_data/via_disk_folder.py +++ b/src/virtualenv/app_data/via_disk_folder.py @@ -1,25 +1,27 @@ -""" -A rough layout of the current storage goes as: - -virtualenv-app-data -├── py - -│ └── *.json/lock -├── wheel -│ ├── house -│ │ └── *.whl -│ └── -> 3.9 -│ ├── img- -│ │ └── image -│ │ └── -> CopyPipInstall / SymlinkPipInstall -│ │ └── -> pip-20.1.1-py2.py3-none-any -│ └── embed -│ └── 3 -> json format versioning -│ └── *.json -> for every distribution contains data about newer embed versions and releases -└─── unzip - └── - ├── py_info.py - ├── debug.py - └── _virtualenv.py +r"""A rough layout of the current storage goes as: + +:: + + virtualenv-app-data + ├── py - + │ └── *.json/lock + ├── wheel + │ ├── house + │ │ └── *.whl + │ └── -> 3.9 + │ ├── img- + │ │ └── image + │ │ └── -> CopyPipInstall / SymlinkPipInstall + │ │ └── -> pip-20.1.1-py2.py3-none-any + │ └── embed + │ └── 3 -> json format versioning + │ └── *.json -> for every distribution contains data about newer embed versions and releases + └─── unzip + └── + ├── py_info.py + ├── debug.py + └── _virtualenv.py + """ # noqa: D415 from __future__ import annotations @@ -29,6 +31,7 @@ from abc import ABC from contextlib import contextmanager, suppress from hashlib import sha256 +from typing import TYPE_CHECKING, Any from virtualenv.util.lock import ReentrantFileLock from virtualenv.util.path import safe_delete @@ -37,6 +40,10 @@ from .base import AppData, ContentStore +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + LOGGER = logging.getLogger(__name__) @@ -46,7 +53,7 @@ class AppDataDiskFolder(AppData): transient = False can_update = True - def __init__(self, folder) -> None: + def __init__(self, folder: str) -> None: self.lock = ReentrantFileLock(folder) def __repr__(self) -> str: @@ -55,22 +62,22 @@ def __repr__(self) -> str: def __str__(self) -> str: return str(self.lock.path) - def reset(self): + def reset(self) -> None: LOGGER.debug("reset app data folder %s", self.lock.path) safe_delete(self.lock.path) - def close(self): + def close(self) -> None: """Do nothing.""" @contextmanager - def locked(self, path): - path_lock = self.lock / path + def locked(self, path: Path) -> Generator[None]: + path_lock = self.lock / path # ty: ignore[unsupported-operator] with path_lock: yield path_lock.path @contextmanager - def extract(self, path, to_folder): - root = ReentrantFileLock(to_folder()) if to_folder is not None else self.lock / "unzip" / __version__ + def extract(self, path: Path, to_folder: Path | None) -> Generator[Path]: + root = ReentrantFileLock(to_folder()) if to_folder is not None else self.lock / "unzip" / __version__ # ty: ignore[call-non-callable] with root.lock_for_key(path.name): dest = root.path / path.name if not dest.exists(): @@ -78,13 +85,13 @@ def extract(self, path, to_folder): yield dest @property - def py_info_at(self): - return self.lock / "py_info" / "2" + def py_info_at(self) -> ReentrantFileLock: + return self.lock / "py_info" / "4" # ty: ignore[invalid-return-type] - def py_info(self, path): + def py_info(self, path: Path) -> PyInfoStoreDisk: return PyInfoStoreDisk(self.py_info_at, path) - def py_info_clear(self): + def py_info_clear(self) -> None: """clear py info.""" py_info_folder = self.py_info_at with py_info_folder: @@ -94,33 +101,33 @@ def py_info_clear(self): if filename.exists(): filename.unlink() - def embed_update_log(self, distribution, for_py_version): - return EmbedDistributionUpdateStoreDisk(self.lock / "wheel" / for_py_version / "embed" / "3", distribution) + def embed_update_log(self, distribution: str, for_py_version: str) -> EmbedDistributionUpdateStoreDisk: + return EmbedDistributionUpdateStoreDisk(self.lock / "wheel" / for_py_version / "embed" / "3", distribution) # ty: ignore[invalid-argument-type] @property - def house(self): + def house(self) -> Path: path = self.lock.path / "wheel" / "house" path.mkdir(parents=True, exist_ok=True) return path - def wheel_image(self, for_py_version, name): + def wheel_image(self, for_py_version: str, name: str) -> Path: return self.lock.path / "wheel" / for_py_version / "image" / "1" / name class JSONStoreDisk(ContentStore, ABC): - def __init__(self, in_folder, key, msg_args) -> None: + def __init__(self, in_folder: ReentrantFileLock, key: str, msg_args: tuple[str, ...]) -> None: self.in_folder = in_folder self.key = key self.msg_args = (*msg_args, self.file) @property - def file(self): + def file(self) -> Path: return self.in_folder.path / f"{self.key}.json" - def exists(self): + def exists(self) -> bool: return self.file.exists() - def read(self): + def read(self) -> Any: # noqa: ANN401 data, bad_format = None, False try: data = json.loads(self.file.read_text(encoding="utf-8")) @@ -136,16 +143,16 @@ def read(self): self.remove() return None - def remove(self): + def remove(self) -> None: self.file.unlink() LOGGER.debug("removed %s %s at %s", *self.msg_args) @contextmanager - def locked(self): + def locked(self) -> Generator[None]: with self.in_folder.lock_for_key(self.key): yield - def write(self, content): + def write(self, content: Any) -> None: # noqa: ANN401 folder = self.file.parent folder.mkdir(parents=True, exist_ok=True) self.file.write_text(json.dumps(content, sort_keys=True, indent=2), encoding="utf-8") @@ -153,13 +160,13 @@ def write(self, content): class PyInfoStoreDisk(JSONStoreDisk): - def __init__(self, in_folder, path) -> None: + def __init__(self, in_folder: ReentrantFileLock, path: Path) -> None: key = sha256(str(path).encode("utf-8")).hexdigest() - super().__init__(in_folder, key, ("python info of", path)) + super().__init__(in_folder, key, ("python info of", path)) # ty: ignore[invalid-argument-type] class EmbedDistributionUpdateStoreDisk(JSONStoreDisk): - def __init__(self, in_folder, distribution) -> None: + def __init__(self, in_folder: ReentrantFileLock, distribution: str) -> None: super().__init__( in_folder, distribution, diff --git a/src/virtualenv/app_data/via_tempdir.py b/src/virtualenv/app_data/via_tempdir.py index 884a570ce..a690f3c79 100644 --- a/src/virtualenv/app_data/via_tempdir.py +++ b/src/virtualenv/app_data/via_tempdir.py @@ -2,11 +2,15 @@ import logging from tempfile import mkdtemp +from typing import TYPE_CHECKING from virtualenv.util.path import safe_delete from .via_disk_folder import AppDataDiskFolder +if TYPE_CHECKING: + from typing import NoReturn + LOGGER = logging.getLogger(__name__) @@ -18,14 +22,14 @@ def __init__(self) -> None: super().__init__(folder=mkdtemp()) LOGGER.debug("created temporary app data folder %s", self.lock.path) - def reset(self): + def reset(self) -> None: """This is a temporary folder, is already empty to start with.""" - def close(self): + def close(self) -> None: 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): + def embed_update_log(self, distribution: str, for_py_version: str) -> NoReturn: raise NotImplementedError diff --git a/src/virtualenv/config/cli/parser.py b/src/virtualenv/config/cli/parser.py index fd1dc0956..eeb58fc6b 100644 --- a/src/virtualenv/config/cli/parser.py +++ b/src/virtualenv/config/cli/parser.py @@ -3,6 +3,11 @@ import os from argparse import SUPPRESS, ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace from collections import OrderedDict +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from argparse import Action + from collections.abc import Mapping, Sequence from virtualenv.config.convert import get_type from virtualenv.config.env_var import get_env_var @@ -10,27 +15,46 @@ class VirtualEnvOptions(Namespace): - def __init__(self, **kwargs) -> None: + def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 super().__init__(**kwargs) - self._src = None - self._sources = {} + self._src: str | None = None + self._sources: dict[str, str] = {} + + def set_src(self, key: str, value: Any, src: str) -> None: # noqa: ANN401 + """Set an option value and record where it came from. - def set_src(self, key, value, src): + :param key: the option name + :param value: the option value + :param src: the source of the value (e.g. ``"cli"``, ``"env var"``, ``"default"``) + + """ setattr(self, key, value) if src.startswith("env var"): src = "env var" self._sources[key] = src - def __setattr__(self, key, value) -> None: - if getattr(self, "_src", None) is not None: - self._sources[key] = self._src + def __setattr__(self, key: str, value: Any) -> None: # noqa: ANN401 + if (src := getattr(self, "_src", None)) is not None: + self._sources[key] = src super().__setattr__(key, value) - def get_source(self, key): + def get_source(self, key: str) -> str | None: + """Return the source that provided a given option value. + + :param key: the option name + + :returns: the source string (e.g. ``"cli"``, ``"env var"``, ``"default"``), or ``None`` if not tracked + + """ return self._sources.get(key) @property - def verbosity(self): + def verbosity(self) -> int | None: + """The verbosity level, computed as ``verbose - quiet``, clamped to zero. + + :returns: the verbosity level, or ``None`` if neither ``--verbose`` nor ``--quiet`` has been parsed yet + + """ if not hasattr(self, "verbose") and not hasattr(self, "quiet"): return None return max(self.verbose - self.quiet, 0) @@ -42,7 +66,13 @@ def __repr__(self) -> str: class VirtualEnvConfigParser(ArgumentParser): """Custom option parser which updates its defaults by checking the configuration files and environmental vars.""" - def __init__(self, options=None, env=None, *args, **kwargs) -> None: + def __init__( + self, + options: VirtualEnvOptions | None = None, + env: Mapping[str, str] | None = None, + *args: Any, # noqa: ANN401 + **kwargs: Any, # noqa: ANN401 + ) -> None: env = os.environ if env is None else env self.file_config = IniConfig(env) self.epilog_list = [] @@ -60,14 +90,14 @@ def __init__(self, options=None, env=None, *args, **kwargs) -> None: self._interpreter = None self._app_data = None - def _fix_defaults(self): + def _fix_defaults(self) -> None: for action in self._actions: action_id = id(action) if action_id not in self._fixed: self._fix_default(action) self._fixed.add(action_id) - def _fix_default(self, action): + def _fix_default(self, action: Action) -> None: if hasattr(action, "default") and hasattr(action, "dest") and action.default != SUPPRESS: as_type = get_type(action) names = OrderedDict((i.lstrip("-").replace("-", "_"), None) for i in action.option_strings) @@ -87,11 +117,13 @@ def _fix_default(self, action): outcome = action.default, "default" self.options.set_src(action.dest, *outcome) - def enable_help(self): + def enable_help(self) -> None: self._fix_defaults() self.add_argument("-h", "--help", action="help", default=SUPPRESS, help="show this help message and exit") - def parse_known_args(self, args=None, namespace=None): + def parse_known_args( + self, args: Sequence[str] | None = None, namespace: VirtualEnvOptions | None = None + ) -> tuple[VirtualEnvOptions, list[str]]: if namespace is None: namespace = self.options elif namespace is not self.options: @@ -107,12 +139,12 @@ def parse_known_args(self, args=None, namespace=None): class HelpFormatter(ArgumentDefaultsHelpFormatter): - def __init__(self, prog, **kwargs) -> None: + def __init__(self, prog: str, **kwargs: Any) -> None: # noqa: ANN401 super().__init__(prog, max_help_position=32, width=240, **kwargs) - def _get_help_string(self, action): + def _get_help_string(self, action: Action) -> str | None: text = super()._get_help_string(action) - if hasattr(action, "default_source"): + if text is not None and hasattr(action, "default_source"): default = " (default: %(default)s)" if text.endswith(default): text = f"{text[: -len(default)]} (default: %(default)s -> from %(default_source)s)" diff --git a/src/virtualenv/config/convert.py b/src/virtualenv/config/convert.py index ef7581dbd..8e5e5cac7 100644 --- a/src/virtualenv/config/convert.py +++ b/src/virtualenv/config/convert.py @@ -2,20 +2,24 @@ import logging import os -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + from argparse import Action + from typing import Any LOGGER = logging.getLogger(__name__) class TypeData: - def __init__(self, default_type, as_type) -> None: + def __init__(self, default_type: type, as_type: type) -> None: self.default_type = default_type self.as_type = as_type def __repr__(self) -> str: return f"{self.__class__.__name__}(base={self.default_type}, as={self.as_type})" - def convert(self, value): + def convert(self, value: str) -> Any: # noqa: ANN401 return self.default_type(value) @@ -31,7 +35,7 @@ class BoolType(TypeData): "off": False, } - def convert(self, value): + def convert(self, value: str) -> bool: if value.lower() not in self.BOOLEAN_STATES: msg = f"Not a boolean: {value}" raise ValueError(msg) @@ -39,17 +43,17 @@ def convert(self, value): class NoneType(TypeData): - def convert(self, value): + def convert(self, value: str) -> str | None: if not value: return None return str(value) class ListType(TypeData): - def _validate(self): + def _validate(self) -> None: """no op.""" - def convert(self, value, flatten=True): # noqa: ARG002, FBT002 + def convert(self, value: str | list[str], flatten: bool = True) -> list[Any]: # noqa: ARG002, FBT002 values = self.split_values(value) result = [] for a_value in values: @@ -57,12 +61,11 @@ def convert(self, value, flatten=True): # noqa: ARG002, FBT002 result.extend(sub_values) return [self.as_type(i) for i in result] - def split_values(self, value): - """ - Split the provided value into a list. + def split_values(self, value: str | bytes | list[str]) -> list[str]: + """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. - First this is done by newlines. If there were no newlines in the text, - then we next try to split by comma. """ if isinstance(value, (str, bytes)): # Use `splitlines` rather than a custom check for whether there is @@ -70,15 +73,15 @@ def split_values(self, value): # logic is supported here. values = value.splitlines() if len(values) <= 1: - values = value.split(",") + values = value.split(",") # ty: ignore[invalid-argument-type] values = filter(None, [x.strip() for x in values]) else: values = list(value) - return values + return values # ty: ignore[invalid-return-type] -def convert(value, as_type, source): +def convert(value: str, as_type: TypeData, source: str) -> Any: # noqa: ANN401 """Convert the value as a given type where the value comes from the given source.""" try: return as_type.convert(value) @@ -90,10 +93,10 @@ def convert(value, as_type, source): _CONVERT = {bool: BoolType, type(None): NoneType, list: ListType} -def get_type(action): +def get_type(action: Action) -> TypeData: default_type = type(action.default) as_type = default_type if action.type is None else action.type - return _CONVERT.get(default_type, TypeData)(default_type, as_type) + return _CONVERT.get(default_type, TypeData)(default_type, as_type) # ty: ignore[invalid-argument-type] __all__ = [ diff --git a/src/virtualenv/config/env_var.py b/src/virtualenv/config/env_var.py index e12723471..498e3db9c 100644 --- a/src/virtualenv/config/env_var.py +++ b/src/virtualenv/config/env_var.py @@ -1,18 +1,26 @@ from __future__ import annotations from contextlib import suppress +from typing import TYPE_CHECKING from .convert import convert +if TYPE_CHECKING: + from collections.abc import Mapping + from typing import Any -def get_env_var(key, as_type, env): - """ - Get the environment variable option. + from .convert import TypeData + + +def get_env_var(key: str, as_type: TypeData, env: Mapping[str, str]) -> tuple[Any, str] | None: + """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: + + :returns: the converted value and source, or None if not set + """ environ_key = f"VIRTUALENV_{key.upper()}" if env.get(environ_key): diff --git a/src/virtualenv/config/ini.py b/src/virtualenv/config/ini.py index ed0a1b930..67695d3ee 100644 --- a/src/virtualenv/config/ini.py +++ b/src/virtualenv/config/ini.py @@ -4,12 +4,18 @@ import os from configparser import ConfigParser from pathlib import Path -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from platformdirs import user_config_dir from .convert import convert +if TYPE_CHECKING: + from collections.abc import Mapping + from typing import Any + + from .convert import TypeData + LOGGER = logging.getLogger(__name__) @@ -19,7 +25,7 @@ class IniConfig: section = "virtualenv" - def __init__(self, env=None) -> None: + def __init__(self, env: Mapping[str, str] | None = 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 @@ -48,11 +54,11 @@ def __init__(self, env=None) -> None: if exception is not None: LOGGER.error("failed to read config file %s because %r", config_file, exception) - def _load(self): + def _load(self) -> None: 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): + def get(self, key: str, as_type: TypeData) -> tuple[Any, str] | None: cache_key = key, as_type if cache_key in self._cache: return self._cache[cache_key] @@ -70,7 +76,7 @@ def __bool__(self) -> bool: return bool(self.has_config_file) and bool(self.has_virtualenv_section) @property - def epilog(self): + def epilog(self) -> str: 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/creator.py b/src/virtualenv/create/creator.py index 1e577ada5..9fa60d30a 100644 --- a/src/virtualenv/create/creator.py +++ b/src/virtualenv/create/creator.py @@ -10,10 +10,21 @@ from ast import literal_eval from collections import OrderedDict from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from argparse import ArgumentParser + from typing import Any, NoReturn + + from python_discovery import PythonInfo + + from virtualenv.app_data.base import AppData + from virtualenv.config.cli.parser import VirtualEnvOptions + +from os.path import commonpath -from virtualenv.discovery.cached_py_info import LogCmd from virtualenv.util.path import safe_delete -from virtualenv.util.subprocess import run_cmd +from virtualenv.util.subprocess import LogCmd, run_cmd from virtualenv.version import __version__ from .pyenv_cfg import PyEnvCfg @@ -31,12 +42,12 @@ def __init__(self) -> None: class Creator(ABC): """A class that given a python Interpreter creates a virtual environment.""" - def __init__(self, options, interpreter) -> None: - """ - Construct a new virtual environment creator. + def __init__(self, options: VirtualEnvOptions, interpreter: PythonInfo) -> 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 + """ self.interpreter = interpreter self._debug = None @@ -46,11 +57,35 @@ def __init__(self, options, interpreter) -> None: self.pyenv_cfg = PyEnvCfg.from_folder(self.dest) self.app_data = options.app_data self.env = options.env + self.prompt = getattr(options, "prompt", None) + + if TYPE_CHECKING: + + @property + def exe(self) -> Path: ... + + @property + def env_name(self) -> str: ... + + @property + def bin_dir(self) -> Path: ... + + @property + def script_dir(self) -> Path: ... + + @property + def libs(self) -> list[Path]: ... + + @property + def purelib(self) -> Path: ... + + @property + def platlib(self) -> Path: ... def __repr__(self) -> str: return f"{self.__class__.__name__}({', '.join(f'{k}={v}' for k, v in self._args())})" - def _args(self): + def _args(self) -> list[tuple[str, Any]]: return [ ("dest", str(self.dest)), ("clear", self.clear), @@ -58,25 +93,32 @@ def _args(self): ] @classmethod - def can_create(cls, interpreter): # noqa: ARG003 - """ - Determine if we can create a virtual environment. + def can_create(cls, interpreter: PythonInfo) -> CreatorMeta | bool | None: # 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 \ - :meth:`add_parser_arguments` + + :returns: ``None`` if we can't create, any other object otherwise that will be forwarded to + :meth:`add_parser_arguments` + """ return True @classmethod - def add_parser_arguments(cls, parser, interpreter, meta, app_data): # noqa: ARG003 - """ - Add CLI arguments for the creator. + def add_parser_arguments( + cls, + parser: ArgumentParser, + interpreter: PythonInfo, # noqa: ARG003 + meta: CreatorMeta, # noqa: ARG003 + app_data: AppData, # noqa: ARG003 + ) -> None: + """Add CLI arguments for the creator. :param parser: the CLI parser :param app_data: the application data folder :param interpreter: the interpreter we're asked to create virtual environment for :param meta: value as returned by :meth:`can_create` + """ parser.add_argument( "dest", @@ -99,16 +141,16 @@ def add_parser_arguments(cls, parser, interpreter, meta, app_data): # noqa: ARG ) @abstractmethod - def create(self): + def create(self) -> None: """Perform the virtual environment creation.""" raise NotImplementedError @classmethod - def validate_dest(cls, raw_value): # noqa: C901 + def validate_dest(cls, raw_value: str) -> str: # 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])) + def non_write_able(dest: Path, value: Path) -> NoReturn: + common = Path(commonpath([str(value), str(dest)])) msg = f"the destination {dest.relative_to(common)} is not write-able at {common}" raise ArgumentTypeError(msg) @@ -153,7 +195,7 @@ def non_write_able(dest, value): dest = base return str(value) - def run(self): + def run(self) -> None: if self.dest.exists() and self.clear: LOGGER.debug("delete %s", self.dest) safe_delete(self.dest) @@ -163,7 +205,7 @@ def run(self): if not self.no_vcs_ignore: self.setup_ignore_vcs() - def add_cachedir_tag(self): + def add_cachedir_tag(self) -> None: """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(): @@ -175,14 +217,22 @@ def add_cachedir_tag(self): """).strip() cachedir_tag_file.write_text(cachedir_tag_text, encoding="utf-8") - def set_pyenv_cfg(self): + def set_pyenv_cfg(self) -> None: self.pyenv_cfg.content = OrderedDict() - self.pyenv_cfg["home"] = os.path.dirname(os.path.abspath(self.interpreter.system_executable)) + system_executable = self.interpreter.system_executable or self.interpreter.executable + assert system_executable is not None # noqa: S101 + self.pyenv_cfg["home"] = os.path.dirname(os.path.abspath(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["version"] = ".".join(str(i) for i in self.interpreter.version_info[:3]) + self.pyenv_cfg["executable"] = os.path.realpath(system_executable) + self.pyenv_cfg["command"] = f"{sys.executable} -m virtualenv {self.dest}" self.pyenv_cfg["virtualenv"] = __version__ + if self.prompt is not None: + prompt_value = os.path.basename(os.getcwd()) if self.prompt == "." else self.prompt + self.pyenv_cfg["prompt"] = prompt_value - def setup_ignore_vcs(self): + def setup_ignore_vcs(self) -> None: """Generate ignore instructions for version control systems.""" # mark this folder to be ignored by VCS, handle https://www.python.org/dev/peps/pep-0610/#registered-vcs git_ignore = self.dest / ".gitignore" @@ -195,18 +245,18 @@ def setup_ignore_vcs(self): # Subversion - does not support ignore files, requires direct manipulation with the svn tool @property - def debug(self): - """:return: debug information about the virtual environment (only valid after :meth:`create` has run)""" + def debug(self) -> dict[str, Any] | None: + """:returns: 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 @staticmethod - def debug_script(): + def debug_script() -> Path: return DEBUG_SCRIPT -def get_env_debug_info(env_exe, debug_script, app_data, env): +def get_env_debug_info(env_exe: Path, debug_script: Path, app_data: AppData, env: dict[str, str]) -> dict[str, Any]: env = env.copy() env.pop("PYTHONPATH", None) diff --git a/src/virtualenv/create/debug.py b/src/virtualenv/create/debug.py index 8a4845e98..2590fe136 100644 --- a/src/virtualenv/create/debug.py +++ b/src/virtualenv/create/debug.py @@ -5,7 +5,7 @@ import sys # built-in -def encode_path(value): +def encode_path(value: object) -> str | None: if value is None: return None if not isinstance(value, (str, bytes)): @@ -15,19 +15,20 @@ def encode_path(value): return value -def encode_list_path(value): +def encode_list_path(value: list[object]) -> list[str | None]: return [encode_path(i) for i in value] -def run(): +def run() -> None: # noqa: C901,PLR0915 """Print debug data about the virtual environment.""" try: from collections import OrderedDict # noqa: PLC0415 - except ImportError: # pragma: no cover - # this is possible if the standard library cannot be accessed - OrderedDict = dict # pragma: no cover # noqa: N806 - result = OrderedDict([("sys", OrderedDict())]) + DictType = OrderedDict # noqa: N806 + except ImportError: # pragma: no cover + DictType = dict # pragma: no cover # noqa: N806 + sys_info: dict[str, str | list[str | None] | None] = DictType() + result: dict[str, str | dict[str, str | list[str | None] | None] | None] = DictType([("sys", sys_info)]) path_keys = ( "executable", "_base_executable", @@ -42,9 +43,9 @@ def run(): for key in path_keys: value = getattr(sys, key, None) 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) + sys_info[key] = value + sys_info["fs_encoding"] = sys.getfilesystemencoding() + sys_info["io_encoding"] = getattr(sys.stdout, "encoding", None) result["version"] = sys.version try: @@ -52,7 +53,8 @@ def run(): # https://bugs.python.org/issue22199 makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None)) - result["makefile_filename"] = encode_path(makefile()) + if makefile is not None: + result["makefile_filename"] = encode_path(makefile()) except ImportError: pass diff --git a/src/virtualenv/create/describe.py b/src/virtualenv/create/describe.py index 1ee250cbc..a10ffd17b 100644 --- a/src/virtualenv/create/describe.py +++ b/src/virtualenv/create/describe.py @@ -3,16 +3,22 @@ from abc import ABC from collections import OrderedDict from pathlib import Path +from typing import TYPE_CHECKING from virtualenv.info import IS_WIN +if TYPE_CHECKING: + from typing import Any + + from python_discovery import PythonInfo + 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) -> None: + def __init__(self, dest: Path, interpreter: PythonInfo) -> None: self.interpreter = interpreter self.dest = dest self._stdlib = None @@ -21,84 +27,84 @@ def __init__(self, dest, interpreter) -> None: self._conf_vars = None @property - def bin_dir(self): + def bin_dir(self) -> Path: return self.script_dir @property - def script_dir(self): + def script_dir(self) -> Path: return self.dest / self.interpreter.install_path("scripts") @property - def purelib(self): + def purelib(self) -> Path: return self.dest / self.interpreter.install_path("purelib") @property - def platlib(self): + def platlib(self) -> Path: return self.dest / self.interpreter.install_path("platlib") @property - def libs(self): + def libs(self) -> list[Path]: return list(OrderedDict(((self.platlib, None), (self.purelib, None))).keys()) @property - def stdlib(self): + def stdlib(self) -> Path: if self._stdlib is None: self._stdlib = Path(self.interpreter.sysconfig_path("stdlib", config_var=self._config_vars)) return self._stdlib @property - def stdlib_platform(self): + def stdlib_platform(self) -> Path: if self._stdlib_platform is None: self._stdlib_platform = Path(self.interpreter.sysconfig_path("platstdlib", config_var=self._config_vars)) return self._stdlib_platform @property - def _config_vars(self): + def _config_vars(self) -> dict[str, Any]: if self._conf_vars is None: self._conf_vars = self._calc_config_vars(self.dest) return self._conf_vars - def _calc_config_vars(self, to): + def _calc_config_vars(self, to: Path) -> dict[str, Any]: 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()} + return { + k: (to if isinstance(v, str) and v.startswith(self.interpreter.prefix) else v) for k, v in sys_vars.items() + } @classmethod - def can_describe(cls, interpreter): # noqa: ARG003 + def can_describe(cls, interpreter: PythonInfo) -> bool: # noqa: ARG003 """Knows means it knows how the output will look.""" return True @property - def env_name(self): + def env_name(self) -> str: return self.dest.parts[-1] @property - def exe(self): + def exe(self) -> Path: return self.bin_dir / f"{self.exe_stem()}{self.suffix}" @classmethod - def exe_stem(cls): + def exe_stem(cls) -> str: """Executable name without suffix - there seems to be no standard way to get this without creating it.""" raise NotImplementedError - def script(self, name): + def script(self, name: str) -> Path: return self.script_dir / f"{name}{self.suffix}" class Python3Supports(Describe, ABC): - @classmethod - def can_describe(cls, interpreter): - return interpreter.version_info.major == 3 and super().can_describe(interpreter) # noqa: PLR2004 + pass class PosixSupports(Describe, ABC): @classmethod - def can_describe(cls, interpreter): + def can_describe(cls, interpreter: PythonInfo) -> bool: return interpreter.os == "posix" and super().can_describe(interpreter) class WindowsSupports(Describe, ABC): @classmethod - def can_describe(cls, interpreter): + def can_describe(cls, interpreter: PythonInfo) -> bool: return interpreter.os == "nt" and super().can_describe(interpreter) diff --git a/src/virtualenv/create/pyenv_cfg.py b/src/virtualenv/create/pyenv_cfg.py index fecd0ff29..6901850fd 100644 --- a/src/virtualenv/create/pyenv_cfg.py +++ b/src/virtualenv/create/pyenv_cfg.py @@ -3,61 +3,70 @@ import logging import os from collections import OrderedDict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path LOGGER = logging.getLogger(__name__) class PyEnvCfg: - def __init__(self, content, path) -> None: + def __init__(self, content: OrderedDict[str, str], path: Path) -> None: self.content = content self.path = path @classmethod - def from_folder(cls, folder): + def from_folder(cls, folder: Path) -> PyEnvCfg: return cls.from_file(folder / "pyvenv.cfg") @classmethod - def from_file(cls, path): + def from_file(cls, path: Path) -> PyEnvCfg: content = cls._read_values(path) if path.exists() else OrderedDict() return PyEnvCfg(content, path) @staticmethod - def _read_values(path): + def _read_values(path: Path) -> OrderedDict[str, str]: content = OrderedDict() for line in path.read_text(encoding="utf-8").splitlines(): equals_at = line.index("=") key = line[:equals_at].strip() value = line[equals_at + 1 :].strip() + if len(value) > 1 and value[0] in {"'", '"'} and value[0] == value[-1]: + value = value[1:-1] content[key] = value return content - def write(self): + def write(self) -> None: LOGGER.debug("write %s", self.path) text = "" for key, value in self.content.items(): # Use abspath to normalize relative paths but preserve symlinks (match venv behavior) # See issue #2770 - realpath resolves symlinks which breaks prefix symlinks - normalized_value = os.path.abspath(value) if value and os.path.exists(value) else value + if key == "prompt" and value: + normalized_value = f'"{value}"' + else: + normalized_value = os.path.abspath(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") - def refresh(self): + def refresh(self) -> OrderedDict[str, str]: self.content = self._read_values(self.path) return self.content - def __setitem__(self, key, value) -> None: + def __setitem__(self, key: str, value: str) -> None: self.content[key] = value - def __getitem__(self, key): + def __getitem__(self, key: str) -> str: return self.content[key] - def __contains__(self, item) -> bool: + def __contains__(self, item: str) -> bool: return item in self.content - def update(self, other): + def update(self, other: dict[str, str]) -> PyEnvCfg: self.content.update(other) return self diff --git a/src/virtualenv/create/via_global_ref/_virtualenv.py b/src/virtualenv/create/via_global_ref/_virtualenv.py index 0d95b28e0..f63332ce5 100644 --- a/src/virtualenv/create/via_global_ref/_virtualenv.py +++ b/src/virtualenv/create/via_global_ref/_virtualenv.py @@ -5,21 +5,26 @@ import contextlib import os import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import types + from collections.abc import Callable + from importlib.machinery import ModuleSpec VIRTUALENV_PATCH_FILE = os.path.abspath(__file__) -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. +def patch_dist(dist: types.ModuleType) -> None: + """Distutils allows user to configure some arguments via a configuration file: 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 - def parse_config_files(self, *args, **kwargs): + def parse_config_files(self, *args: object, **kwargs: object) -> object: # noqa: ANN001 result = old_parse_config_files(self, *args, **kwargs) install = self.get_option_dict("install") @@ -50,7 +55,7 @@ class _Finder: # See https://github.com/pypa/virtualenv/issues/1895 for details. lock = [] # noqa: RUF012 - def find_spec(self, fullname, path, target=None): # noqa: ARG002 + def find_spec(self, fullname: str, path: object, target: object = None) -> ModuleSpec | None: # noqa: ARG002 # Guard against race conditions during file rewrite by checking if _DISTUTILS_PATCH is defined. # This can happen when the file is being overwritten while it's being imported by another process. # See https://github.com/pypa/virtualenv/issues/2969 for details. @@ -77,7 +82,7 @@ def find_spec(self, fullname, path, target=None): # noqa: ARG002 with self.lock[0]: self.fullname = fullname try: - spec = find_spec(fullname, path) + spec = find_spec(fullname, path) # ty: ignore[invalid-argument-type] 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") @@ -95,7 +100,7 @@ def find_spec(self, fullname, path, target=None): # noqa: ARG002 return None @staticmethod - def exec_module(old, module): + def exec_module(old: Callable[..., object], module: types.ModuleType) -> None: old(module) try: distutils_patch = _DISTUTILS_PATCH @@ -107,7 +112,7 @@ def exec_module(old, module): patch_dist(module) @staticmethod - def load_module(old, name): + def load_module(old: Callable[..., types.ModuleType], name: str) -> types.ModuleType: module = old(name) try: distutils_patch = _DISTUTILS_PATCH diff --git a/src/virtualenv/create/via_global_ref/api.py b/src/virtualenv/create/via_global_ref/api.py index 3f04f4653..5cb8e1297 100644 --- a/src/virtualenv/create/via_global_ref/api.py +++ b/src/virtualenv/create/via_global_ref/api.py @@ -4,10 +4,20 @@ import os from abc import ABC from pathlib import Path +from typing import TYPE_CHECKING from virtualenv.create.creator import Creator, CreatorMeta from virtualenv.info import fs_supports_symlink +if TYPE_CHECKING: + from argparse import ArgumentParser + from typing import Any + + from python_discovery import PythonInfo + + from virtualenv.app_data.base import AppData + from virtualenv.config.cli.parser import VirtualEnvOptions + LOGGER = logging.getLogger(__name__) @@ -20,22 +30,30 @@ def __init__(self) -> None: self.symlink_error = "the filesystem does not supports symlink" @property - def can_copy(self): + def can_copy(self) -> bool: return not self.copy_error @property - def can_symlink(self): + def can_symlink(self) -> bool: return not self.symlink_error class ViaGlobalRefApi(Creator, ABC): - def __init__(self, options, interpreter) -> None: + def __init__(self, options: VirtualEnvOptions, interpreter: PythonInfo) -> None: super().__init__(options, interpreter) self.symlinks = self._should_symlink(options) self.enable_system_site_package = options.system_site + if TYPE_CHECKING: + + @property + def purelib(self) -> Path: ... + + @property + def script_dir(self) -> Path: ... + @staticmethod - def _should_symlink(options): + def _should_symlink(options: VirtualEnvOptions) -> bool: # Priority of where the option is set to follow the order: CLI, env var, file, hardcoded. # If both set at same level prefers copy over symlink. copies, symlinks = getattr(options, "copies", False), getattr(options, "symlinks", False) @@ -52,7 +70,9 @@ def _should_symlink(options): return False # fallback to copy @classmethod - def add_parser_arguments(cls, parser, interpreter, meta, app_data): + def add_parser_arguments( + cls, parser: ArgumentParser, interpreter: PythonInfo, meta: ViaGlobalRefMeta, app_data: AppData + ) -> None: # ty: ignore[invalid-method-override] super().add_parser_arguments(parser, interpreter, meta, app_data) parser.add_argument( "--system-site-packages", @@ -88,10 +108,10 @@ def add_parser_arguments(cls, parser, interpreter, meta, app_data): help="try to use copies rather than symlinks, even when symlinks are the default for the platform", ) - def create(self): + def create(self) -> None: self.install_patch() - def install_patch(self): + def install_patch(self) -> None: text = self.env_patch_text() if text: pth = self.purelib / "_virtualenv.pth" @@ -101,16 +121,17 @@ def install_patch(self): LOGGER.debug("create %s", dest_path) dest_path.write_text(text, encoding="utf-8") - def env_patch_text(self): + def env_patch_text(self) -> str: """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(encoding="utf-8") + # script_dir and purelib are defined in subclasses return text.replace('"__SCRIPT_DIR__"', repr(os.path.relpath(str(self.script_dir), str(self.purelib)))) - def _args(self): + def _args(self) -> list[tuple[str, Any]]: return [*super()._args(), ("global", self.enable_system_site_package)] - def set_pyenv_cfg(self): + def set_pyenv_cfg(self) -> None: super().set_pyenv_cfg() self.pyenv_cfg["include-system-site-packages"] = "true" if self.enable_system_site_package else "false" 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 791b1d93d..005c23640 100644 --- a/src/virtualenv/create/via_global_ref/builtin/builtin_way.py +++ b/src/virtualenv/create/via_global_ref/builtin/builtin_way.py @@ -1,15 +1,21 @@ from __future__ import annotations from abc import ABC +from typing import TYPE_CHECKING from virtualenv.create.creator import Creator from virtualenv.create.describe import Describe +if TYPE_CHECKING: + from python_discovery import PythonInfo + + from virtualenv.config.cli.parser import VirtualEnvOptions + 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) -> None: + def __init__(self, options: VirtualEnvOptions, interpreter: PythonInfo) -> None: Creator.__init__(self, options, interpreter) Describe.__init__(self, self.dest, interpreter) 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 5cc993bda..7fba31c3e 100644 --- a/src/virtualenv/create/via_global_ref/builtin/cpython/common.py +++ b/src/virtualenv/create/via_global_ref/builtin/cpython/common.py @@ -4,19 +4,25 @@ from abc import ABC from collections import OrderedDict from pathlib import Path +from typing import TYPE_CHECKING from virtualenv.create.describe import PosixSupports, WindowsSupports from virtualenv.create.via_global_ref.builtin.ref import RefMust, RefWhen from virtualenv.create.via_global_ref.builtin.via_global_self_do import ViaGlobalRefVirtualenvBuiltin +if TYPE_CHECKING: + from collections.abc import Generator + + from python_discovery import PythonInfo + class CPython(ViaGlobalRefVirtualenvBuiltin, ABC): @classmethod - def can_describe(cls, interpreter): + def can_describe(cls, interpreter: PythonInfo) -> bool: return interpreter.implementation == "CPython" and super().can_describe(interpreter) @classmethod - def exe_stem(cls): + def exe_stem(cls) -> str: return "python" @@ -24,41 +30,58 @@ 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", f"python{major}", f"python{major}.{minor}", host_exe.name]) + def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], str, str]]: + host_exe = Path(interpreter.system_executable) # ty: ignore[invalid-argument-type] + minor = interpreter.version_info.minor + names = [ + "python", + "python3", + f"python3.{minor}", + *((f"python3.{minor}t",) if interpreter.free_threaded else ()), + host_exe.name, + ] + targets = OrderedDict((i, None) for i in names) yield host_exe, list(targets.keys()), RefMust.NA, RefWhen.ANY class CPythonWindows(CPython, WindowsSupports, ABC): @classmethod - def _executables(cls, interpreter): + def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], str, str]]: # symlink of the python executables does not work reliably, copy always instead # - https://bugs.python.org/issue42013 # - venv host = cls.host_python(interpreter) - names = {"python.exe", host.name} - if interpreter.version_info.major == 3: # noqa: PLR2004 - names.update({"python3.exe", "python3"}) + minor = interpreter.version_info.minor + names = { + "python.exe", + "python3.exe", + "python3", + host.name, + *((f"python3.{minor}t.exe",) if interpreter.free_threaded else ()), + } for path in (host.parent / n for n in names): yield host, [path.name], RefMust.COPY, RefWhen.ANY # for more info on pythonw.exe see https://stackoverflow.com/a/30313091 python_w = host.parent / "pythonw.exe" - yield python_w, [python_w.name], RefMust.COPY, RefWhen.ANY + yield ( + python_w, + [python_w.name, "pythonw3.exe", *((f"pythonw3.{minor}t.exe",) if interpreter.free_threaded else ())], + RefMust.COPY, + RefWhen.ANY, + ) @classmethod - def host_python(cls, interpreter): - return Path(interpreter.system_executable) + def host_python(cls, interpreter: PythonInfo) -> Path: + return Path(interpreter.system_executable) # ty: ignore[invalid-argument-type] -def is_mac_os_framework(interpreter): +def is_mac_os_framework(interpreter: PythonInfo) -> bool: if interpreter.platform == "darwin": return interpreter.sysconfig_vars.get("PYTHONFRAMEWORK") == "Python3" return False -def is_macos_brew(interpreter): +def is_macos_brew(interpreter: PythonInfo) -> bool: return interpreter.platform == "darwin" and _BREW.fullmatch(interpreter.system_prefix) is not None 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 6616b241d..f66f28429 100644 --- a/src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py +++ b/src/virtualenv/create/via_global_ref/builtin/cpython/cpython3.py @@ -6,13 +6,25 @@ from operator import methodcaller as method from pathlib import Path from textwrap import dedent +from typing import TYPE_CHECKING from virtualenv.create.describe import Python3Supports -from virtualenv.create.via_global_ref.builtin.ref import ExePathRefToDest, PathRefToDest +from virtualenv.create.via_global_ref.builtin.ref import ExePathRefToDest, PathRefToDest, RefWhen from virtualenv.create.via_global_ref.store import is_store_python +from virtualenv.util.path import copy as copy_path +from virtualenv.util.path import ensure_dir from .common import CPython, CPythonPosix, CPythonWindows, is_mac_os_framework, is_macos_brew +if TYPE_CHECKING: + from collections.abc import Generator + + from python_discovery import PythonInfo + + from virtualenv.create.via_global_ref.builtin.ref import PathRef + from virtualenv.create.via_global_ref.builtin.via_global_self_do import BuiltinViaGlobalRefMeta + from virtualenv.create.via_global_ref.venv import Venv + class CPython3(CPython, Python3Supports, abc.ABC): """CPython 3 or later.""" @@ -20,14 +32,45 @@ class CPython3(CPython, Python3Supports, abc.ABC): class CPython3Posix(CPythonPosix, CPython3): @classmethod - def can_describe(cls, interpreter): + def can_describe(cls, interpreter: PythonInfo) -> bool: 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): + @classmethod + def sources(cls, interpreter: PythonInfo) -> Generator[PathRef]: # ty: ignore[invalid-method-override] + yield from super().sources(interpreter) + if shared_lib := cls._shared_libpython(interpreter): + yield PathRefToDest(shared_lib, dest=cls._to_lib, when=RefWhen.COPY) + + @classmethod + def _to_lib(cls, creator: CPython3Posix, src: Path) -> Path: + return creator.dest / "lib" / src.name + + @classmethod + def _shared_libpython(cls, interpreter: PythonInfo) -> Path | None: + if not interpreter.sysconfig_vars.get("Py_ENABLE_SHARED"): + return None + if not (instsoname := interpreter.sysconfig_vars.get("INSTSONAME")): + return None + if not (libdir := interpreter.sysconfig_vars.get("LIBDIR")): + return None + if not (lib_path := Path(libdir) / instsoname).exists(): + return None + return lib_path + + def install_venv_shared_libs(self, venv_creator: Venv) -> None: + if venv_creator.symlinks: + return + if not (shared_lib := self._shared_libpython(venv_creator.interpreter)): + return + dest = venv_creator.dest / "lib" / shared_lib.name + ensure_dir(dest.parent) + copy_path(shared_lib, dest) + + def env_patch_text(self) -> str: text = super().env_patch_text() if self.pyvenv_launch_patch_active(self.interpreter): text += dedent( @@ -41,7 +84,7 @@ def env_patch_text(self): return text @classmethod - def pyvenv_launch_patch_active(cls, interpreter): + def pyvenv_launch_patch_active(cls, interpreter: PythonInfo) -> bool: ver = interpreter.version_info return interpreter.platform == "darwin" and ((3, 7, 8) > ver >= (3, 7) or (3, 8, 3) > ver >= (3, 8)) @@ -50,32 +93,32 @@ class CPython3Windows(CPythonWindows, CPython3): """CPython 3 on Windows.""" @classmethod - def setup_meta(cls, interpreter): + def setup_meta(cls, interpreter: PythonInfo) -> BuiltinViaGlobalRefMeta | None: # ty: ignore[invalid-method-override] if is_store_python(interpreter): # store python is not supported here return None return super().setup_meta(interpreter) @classmethod - def sources(cls, interpreter): + def sources(cls, interpreter: PythonInfo) -> Generator[PathRef]: # ty: ignore[invalid-method-override] if cls.has_shim(interpreter): refs = cls.executables(interpreter) else: refs = chain( - cls.executables(interpreter), + cls.executables(interpreter), # ty: ignore[invalid-argument-type] cls.dll_and_pyd(interpreter), cls.python_zip(interpreter), ) yield from refs @classmethod - def executables(cls, interpreter): + def executables(cls, interpreter: PythonInfo) -> list[PathRef] | Generator[PathRef]: sources = super().sources(interpreter) if interpreter.version_info >= (3, 13): - # Create new refs with corrected launcher paths + t_suffix = "t" if interpreter.free_threaded else "" updated_sources = [] for ref in sources: if ref.src.name == "python.exe": - launcher_path = ref.src.with_name("venvlauncher.exe") + launcher_path = ref.src.with_name(f"venvlauncher{t_suffix}.exe") if launcher_path.exists(): new_ref = ExePathRefToDest( launcher_path, dest=ref.dest, targets=[ref.base, *ref.aliases], must=ref.must, when=ref.when @@ -83,7 +126,7 @@ def executables(cls, interpreter): updated_sources.append(new_ref) continue elif ref.src.name == "pythonw.exe": - w_launcher_path = ref.src.with_name("venvwlauncher.exe") + w_launcher_path = ref.src.with_name(f"venvwlauncher{t_suffix}.exe") if w_launcher_path.exists(): new_ref = ExePathRefToDest( w_launcher_path, @@ -94,37 +137,38 @@ def executables(cls, interpreter): ) updated_sources.append(new_ref) continue - # Keep the original ref unchanged updated_sources.append(ref) return updated_sources return sources @classmethod - def has_shim(cls, interpreter): + def has_shim(cls, interpreter: PythonInfo) -> bool: return interpreter.version_info.minor >= 7 and cls.shim(interpreter) is not None # noqa: PLR2004 @classmethod - def shim(cls, interpreter): + def shim(cls, interpreter: PythonInfo) -> Path | None: root = Path(interpreter.system_stdlib) / "venv" / "scripts" / "nt" - # Before 3.13 the launcher was called python.exe, after is venvlauncher.exe - # https://github.com/python/cpython/issues/112984 - exe_name = "venvlauncher.exe" if interpreter.version_info >= (3, 13) else "python.exe" - shim = root / exe_name - if shim.exists(): + if interpreter.version_info >= (3, 13): + # https://github.com/python/cpython/issues/112984 + t_suffix = "t" if interpreter.free_threaded else "" + exe_name = f"venvlauncher{t_suffix}.exe" + else: + exe_name = "python.exe" + if (shim := root / exe_name).exists(): return shim return None @classmethod - def host_python(cls, interpreter): + def host_python(cls, interpreter: PythonInfo) -> Path: if cls.has_shim(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 cls.shim(interpreter) # ty: ignore[invalid-return-type] return super().host_python(interpreter) @classmethod - def dll_and_pyd(cls, interpreter): - folders = [Path(interpreter.system_executable).parent] + def dll_and_pyd(cls, interpreter: PythonInfo) -> Generator[PathRefToDest]: + folders = [Path(interpreter.system_executable).parent] # ty: ignore[invalid-argument-type] # May be missing on some Python hosts. # See https://github.com/pypa/virtualenv/issues/2368 @@ -143,32 +187,29 @@ def dll_and_pyd(cls, interpreter): yield PathRefToDest(file, cls.to_bin) @classmethod - def _is_pywin32_dll(cls, filename): + def _is_pywin32_dll(cls, filename: str) -> bool: """Check if a DLL file belongs to pywin32.""" # pywin32 DLLs follow patterns like: pywintypes39.dll, pythoncom39.dll name_lower = filename.lower() return name_lower.startswith(("pywintypes", "pythoncom")) @classmethod - def python_zip(cls, interpreter): + def python_zip(cls, interpreter: PythonInfo) -> Generator[PathRefToDest]: + """``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, ``discovery.py_info.PythonInfo`` class + (interpreter), and ``python -m sysconfig`` output. + + The embeddable Python distribution for Windows includes ``python{VERSION}.zip`` and ``python{VERSION}._pth`` + files. User can move/rename the zip file and edit ``sys.path`` by editing the ``_pth`` file. Here the + ``pattern`` is used only for the default zip file name. + """ - "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: + if (path := next(existing_paths, None)) is not None: yield PathRefToDest(path, cls.to_bin) 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 0ddbf9a33..19d9a782b 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 @@ -9,6 +9,7 @@ from abc import ABC, abstractmethod from pathlib import Path from textwrap import dedent +from typing import TYPE_CHECKING from virtualenv.create.via_global_ref.builtin.ref import ( ExePathRefToDest, @@ -20,18 +21,25 @@ from .common import CPython, CPythonPosix, is_mac_os_framework, is_macos_brew from .cpython3 import CPython3 +if TYPE_CHECKING: + from collections.abc import Callable, Generator + from io import BufferedRandom + + from python_discovery import PythonInfo + + from virtualenv.create.via_global_ref.builtin.ref import PathRef + LOGGER = logging.getLogger(__name__) class CPythonmacOsFramework(CPython, ABC): @classmethod - def can_describe(cls, interpreter): + def can_describe(cls, interpreter: PythonInfo) -> bool: return is_mac_os_framework(interpreter) and super().can_describe(interpreter) - def create(self): + def create(self) -> None: 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: @@ -40,44 +48,48 @@ def create(self): 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) + fix_mach_o(str(exe), current, target, self.interpreter.max_size) # ty: ignore[invalid-argument-type] + try: + subprocess.check_call(["codesign", "--force", "--sign", "-", str(exe)]) # noqa: S607 + except (OSError, subprocess.CalledProcessError) as e: + LOGGER.warning("Could not ad-hoc re-sign %s: %s", exe, e) @classmethod - def _executables(cls, interpreter): + def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], str, str]]: 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 - fixed_host_exe = Path(interpreter.prefix) / "Resources" / "Python.app" / "Contents" / "MacOS" / "Python" + fixed_host_exe = Path(interpreter.prefix) / "Resources" / "Python.app" / "Contents" / "MacOS" / "Python" # ty: ignore[invalid-argument-type] yield fixed_host_exe, targets, must, when @abstractmethod - def current_mach_o_image_path(self): + def current_mach_o_image_path(self) -> str: raise NotImplementedError @abstractmethod - def desired_mach_o_image_path(self): + def desired_mach_o_image_path(self) -> str: raise NotImplementedError class CPython3macOsFramework(CPythonmacOsFramework, CPython3, CPythonPosix): - def current_mach_o_image_path(self): + def current_mach_o_image_path(self) -> str: return "@executable_path/../../../../Python3" - def desired_mach_o_image_path(self): + def desired_mach_o_image_path(self) -> str: return "@executable_path/../.Python" @classmethod - def sources(cls, interpreter): + def sources(cls, interpreter: PythonInfo) -> Generator[PathRef]: # ty: ignore[invalid-method-override] yield from super().sources(interpreter) # add a symlink to the host python image - exe = Path(interpreter.prefix) / "Python3" + exe = Path(interpreter.prefix) / "Python3" # ty: ignore[invalid-argument-type] yield PathRefToDest(exe, dest=lambda self, _: self.dest / ".Python", must=RefMust.SYMLINK) @property - def reload_code(self): - result = super().reload_code + def reload_code(self) -> str: + result = super().reload_code # ty: ignore[unresolved-attribute] return dedent( f""" # the bundled site.py always adds the global site package if we're on python framework build, escape this @@ -92,9 +104,8 @@ def reload_code(self): ) -def fix_mach_o(exe, current, new, max_size): - """ - https://en.wikipedia.org/wiki/Mach-O. +def fix_mach_o(exe: str, current: str, new: str, max_size: int) -> None: + """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 @@ -110,11 +121,12 @@ def fix_mach_o(exe, current, new, max_size): Lisp. With the introduction of Mac OS X 10.6 platform the Mach-O file underwent a significant modification that causes - binaries compiled on a computer running 10.6 or later to be (by default) executable only on computers running Mac - OS X 10.6 or later. The difference stems from load commands that the dynamic linker, in previous Mac OS X versions, + binaries compiled on a computer running 10.6 or later to be (by default) executable only on computers running Mac OS + X 10.6 or later. The difference stems from load commands that the dynamic linker, in previous Mac OS X versions, does not understand. Another significant change to the Mach-O format is the change in how the Link Edit tables (found in the __LINKEDIT section) function. In 10.6 these new Link Edit tables are compressed by removing unused and unneeded bits of information, however Mac OS X 10.5 and earlier cannot read this new Link Edit table format. + """ try: LOGGER.debug("change Mach-O for %s from %s to %s", exe, current, new) @@ -129,7 +141,7 @@ def fix_mach_o(exe, current, new, max_size): raise -def _builtin_change_mach_o(maxint): # noqa: C901 +def _builtin_change_mach_o(maxint: int) -> Callable[[str, str, str], None]: # noqa: C901 MH_MAGIC = 0xFEEDFACE # noqa: N806 MH_CIGAM = 0xCEFAEDFE # noqa: N806 MH_MAGIC_64 = 0xFEEDFACF # noqa: N806 @@ -142,7 +154,7 @@ def _builtin_change_mach_o(maxint): # noqa: C901 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) -> None: + def __init__(self, file_obj: FileView | BufferedRandom, start: int = 0, size: int = maxint) -> None: if isinstance(file_obj, FileView): self._file_obj = file_obj._file_obj # noqa: SLF001 else: @@ -154,15 +166,15 @@ def __init__(self, file_obj, start=0, size=maxint) -> None: def __repr__(self) -> str: return f"" - def tell(self): + def tell(self) -> int: return self._pos - def _checkwindow(self, seek_to, op): + def _checkwindow(self, seek_to: int, op: str) -> None: if not (self._start <= seek_to <= self._end): 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): + def seek(self, offset: int, whence: int = 0) -> None: seek_to = offset if whence == os.SEEK_SET: seek_to += self._start @@ -177,7 +189,7 @@ def seek(self, offset, whence=0): self._file_obj.seek(seek_to) self._pos = seek_to - self._start - def write(self, content): + def write(self, content: bytes) -> None: here = self._start + self._pos self._checkwindow(here, "write") self._checkwindow(here + len(content), "write") @@ -185,7 +197,7 @@ def write(self, content): self._file_obj.write(content) self._pos += len(content) - def read(self, size=maxint): + def read(self, size: int = maxint) -> bytes: assert size >= 0 # noqa: S101 here = self._start + self._pos self._checkwindow(here, "read") @@ -195,22 +207,19 @@ def read(self, size=maxint): self._pos += len(read_bytes) return read_bytes - def read_data(file, endian, num=1): + def read_data(file: FileView, endian: str, num: int = 1) -> int | tuple[int, ...]: """Read a given number of 32-bits unsigned integers from the given file with the given endianness.""" res = struct.unpack(endian + "L" * num, file.read(num * 4)) if len(res) == 1: return res[0] return res - 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 mach_o_change(at_path: str, what: str, value: str) -> None: # 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.""" - def do_macho(file, bits, endian): + def do_macho(file: FileView, bits: int, endian: str) -> None: # 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) # ty: ignore[not-iterable] # 64-bits header has one more field. if bits == 64: # noqa: PLR2004 read_data(file, endian) @@ -218,32 +227,32 @@ def do_macho(file, bits, endian): for _ in range(n_commands): where = file.tell() # Read command header - cmd, cmd_size = read_data(file, endian, 2) + cmd, cmd_size = read_data(file, endian, 2) # ty: ignore[not-iterable] if cmd == LC_LOAD_DYLIB: # The first data field in LC_LOAD_DYLIB commands is the offset of the name, starting from the # beginning of the command. name_offset = read_data(file, endian) - file.seek(where + name_offset, os.SEEK_SET) + file.seek(where + name_offset, os.SEEK_SET) # ty: ignore[unsupported-operator] # Read the NUL terminated string - load = file.read(cmd_size - name_offset).decode() + load = file.read(cmd_size - name_offset).decode() # ty: ignore[unsupported-operator] load = load[: load.index("\0")] # If the string is what is being replaced, overwrite it. if load == what: - file.seek(where + name_offset, os.SEEK_SET) + file.seek(where + name_offset, os.SEEK_SET) # ty: ignore[unsupported-operator] file.write(value.encode() + b"\0") # Seek to the next command file.seek(where + cmd_size, os.SEEK_SET) - def do_file(file, offset=0, size=maxint): + def do_file(file: FileView | BufferedRandom, offset: int = 0, size: int = maxint) -> None: file = FileView(file, offset, size) # Read magic number magic = read_data(file, BIG_ENDIAN) if magic == FAT_MAGIC: # Fat binaries contain nfat_arch Mach-O binaries n_fat_arch = read_data(file, BIG_ENDIAN) - for _ in range(n_fat_arch): + for _ in range(n_fat_arch): # ty: ignore[invalid-argument-type] # 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) # ty: ignore[not-iterable] do_file(file, offset, size) elif magic == MH_MAGIC: do_macho(file, 32, BIG_ENDIAN) @@ -264,11 +273,11 @@ def do_file(file, offset=0, size=maxint): class CPython3macOsBrew(CPython3, CPythonPosix): @classmethod - def can_describe(cls, interpreter): + def can_describe(cls, interpreter: PythonInfo) -> bool: return is_macos_brew(interpreter) and super().can_describe(interpreter) @classmethod - def setup_meta(cls, interpreter): # noqa: ARG003 + def setup_meta(cls, interpreter: PythonInfo) -> BuiltinViaGlobalRefMeta: # noqa: ARG003 meta = BuiltinViaGlobalRefMeta() meta.copy_error = "Brew disables copy creation: https://github.com/Homebrew/homebrew-core/issues/138159" return meta diff --git a/src/virtualenv/create/via_global_ref/builtin/graalpy/__init__.py b/src/virtualenv/create/via_global_ref/builtin/graalpy/__init__.py index 8bfe887e7..33bb22ded 100644 --- a/src/virtualenv/create/via_global_ref/builtin/graalpy/__init__.py +++ b/src/virtualenv/create/via_global_ref/builtin/graalpy/__init__.py @@ -1,24 +1,36 @@ from __future__ import annotations -from abc import ABC +from abc import ABC, abstractmethod from pathlib import Path +from typing import TYPE_CHECKING from virtualenv.create.describe import PosixSupports, WindowsSupports from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest, RefMust, RefWhen from virtualenv.create.via_global_ref.builtin.via_global_self_do import ViaGlobalRefVirtualenvBuiltin +if TYPE_CHECKING: + from collections.abc import Generator, Iterator + + from python_discovery import PythonInfo + class GraalPy(ViaGlobalRefVirtualenvBuiltin, ABC): @classmethod - def can_describe(cls, interpreter): + @abstractmethod + def _native_lib(cls, lib_dir: Path, platform: str) -> Path: + """Return the path to the native library for this platform.""" + raise NotImplementedError + + @classmethod + def can_describe(cls, interpreter: PythonInfo) -> bool: return interpreter.implementation == "GraalVM" and super().can_describe(interpreter) @classmethod - def exe_stem(cls): + def exe_stem(cls) -> str: return "graalpy" @classmethod - def exe_names(cls, interpreter): + def exe_names(cls, interpreter: PythonInfo) -> set[str]: return { cls.exe_stem(), "python", @@ -27,15 +39,15 @@ def exe_names(cls, interpreter): } @classmethod - def _executables(cls, interpreter): - host = Path(interpreter.system_executable) + def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], RefMust, RefWhen], None, None]: # ty: ignore[invalid-method-override] + host = Path(interpreter.system_executable) # ty: ignore[invalid-argument-type] targets = sorted(f"{name}{cls.suffix}" for name in cls.exe_names(interpreter)) yield host, targets, RefMust.NA, RefWhen.ANY @classmethod - def sources(cls, interpreter): + def sources(cls, interpreter: PythonInfo) -> Generator[PathRefToDest]: # ty: ignore[invalid-method-override] yield from super().sources(interpreter) - python_dir = Path(interpreter.system_executable).resolve().parent + python_dir = Path(interpreter.system_executable).resolve().parent # ty: ignore[invalid-argument-type] if python_dir.name in {"bin", "Scripts"}: python_dir = python_dir.parent @@ -49,14 +61,14 @@ def sources(cls, interpreter): yield PathRefToDest(jvm_dir, dest=lambda self, s: self.bin_dir.parent / s.name) @classmethod - def _shared_libs(cls, python_dir): + def _shared_libs(cls, python_dir: Path) -> Iterator[Path]: raise NotImplementedError - def set_pyenv_cfg(self): + def set_pyenv_cfg(self) -> None: super().set_pyenv_cfg() # GraalPy 24.0 and older had home without the bin version = self.interpreter.version_info - if version.major == 3 and version.minor <= 10: # noqa: PLR2004 + if version.minor <= 10: # noqa: PLR2004 home = Path(self.pyenv_cfg["home"]) if home.name == "bin": self.pyenv_cfg["home"] = str(home.parent) @@ -64,7 +76,7 @@ def set_pyenv_cfg(self): class GraalPyPosix(GraalPy, PosixSupports): @classmethod - def _native_lib(cls, lib_dir, platform): + def _native_lib(cls, lib_dir: Path, platform: str) -> Path: if platform == "darwin": return lib_dir / "libpythonvm.dylib" return lib_dir / "libpythonvm.so" @@ -72,13 +84,13 @@ def _native_lib(cls, lib_dir, platform): class GraalPyWindows(GraalPy, WindowsSupports): @classmethod - def _native_lib(cls, lib_dir, _platform): + def _native_lib(cls, lib_dir: Path, platform: str) -> Path: # noqa: ARG003 return lib_dir / "pythonvm.dll" - def set_pyenv_cfg(self): + def set_pyenv_cfg(self) -> None: # GraalPy needs an additional entry in pyvenv.cfg on Windows super().set_pyenv_cfg() - self.pyenv_cfg["venvlauncher_command"] = self.interpreter.system_executable + self.pyenv_cfg["venvlauncher_command"] = self.interpreter.system_executable # ty: ignore[invalid-assignment] __all__ = [ 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 ca4b45ff1..4bf391107 100644 --- a/src/virtualenv/create/via_global_ref/builtin/pypy/common.py +++ b/src/virtualenv/create/via_global_ref/builtin/pypy/common.py @@ -2,28 +2,36 @@ import abc from pathlib import Path +from typing import TYPE_CHECKING from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest, RefMust, RefWhen from virtualenv.create.via_global_ref.builtin.via_global_self_do import ViaGlobalRefVirtualenvBuiltin +if TYPE_CHECKING: + from collections.abc import Generator, Iterator + + from python_discovery import PythonInfo + + from virtualenv.create.via_global_ref.builtin.ref import PathRef + class PyPy(ViaGlobalRefVirtualenvBuiltin, abc.ABC): @classmethod - def can_describe(cls, interpreter): + def can_describe(cls, interpreter: PythonInfo) -> bool: return interpreter.implementation == "PyPy" and super().can_describe(interpreter) @classmethod - def _executables(cls, interpreter): - host = Path(interpreter.system_executable) + def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], str, str]]: + host = Path(interpreter.system_executable) # ty: ignore[invalid-argument-type] 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): + def executables(cls, interpreter: PythonInfo) -> Generator[PathRef]: yield from super().sources(interpreter) @classmethod - def exe_names(cls, interpreter): + def exe_names(cls, interpreter: PythonInfo) -> set[str]: return { cls.exe_stem(), "python", @@ -32,19 +40,19 @@ def exe_names(cls, interpreter): } @classmethod - def sources(cls, interpreter): + def sources(cls, interpreter: PythonInfo) -> Generator[PathRef]: # ty: ignore[invalid-method-override] 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) @classmethod - def _add_shared_libs(cls, interpreter): + def _add_shared_libs(cls, interpreter: PythonInfo) -> Generator[Path]: # https://bitbucket.org/pypy/pypy/issue/1922/future-proofing-virtualenv - python_dir = Path(interpreter.system_executable).resolve().parent + python_dir = Path(interpreter.system_executable).resolve().parent # ty: ignore[invalid-argument-type] yield from cls._shared_libs(python_dir) @classmethod - def _shared_libs(cls, python_dir): + def _shared_libs(cls, python_dir: Path) -> Iterator[Path]: raise NotImplementedError 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 fa61ebc95..69a6e607a 100644 --- a/src/virtualenv/create/via_global_ref/builtin/pypy/pypy3.py +++ b/src/virtualenv/create/via_global_ref/builtin/pypy/pypy3.py @@ -2,20 +2,28 @@ import abc from pathlib import Path +from typing import TYPE_CHECKING from virtualenv.create.describe import PosixSupports, Python3Supports, WindowsSupports from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest from .common import PyPy +if TYPE_CHECKING: + from collections.abc import Generator, Iterator + + from python_discovery import PythonInfo + + from virtualenv.create.via_global_ref.builtin.ref import PathRef + class PyPy3(PyPy, Python3Supports, abc.ABC): @classmethod - def exe_stem(cls): + def exe_stem(cls) -> str: return "pypy3" @classmethod - def exe_names(cls, interpreter): + def exe_names(cls, interpreter: PythonInfo) -> set[str]: return super().exe_names(interpreter) | {"pypy"} @@ -23,50 +31,49 @@ class PyPy3Posix(PyPy3, PosixSupports): """PyPy 3 on POSIX.""" @classmethod - def _shared_libs(cls, python_dir): + def _shared_libs(cls, python_dir: Path) -> Iterator[Path]: # glob for libpypy3-c.so, libpypy3-c.dylib, libpypy3.9-c.so ... return python_dir.glob("libpypy3*.*") - def to_lib(self, src): + def to_lib(self, src: Path) -> Path: return self.dest / "lib" / src.name @classmethod - def sources(cls, interpreter): + def sources(cls, interpreter: PythonInfo) -> Generator[PathRef]: yield from super().sources(interpreter) - # PyPy >= 3.8 supports a standard prefix installation, where older - # versions always used a portable/development style installation. - # If this is a standard prefix installation, skip the below: + # PyPy >= 3.8 supports a standard prefix installation, where older versions always used a portable/development + # style installation. If this is a standard prefix installation, skip the below: if interpreter.system_prefix == "/usr": return - # Also copy/symlink anything under prefix/lib, which, for "portable" - # PyPy builds, includes the tk,tcl runtime and a number of shared - # objects. In distro-specific builds or on conda this should be empty - # (on PyPy3.8+ it will, like on CPython, hold the stdlib). + # Also copy/symlink anything under prefix/lib, which, for "portable" PyPy builds, includes the tk,tcl runtime + # and a number of shared objects. In distro-specific builds or on conda this should be empty (on PyPy3.8+ it + # will, like on CPython, hold the stdlib). host_lib = Path(interpreter.system_prefix) / "lib" stdlib = Path(interpreter.system_stdlib) if host_lib.exists() and host_lib.is_dir(): - for path in host_lib.iterdir(): - if stdlib == path: - # For PyPy3.8+ the stdlib lives in lib/pypy3.8 - # We need to avoid creating a symlink to it since that - # will defeat the purpose of a virtualenv - continue - yield PathRefToDest(path, dest=cls.to_lib) + if (deps_file := host_lib / "PYPY_PORTABLE_DEPS.txt").exists(): + for line in deps_file.read_text(encoding="utf-8").splitlines(): + dep = line.strip() + if dep and (path := host_lib / dep).exists(): + yield PathRefToDest(path, dest=cls.to_lib) + else: + for path in host_lib.iterdir(): + if stdlib == path: + continue + yield PathRefToDest(path, dest=cls.to_lib) class Pypy3Windows(PyPy3, WindowsSupports): """PyPy 3 on Windows.""" @property - def less_v37(self): + def less_v37(self) -> bool: 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) - yield from srcs + def _shared_libs(cls, python_dir: Path) -> Iterator[Path]: + # PyPy does not use a PEP 397 launcher, so all DLLs from the interpreter directory are needed for the venv + yield from python_dir.glob("*.dll") __all__ = [ diff --git a/src/virtualenv/create/via_global_ref/builtin/ref.py b/src/virtualenv/create/via_global_ref/builtin/ref.py index e2fd45ffe..f7ac0903e 100644 --- a/src/virtualenv/create/via_global_ref/builtin/ref.py +++ b/src/virtualenv/create/via_global_ref/builtin/ref.py @@ -1,27 +1,37 @@ -""" -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. -""" # noqa: D205 +"""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 annotations import os +import sys from abc import ABC, abstractmethod from collections import OrderedDict from stat import S_IXGRP, S_IXOTH, S_IXUSR +from typing import TYPE_CHECKING from virtualenv.info import fs_is_case_sensitive, fs_supports_symlink from virtualenv.util.path import copy, make_exe, symlink +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path -class RefMust: +if sys.version_info >= (3, 11): # pragma: no cover (py311+) + from enum import StrEnum +else: # pragma: no cover (py311+) + from enum import Enum + + class StrEnum(str, Enum): + pass + + +class RefMust(StrEnum): NA = "NA" COPY = "copy" SYMLINK = "symlink" -class RefWhen: +class RefWhen(StrEnum): ANY = "ANY" COPY = "copy" SYMLINK = "symlink" @@ -33,7 +43,7 @@ class PathRef(ABC): FS_SUPPORTS_SYMLINK = fs_supports_symlink() FS_CASE_SENSITIVE = fs_is_case_sensitive() - def __init__(self, src, must=RefMust.NA, when=RefWhen.ANY) -> None: + def __init__(self, src: Path, must: str = RefMust.NA, when: str = RefWhen.ANY) -> None: self.must = must self.when = when self.src = src @@ -41,15 +51,15 @@ def __init__(self, src, must=RefMust.NA, when=RefWhen.ANY) -> None: self.exists = src.exists() except OSError: self.exists = False - self._can_read = None if self.exists else False - self._can_copy = None if self.exists else False - self._can_symlink = None if self.exists else False + self._can_read: bool | None = None if self.exists else False + self._can_copy: bool | None = None if self.exists else False + self._can_symlink: bool | None = None if self.exists else False def __repr__(self) -> str: return f"{self.__class__.__name__}(src={self.src})" @property - def can_read(self): + def can_read(self) -> bool: if self._can_read is None: if self.src.is_file(): try: @@ -62,7 +72,7 @@ def can_read(self): return self._can_read @property - def can_copy(self): + def can_copy(self) -> bool: if self._can_copy is None: if self.must == RefMust.SYMLINK: self._can_copy = self.can_symlink @@ -71,7 +81,7 @@ def can_copy(self): return self._can_copy @property - def can_symlink(self): + def can_symlink(self) -> bool: if self._can_symlink is None: if self.must == RefMust.COPY: self._can_symlink = self.can_copy @@ -80,10 +90,10 @@ def can_symlink(self): return self._can_symlink @abstractmethod - def run(self, creator, symlinks): + def run(self, creator: object, symlinks: bool) -> None: raise NotImplementedError - def method(self, symlinks): + def method(self, symlinks: bool) -> Callable[..., None]: if self.must == RefMust.SYMLINK: return symlink if self.must == RefMust.COPY: @@ -94,18 +104,18 @@ def method(self, symlinks): 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) -> None: + def __init__(self, src: Path, must: str = RefMust.NA, when: str = RefWhen.ANY) -> None: super().__init__(src, must, when) - self._can_run = None + self._can_run: bool | None = None @property - def can_symlink(self): + def can_symlink(self) -> bool: if self.FS_SUPPORTS_SYMLINK: return self.can_run return False @property - def can_run(self): + def can_run(self) -> bool: if self._can_run is None: mode = self.src.stat().st_mode for key in [S_IXUSR, S_IXGRP, S_IXOTH]: @@ -114,17 +124,17 @@ def can_run(self): break else: self._can_run = False - return self._can_run + return self._can_run # ty: ignore[invalid-return-type] class PathRefToDest(PathRef): """Link a path on the file system.""" - def __init__(self, src, dest, must=RefMust.NA, when=RefWhen.ANY) -> None: + def __init__(self, src: Path, dest: Callable[..., Path], must: str = RefMust.NA, when: str = RefWhen.ANY) -> None: super().__init__(src, must, when) self.dest = dest - def run(self, creator, symlinks): + def run(self, creator: object, symlinks: bool) -> None: dest = self.dest(creator, self.src) method = self.method(symlinks) dest_iterable = dest if isinstance(dest, list) else (dest,) @@ -137,7 +147,9 @@ def run(self, creator, symlinks): class ExePathRefToDest(PathRefToDest, ExePathRef): """Link a exe path on the file system.""" - def __init__(self, src, targets, dest, must=RefMust.NA, when=RefWhen.ANY) -> None: + def __init__( + self, src: Path, targets: list[str], dest: Callable[..., Path], must: str = RefMust.NA, when: str = RefWhen.ANY + ) -> None: ExePathRef.__init__(self, src, must, when) PathRefToDest.__init__(self, src, dest, must, when) if not self.FS_CASE_SENSITIVE: @@ -146,7 +158,7 @@ def __init__(self, src, targets, dest, must=RefMust.NA, when=RefWhen.ANY) -> Non self.aliases = targets[1:] self.dest = dest - def run(self, creator, symlinks): + def run(self, creator: object, symlinks: bool) -> None: bin_dir = self.dest(creator, self.src).parent dest = bin_dir / self.base method = self.method(symlinks) diff --git a/src/virtualenv/create/via_global_ref/builtin/rustpython/__init__.py b/src/virtualenv/create/via_global_ref/builtin/rustpython/__init__.py new file mode 100644 index 000000000..dbeb1c28f --- /dev/null +++ b/src/virtualenv/create/via_global_ref/builtin/rustpython/__init__.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from abc import ABC +from pathlib import Path +from typing import TYPE_CHECKING + +from virtualenv.create.describe import PosixSupports, Python3Supports, WindowsSupports +from virtualenv.create.via_global_ref.builtin.ref import RefMust, RefWhen +from virtualenv.create.via_global_ref.builtin.via_global_self_do import ViaGlobalRefVirtualenvBuiltin + +if TYPE_CHECKING: + from collections.abc import Generator + + from python_discovery import PythonInfo + + +class RustPython(ViaGlobalRefVirtualenvBuiltin, Python3Supports, ABC): + @classmethod + def can_describe(cls, interpreter: PythonInfo) -> bool: + return interpreter.implementation == "RustPython" and super().can_describe(interpreter) + + @classmethod + def exe_stem(cls) -> str: + return "rustpython" + + @classmethod + def exe_names(cls, interpreter: PythonInfo) -> set[str]: + return { + cls.exe_stem(), + "python", + f"python{interpreter.version_info.major}", + f"python{interpreter.version_info.major}.{interpreter.version_info.minor}", + } + + @classmethod + def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], RefMust, RefWhen], None, None]: # ty: ignore[invalid-method-override] + host = Path(interpreter.system_executable) # ty: ignore[invalid-argument-type] + targets = sorted(f"{name}{cls.suffix}" for name in cls.exe_names(interpreter)) + yield host, targets, RefMust.NA, RefWhen.ANY + + +class RustPythonPosix(RustPython, PosixSupports): + """RustPython on POSIX.""" + + +class RustPythonWindows(RustPython, WindowsSupports): + """RustPython on Windows.""" + + +__all__ = [ + "RustPythonPosix", + "RustPythonWindows", +] 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 2f7f2f11a..f86861e63 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,6 +1,7 @@ from __future__ import annotations from abc import ABC +from typing import TYPE_CHECKING from virtualenv.create.via_global_ref.api import ViaGlobalRefApi, ViaGlobalRefMeta from virtualenv.create.via_global_ref.builtin.ref import ( @@ -12,20 +13,32 @@ from .builtin_way import VirtualenvBuiltin +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + from python_discovery import PythonInfo + + from virtualenv.config.cli.parser import VirtualEnvOptions + from virtualenv.create.via_global_ref.builtin.ref import PathRef + from virtualenv.create.via_global_ref.venv import Venv + class BuiltinViaGlobalRefMeta(ViaGlobalRefMeta): def __init__(self) -> None: super().__init__() - self.sources = [] + self.sources: list[PathRef] = [] class ViaGlobalRefVirtualenvBuiltin(ViaGlobalRefApi, VirtualenvBuiltin, ABC): - def __init__(self, options, interpreter) -> None: + def __init__(self, options: VirtualEnvOptions, interpreter: PythonInfo) -> None: super().__init__(options, interpreter) - self._sources = getattr(options.meta, "sources", None) # if we're created as a describer this might be missing + self._sources: list[PathRef] = ( + getattr(options.meta, "sources", None) or [] + ) # if created as a describer this might be missing @classmethod - def can_create(cls, interpreter): + def can_create(cls, interpreter: PythonInfo) -> BuiltinViaGlobalRefMeta | None: """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): @@ -36,7 +49,7 @@ def can_create(cls, interpreter): return meta @classmethod - def _sources_can_be_applied(cls, interpreter, meta): + def _sources_can_be_applied(cls, interpreter: PythonInfo, meta: BuiltinViaGlobalRefMeta) -> None: for src in cls.sources(interpreter): if src.exists: if meta.can_copy and not src.can_copy: @@ -58,22 +71,22 @@ def _sources_can_be_applied(cls, interpreter, meta): meta.sources.append(src) @classmethod - def setup_meta(cls, interpreter): # noqa: ARG003 + def setup_meta(cls, interpreter: PythonInfo) -> BuiltinViaGlobalRefMeta: # noqa: ARG003 return BuiltinViaGlobalRefMeta() @classmethod - def sources(cls, interpreter): + def sources(cls, interpreter: PythonInfo) -> Generator[ExePathRefToDest]: for host_exe, targets, must, when in cls._executables(interpreter): yield ExePathRefToDest(host_exe, dest=cls.to_bin, targets=targets, must=must, when=when) - def to_bin(self, src): + def to_bin(self, src: Path) -> Path: return self.bin_dir / src.name @classmethod - def _executables(cls, interpreter): + def _executables(cls, interpreter: PythonInfo) -> Generator[tuple[Path, list[str], str, str]]: raise NotImplementedError - def create(self): + def create(self) -> None: dirs = self.ensure_directories() for directory in list(dirs): if any(i for i in dirs if i is not directory and directory.parts == i.parts[: len(directory.parts)]): @@ -98,18 +111,22 @@ def create(self): self.enable_system_site_package = true_system_site super().create() - def ensure_directories(self): - return {self.dest, self.bin_dir, self.script_dir, self.stdlib} | set(self.libs) + @property + def include_dir(self) -> Path: + return self.dest / ("Include" if self.interpreter.os == "nt" else "include") + + def install_venv_shared_libs(self, venv_creator: Venv) -> None: + pass + + def ensure_directories(self) -> set[Path]: + return {self.dest, self.bin_dir, self.script_dir, self.stdlib, self.include_dir} | set(self.libs) - 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). - """ # noqa: D205 + def set_pyenv_cfg(self) -> None: + """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().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 + self.pyenv_cfg["base-executable"] = self.interpreter.system_executable # ty: ignore[invalid-assignment] __all__ = [ diff --git a/src/virtualenv/create/via_global_ref/store.py b/src/virtualenv/create/via_global_ref/store.py index 4be668921..db9d475ad 100644 --- a/src/virtualenv/create/via_global_ref/store.py +++ b/src/virtualenv/create/via_global_ref/store.py @@ -1,16 +1,22 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from python_discovery import PythonInfo -def handle_store_python(meta, interpreter): + from virtualenv.create.via_global_ref.api import ViaGlobalRefMeta + + +def handle_store_python(meta: ViaGlobalRefMeta, interpreter: PythonInfo) -> ViaGlobalRefMeta: if is_store_python(interpreter): meta.symlink_error = "Windows Store Python does not support virtual environments via symlink" return meta -def is_store_python(interpreter): - parts = Path(interpreter.system_executable).parts +def is_store_python(interpreter: PythonInfo) -> bool: + parts = Path(interpreter.system_executable).parts # ty: ignore[invalid-argument-type] return ( len(parts) > 4 # noqa: PLR2004 and parts[-4] == "Microsoft" diff --git a/src/virtualenv/create/via_global_ref/venv.py b/src/virtualenv/create/via_global_ref/venv.py index d5037bc3b..9c95d12b9 100644 --- a/src/virtualenv/create/via_global_ref/venv.py +++ b/src/virtualenv/create/via_global_ref/venv.py @@ -2,64 +2,72 @@ import logging from copy import copy +from typing import TYPE_CHECKING + +from python_discovery import PythonInfo from virtualenv.create.via_global_ref.store import handle_store_python -from virtualenv.discovery.py_info import PythonInfo 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.common import is_mac_os_framework from .builtin.cpython.mac_os import CPython3macOsBrew from .builtin.pypy.pypy3 import Pypy3Windows +if TYPE_CHECKING: + from typing import Any + + from virtualenv.config.cli.parser import VirtualEnvOptions + LOGGER = logging.getLogger(__name__) class Venv(ViaGlobalRefApi): - def __init__(self, options, interpreter) -> None: + def __init__(self, options: VirtualEnvOptions, interpreter: PythonInfo) -> None: self.describe = options.describe 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): + def _args(self) -> list[tuple[str, Any]]: return super()._args() + ([("describe", self.describe.__class__.__name__)] if self.describe else []) @classmethod - def can_create(cls, interpreter): + def can_create(cls, interpreter: PythonInfo) -> ViaGlobalRefMeta | None: if interpreter.has_venv: if CPython3macOsBrew.can_describe(interpreter): return CPython3macOsBrew.setup_meta(interpreter) meta = ViaGlobalRefMeta() if interpreter.platform == "win32": meta = handle_store_python(meta, interpreter) + if is_mac_os_framework(interpreter): + meta.copy_error = "macOS framework builds do not support copy-based virtual environments" return meta return None - def create(self): + def create(self) -> None: if self.can_be_inline: self.create_inline() else: self.create_via_sub_process() - for lib in self.libs: + for lib in self.libs: # ty: ignore[not-iterable] ensure_dir(lib) + if self.describe is not None: + self.describe.install_venv_shared_libs(self) 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 + def executables_for_win_pypy_less_v37(self) -> None: + """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.""" 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): + def create_inline(self) -> None: from venv import EnvBuilder # noqa: PLC0415 builder = EnvBuilder( @@ -70,27 +78,29 @@ def create_inline(self): ) builder.create(str(self.dest)) - def create_via_sub_process(self): + def create_via_sub_process(self) -> None: cmd = self.get_host_create_cmd() LOGGER.info("using host built-in venv to create via %s", " ".join(cmd)) code, out, err = run_cmd(cmd) if code != 0: raise ProcessCallFailedError(code, out, err, cmd) - def get_host_create_cmd(self): + def get_host_create_cmd(self) -> list[str]: cmd = [self.interpreter.system_executable, "-m", "venv", "--without-pip"] + if self.interpreter.version_info >= (3, 13): + cmd.append("--without-scm-ignore-files") if self.enable_system_site_package: cmd.append("--system-site-packages") cmd.extend(("--symlinks" if self.symlinks else "--copies", str(self.dest))) - return cmd + return cmd # ty: ignore[invalid-return-type] - def set_pyenv_cfg(self): + def set_pyenv_cfg(self) -> None: # prefer venv options over ours, but keep our extra venv_content = copy(self.pyenv_cfg.refresh()) super().set_pyenv_cfg() self.pyenv_cfg.update(venv_content) - def __getattribute__(self, item): + def __getattribute__(self, item: str) -> object: describe = object.__getattribute__(self, "describe") if describe is not None and hasattr(describe, item): element = getattr(describe, item) diff --git a/src/virtualenv/discovery/builtin.py b/src/virtualenv/discovery/builtin.py index 43e48fa87..395e77116 100644 --- a/src/virtualenv/discovery/builtin.py +++ b/src/virtualenv/discovery/builtin.py @@ -1,38 +1,43 @@ +"""Virtualenv-specific Builtin discovery wrapping py_discovery.""" + from __future__ import annotations -import logging -import os import sys -from contextlib import suppress -from pathlib import Path from typing import TYPE_CHECKING -from platformdirs import user_data_path - -from virtualenv.info import IS_WIN, fs_path_id +from python_discovery import get_interpreter as _get_interpreter 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 Callable, Generator, Iterable, Mapping, Sequence + from collections.abc import Iterable, Mapping, Sequence + + from python_discovery import PyInfoCache, PythonInfo + + from virtualenv.config.cli.parser import VirtualEnvOptions + - from virtualenv.app_data.base import AppData -LOGGER = logging.getLogger(__name__) +def get_interpreter( + key: str, + try_first_with: Iterable[str], + cache: PyInfoCache | None = None, + env: Mapping[str, str] | None = None, + app_data: PyInfoCache | None = None, +) -> PythonInfo | None: + return _get_interpreter(key, try_first_with, cache or app_data, env) class Builtin(Discover): python_spec: Sequence[str] - app_data: AppData + app_data: PyInfoCache try_first_with: Sequence[str] - def __init__(self, options) -> None: + def __init__(self, options: VirtualEnvOptions) -> None: super().__init__(options) self.python_spec = options.python or [sys.executable] if self._env.get("VIRTUALENV_PYTHON"): - self.python_spec = self.python_spec[1:] + self.python_spec[:1] # Rotate the list + self.python_spec = self.python_spec[1:] + self.python_spec[:1] self.app_data = options.app_data self.try_first_with = options.try_first_with @@ -62,8 +67,12 @@ def add_parser_arguments(cls, parser: ArgumentParser) -> None: 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: + if result := get_interpreter( + python_spec, + self.try_first_with, + app_data=self.app_data, + env=self._env, + ): return result return None @@ -72,195 +81,7 @@ def __repr__(self) -> str: return f"{self.__class__.__name__} discover of python_spec={spec!r}" -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) - 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 - LOGGER.info("proposed %s", interpreter) - if interpreter.satisfies(spec, impl_must_match): - LOGGER.debug("accepted %s", interpreter) - return interpreter - proposed_paths.add(key) - return 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. if it's a path and exists, and is absolute path, this is the only option we consider - env = os.environ if env is None else env - tested_exes: set[str] = set() - if spec.is_abs: - try: - os.lstat(spec.path) # Windows Store Python does not work with os.path.exists, but does for os.lstat - except OSError: - pass - else: - 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 - return - - # 1. try with first - for py_exe in try_first_with: - path = os.path.abspath(py_exe) - try: - os.lstat(path) # Windows Store Python does not work with os.path.exists, but does for os.lstat - except OSError: - pass - else: - 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: - try: - os.lstat(spec.path) # Windows Store Python does not work with os.path.exists, but does for os.lstat - except OSError: - pass - else: - 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 - 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 # 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 - - # try to find on path, the path order matters (as the candidates are less easy to control by end user) - 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 - - # otherwise try uv-managed python (~/.local/share/uv/python or platform equivalent) - if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"): - uv_python_path = Path(uv_python_dir).expanduser() - elif xdg_data_home := os.getenv("XDG_DATA_HOME"): - uv_python_path = Path(xdg_data_home).expanduser() / "uv" / "python" - else: - uv_python_path = user_data_path("uv") / "python" - - for exe_path in uv_python_path.glob("*/bin/python"): - interpreter = PathPythonInfo.from_exe(str(exe_path), app_data, raise_on_error=False, env=env) - if interpreter is not None: - yield interpreter, True - - -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 path: - for p in map(Path, path.split(os.pathsep)): - with suppress(OSError): - if p.is_dir() and next(p.iterdir(), None): - yield p - - -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) -> 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_path in self.path.iterdir(): - try: - if file_path.is_dir(): - continue - if IS_WIN: - pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") - if not any(file_path.name.upper().endswith(ext) for ext in pathext): - continue - elif not (file_path.stat().st_mode & os.X_OK): - continue - except OSError: - pass - content += " " - content += file_path.name - return content - - -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" - - 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 26e54a8da..76341c52a 100644 --- a/src/virtualenv/discovery/cached_py_info.py +++ b/src/virtualenv/discovery/cached_py_info.py @@ -1,198 +1,10 @@ -""" - -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. -""" # noqa: D205 +"""Backward-compatibility re-export — use ``python_discovery`` directly.""" from __future__ import annotations -import hashlib -import logging -import os -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.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): # 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 - LOGGER.info("%s", result) - result = None - return result - - -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) - if not ignore_cache and exe_path in _CACHE: # check in the in-memory cache - result = _CACHE[exe_path] - else: # otherwise go through the app data cache - py_info = _get_via_file_cache(cls, app_data, exe_path, exe, env) - result = _CACHE[exe_path] = py_info - # independent if it was from the file or in-memory cache fix the original executable location - if isinstance(result, PythonInfo): - result.executable = exe - return result - - -def _get_via_file_cache(cls, app_data, path, exe, env): - path_text = str(path) - try: - path_modified = path.stat().st_mtime - except OSError: - path_modified = -1 - py_info_script = Path(os.path.abspath(__file__)).parent / "py_info.py" - try: - py_info_hash = hashlib.sha256(py_info_script.read_bytes()).hexdigest() - except OSError: - py_info_hash = None - - if app_data is None: - app_data = AppDataDisabled() - py_info, py_info_store = None, app_data.py_info(path) - with py_info_store.locked(): - if py_info_store.exists(): # if exists and matches load - data = py_info_store.read() - of_path = data.get("path") - of_st_mtime = data.get("st_mtime") - of_content = data.get("content") - of_hash = data.get("hash") - if of_path == path_text and of_st_mtime == path_modified and of_hash == py_info_hash: - 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() - py_info = None - else: - py_info_store.remove() - 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(), # noqa: SLF001 - "hash": py_info_hash, - } - 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), 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) - # Force UTF-8 mode to handle output from Microsoft Store Python stub on Windows - # See https://github.com/pypa/virtualenv/issues/2812 - env["PYTHONUTF8"] = "1" - LOGGER.debug("get interpreter info via cmd: %s", LogCmd(cmd)) - try: - process = Popen( - cmd, - universal_newlines=True, - stdin=subprocess.PIPE, - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - env=env, - encoding="utf-8", - errors="backslashreplace", - ) - out, err = process.communicate() - code = process.returncode - except OSError as os_error: - out, err, code = "", os_error.strerror, os_error.errno - result, failure = None, None - if code == 0: - out_starts = out.find(start_cookie[::-1]) +from python_discovery._cached_py_info import clear, from_exe # noqa: PLC2701 - 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 = 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: - def __init__(self, cmd, env=None) -> None: - self.cmd = cmd - self.env = env - - def __repr__(self) -> str: - cmd_repr = " ".join(quote(str(c)) for c in self.cmd) - if self.env is not None: - cmd_repr = f"{cmd_repr} env of {self.env!r}" - return cmd_repr - - -def clear(app_data): - app_data.py_info_clear() - _CACHE.clear() - - -___all___ = [ - "from_exe", +__all__ = [ "clear", - "LogCmd", + "from_exe", ] diff --git a/src/virtualenv/discovery/discover.py b/src/virtualenv/discovery/discover.py index 0aaa17c8e..79ccab740 100644 --- a/src/virtualenv/discovery/discover.py +++ b/src/virtualenv/discovery/discover.py @@ -1,42 +1,37 @@ +"""Virtualenv-specific Discover base class for plugin-based Python discovery.""" + from __future__ import annotations +import os from abc import ABC, abstractmethod +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from argparse import ArgumentParser + from collections.abc import Mapping -class Discover(ABC): - """Discover and provide the requested Python interpreter.""" + from python_discovery import PythonInfo - @classmethod - def add_parser_arguments(cls, parser): - """ - Add CLI arguments for this discovery mechanisms. + from virtualenv.config.cli.parser import VirtualEnvOptions - :param parser: the CLI parser - """ - raise NotImplementedError - def __init__(self, options) -> None: - """ - Create a new discovery mechanism. +class Discover(ABC): + @classmethod + def add_parser_arguments(cls, parser: ArgumentParser) -> None: + raise NotImplementedError - :param options: the parsed options as defined within :meth:`add_parser_arguments` - """ + def __init__(self, options: VirtualEnvOptions) -> None: self._has_run = False - self._interpreter = None - self._env = options.env + self._interpreter: PythonInfo | None = None + self._env: Mapping[str, str] = options.env if options.env is not None else os.environ @abstractmethod - def run(self): - """ - Discovers an interpreter. - - :return: the interpreter ready to use for virtual environment creation - """ + def run(self) -> PythonInfo | None: raise NotImplementedError @property - def interpreter(self): - """:return: the interpreter as returned by :meth:`run`, cached""" + def interpreter(self) -> PythonInfo | None: + """:returns: the interpreter as returned by :meth:`run`, cached""" if self._has_run is False: self._interpreter = self.run() self._has_run = True diff --git a/src/virtualenv/discovery/py_info.py b/src/virtualenv/discovery/py_info.py index e72c82dd6..511586593 100644 --- a/src/virtualenv/discovery/py_info.py +++ b/src/virtualenv/discovery/py_info.py @@ -1,687 +1,9 @@ -""" -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 -""" +"""Backward-compatibility re-export — use ``python_discovery.PythonInfo`` directly.""" from __future__ import annotations -import json -import logging -import os -import platform -import re -import struct -import sys -import sysconfig -import warnings -from collections import OrderedDict, namedtuple -from string import digits - -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)])) - - -EXTENSIONS = _get_path_extensions() -_CONF_VAR_RE = re.compile(r"\{\w+}") - - -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 = sys.platform - self.implementation = platform.python_implementation() - if self.implementation == "PyPy": - 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(*sys.version_info) - # Use the same implementation as found in stdlib platform.architecture - # to account for platforms where the maximum integer is not equal the - # pointer size. - self.architecture = 32 if struct.calcsize("P") == 4 else 64 # noqa: PLR2004 - - # 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 - self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1 - - # information about the prefix - determines python home - 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 = abs_path(getattr(sys, "base_exec_prefix", None)) - self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None)) - - 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: - __import__("venv") - has = True - except ImportError: - has = False - self.has_venv = has - self.path = sys.path - self.file_system_encoding = sys.getfilesystemencoding() - self.stdout_encoding = getattr(sys.stdout, "encoding", None) - - scheme_names = sysconfig.get_scheme_names() - - if "venv" in scheme_names: - self.sysconfig_scheme = "venv" - 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 = {} - # 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 = {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 = { - k: v - for k, v in [ - # a list of content to store from sysconfig - ("makefile_filename", makefile()), - ] - if k is not None - } - - config_var_keys = set() - for element in self.sysconfig_paths.values(): - config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element)) - config_var_keys.add("PYTHONFRAMEWORK") - - self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys} - - if "TCL_LIBRARY" in os.environ: - self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs() - else: - self.tcl_lib, self.tk_lib = None, None - - 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 - - @staticmethod - def _get_tcl_tk_libs(): - """ - Detects the tcl and tk libraries using tkinter. - - This works reliably but spins up tkinter, which is heavy if you don't need it. - """ - tcl_lib, tk_lib = None, None - try: - import tkinter as tk # noqa: PLC0415 - except ImportError: - pass - else: - try: - tcl = tk.Tcl() - tcl_lib = tcl.eval("info library") - - # Try to get TK library path directly first - try: - tk_lib = tcl.eval("set tk_library") - if tk_lib and os.path.isdir(tk_lib): - pass # We found it directly - else: - tk_lib = None # Reset if invalid - except tk.TclError: - tk_lib = None - - # If direct query failed, try constructing the path - if tk_lib is None: - tk_version = tcl.eval("package require Tk") - tcl_parent = os.path.dirname(tcl_lib) - - # Try different version formats - version_variants = [ - tk_version, # Full version like "8.6.12" - ".".join(tk_version.split(".")[:2]), # Major.minor like "8.6" - tk_version.split(".")[0], # Just major like "8" - ] - - for version in version_variants: - tk_lib_path = os.path.join(tcl_parent, f"tk{version}") - if not os.path.isdir(tk_lib_path): - continue - # Validate it's actually a TK directory - if os.path.exists(os.path.join(tk_lib_path, "tk.tcl")): - tk_lib = tk_lib_path - break - - except tk.TclError: - pass - - return tcl_lib, tk_lib - - def _fast_get_system_executable(self): - """Try to get the system executable by just looking at properties.""" - # 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 - if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)): - return self.original_executable - - # if this is NOT a virtual environment, can't determine easily, bail out - if self.real_prefix is not None: - return None - - base_executable = getattr(sys, "_base_executable", None) # some platforms may set this to help us - if base_executable is None: # use the saved system executable if present - return None - - # we know we're in a virtual environment, can not be us - if sys.executable == base_executable: - return None - - # We're not in a venv and base_executable exists; use it directly - if os.path.exists(base_executable): - return base_executable - - # Try fallback for POSIX virtual environments - return self._try_posix_fallback_executable(base_executable) - - def _try_posix_fallback_executable(self, base_executable): - """ - Try to find a versioned Python binary as fallback for POSIX virtual environments. - - 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" or (major, minor) < (3, 11): - return None - - # search relative to the directory of sys._base_executable - base_dir = os.path.dirname(base_executable) - candidates = [f"python{major}", f"python{major}.{minor}"] - if self.implementation == "PyPy": - candidates.extend(["pypy", "pypy3", f"pypy{major}", f"pypy{major}.{minor}"]) - - for candidate in candidates: - full_path = os.path.join(base_dir, candidate) - if os.path.exists(full_path): - return full_path - - return None # in this case we just can't tell easily without poking around FS and calling them, bail - - def install_path(self, key): - result = self.distutils_install.get(key) - if result is None: # use sysconfig if sysconfig_scheme is set or distutils is unavailable - # set prefixes to empty => result is relative from cwd - prefixes = self.prefix, self.exec_prefix, self.base_prefix, self.base_exec_prefix - config_var = {k: "" if v in prefixes else v for k, v in self.sysconfig_vars.items()} - result = self.sysconfig_path(key, config_var=config_var).lstrip(os.sep) - return result - - @staticmethod - def _distutils_install(): - # use distutils primarily because that's what pip does - # https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95 - # note here we don't import Distribution directly to allow setuptools to patch it - with warnings.catch_warnings(): # disable warning for PEP-632 - warnings.simplefilter("ignore") - try: - 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 # noqa: SLF001 - - with warnings.catch_warnings(): # disable warning for PEP-632 - warnings.simplefilter("ignore") - i = d.get_command_obj("install", create=True) - - i.prefix = os.sep # paths generated are relative to prefix that contains the path sep, this makes it relative - i.finalize_options() - return {key: (getattr(i, f"install_{key}")[1:]).lstrip(os.sep) for key in SCHEME_KEYS} - - @property - def version_str(self): - return ".".join(str(i) for i in self.version_info[0:3]) - - @property - def version_release_str(self): - return ".".join(str(i) for i in self.version_info[0:2]) - - @property - def python_name(self): - version_info = self.version_info - return f"python{version_info.major}.{version_info.minor}" - - @property - def is_old_virtualenv(self): - return self.real_prefix is not None - - @property - def is_venv(self): - return self.base_prefix is not None - - def sysconfig_path(self, key, config_var=None, sep=os.sep): - pattern = self.sysconfig_paths.get(key) - if pattern is None: - return "" - if config_var is None: - config_var = self.sysconfig_vars - else: - base = self.sysconfig_vars.copy() - base.update(config_var) - config_var = base - return pattern.format(**config_var).replace("/", sep) - - def creators(self, refresh=False): # noqa: FBT002 - if self._creators is None or refresh is True: - from virtualenv.run.plugin.creators import CreatorSelector # noqa: PLC0415 - - self._creators = CreatorSelector.for_interpreter(self) - return self._creators - - @property - def system_include(self): - path = self.sysconfig_path( - "include", - { - 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 - fallback = os.path.join(self.prefix, os.path.dirname(self.install_path("headers"))) - if os.path.exists(fallback): - path = fallback - return path - - @property - def system_prefix(self): - return self.real_prefix or self.base_prefix or self.prefix - - @property - def system_exec_prefix(self): - return self.real_prefix or self.base_exec_prefix or self.exec_prefix - - 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) -> str: - return "{}({})".format( - self.__class__.__name__, - ", ".join( - f"{k}={v}" - for k, v in ( - ("spec", self.spec), - ( - "system" - if self.system_executable is not None and self.system_executable != self.executable - else None, - self.system_executable, - ), - ( - "original" - 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", f"{self.file_system_encoding}-{self.stdout_encoding}"), - ) - if k is not None - ), - ) - - @property - def spec(self): - return "{}{}{}-{}".format( - self.implementation, - ".".join(str(i) for i in self.version_info), - "t" if self.free_threaded else "", - self.architecture, - ) - - @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 # noqa: PLC0415 - - clear(app_data) - cls._cache_exe_discovery.clear() - - def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911, PLR0912 - """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 - if not spec.is_abs: - # if path set, and is not our original executable name, this does not match - basename = os.path.basename(self.original_executable) - spec_path = spec.path - if sys.platform == "win32": - basename, suffix = os.path.splitext(basename) - if spec_path.endswith(suffix): - spec_path = spec_path[: -len(suffix)] - if basename != spec_path: - 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 - - if spec.free_threaded is not None and spec.free_threaded != self.free_threaded: - return False - - if spec.version_specifier is not None: - version_info = self.version_info - release = f"{version_info.major}.{version_info.minor}.{version_info.micro}" - if version_info.releaselevel != "final": - suffix = { - "alpha": "a", - "beta": "b", - "candidate": "rc", - }.get(version_info.releaselevel) - if suffix is not None: - release = f"{release}{suffix}{version_info.serial}" - if not spec.version_specifier.contains(release): - return False - - for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro)): - if req is not None and our is not None and our != req: - return False - return True - - _current_system = None - _current = None - - @classmethod - 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) -> 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 - - def _to_json(self): - # don't save calculated paths, as these are non primitive types - return json.dumps(self._to_dict(), indent=2) - - def _to_dict(self): - 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( # 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 # 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) - - if isinstance(proposed, PythonInfo) and resolve_to_host: - try: - proposed = proposed._resolve_to_system(app_data, proposed) # noqa: SLF001 - except Exception as exception: - if raise_on_error: - raise - LOGGER.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception) - proposed = None - return proposed - - @classmethod - 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(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__ = data.copy() - return result - - @classmethod - def _resolve_to_system(cls, app_data, target): - start_executable = target.executable - prefixes = OrderedDict() - while target.system_executable is None: - prefix = target.real_prefix or target.base_prefix or target.prefix - if prefix in prefixes: - if len(prefixes) == 1: - # if we're linking back to ourselves accept ourselves with a WARNING - 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): - 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: - target = cls.from_exe(target.system_executable, app_data) - target.executable = start_executable - return target - - _cache_exe_discovery = {} # noqa: RUF012 - - 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: - LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key]) - return self._cache_exe_discovery[key] - 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) - discovered = [] - env = os.environ if env is None else env - for folder in possible_folders: - for name in possible_names: - info = self._check_exe(app_data, folder, name, exact, discovered, env) - if info is not None: - self._cache_exe_discovery[key] = info - return info - if exact is False and discovered: - info = self._select_most_likely(discovered, self) - folders = os.pathsep.join(possible_folders) - self._cache_exe_discovery[key] = info - 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): # noqa: PLR0913 - exe_path = os.path.join(folder, name) - if not os.path.exists(exe_path): - return None - info = self.from_exe(exe_path, app_data, resolve_to_host=False, raise_on_error=False, env=env) - if info is None: # ignore if for some reason we can't query - return None - for item in ["implementation", "architecture", "version_info"]: - found = getattr(info, item) - searched = getattr(self, item) - if found != searched: - if item == "version_info": - found, searched = ".".join(str(i) for i in found), ".".join(str(i) for i in searched) - executable = info.executable - LOGGER.debug("refused interpreter %s because %s differs %s != %s", executable, item, found, searched) - if exact is False: - discovered.append(info) - break - else: - return info - return None - - @staticmethod - def _select_most_likely(discovered, target): - # no exact match found, start relaxing our requirements then to facilitate system package upgrades that - # could cause this (when using copy strategy of the host python) - def sort_by(info): - # we need to setup some priority of traits, this is as follows: - # implementation, major, minor, micro, architecture, tag, serial - matches = [ - info.implementation == target.implementation, - info.version_info.major == target.version_info.major, - info.version_info.minor == target.version_info.minor, - info.architecture == target.architecture, - info.version_info.micro == target.version_info.micro, - info.version_info.releaselevel == target.version_info.releaselevel, - info.version_info.serial == target.version_info.serial, - ] - 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 - return sorted_discovered[0] - - def _find_possible_folders(self, inside_folder): - candidate_folder = OrderedDict() - executables = OrderedDict() - executables[os.path.realpath(self.executable)] = None - executables[self.executable] = None - executables[os.path.realpath(self.original_executable)] = None - executables[self.original_executable] = None - 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[f"{inside_folder}{relative}"] = None - - # or at root level - candidate_folder[inside_folder] = None - 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]) - mods = [""] - if self.free_threaded: - mods.append("t") - for mod in mods: - for arch in [f"-{self.architecture}", ""]: - for ext in EXTENSIONS: - candidate = f"{name}{version}{mod}{arch}{ext}" - name_candidate[candidate] = None - return list(name_candidate.keys()) - - def _possible_base(self): - possible_base = OrderedDict() - basename = os.path.splitext(os.path.basename(self.executable))[0].rstrip(digits) - possible_base[basename] = None - possible_base[self.implementation] = None - # python is always the final option as in practice is used by multiple implementation as exe name - if "python" in possible_base: - del possible_base["python"] - possible_base["python"] = None - for base in possible_base: - lower = base.lower() - yield lower - from virtualenv.info import fs_is_case_sensitive # noqa: PLC0415 - - if fs_is_case_sensitive(): - if base != lower: - yield base - upper = base.upper() - if upper != base: - yield upper - - -if __name__ == "__main__": - # dump a JSON representation of the current python - - 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 +from python_discovery import PythonInfo - info = PythonInfo()._to_json() # noqa: SLF001 - sys.stdout.write("".join((start_cookie[::-1], info, end_cookie[::-1]))) +__all__ = [ + "PythonInfo", +] diff --git a/src/virtualenv/discovery/py_spec.py b/src/virtualenv/discovery/py_spec.py index 4bd8b4209..c407f6e0f 100644 --- a/src/virtualenv/discovery/py_spec.py +++ b/src/virtualenv/discovery/py_spec.py @@ -1,219 +1,9 @@ -"""A Python specification is an abstract requirement definition of an interpreter.""" +"""Backward-compatibility re-export — use ``python_discovery.PythonSpec`` directly.""" from __future__ import annotations -import contextlib -import os -import re - -from virtualenv.util.specifier import SimpleSpecifierSet, SimpleVersion - -PATTERN = re.compile(r"^(?P[a-zA-Z]+)?(?P[0-9.]+)?(?Pt)?(?:-(?P32|64))?$") -SPECIFIER_PATTERN = re.compile(r"^(?:(?P[A-Za-z]+)\s*)?(?P(?:===|==|~=|!=|<=|>=|<|>).+)$") - - -class PythonSpec: - """Contains specification about a Python Interpreter.""" - - 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, - *, - free_threaded: bool | None = None, - version_specifier: SpecifierSet | None = None, - ) -> None: - self.str_spec = str_spec - self.implementation = implementation - self.major = major - self.minor = minor - self.micro = micro - self.free_threaded = free_threaded - self.architecture = architecture - self.path = path - self.version_specifier = version_specifier - - @classmethod - def from_string_spec(cls, string_spec: str): # noqa: C901, PLR0912 - impl, major, minor, micro, threaded, arch, path = None, None, None, None, None, None, None - version_specifier = None - if os.path.isabs(string_spec): # noqa: PLR1702 - path = string_spec - else: - ok = False - match = re.match(PATTERN, string_spec) - if match: - - def _int_or_none(val): - return None if val is None else int(val) - - try: - groups = match.groupdict() - version = groups["version"] - if version is not None: - versions = tuple(int(i) for i in version.split(".") if i) - if len(versions) > 3: # noqa: PLR2004 - raise ValueError # noqa: TRY301 - if len(versions) == 3: # noqa: PLR2004 - major, minor, micro = versions - 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: # noqa: PLR2004 - minor = int(str(version_data)[1:]) - threaded = bool(groups["threaded"]) - ok = True - except ValueError: - pass - else: - impl = groups["impl"] - if impl in {"py", "python"}: - impl = None - arch = _int_or_none(groups["arch"]) - - if not ok: - specifier_match = SPECIFIER_PATTERN.match(string_spec.strip()) - if specifier_match and SpecifierSet is not None: - impl = specifier_match.group("impl") - spec_text = specifier_match.group("spec").strip() - try: - version_specifier = SpecifierSet(spec_text) - except InvalidSpecifier: - pass - else: - if impl in {"py", "python"}: - impl = None - return cls( - string_spec, - impl, - None, - None, - None, - None, - None, - free_threaded=None, - version_specifier=version_specifier, - ) - path = string_spec - - return cls( - string_spec, - impl, - major, - minor, - micro, - arch, - path, - free_threaded=threaded, - version_specifier=version_specifier, - ) - - 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)}" - mod = "t?" if self.free_threaded else "" - 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}{mod}){version_conditional}{suffix}$", - flags=re.IGNORECASE, - ) - - @property - def is_abs(self): - return self.path is not None and os.path.isabs(self.path) - - def _check_version_specifier(self, spec): - """Check if version specifier is satisfied.""" - components: list[int] = [] - for part in (self.major, self.minor, self.micro): - if part is None: - break - components.append(part) - if not components: - return True - - version_str = ".".join(str(part) for part in components) - with contextlib.suppress(InvalidVersion): - Version(version_str) - for item in spec.version_specifier: - # Check precision requirements - required_precision = self._get_required_precision(item) - if required_precision is None or len(components) < required_precision: - continue - if not item.contains(version_str): - return False - return True - - @staticmethod - def _get_required_precision(item): - """Get the required precision for a specifier item.""" - with contextlib.suppress(AttributeError, ValueError): - return len(item.version.release) - return None - - def satisfies(self, spec): # noqa: PLR0911 - """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(): - return False - if spec.architecture is not None and spec.architecture != self.architecture: - return False - if spec.free_threaded is not None and spec.free_threaded != self.free_threaded: - return False - - if spec.version_specifier is not None and not self._check_version_specifier(spec): - return False - - for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)): - if req is not None and our is not None and our != req: - return False - return True - - def __repr__(self) -> str: - name = type(self).__name__ - params = ( - "implementation", - "major", - "minor", - "micro", - "architecture", - "path", - "free_threaded", - "version_specifier", - ) - return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})" - - -# Create aliases for backward compatibility -SpecifierSet = SimpleSpecifierSet -Version = SimpleVersion -InvalidSpecifier = ValueError -InvalidVersion = ValueError +from python_discovery import PythonSpec __all__ = [ - "InvalidSpecifier", - "InvalidVersion", "PythonSpec", - "SpecifierSet", - "Version", ] diff --git a/src/virtualenv/discovery/windows/__init__.py b/src/virtualenv/discovery/windows/__init__.py deleted file mode 100644 index b7206406a..000000000 --- a/src/virtualenv/discovery/windows/__init__.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from virtualenv.discovery.py_info import PythonInfo -from virtualenv.discovery.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): - # see if PEP-514 entries are good - - # start with higher python versions in an effort to use the latest version available - # 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, - ) - - for name, major, minor, arch, threaded, exe, _ in existing: - # 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, free_threaded=threaded) - 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 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 deleted file mode 100644 index a75dad36d..000000000 --- a/src/virtualenv/discovery/windows/pep514.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Implement https://www.python.org/dev/peps/pep-0514/ to discover interpreters - Windows only.""" - -from __future__ import annotations - -import os -import re -import winreg -from logging import basicConfig, getLogger - -LOGGER = getLogger(__name__) - - -def enum_keys(key): - at = 0 - while True: - try: - yield winreg.EnumKey(key, at) - except OSError: - break - at += 1 - - -def get_value(key, value_name): - try: - return winreg.QueryValueEx(key, value_name)[0] - except OSError: - return None - - -def discover_pythons(): - for hive, hive_name, key, flags, default_arch in [ - (winreg.HKEY_CURRENT_USER, "HKEY_CURRENT_USER", r"Software\Python", 0, 64), - (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_64KEY, 64), - (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_32KEY, 32), - ]: - yield from process_set(hive, hive_name, key, flags, default_arch) - - -def process_set(hive, hive_name, key, flags, default_arch): - try: - with winreg.OpenKeyEx(hive, key, 0, winreg.KEY_READ | flags) as root_key: - for company in enum_keys(root_key): - if company == "PyLauncher": # reserved - continue - yield from process_company(hive_name, company, root_key, default_arch) - except OSError: - pass - - -def process_company(hive_name, company, root_key, default_arch): - with winreg.OpenKeyEx(root_key, company) as company_key: - for tag in enum_keys(company_key): - spec = process_tag(hive_name, company, company_key, tag, default_arch) - if spec is not None: - yield spec - - -def process_tag(hive_name, company, company_key, tag, default_arch): - with winreg.OpenKeyEx(company_key, tag) as tag_key: - version = load_version_data(hive_name, company, tag, tag_key) - if version is not None: # if failed to get version bail - major, minor, _ = version - arch = load_arch_data(hive_name, company, tag, tag_key, default_arch) - if arch is not None: - exe_data = load_exe(hive_name, company, company_key, tag) - if exe_data is not None: - exe, args = exe_data - threaded = load_threaded(hive_name, company, tag, tag_key) - return company, major, minor, arch, threaded, exe, args - return None - return None - return None - - -def load_exe(hive_name, company, company_key, tag): - key_path = f"{hive_name}/{company}/{tag}" - try: - 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: - 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(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 = f"{hive_name}/{company}/{tag}/SysArchitecture" - try: - return parse_arch(arch_str) - except ValueError as sys_arch: - msg(key_path, sys_arch) - return default_arch - - -def parse_arch(arch_str): - if isinstance(arch_str, str): - match = re.match(r"^(\d+)bit$", arch_str) - if match: - return int(next(iter(match.groups()))) - error = f"invalid format {arch_str}" - else: - 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"), f"{hive_name}/{company}/{tag}/SysVersion"), - (tag, f"{hive_name}/{company}/{tag}"), - ]: - if candidate is not None: - try: - return parse_version(candidate) - except ValueError as sys_version: - msg(key_path, sys_version) - return None - - -def parse_version(version_str): - 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 = f"invalid format {version_str}" - else: - error = f"version is not string: {version_str!r}" - raise ValueError(error) - - -def load_threaded(hive_name, company, tag, tag_key): - display_name = get_value(tag_key, "DisplayName") - if display_name is not None: - if isinstance(display_name, str): - if "freethreaded" in display_name.lower(): - return True - else: - key_path = f"{hive_name}/{company}/{tag}/DisplayName" - msg(key_path, f"display name is not string: {display_name!r}") - return bool(re.match(r"^\d+(\.\d+){0,2}t$", tag, flags=re.IGNORECASE)) - - -def msg(path, what): - LOGGER.warning("PEP-514 violation in Windows Registry at %s error: %s", path, what) - - -def _run(): - basicConfig() - interpreters = [repr(spec) for spec in discover_pythons()] - print("\n".join(sorted(interpreters))) # noqa: T201 - - -if __name__ == "__main__": - _run() diff --git a/src/virtualenv/info.py b/src/virtualenv/info.py index e3542a7e2..4ba74a12a 100644 --- a/src/virtualenv/info.py +++ b/src/virtualenv/info.py @@ -9,6 +9,7 @@ IMPLEMENTATION = platform.python_implementation() IS_PYPY = IMPLEMENTATION == "PyPy" IS_GRAALPY = IMPLEMENTATION == "GraalVM" +IS_RUSTPYTHON = IMPLEMENTATION == "RustPython" IS_CPYTHON = IMPLEMENTATION == "CPython" IS_WIN = sys.platform == "win32" IS_MAC_ARM64 = sys.platform == "darwin" and platform.machine() == "arm64" @@ -18,7 +19,7 @@ LOGGER = logging.getLogger(__name__) -def fs_is_case_sensitive(): +def fs_is_case_sensitive() -> bool: global _FS_CASE_SENSITIVE # noqa: PLW0603 if _FS_CASE_SENSITIVE is None: @@ -28,7 +29,7 @@ def fs_is_case_sensitive(): return _FS_CASE_SENSITIVE -def fs_supports_symlink(): +def fs_supports_symlink() -> bool: global _CAN_SYMLINK # noqa: PLW0603 if _CAN_SYMLINK is None: @@ -61,6 +62,7 @@ def fs_path_id(path: str) -> str: "IS_GRAALPY", "IS_MAC_ARM64", "IS_PYPY", + "IS_RUSTPYTHON", "IS_WIN", "IS_ZIPAPP", "ROOT", diff --git a/src/virtualenv/py.typed b/src/virtualenv/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/virtualenv/report.py b/src/virtualenv/report.py index c9682a8f6..87eb56a03 100644 --- a/src/virtualenv/report.py +++ b/src/virtualenv/report.py @@ -16,7 +16,7 @@ LOGGER = logging.getLogger() -def setup_report(verbosity, show_pid=False): # noqa: FBT002 +def setup_report(verbosity: int, show_pid: bool = False) -> int: # noqa: FBT002 _clean_handlers(LOGGER) verbosity = min(verbosity, MAX_LEVEL) # pragma: no cover level = LEVELS[verbosity] @@ -38,7 +38,7 @@ def setup_report(verbosity, show_pid=False): # noqa: FBT002 return verbosity -def _clean_handlers(log): +def _clean_handlers(log: logging.Logger) -> None: for log_handler in list(log.handlers): # remove handlers of libraries log.removeHandler(log_handler) diff --git a/src/virtualenv/run/__init__.py b/src/virtualenv/run/__init__.py index 03190502b..79ebc426c 100644 --- a/src/virtualenv/run/__init__.py +++ b/src/virtualenv/run/__init__.py @@ -3,9 +3,10 @@ import logging import os from functools import partial +from typing import TYPE_CHECKING from virtualenv.app_data import make_app_data -from virtualenv.config.cli.parser import VirtualEnvConfigParser +from virtualenv.config.cli.parser import VirtualEnvConfigParser, VirtualEnvOptions from virtualenv.report import LEVELS, setup_report from virtualenv.run.session import Session from virtualenv.seed.wheels.periodic_update import manual_upgrade @@ -16,16 +17,28 @@ from .plugin.discovery import get_discover from .plugin.seeders import SeederSelector +if TYPE_CHECKING: + from collections.abc import MutableMapping -def cli_run(args, options=None, setup_logging=True, env=None): # noqa: FBT002 - """ - Create a virtual environment given some command line interface arguments. + from .plugin.base import ComponentBuilder + + +def cli_run( + args: list[str], + options: VirtualEnvOptions | None = None, + setup_logging: bool = True, # noqa: FBT002 + env: MutableMapping[str, str] | None = None, +) -> Session: + """Create a virtual environment given some command line interface arguments. :param args: the command line arguments :param options: passing in a ``VirtualEnvOptions`` object allows return of the parsed options :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) + + :returns: the session object of the creation (its structure for now is experimental and might change on short + notice) + """ env = os.environ if env is None else env of_session = session_via_cli(args, options, setup_logging, env) @@ -34,33 +47,47 @@ def cli_run(args, options=None, setup_logging=True, env=None): # noqa: FBT002 return of_session -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. +def session_via_cli( + args: list[str], + options: VirtualEnvOptions | None = None, + setup_logging: bool = True, # noqa: FBT002 + env: MutableMapping[str, str] | None = None, +) -> Session: + """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. :param args: the command line arguments :param options: passing in a ``VirtualEnvOptions`` object allows return of the parsed options :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 + + :returns: the session object of the creation (its structure for now is experimental and might change on short + notice) + + """ env = os.environ if env is None else env parser, elements = build_parser(args, options, setup_logging, env) - options = parser.parse_args(args) - options.py_version = parser._interpreter.version_info # noqa: SLF001 - creator, seeder, activators = tuple(e.create(options) for e in elements) # create types + options = parser.parse_args(args) # ty: ignore[invalid-assignment] + options.py_version = parser._interpreter.version_info # noqa: SLF001 # ty: ignore[invalid-assignment, unresolved-attribute] + creator, seeder, activators = tuple( + e.create(options) # ty: ignore[invalid-argument-type] + for e in elements + ) # create types return Session( - options.verbosity, - options.app_data, - parser._interpreter, # noqa: SLF001 - creator, - seeder, - activators, + options.verbosity, # ty: ignore[unresolved-attribute, invalid-argument-type] + options.app_data, # ty: ignore[unresolved-attribute] + parser._interpreter, # noqa: SLF001 # ty: ignore[invalid-argument-type] + creator, # ty: ignore[invalid-argument-type] + seeder, # ty: ignore[invalid-argument-type] + activators, # ty: ignore[invalid-argument-type] ) -def build_parser(args=None, options=None, setup_logging=True, env=None): # noqa: FBT002 +def build_parser( + args: list[str] | None = None, + options: VirtualEnvOptions | None = None, + setup_logging: bool = True, # noqa: FBT002 + env: MutableMapping[str, str] | None = None, +) -> tuple[VirtualEnvConfigParser, list[ComponentBuilder]]: parser = VirtualEnvConfigParser(options, os.environ if env is None else env) add_version_flag(parser) parser.add_argument( @@ -79,7 +106,7 @@ def build_parser(args=None, options=None, setup_logging=True, env=None): # noqa if interpreter is None: msg = f"failed to find interpreter for {discover}" raise RuntimeError(msg) - elements = [ + elements: list[ComponentBuilder] = [ CreatorSelector(interpreter, parser), SeederSelector(interpreter, parser), ActivationSelector(interpreter, parser), @@ -91,18 +118,20 @@ def build_parser(args=None, options=None, setup_logging=True, env=None): # noqa return parser, elements -def build_parser_only(args=None): +def build_parser_only(args: list[str] | None = None) -> VirtualEnvConfigParser: """Used to provide a parser for the doc generation.""" return build_parser(args)[0] -def handle_extra_commands(options): +def handle_extra_commands(options: VirtualEnvOptions) -> None: if options.upgrade_embed_wheels: result = manual_upgrade(options.app_data, options.env) raise SystemExit(result) -def load_app_data(args, parser, options): +def load_app_data( + args: list[str] | None, parser: VirtualEnvConfigParser, options: VirtualEnvOptions | None +) -> VirtualEnvOptions: parser.add_argument( "--read-only-app-data", action="store_true", @@ -133,7 +162,7 @@ def load_app_data(args, parser, options): return options -def add_version_flag(parser): +def add_version_flag(parser: VirtualEnvConfigParser) -> None: import virtualenv # noqa: PLC0415 parser.add_argument( @@ -144,7 +173,7 @@ def add_version_flag(parser): ) -def _do_report_setup(parser, args, setup_logging): +def _do_report_setup(parser: VirtualEnvConfigParser, args: list[str] | None, setup_logging: bool) -> None: 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( @@ -159,7 +188,7 @@ def _do_report_setup(parser, args, setup_logging): return option, _ = parser.parse_known_args(args) if setup_logging: - setup_report(option.verbosity) + setup_report(option.verbosity) # ty: ignore[invalid-argument-type] __all__ = [ diff --git a/src/virtualenv/run/plugin/activators.py b/src/virtualenv/run/plugin/activators.py index a0e866948..6ef5df014 100644 --- a/src/virtualenv/run/plugin/activators.py +++ b/src/virtualenv/run/plugin/activators.py @@ -2,21 +2,32 @@ from argparse import ArgumentTypeError from collections import OrderedDict +from typing import TYPE_CHECKING from .base import ComponentBuilder +if TYPE_CHECKING: + from collections.abc import Sequence + + from python_discovery import PythonInfo + + from virtualenv.activation.activator import Activator + from virtualenv.config.cli.parser import VirtualEnvConfigParser, VirtualEnvOptions + class ActivationSelector(ComponentBuilder): - def __init__(self, interpreter, parser) -> None: + def __init__(self, interpreter: PythonInfo, parser: VirtualEnvConfigParser) -> None: self.default = None possible = OrderedDict( - (k, v) for k, v in self.options("virtualenv.activate").items() if v.supports(interpreter) + (k, v) + for k, v in self.options("virtualenv.activate").items() + if v.supports(interpreter) # ty: ignore[unresolved-attribute] ) super().__init__(interpreter, parser, "activators", possible) self.parser.description = "options for activation scripts" self.active = None - def add_selector_arg_parse(self, name, choices): + def add_selector_arg_parse(self, name: str, choices: Sequence[str]) -> None: self.default = ",".join(choices) self.parser.add_argument( f"--{name}", @@ -27,7 +38,7 @@ def add_selector_arg_parse(self, name, choices): type=self._extract_activators, ) - def _extract_activators(self, entered_str): + def _extract_activators(self, entered_str: str) -> list[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: @@ -35,7 +46,7 @@ def _extract_activators(self, entered_str): raise ArgumentTypeError(msg) return elements - def handle_selected_arg_parse(self, options): + def handle_selected_arg_parse(self, options: VirtualEnvOptions) -> None: # ty: ignore[invalid-method-override] selected_activators = ( self._extract_activators(self.default) if options.activators is self.default else options.activators ) @@ -51,9 +62,10 @@ def handle_selected_arg_parse(self, options): default=None, ) for activator in self.active.values(): - activator.add_parser_arguments(self.parser, self.interpreter) + activator.add_parser_arguments(self.parser, self.interpreter) # ty: ignore[unresolved-attribute] - def create(self, options): + def create(self, options: VirtualEnvOptions) -> list[Activator]: + assert self.active is not None # noqa: S101 # Set by handle_selected_arg_parse return [activator_class(options) for activator_class in self.active.values()] diff --git a/src/virtualenv/run/plugin/base.py b/src/virtualenv/run/plugin/base.py index 97c4792ec..489517514 100644 --- a/src/virtualenv/run/plugin/base.py +++ b/src/virtualenv/run/plugin/base.py @@ -3,6 +3,14 @@ import sys from collections import OrderedDict from importlib.metadata import entry_points +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + from python_discovery import PythonInfo + + from virtualenv.config.cli.parser import VirtualEnvConfigParser, VirtualEnvOptions importlib_metadata_version = () @@ -12,20 +20,22 @@ class PluginLoader: _ENTRY_POINTS = None @classmethod - def entry_points_for(cls, key): + def entry_points_for(cls, key: str) -> OrderedDict[str, type]: 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)) - 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().select(group=key)) # ty: ignore[unresolved-attribute] + return OrderedDict((e.name, e.load()) for e in cls.entry_points().get(key, {})) # ty: ignore[unresolved-attribute] @staticmethod - def entry_points(): + def entry_points() -> object: if PluginLoader._ENTRY_POINTS is None: PluginLoader._ENTRY_POINTS = entry_points() return PluginLoader._ENTRY_POINTS class ComponentBuilder(PluginLoader): - def __init__(self, interpreter, parser, name, possible) -> None: + def __init__( + self, interpreter: PythonInfo, parser: VirtualEnvConfigParser, name: str, possible: dict[str, type] + ) -> None: self.interpreter = interpreter self.name = name self._impl_class = None @@ -34,15 +44,15 @@ def __init__(self, interpreter, parser, name, possible) -> None: self.add_selector_arg_parse(name, list(self.possible)) @classmethod - def options(cls, key): + def options(cls, key: str) -> OrderedDict[str, type]: if cls._OPTIONS is None: cls._OPTIONS = cls.entry_points_for(key) return cls._OPTIONS - def add_selector_arg_parse(self, name, choices): + def add_selector_arg_parse(self, name: str, choices: Sequence[str]) -> None: raise NotImplementedError - def handle_selected_arg_parse(self, options): + def handle_selected_arg_parse(self, options: VirtualEnvOptions) -> str: selected = getattr(options, self.name) if selected not in self.possible: msg = f"No implementation for {self.interpreter}" @@ -51,11 +61,13 @@ def handle_selected_arg_parse(self, options): self.populate_selected_argparse(selected, options.app_data) return selected - def populate_selected_argparse(self, selected, app_data): + def populate_selected_argparse(self, selected: str, app_data: object) -> None: self.parser.description = f"options for {self.name} {selected}" - self._impl_class.add_parser_arguments(self.parser, self.interpreter, app_data) + assert self._impl_class is not None # noqa: S101 # Set by handle_selected_arg_parse + self._impl_class.add_parser_arguments(self.parser, self.interpreter, app_data) # ty: ignore[unresolved-attribute] - def create(self, options): + def create(self, options: VirtualEnvOptions) -> object: + assert self._impl_class is not None # noqa: S101 # Set by handle_selected_arg_parse return self._impl_class(options, self.interpreter) diff --git a/src/virtualenv/run/plugin/creators.py b/src/virtualenv/run/plugin/creators.py index 6bb11845d..d1de11c2b 100644 --- a/src/virtualenv/run/plugin/creators.py +++ b/src/virtualenv/run/plugin/creators.py @@ -9,6 +9,11 @@ from .base import ComponentBuilder if TYPE_CHECKING: + from collections.abc import Sequence + + from python_discovery import PythonInfo + + from virtualenv.config.cli.parser import VirtualEnvConfigParser, VirtualEnvOptions from virtualenv.create.creator import Creator, CreatorMeta @@ -20,19 +25,19 @@ class CreatorInfo(NamedTuple): class CreatorSelector(ComponentBuilder): - def __init__(self, interpreter, parser) -> None: + def __init__(self, interpreter: PythonInfo, parser: VirtualEnvConfigParser) -> None: creators, self.key_to_meta, self.describe, self.builtin_key = self.for_interpreter(interpreter) - super().__init__(interpreter, parser, "creator", creators) + super().__init__(interpreter, parser, "creator", creators) # ty: ignore[invalid-argument-type] @classmethod - def for_interpreter(cls, interpreter): + def for_interpreter(cls, interpreter: PythonInfo) -> CreatorInfo: key_to_class, key_to_meta, builtin_key, describe = OrderedDict(), {}, None, None errors = defaultdict(list) for key, creator_class in cls.options("virtualenv.create").items(): if key == "builtin": msg = "builtin creator is a reserved name" raise RuntimeError(msg) - meta = creator_class.can_create(interpreter) + meta = creator_class.can_create(interpreter) # ty: ignore[unresolved-attribute] if meta: if meta.error: errors[meta.error].append(creator_class) @@ -55,10 +60,10 @@ def for_interpreter(cls, interpreter): key_to_class=key_to_class, key_to_meta=key_to_meta, describe=describe, - builtin_key=builtin_key, + builtin_key=builtin_key or "", ) - def add_selector_arg_parse(self, name, choices): + def add_selector_arg_parse(self, name: str, choices: Sequence[str]) -> None: # prefer the built-in venv if present, otherwise fallback to first defined type choices = sorted(choices, key=lambda a: 0 if a == "builtin" else 1) default_value = self._get_default(choices) @@ -71,18 +76,20 @@ def add_selector_arg_parse(self, name, choices): ) @staticmethod - def _get_default(choices): + def _get_default(choices: list[str]) -> str: return next(iter(choices)) - def populate_selected_argparse(self, selected, app_data): + def populate_selected_argparse(self, selected: str, app_data: object) -> None: 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) + assert self._impl_class is not None # noqa: S101 # Set by handle_selected_arg_parse + self._impl_class.add_parser_arguments(self.parser, self.interpreter, self.key_to_meta[selected], app_data) # ty: ignore[unresolved-attribute] - def create(self, options): + def create(self, options: VirtualEnvOptions) -> Creator: options.meta = self.key_to_meta[getattr(options, self.name)] + assert self._impl_class is not None # noqa: S101 # Set by handle_selected_arg_parse if not issubclass(self._impl_class, Describe): - options.describe = self.describe(options, self.interpreter) - return super().create(options) + options.describe = self.describe(options, self.interpreter) # ty: ignore[call-non-callable, invalid-argument-type] + return super().create(options) # ty: ignore[invalid-return-type] __all__ = [ diff --git a/src/virtualenv/run/plugin/discovery.py b/src/virtualenv/run/plugin/discovery.py index 5e8b2392f..837a3bcd7 100644 --- a/src/virtualenv/run/plugin/discovery.py +++ b/src/virtualenv/run/plugin/discovery.py @@ -1,13 +1,19 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from .base import PluginLoader +if TYPE_CHECKING: + from virtualenv.config.cli.parser import VirtualEnvConfigParser + from virtualenv.discovery.discover import Discover + class Discovery(PluginLoader): """Discovery plugins.""" -def get_discover(parser, args): +def get_discover(parser: VirtualEnvConfigParser, args: list[str] | None) -> Discover: discover_types = Discovery.entry_points_for("virtualenv.discovery") discovery_parser = parser.add_argument_group( title="discovery", @@ -29,13 +35,22 @@ def get_discover(parser, args): help="interpreter discovery method", ) options, _ = parser.parse_known_args(args) - discover_class = discover_types[options.discovery] - discover_class.add_parser_arguments(discovery_parser) + discovery = options.discovery + if discovery not in discover_types: + available = ", ".join(sorted(discover_types)) + msg = ( + f"discovery {discovery!r} is not available. " + f"Available discovery methods: {available}. " + f"Is the plugin installed?" + ) + raise RuntimeError(msg) + discover_class = discover_types[discovery] + discover_class.add_parser_arguments(discovery_parser) # ty: ignore[unresolved-attribute] options, _ = parser.parse_known_args(args, namespace=options) return discover_class(options) -def _get_default_discovery(discover_types): +def _get_default_discovery(discover_types: dict[str, type]) -> list[str]: return list(discover_types.keys()) diff --git a/src/virtualenv/run/plugin/seeders.py b/src/virtualenv/run/plugin/seeders.py index b1da34c5d..377b58e06 100644 --- a/src/virtualenv/run/plugin/seeders.py +++ b/src/virtualenv/run/plugin/seeders.py @@ -1,14 +1,24 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from .base import ComponentBuilder +if TYPE_CHECKING: + from collections.abc import Sequence + + from python_discovery import PythonInfo + + from virtualenv.config.cli.parser import VirtualEnvConfigParser, VirtualEnvOptions + from virtualenv.seed.seeder import Seeder + class SeederSelector(ComponentBuilder): - def __init__(self, interpreter, parser) -> None: + def __init__(self, interpreter: PythonInfo, parser: VirtualEnvConfigParser) -> None: possible = self.options("virtualenv.seed") super().__init__(interpreter, parser, "seeder", possible) - def add_selector_arg_parse(self, name, choices): + def add_selector_arg_parse(self, name: str, choices: Sequence[str]) -> None: self.parser.add_argument( f"--{name}", choices=choices, @@ -25,13 +35,14 @@ def add_selector_arg_parse(self, name, choices): ) @staticmethod - def _get_default(): + def _get_default() -> str: return "app-data" - def handle_selected_arg_parse(self, options): + def handle_selected_arg_parse(self, options: VirtualEnvOptions) -> str: return super().handle_selected_arg_parse(options) - def create(self, options): + def create(self, options: VirtualEnvOptions) -> Seeder: + assert self._impl_class is not None # noqa: S101 # Set by handle_selected_arg_parse return self._impl_class(options) diff --git a/src/virtualenv/run/session.py b/src/virtualenv/run/session.py index def795328..3de6726b2 100644 --- a/src/virtualenv/run/session.py +++ b/src/virtualenv/run/session.py @@ -2,6 +2,23 @@ import json import logging +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from types import TracebackType + + from python_discovery import PythonInfo + + from virtualenv.activation.activator import Activator + from virtualenv.app_data.base import AppData + from virtualenv.create.creator import Creator + from virtualenv.seed.seeder import Seeder + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self LOGGER = logging.getLogger(__name__) @@ -9,7 +26,15 @@ class Session: """Represents a virtual environment creation session.""" - def __init__(self, verbosity, app_data, interpreter, creator, seeder, activators) -> None: # noqa: PLR0913 + def __init__( # noqa: PLR0913 + self, + verbosity: int, + app_data: AppData, + interpreter: PythonInfo, + creator: Creator, + seeder: Seeder, + activators: list[Activator], + ) -> None: self._verbosity = verbosity self._app_data = app_data self._interpreter = interpreter @@ -18,58 +43,63 @@ def __init__(self, verbosity, app_data, interpreter, creator, seeder, activators self._activators = activators @property - def verbosity(self): + def verbosity(self) -> int: """The verbosity of the run.""" return self._verbosity @property - def interpreter(self): + def interpreter(self) -> PythonInfo: """Create a virtual environment based on this reference interpreter.""" return self._interpreter @property - def creator(self): + def creator(self) -> Creator: """The creator used to build the virtual environment (must be compatible with the interpreter).""" return self._creator @property - def seeder(self): + def seeder(self) -> Seeder: """The mechanism used to provide the seed packages (pip, setuptools, wheel).""" return self._seeder @property - def activators(self): + def activators(self) -> list[Activator]: """Activators used to generate activations scripts.""" return self._activators - def run(self): + def run(self) -> None: self._create() self._seed() self._activate() self.creator.pyenv_cfg.write() - def _create(self): + def _create(self) -> None: LOGGER.info("create virtual environment via %s", self.creator) self.creator.run() LOGGER.debug(_DEBUG_MARKER) LOGGER.debug("%s", _Debug(self.creator)) - def _seed(self): + def _seed(self) -> None: if self.seeder is not None and self.seeder.enabled: LOGGER.info("add seed packages via %s", self.seeder) self.seeder.run(self.creator) - def _activate(self): + def _activate(self) -> None: if 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) - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: self._app_data.close() @@ -79,7 +109,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): class _Debug: """lazily populate debug.""" - def __init__(self, creator) -> None: + def __init__(self, creator: Creator) -> None: self.creator = creator def __repr__(self) -> str: diff --git a/src/virtualenv/seed/embed/base_embed.py b/src/virtualenv/seed/embed/base_embed.py index 72fc5a34a..9980dab1a 100644 --- a/src/virtualenv/seed/embed/base_embed.py +++ b/src/virtualenv/seed/embed/base_embed.py @@ -4,16 +4,25 @@ from abc import ABC from argparse import SUPPRESS from pathlib import Path +from typing import TYPE_CHECKING from virtualenv.seed.seeder import Seeder from virtualenv.seed.wheels import Version +if TYPE_CHECKING: + from argparse import ArgumentParser + + from python_discovery import PythonInfo + + from virtualenv.app_data.base import AppData + from virtualenv.config.cli.parser import VirtualEnvOptions + LOGGER = logging.getLogger(__name__) PERIODIC_UPDATE_ON_BY_DEFAULT = True class BaseEmbed(Seeder, ABC): - def __init__(self, options) -> None: + def __init__(self, options: VirtualEnvOptions) -> None: super().__init__(options, enabled=options.no_seed is False) self.download = options.download @@ -46,7 +55,7 @@ def __init__(self, options) -> None: self.enabled = False @classmethod - def distributions(cls) -> dict[str, Version]: + def distributions(cls) -> dict[str, str]: return { "pip": Version.bundle, "setuptools": Version.bundle, @@ -61,7 +70,7 @@ def distribution_to_versions(self) -> dict[str, str]: } @classmethod - def add_parser_arguments(cls, parser, interpreter, app_data): # noqa: ARG003 + def add_parser_arguments(cls, parser: ArgumentParser, interpreter: PythonInfo, app_data: AppData) -> None: # noqa: ARG003 group = parser.add_mutually_exclusive_group() group.add_argument( "--no-download", diff --git a/src/virtualenv/seed/embed/pip_invoke.py b/src/virtualenv/seed/embed/pip_invoke.py index b733c5148..fb442291c 100644 --- a/src/virtualenv/seed/embed/pip_invoke.py +++ b/src/virtualenv/seed/embed/pip_invoke.py @@ -3,19 +3,27 @@ import logging from contextlib import contextmanager from subprocess import Popen +from typing import TYPE_CHECKING -from virtualenv.discovery.cached_py_info import LogCmd from virtualenv.seed.embed.base_embed import BaseEmbed from virtualenv.seed.wheels import Version, get_wheel, pip_wheel_env_run +from virtualenv.util.subprocess import LogCmd + +if TYPE_CHECKING: + from collections.abc import Generator + from pathlib import Path + + from virtualenv.config.cli.parser import VirtualEnvOptions + from virtualenv.create.creator import Creator LOGGER = logging.getLogger(__name__) class PipInvoke(BaseEmbed): - def __init__(self, options) -> None: + def __init__(self, options: VirtualEnvOptions) -> None: super().__init__(options) - def run(self, creator): + def run(self, creator: Creator) -> None: if not self.enabled: return for_py_version = creator.interpreter.version_release_str @@ -24,7 +32,7 @@ def run(self, creator): self._execute(cmd, env) @staticmethod - def _execute(cmd, env): + def _execute(cmd: list[str], env: dict[str, str]) -> Popen[bytes]: LOGGER.debug("pip seed by running: %s", LogCmd(cmd, env)) process = Popen(cmd, env=env) process.communicate() @@ -34,8 +42,18 @@ def _execute(cmd, env): return process @contextmanager - def get_pip_install_cmd(self, exe, for_py_version): - cmd = [str(exe), "-m", "pip", "-q", "install", "--only-binary", ":all:", "--disable-pip-version-check"] + def get_pip_install_cmd(self, exe: Path, for_py_version: str) -> Generator[list[str], None, None]: + cmd = [ + str(exe), + "-m", + "pip", + "-q", + "install", + "--only-binary", + ":all:", + "--disable-pip-version-check", + "--ignore-installed", + ] if not self.download: cmd.append("--no-index") folders = set() 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 d5e87ad93..89348aaa0 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 @@ -9,16 +9,20 @@ from itertools import chain from pathlib import Path from tempfile import mkdtemp +from typing import TYPE_CHECKING from distlib.scripts import ScriptMaker, enquote_executable from virtualenv.util.path import safe_delete +if TYPE_CHECKING: + from virtualenv.create.creator import Creator + LOGGER = logging.getLogger(__name__) class PipInstall(ABC): - def __init__(self, wheel, creator, image_folder) -> None: + def __init__(self, wheel: Path, creator: Creator, image_folder: Path) -> None: self._wheel = wheel self._creator = creator self._image_dir = image_folder @@ -27,10 +31,10 @@ def __init__(self, wheel, creator, image_folder) -> None: self._console_entry_points = None @abstractmethod - def _sync(self, src, dst): + def _sync(self, src: Path, dst: Path) -> None: raise NotImplementedError - def install(self, version_info): + def install(self, version_info: tuple[int, ...]) -> None: self._extracted = True self._uninstall_previous_version() # sync image @@ -40,11 +44,11 @@ def install(self, version_info): # generate console executables consoles = set() script_dir = self._creator.script_dir - for name, module in self._console_scripts.items(): + for name, module in self._console_scripts.items(): # ty: ignore[unresolved-attribute] consoles.update(self._create_console_entry_point(name, module, script_dir, version_info)) LOGGER.debug("generated console scripts %s", " ".join(i.name for i in consoles)) - def build_image(self): + def build_image(self) -> None: # 1. first extract the wheel LOGGER.debug("build install image for %s to %s", self._wheel.name, self._image_dir) with zipfile.ZipFile(str(self._wheel)) as zip_ref: @@ -56,7 +60,7 @@ def build_image(self): # 3. finally fix the records file self._fix_records(new_files) - def _shorten_path_if_needed(self, zip_ref): + def _shorten_path_if_needed(self, zip_ref: zipfile.ZipFile) -> None: if os.name == "nt": to_folder = str(self._image_dir) # https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation @@ -70,16 +74,16 @@ def _shorten_path_if_needed(self, zip_ref): to_folder = get_short_path_name(to_folder) self._image_dir = Path(to_folder) - def _records_text(self, files): + def _records_text(self, files: set[Path] | list[Path]) -> str: return "\n".join(f"{os.path.relpath(str(rec), str(self._image_dir))},," for rec in files) - def _generate_new_files(self): + def _generate_new_files(self) -> set[Path]: new_files = set() - installer = self._dist_info / "INSTALLER" + installer = self._dist_info / "INSTALLER" # ty: ignore[unsupported-operator] 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 / f"{self._dist_info.stem}.virtualenv" + marker = self._image_dir / f"{self._dist_info.stem}.virtualenv" # ty: ignore[unresolved-attribute] marker.write_text("", encoding="utf-8") new_files.add(marker) folder = mkdtemp() @@ -87,17 +91,17 @@ def _generate_new_files(self): to_folder = Path(folder) 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(): + for name, module in self._console_scripts.items(): # ty: ignore[unresolved-attribute] new_files.update( 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) + for i in self._create_console_entry_point(name, module, to_folder, version_info) # ty: ignore[invalid-argument-type] ) finally: - safe_delete(folder) + safe_delete(folder) # ty: ignore[invalid-argument-type] return new_files @property - def _dist_info(self): + def _dist_info(self) -> Path | None: if self._extracted is False: return None # pragma: no cover if self.__dist_info is None: @@ -113,16 +117,16 @@ def _dist_info(self): return self.__dist_info @abstractmethod - def _fix_records(self, extra_record_data): + def _fix_records(self, extra_record_data: set[Path]) -> None: raise NotImplementedError @property - def _console_scripts(self): + def _console_scripts(self) -> dict[str, str] | None: if self._extracted is False: return None # pragma: no cover if self._console_entry_points is None: self._console_entry_points = {} - entry_points = self._dist_info / "entry_points.txt" + entry_points = self._dist_info / "entry_points.txt" # ty: ignore[unsupported-operator] if entry_points.exists(): parser = ConfigParser() with entry_points.open(encoding="utf-8") as file_handler: @@ -134,7 +138,9 @@ def _console_scripts(self): self._console_entry_points[our_name] = value return self._console_entry_points - def _create_console_entry_point(self, name, value, to_folder, version_info): + def _create_console_entry_point( + self, name: str, value: str, to_folder: Path, version_info: tuple[int, ...] + ) -> list[Path]: result = [] maker = ScriptMakerCustom(to_folder, version_info, self._creator.exe, name) specification = f"{name} = {value}" @@ -142,8 +148,8 @@ def _create_console_entry_point(self, name, value, to_folder, version_info): result.extend(Path(i) for i in new_files) return result - def _uninstall_previous_version(self): - dist_name = self._dist_info.stem.split("-")[0] + def _uninstall_previous_version(self) -> None: + dist_name = self._dist_info.stem.split("-")[0] # ty: ignore[unresolved-attribute] 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) @@ -151,7 +157,7 @@ def _uninstall_previous_version(self): self._uninstall_dist(existing_dist) @staticmethod - def _uninstall_dist(dist): + def _uninstall_dist(dist: Path) -> None: dist_base = dist.parent LOGGER.debug("uninstall existing distribution %s from %s", dist.stem, dist_base) @@ -178,25 +184,27 @@ def _uninstall_dist(dist): else: path.unlink() - def clear(self): + def clear(self) -> None: if self._image_dir.exists(): safe_delete(self._image_dir) - def has_image(self): + def has_image(self) -> bool: return self._image_dir.exists() and any(self._image_dir.iterdir()) class ScriptMakerCustom(ScriptMaker): - def __init__(self, target_dir, version_info, executable, name) -> None: + def __init__(self, target_dir: Path, version_info: tuple[int, ...], executable: Path, name: str) -> 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)) - self.version_info = version_info.major, version_info.minor + self.version_info = version_info.major, version_info.minor # ty: ignore[unresolved-attribute] self.variants = {"", "X", "X.Y"} self._name = name - def _write_script(self, names, shebang, script_bytes, filenames, ext): + def _write_script( + self, names: set[str], shebang: bytes, script_bytes: bytes, filenames: list[str], ext: str + ) -> None: names.add(f"{self._name}{self.version_info[0]}.{self.version_info[1]}") super()._write_script(names, shebang, script_bytes, filenames, ext) 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 b5f01aa91..e218bf0b1 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 @@ -2,23 +2,27 @@ import os from pathlib import Path +from typing import TYPE_CHECKING from virtualenv.util.path import copy from .base import PipInstall +if TYPE_CHECKING: + from collections.abc import Generator + class CopyPipInstall(PipInstall): - def _sync(self, src, dst): + def _sync(self, src: Path, dst: Path) -> None: copy(src, dst) - def _generate_new_files(self): + def _generate_new_files(self) -> set[Path]: # create the pyc files new_files = super()._generate_new_files() new_files.update(self._cache_files()) return new_files - def _cache_files(self): + def _cache_files(self) -> Generator[Path, None, None]: version = self._creator.interpreter.version_info 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): @@ -29,9 +33,9 @@ def _cache_files(self): 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 (self._dist_info / "RECORD").open("ab") as file_handler: + def _fix_records(self, extra_record_data: set[Path]) -> None: + extra_record_data_str = self._records_text(extra_record_data) + with (self._dist_info / "RECORD").open("ab") as file_handler: # ty: ignore[unsupported-operator] file_handler.write(extra_record_data_str.encode("utf-8")) 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 7eb9f5f47..855285673 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 @@ -3,17 +3,21 @@ import os from stat import S_IREAD, S_IRGRP, S_IROTH from subprocess import PIPE, Popen +from typing import TYPE_CHECKING from virtualenv.util.path import safe_delete, set_tree from .base import PipInstall +if TYPE_CHECKING: + from pathlib import Path + class SymlinkPipInstall(PipInstall): - def _sync(self, src, dst): + def _sync(self, src: Path, dst: Path) -> None: os.symlink(str(src), str(dst)) - def _generate_new_files(self): + def _generate_new_files(self) -> set[Path]: # create the pyc files, as the build image will be R/O cmd = [str(self._creator.exe), "-m", "compileall", str(self._image_dir)] process = Popen(cmd, stdout=PIPE, stderr=PIPE) @@ -37,17 +41,17 @@ def _generate_new_files(self): new_files.add(file) return new_files - 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)) - (self._dist_info / "RECORD").write_text(extra_record_data_str, encoding="utf-8") + def _fix_records(self, extra_record_data: set[Path]) -> None: + extra_record_data.update(i for i in self._image_dir.iterdir()) + extra_record_data_str = self._records_text(sorted(extra_record_data, key=str)) # ty: ignore[invalid-argument-type] + (self._dist_info / "RECORD").write_text(extra_record_data_str, encoding="utf-8") # ty: ignore[unsupported-operator] - def build_image(self): + def build_image(self) -> None: 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): + def clear(self) -> None: if self._image_dir.exists(): safe_delete(self._image_dir) super().clear() 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 a2e9630c6..092ef74bc 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 @@ -9,6 +9,7 @@ from pathlib import Path from subprocess import CalledProcessError from threading import Lock, Thread +from typing import TYPE_CHECKING from virtualenv.info import fs_supports_symlink from virtualenv.seed.embed.base_embed import BaseEmbed @@ -17,16 +18,29 @@ from .pip_install.copy import CopyPipInstall from .pip_install.symlink import SymlinkPipInstall +if TYPE_CHECKING: + from argparse import ArgumentParser + from collections.abc import Generator + + from python_discovery import PythonInfo + + from virtualenv.app_data.base import AppData + from virtualenv.config.cli.parser import VirtualEnvOptions + from virtualenv.create.creator import Creator + from virtualenv.seed.wheels.util import Wheel + + from .pip_install.base import PipInstall + LOGGER = logging.getLogger(__name__) class FromAppData(BaseEmbed): - def __init__(self, options) -> None: + def __init__(self, options: VirtualEnvOptions) -> None: super().__init__(options) self.symlinks = options.symlink_app_data @classmethod - def add_parser_arguments(cls, parser, interpreter, app_data): + def add_parser_arguments(cls, parser: ArgumentParser, interpreter: PythonInfo, app_data: AppData) -> None: 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 - " @@ -38,7 +52,7 @@ def add_parser_arguments(cls, parser, interpreter, app_data): default=False, ) - def run(self, creator): + def run(self, creator: Creator) -> None: if not self.enabled: return with self._get_seed_wheels(creator) as name_to_whl: @@ -46,7 +60,7 @@ def run(self, creator): installer_class = self.installer_class(pip_version) exceptions = {} - def _install(name, wheel): + def _install(name: str, wheel: Wheel) -> None: try: LOGGER.debug("install %s from wheel %s via %s", name, wheel, installer_class.__name__) key = Path(installer_class.__name__) / wheel.path.stem @@ -56,7 +70,7 @@ def _install(name, wheel): with parent.non_reentrant_lock_for_key(wheel_img.name): if not installer.has_image(): installer.build_image() - installer.install(creator.interpreter.version_info) + installer.install(creator.interpreter.version_info) # ty: ignore[invalid-argument-type] except Exception: # noqa: BLE001 exceptions[name] = sys.exc_info() @@ -73,10 +87,10 @@ def _install(name, wheel): raise RuntimeError("\n".join(messages)) @contextmanager - def _get_seed_wheels(self, creator): # noqa: C901 + def _get_seed_wheels(self, creator: Creator) -> Generator[dict[str, Wheel], None, None]: # noqa: C901 name_to_whl, lock, fail = {}, Lock(), {} - def _get(distribution, version): + def _get(distribution: str, version: str | None) -> None: for_py_version = creator.interpreter.version_release_str failure, result = None, None # fallback to download in case the exact version is not available @@ -130,7 +144,7 @@ def _get(distribution, version): raise RuntimeError(msg) yield name_to_whl - def installer_class(self, pip_version_tuple): + def installer_class(self, pip_version_tuple: tuple[int, ...] | None) -> type[PipInstall]: if self.symlinks and pip_version_tuple and pip_version_tuple >= (19, 3): # symlink support requires pip 19.3+ return SymlinkPipInstall return CopyPipInstall diff --git a/src/virtualenv/seed/seeder.py b/src/virtualenv/seed/seeder.py index 58fd8f416..4ae6276be 100644 --- a/src/virtualenv/seed/seeder.py +++ b/src/virtualenv/seed/seeder.py @@ -1,39 +1,49 @@ from __future__ import annotations from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from argparse import ArgumentParser + + from python_discovery import PythonInfo + + from virtualenv.app_data.base import AppData + from virtualenv.config.cli.parser import VirtualEnvOptions + from virtualenv.create.creator import Creator class Seeder(ABC): """A seeder will install some seed packages into a virtual environment.""" - def __init__(self, options, enabled) -> None: - """ - Create. + def __init__(self, options: VirtualEnvOptions, enabled: bool) -> 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 + """ self.enabled = enabled self.env = options.env @classmethod - def add_parser_arguments(cls, parser, interpreter, app_data): - """ - Add CLI arguments for this seed mechanisms. + def add_parser_arguments(cls, parser: ArgumentParser, interpreter: PythonInfo, app_data: AppData) -> None: + """Add CLI arguments for this seed mechanisms. :param parser: the CLI parser :param app_data: the CLI parser :param interpreter: the interpreter this virtual environment is based of + """ raise NotImplementedError @abstractmethod - def run(self, creator): - """ - Perform the seed operation. + def run(self, creator: Creator) -> None: + """Perform the seed operation. + + :param creator: the creator (based of :class:`virtualenv.create.creator.Creator`) we used to create this virtual + environment - :param creator: the creator (based of :class:`virtualenv.create.creator.Creator`) we used to create this \ - virtual environment """ raise NotImplementedError diff --git a/src/virtualenv/seed/wheels/acquire.py b/src/virtualenv/seed/wheels/acquire.py index eb2fb5b45..b6a6533ad 100644 --- a/src/virtualenv/seed/wheels/acquire.py +++ b/src/virtualenv/seed/wheels/acquire.py @@ -7,24 +7,28 @@ from operator import eq, lt from pathlib import Path from subprocess import PIPE, CalledProcessError, Popen +from typing import TYPE_CHECKING from .bundle import from_bundle from .periodic_update import add_wheel_to_update_log from .util import Version, Wheel, discover_wheels +if TYPE_CHECKING: + from virtualenv.app_data.base import AppData + LOGGER = logging.getLogger(__name__) def get_wheel( # noqa: PLR0913 - distribution, - version, - for_py_version, - search_dirs, - download, - app_data, - do_periodic_update, - env, -): + distribution: str, + version: str | None, + for_py_version: str, + search_dirs: list[Path], + download: bool, + app_data: AppData, + do_periodic_update: bool, + env: dict[str, str], +) -> Wheel | None: """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 @@ -50,7 +54,15 @@ def get_wheel( # noqa: PLR0913 return wheel -def download_wheel(distribution, version_spec, for_py_version, search_dirs, app_data, to_folder, env): # noqa: PLR0913 +def download_wheel( # noqa: PLR0913 + distribution: str, + version_spec: str | None, + for_py_version: str, + search_dirs: list[Path], + app_data: AppData, + to_folder: Path, + env: dict[str, str], +) -> Wheel: to_download = f"{distribution}{version_spec or ''}" LOGGER.debug("download wheel %s %s to %s", to_download, for_py_version, to_folder) cmd = [ @@ -77,11 +89,13 @@ def download_wheel(distribution, version_spec, for_py_version, search_dirs, app_ kwargs = {"output": out, "stderr": err} raise CalledProcessError(process.returncode, cmd, **kwargs) result = _find_downloaded_wheel(distribution, version_spec, for_py_version, to_folder, out) - LOGGER.debug("downloaded wheel %s", result.name) - return result + LOGGER.debug("downloaded wheel %s", result.name) # ty: ignore[unresolved-attribute] + return result # ty: ignore[invalid-return-type] -def _find_downloaded_wheel(distribution, version_spec, for_py_version, to_folder, out): +def _find_downloaded_wheel( + distribution: str, version_spec: str | None, for_py_version: str, to_folder: Path, out: str +) -> Wheel | None: for line in out.splitlines(): stripped_line = line.lstrip() for marker in ("Saved ", "File was already downloaded "): @@ -91,7 +105,9 @@ def _find_downloaded_wheel(distribution, version_spec, for_py_version, to_folder 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): +def find_compatible_in_house( + distribution: str, version_spec: str | None, for_py_version: str, in_folder: Path +) -> Wheel | None: wheels = discover_wheels(in_folder, distribution, None, for_py_version) start, end = 0, len(wheels) if version_spec is not None and version_spec: @@ -107,7 +123,7 @@ def find_compatible_in_house(distribution, version_spec, for_py_version, in_fold return None if start == end else wheels[start] -def pip_wheel_env_run(search_dirs, app_data, env): +def pip_wheel_env_run(search_dirs: list[Path], app_data: AppData, env: dict[str, str]) -> dict[str, str]: env = env.copy() env.update({"PIP_USE_WHEEL": "1", "PIP_USER": "0", "PIP_NO_INPUT": "1", "PYTHONIOENCODING": "utf-8"}) wheel = get_wheel( diff --git a/src/virtualenv/seed/wheels/bundle.py b/src/virtualenv/seed/wheels/bundle.py index 523e45ca2..0a6580a9b 100644 --- a/src/virtualenv/seed/wheels/bundle.py +++ b/src/virtualenv/seed/wheels/bundle.py @@ -1,12 +1,27 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from virtualenv.seed.wheels.embed import get_embed_wheel from .periodic_update import periodic_update from .util import Version, Wheel, discover_wheels +if TYPE_CHECKING: + from pathlib import Path + + from virtualenv.app_data.base import AppData + -def from_bundle(distribution, version, for_py_version, search_dirs, app_data, do_periodic_update, env): # noqa: PLR0913 +def from_bundle( # noqa: PLR0913 + distribution: str, + version: str | None, + for_py_version: str, + search_dirs: list[Path], + app_data: AppData, + do_periodic_update: bool, + env: dict[str, str], +) -> Wheel | None: """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) @@ -24,19 +39,19 @@ def from_bundle(distribution, version, for_py_version, search_dirs, app_data, do return wheel -def load_embed_wheel(app_data, distribution, for_py_version, version): +def load_embed_wheel(app_data: AppData, distribution: str, for_py_version: str, version: str | None) -> Wheel | None: wheel = get_embed_wheel(distribution, for_py_version) if wheel is not None: version_match = version == wheel.version if version is None or version_match: - with app_data.ensure_extracted(wheel.path, lambda: app_data.house) as wheel_path: + with app_data.ensure_extracted(wheel.path, lambda: app_data.house) as wheel_path: # ty: ignore[invalid-argument-type] wheel = Wheel(wheel_path) else: # if version does not match ignore wheel = None return wheel -def from_dir(distribution, version, for_py_version, directories): +def from_dir(distribution: str, version: str | None, for_py_version: str, directories: list[Path]) -> Wheel | None: """Load a compatible wheel from a given folder.""" for folder in directories: for wheel in discover_wheels(folder, distribution, version, for_py_version): diff --git a/src/virtualenv/seed/wheels/embed/__init__.py b/src/virtualenv/seed/wheels/embed/__init__.py index ef6f78e86..c416fe0ef 100644 --- a/src/virtualenv/seed/wheels/embed/__init__.py +++ b/src/virtualenv/seed/wheels/embed/__init__.py @@ -8,42 +8,42 @@ BUNDLE_SUPPORT = { "3.8": { "pip": "pip-25.0.1-py3-none-any.whl", - "setuptools": "setuptools-75.3.2-py3-none-any.whl", + "setuptools": "setuptools-75.3.4-py3-none-any.whl", "wheel": "wheel-0.45.1-py3-none-any.whl", }, "3.9": { - "pip": "pip-25.3-py3-none-any.whl", - "setuptools": "setuptools-80.9.0-py3-none-any.whl", + "pip": "pip-26.0.1-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.10": { - "pip": "pip-25.3-py3-none-any.whl", - "setuptools": "setuptools-80.9.0-py3-none-any.whl", + "pip": "pip-26.0.1-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.11": { - "pip": "pip-25.3-py3-none-any.whl", - "setuptools": "setuptools-80.9.0-py3-none-any.whl", + "pip": "pip-26.0.1-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.12": { - "pip": "pip-25.3-py3-none-any.whl", - "setuptools": "setuptools-80.9.0-py3-none-any.whl", + "pip": "pip-26.0.1-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.13": { - "pip": "pip-25.3-py3-none-any.whl", - "setuptools": "setuptools-80.9.0-py3-none-any.whl", + "pip": "pip-26.0.1-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.14": { - "pip": "pip-25.3-py3-none-any.whl", - "setuptools": "setuptools-80.9.0-py3-none-any.whl", + "pip": "pip-26.0.1-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, "3.15": { - "pip": "pip-25.3-py3-none-any.whl", - "setuptools": "setuptools-80.9.0-py3-none-any.whl", + "pip": "pip-26.0.1-py3-none-any.whl", + "setuptools": "setuptools-82.0.1-py3-none-any.whl", }, } MAX = "3.8" -def get_embed_wheel(distribution, for_py_version): +def get_embed_wheel(distribution: str, for_py_version: str) -> Wheel | None: mapping = BUNDLE_SUPPORT.get(for_py_version, {}) or BUNDLE_SUPPORT[MAX] wheel_file = mapping.get(distribution) if wheel_file is None: diff --git a/src/virtualenv/seed/wheels/embed/pip-25.3-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/pip-26.0.1-py3-none-any.whl similarity index 70% rename from src/virtualenv/seed/wheels/embed/pip-25.3-py3-none-any.whl rename to src/virtualenv/seed/wheels/embed/pip-26.0.1-py3-none-any.whl index 755e1aa0c..580d09a92 100644 Binary files a/src/virtualenv/seed/wheels/embed/pip-25.3-py3-none-any.whl and b/src/virtualenv/seed/wheels/embed/pip-26.0.1-py3-none-any.whl differ diff --git a/src/virtualenv/seed/wheels/embed/setuptools-75.3.2-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-75.3.4-py3-none-any.whl similarity index 91% rename from src/virtualenv/seed/wheels/embed/setuptools-75.3.2-py3-none-any.whl rename to src/virtualenv/seed/wheels/embed/setuptools-75.3.4-py3-none-any.whl index 1b66a67ff..4cacd34c2 100644 Binary files a/src/virtualenv/seed/wheels/embed/setuptools-75.3.2-py3-none-any.whl and b/src/virtualenv/seed/wheels/embed/setuptools-75.3.4-py3-none-any.whl differ diff --git a/src/virtualenv/seed/wheels/embed/setuptools-80.9.0-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-80.9.0-py3-none-any.whl deleted file mode 100644 index 2412ad4a3..000000000 Binary files a/src/virtualenv/seed/wheels/embed/setuptools-80.9.0-py3-none-any.whl and /dev/null differ diff --git a/src/virtualenv/seed/wheels/embed/setuptools-82.0.1-py3-none-any.whl b/src/virtualenv/seed/wheels/embed/setuptools-82.0.1-py3-none-any.whl new file mode 100644 index 000000000..2db749a77 Binary files /dev/null and b/src/virtualenv/seed/wheels/embed/setuptools-82.0.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 8ada2a758..57193ee7c 100644 --- a/src/virtualenv/seed/wheels/periodic_update.py +++ b/src/virtualenv/seed/wheels/periodic_update.py @@ -14,6 +14,7 @@ from subprocess import DEVNULL, Popen from textwrap import dedent from threading import Thread +from typing import TYPE_CHECKING from urllib.error import URLError from urllib.request import urlopen @@ -22,6 +23,11 @@ from virtualenv.seed.wheels.util import Wheel from virtualenv.util.subprocess import CREATE_NO_WINDOW +if TYPE_CHECKING: + from collections.abc import Generator + + from virtualenv.app_data.base import AppData + 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) @@ -30,21 +36,21 @@ def periodic_update( # noqa: PLR0913 - distribution, - of_version, - for_py_version, - wheel, - search_dirs, - app_data, - do_periodic_update, - env, -): + distribution: str, + of_version: str | None, + for_py_version: str, + wheel: Wheel | None, + search_dirs: list[Path], + app_data: AppData, + do_periodic_update: bool, + env: dict[str, str], +) -> Wheel | None: if do_periodic_update: handle_auto_update(distribution, for_py_version, wheel, search_dirs, app_data, env) now = datetime.now(tz=timezone.utc) - def _update_wheel(ver): + def _update_wheel(ver: NewVersion) -> Wheel: updated_wheel = Wheel(app_data.house / ver.filename) LOGGER.debug("using %supdated wheel %s", "periodically " if updated_wheel else "", updated_wheel) return updated_wheel @@ -68,7 +74,14 @@ def _update_wheel(ver): return wheel -def handle_auto_update(distribution, for_py_version, wheel, search_dirs, app_data, env): # noqa: PLR0913 +def handle_auto_update( # noqa: PLR0913 + distribution: str, + for_py_version: str, + wheel: Wheel | None, + search_dirs: list[Path], + app_data: AppData, + env: dict[str, str], +) -> None: 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: @@ -78,12 +91,12 @@ def handle_auto_update(distribution, for_py_version, wheel, search_dirs, app_dat 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): +def add_wheel_to_update_log(wheel: Wheel, for_py_version: str, app_data: AppData) -> None: embed_update_log = app_data.embed_update_log(wheel.distribution, for_py_version) - LOGGER.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) # ty: ignore[unresolved-attribute] u_log = UpdateLog.from_dict(embed_update_log.read()) if any(version.filename == wheel.name for version in u_log.versions): - LOGGER.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) # ty: ignore[unresolved-attribute] return # we don't need a release date for sources other than "periodic" version = NewVersion(wheel.name, datetime.now(tz=timezone.utc), None, "download") @@ -94,31 +107,31 @@ def add_wheel_to_update_log(wheel, for_py_version, app_data): DATETIME_FMT = "%Y-%m-%dT%H:%M:%S.%fZ" -def dump_datetime(value): +def dump_datetime(value: datetime | None) -> str | None: return None if value is None else value.strftime(DATETIME_FMT) -def load_datetime(value): +def load_datetime(value: str | None) -> datetime | None: return None if value is None else datetime.strptime(value, DATETIME_FMT).replace(tzinfo=timezone.utc) class NewVersion: # noqa: PLW1641 - def __init__(self, filename, found_date, release_date, source) -> None: + def __init__(self, filename: str, found_date: datetime, release_date: datetime | None, source: str) -> None: self.filename = filename self.found_date = found_date self.release_date = release_date self.source = source @classmethod - def from_dict(cls, dictionary): + def from_dict(cls, dictionary: dict[str, str | None]) -> NewVersion: return cls( - filename=dictionary["filename"], - found_date=load_datetime(dictionary["found_date"]), + filename=dictionary["filename"], # ty: ignore[invalid-argument-type] + found_date=load_datetime(dictionary["found_date"]), # ty: ignore[invalid-argument-type] release_date=load_datetime(dictionary["release_date"]), - source=dictionary["source"], + source=dictionary["source"], # ty: ignore[invalid-argument-type] ) - def to_dict(self): + def to_dict(self) -> dict[str, str | None]: return { "filename": self.filename, "release_date": dump_datetime(self.release_date), @@ -126,7 +139,7 @@ def to_dict(self): "source": self.source, } - def use(self, now, ignore_grace_period_minor=False, ignore_grace_period_ci=False): # noqa: FBT002 + def use(self, now: datetime, ignore_grace_period_minor: bool = False, ignore_grace_period_ci: bool = False) -> bool: # noqa: FBT002 if self.source == "manual": return True if self.source == "periodic" and (self.found_date < now - GRACE_PERIOD_CI or ignore_grace_period_ci): @@ -142,43 +155,45 @@ def __repr__(self) -> str: f"release_date={self.release_date}, source={self.source})" ) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return type(self) == type(other) and all( # noqa: E721 getattr(self, k) == getattr(other, k) for k in ["filename", "release_date", "found_date", "source"] ) - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return not (self == other) @property - def wheel(self): + def wheel(self) -> Wheel: return Wheel(Path(self.filename)) class UpdateLog: - def __init__(self, started, completed, versions, periodic) -> None: + def __init__( + self, started: datetime | None, completed: datetime | None, versions: list[NewVersion], periodic: bool | None + ) -> None: self.started = started self.completed = completed self.versions = versions self.periodic = periodic @classmethod - def from_dict(cls, dictionary): + def from_dict(cls, dictionary: dict[str, object] | None) -> UpdateLog: if dictionary is None: dictionary = {} return cls( - load_datetime(dictionary.get("started")), - load_datetime(dictionary.get("completed")), - [NewVersion.from_dict(v) for v in dictionary.get("versions", [])], - dictionary.get("periodic"), + load_datetime(dictionary.get("started")), # ty: ignore[invalid-argument-type] + load_datetime(dictionary.get("completed")), # ty: ignore[invalid-argument-type] + [NewVersion.from_dict(v) for v in dictionary.get("versions", [])], # ty: ignore[not-iterable] + dictionary.get("periodic"), # ty: ignore[invalid-argument-type] ) @classmethod - def from_app_data(cls, app_data, distribution, for_py_version): + def from_app_data(cls, app_data: AppData, distribution: str, for_py_version: str) -> UpdateLog: raw_json = app_data.embed_update_log(distribution, for_py_version).read() return cls.from_dict(raw_json) - def to_dict(self): + def to_dict(self) -> dict[str, object]: return { "started": dump_datetime(self.started), "completed": dump_datetime(self.completed), @@ -187,7 +202,7 @@ def to_dict(self): } @property - def needs_update(self): + def needs_update(self) -> bool: now = datetime.now(tz=timezone.utc) if self.completed is None: # never completed return self._check_start(now) @@ -195,11 +210,19 @@ def needs_update(self): return False return self._check_start(now) - def _check_start(self, now): + def _check_start(self, now: datetime) -> bool: 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): # noqa: PLR0913 +def trigger_update( # noqa: PLR0913 + distribution: str, + for_py_version: str, + wheel: Wheel | None, + search_dirs: list[Path], + app_data: AppData, + env: dict[str, str], + periodic: bool, +) -> None: wheel_path = None if wheel is None else str(wheel.path) cmd = [ sys.executable, @@ -220,7 +243,7 @@ def trigger_update(distribution, for_py_version, wheel, search_dirs, app_data, e kwargs = {"stdout": pipe, "stderr": pipe} if not debug and sys.platform == "win32": kwargs["creationflags"] = CREATE_NO_WINDOW - process = Popen(cmd, **kwargs) + process = Popen(cmd, **kwargs) # ty: ignore[no-matching-overload] LOGGER.info( "triggered periodic upgrade of %s%s (for python %s) via background process having PID %d", distribution, @@ -235,7 +258,14 @@ def trigger_update(distribution, for_py_version, wheel, search_dirs, app_data, e process.returncode = 0 -def do_update(distribution, for_py_version, embed_filename, app_data, search_dirs, periodic): # noqa: PLR0913 +def do_update( # noqa: PLR0913 + distribution: str, + for_py_version: str, + embed_filename: str | None, + app_data: str | AppData, + search_dirs: list[str] | list[Path], + periodic: bool, +) -> list[NewVersion] | None: versions = None try: versions = _run_do_update(app_data, distribution, embed_filename, for_py_version, periodic, search_dirs) @@ -245,13 +275,13 @@ def do_update(distribution, for_py_version, embed_filename, app_data, search_dir def _run_do_update( # noqa: C901, PLR0913 - app_data, - distribution, - embed_filename, - for_py_version, - periodic, - search_dirs, -): + app_data: str | AppData, + distribution: str, + embed_filename: str | None, + for_py_version: str, + periodic: bool, + search_dirs: list[str] | list[Path], +) -> list[NewVersion]: from virtualenv.seed.wheels import acquire # noqa: PLC0415 wheel_filename = None if embed_filename is None else Path(embed_filename) @@ -292,7 +322,7 @@ def _run_do_update( # noqa: C901, PLR0913 search_dirs=search_dirs, app_data=app_data, to_folder=wheelhouse, - env=os.environ, + env=os.environ, # ty: ignore[invalid-argument-type] ) if dest is None or (update_versions and update_versions[0].filename == dest.name): break @@ -316,21 +346,21 @@ def _run_do_update( # noqa: C901, PLR0913 return versions -def release_date_for_wheel_path(dest): +def release_date_for_wheel_path(dest: Path) -> datetime | None: wheel = Wheel(dest) # the most accurate is to ask PyPi - e.g. https://pypi.org/pypi/pip/json, # see https://warehouse.pypa.io/api-reference/json/ for more details content = _pypi_get_distribution_info_cached(wheel.distribution) if content is not None: try: - upload_time = content["releases"][wheel.version][0]["upload_time"] + upload_time = content["releases"][wheel.version][0]["upload_time"] # ty: ignore[not-subscriptable] 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(): +def _request_context() -> Generator[ssl.SSLContext | None, None, None]: yield None # fallback to non verified HTTPS (the information we request is not sensitive, so fallback) yield ssl._create_unverified_context() # noqa: S323, SLF001 @@ -339,13 +369,13 @@ def _request_context(): _PYPI_CACHE = {} -def _pypi_get_distribution_info_cached(distribution): +def _pypi_get_distribution_info_cached(distribution: str) -> dict[str, object] | None: if distribution not in _PYPI_CACHE: _PYPI_CACHE[distribution] = _pypi_get_distribution_info(distribution) return _PYPI_CACHE[distribution] -def _pypi_get_distribution_info(distribution): +def _pypi_get_distribution_info(distribution: str) -> dict[str, object] | None: content, url = None, f"https://pypi.org/pypi/{distribution}/json" try: for context in _request_context(): @@ -360,7 +390,7 @@ def _pypi_get_distribution_info(distribution): return content -def manual_upgrade(app_data, env): +def manual_upgrade(app_data: AppData, env: dict[str, str]) -> None: threads = [] for for_py_version, distribution_to_package in BUNDLE_SUPPORT.items(): @@ -374,7 +404,7 @@ def manual_upgrade(app_data, env): thread.join() -def _run_manual_upgrade(app_data, distribution, for_py_version, env): +def _run_manual_upgrade(app_data: AppData, distribution: str, for_py_version: str, env: dict[str, str]) -> None: start = datetime.now(tz=timezone.utc) from .bundle import from_bundle # noqa: PLC0415 @@ -396,7 +426,7 @@ def _run_manual_upgrade(app_data, distribution, for_py_version, env): versions = do_update( distribution=distribution, for_py_version=for_py_version, - embed_filename=current.path, + embed_filename=current.path, # ty: ignore[invalid-argument-type, unresolved-attribute] app_data=app_data, search_dirs=[], periodic=False, diff --git a/src/virtualenv/seed/wheels/util.py b/src/virtualenv/seed/wheels/util.py index 2bc01ae27..ff7721576 100644 --- a/src/virtualenv/seed/wheels/util.py +++ b/src/virtualenv/seed/wheels/util.py @@ -1,36 +1,40 @@ from __future__ import annotations from operator import attrgetter +from typing import TYPE_CHECKING from zipfile import ZipFile +if TYPE_CHECKING: + from pathlib import Path + class Wheel: - def __init__(self, path) -> None: + def __init__(self, path: 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 self._parts = path.stem.split("-") @classmethod - def from_path(cls, path): + def from_path(cls, path: Path) -> Wheel | None: if path is not None and path.suffix == ".whl" and len(path.stem.split("-")) >= 5: # noqa: PLR2004 return cls(path) return None @property - def distribution(self): + def distribution(self) -> str: return self._parts[0] @property - def version(self): + def version(self) -> str: return self._parts[1] @property - def version_tuple(self): + def version_tuple(self) -> tuple[int, ...]: return self.as_version_tuple(self.version) @staticmethod - def as_version_tuple(version): + def as_version_tuple(version: str) -> tuple[int, ...]: result = [] for part in version.split(".")[0:3]: try: @@ -42,10 +46,10 @@ def as_version_tuple(version): return tuple(result) @property - def name(self): + def name(self) -> str: return self.path.name - def support_py(self, py_version): + def support_py(self, py_version: str) -> bool: 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") @@ -79,7 +83,7 @@ def __str__(self) -> str: return str(self.path) -def discover_wheels(from_folder, distribution, version, for_py_version): +def discover_wheels(from_folder: Path, distribution: str, version: str | None, for_py_version: str) -> list[Wheel]: wheels = [] for filename in from_folder.iterdir(): wheel = Wheel.from_path(filename) @@ -101,15 +105,15 @@ class Version: non_version = (bundle, embed) @staticmethod - def of_version(value): + def of_version(value: str | None) -> str | None: return None if value in Version.non_version else value @staticmethod - def as_pip_req(distribution, version): + def as_pip_req(distribution: str, version: str | None) -> str: return f"{distribution}{Version.as_version_spec(version)}" @staticmethod - def as_version_spec(version): + def as_version_spec(version: str | None) -> str: of_version = Version.of_version(version) return "" if of_version is None else f"=={of_version}" diff --git a/src/virtualenv/util/error.py b/src/virtualenv/util/error.py index a317ddc18..d58a989ac 100644 --- a/src/virtualenv/util/error.py +++ b/src/virtualenv/util/error.py @@ -6,7 +6,7 @@ class ProcessCallFailedError(RuntimeError): """Failed a process call.""" - def __init__(self, code, out, err, cmd) -> None: + def __init__(self, code: int, out: str, err: str, cmd: list[str]) -> None: super().__init__(code, out, err, cmd) self.code = code self.out = out diff --git a/src/virtualenv/util/lock.py b/src/virtualenv/util/lock.py index 82c8eed65..51f6cc97d 100644 --- a/src/virtualenv/util/lock.py +++ b/src/virtualenv/util/lock.py @@ -8,14 +8,19 @@ from contextlib import contextmanager, suppress from pathlib import Path from threading import Lock, RLock +from typing import TYPE_CHECKING from filelock import FileLock, Timeout +if TYPE_CHECKING: + from collections.abc import Iterator + from types import TracebackType + LOGGER = logging.getLogger(__name__) class _CountedFileLock(FileLock): - def __init__(self, lock_file) -> None: + def __init__(self, lock_file: str) -> None: parent = os.path.dirname(lock_file) with suppress(OSError): os.makedirs(parent, exist_ok=True) @@ -24,7 +29,11 @@ def __init__(self, lock_file) -> None: self.count = 0 self.thread_safe = RLock() - def acquire(self, timeout=None, poll_interval=0.05): + def acquire( # ty: ignore[invalid-method-override] + self, + timeout: float | None = None, + poll_interval: float = 0.05, + ) -> None: if not self.thread_safe.acquire(timeout=-1 if timeout is None else timeout): raise Timeout(self.lock_file) if self.count == 0: @@ -35,7 +44,7 @@ def acquire(self, timeout=None, poll_interval=0.05): raise self.count += 1 - def release(self, force=False): # noqa: FBT002 + def release(self, force: bool = False) -> None: # noqa: FBT002 with self.thread_safe: if self.count > 0: if self.count == 1: @@ -51,41 +60,43 @@ def release(self, force=False): # noqa: FBT002 class PathLockBase(ABC): - def __init__(self, folder) -> None: + def __init__(self, folder: str | Path) -> None: path = Path(folder) self.path = path.resolve() if path.exists() else path def __repr__(self) -> str: return f"{self.__class__.__name__}({self.path})" - def __truediv__(self, other): + def __truediv__(self, other: str) -> PathLockBase: return type(self)(self.path / other) @abstractmethod - def __enter__(self): + def __enter__(self) -> None: raise NotImplementedError @abstractmethod - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: raise NotImplementedError @abstractmethod @contextmanager - def lock_for_key(self, name, no_block=False): # noqa: FBT002 + def lock_for_key(self, name: str, no_block: bool = False) -> Iterator[None]: # noqa: FBT002 raise NotImplementedError @abstractmethod @contextmanager - def non_reentrant_lock_for_key(self, name): + def non_reentrant_lock_for_key(self, name: str) -> Iterator[None]: raise NotImplementedError class ReentrantFileLock(PathLockBase): - def __init__(self, folder) -> None: + def __init__(self, folder: str | Path) -> None: super().__init__(folder) self._lock = None - def _create_lock(self, name=""): + def _create_lock(self, name: str = "") -> _CountedFileLock: lock_file = str(self.path / f"{name}.lock") with _store_lock: if lock_file not in _lock_store: @@ -93,7 +104,7 @@ def _create_lock(self, name=""): return _lock_store[lock_file] @staticmethod - def _del_lock(lock): + def _del_lock(lock: _CountedFileLock | None) -> None: if lock is not None: with _store_lock, lock.thread_safe: if lock.count == 0: @@ -102,16 +113,18 @@ def _del_lock(lock): def __del__(self) -> None: self._del_lock(self._lock) - def __enter__(self): + def __enter__(self) -> None: self._lock = self._create_lock() self._lock_file(self._lock) - def __exit__(self, exc_type, exc_val, exc_tb): - self._release(self._lock) + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: + self._release(self._lock) # ty: ignore[invalid-argument-type] self._del_lock(self._lock) self._lock = None - def _lock_file(self, lock, no_block=False): # noqa: FBT002 + def _lock_file(self, lock: _CountedFileLock, no_block: bool = False) -> None: # 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. @@ -128,11 +141,11 @@ def _lock_file(self, lock, no_block=False): # noqa: FBT002 lock.acquire() @staticmethod - def _release(lock): + def _release(lock: _CountedFileLock) -> None: lock.release() @contextmanager - def lock_for_key(self, name, no_block=False): # noqa: FBT002 + def lock_for_key(self, name: str, no_block: bool = False) -> Iterator[None]: # noqa: FBT002 lock = self._create_lock(name) try: try: @@ -145,24 +158,26 @@ def lock_for_key(self, name, no_block=False): # noqa: FBT002 lock = None @contextmanager - def non_reentrant_lock_for_key(self, name): + def non_reentrant_lock_for_key(self, name: str) -> Iterator[None]: with _CountedFileLock(str(self.path / f"{name}.lock")): yield class NoOpFileLock(PathLockBase): - def __enter__(self): + def __enter__(self) -> None: raise NotImplementedError - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: raise NotImplementedError @contextmanager - def lock_for_key(self, name, no_block=False): # noqa: ARG002, FBT002 + def lock_for_key(self, name: str, no_block: bool = False) -> Iterator[None]: # noqa: ARG002, FBT002 yield @contextmanager - def non_reentrant_lock_for_key(self, name): # noqa: ARG002 + def non_reentrant_lock_for_key(self, name: str) -> Iterator[None]: # noqa: ARG002 yield diff --git a/src/virtualenv/util/path/_permission.py b/src/virtualenv/util/path/_permission.py index 8dcad0ce9..70bb24b6e 100644 --- a/src/virtualenv/util/path/_permission.py +++ b/src/virtualenv/util/path/_permission.py @@ -2,9 +2,13 @@ import os from stat import S_IXGRP, S_IXOTH, S_IXUSR +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from pathlib import Path -def make_exe(filename): + +def make_exe(filename: Path) -> None: original_mode = filename.stat().st_mode levels = [S_IXUSR, S_IXGRP, S_IXOTH] for at in range(len(levels), 0, -1): @@ -18,7 +22,7 @@ def make_exe(filename): continue -def set_tree(folder, stat): +def set_tree(folder: Path, stat: int) -> None: 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 a09ecac34..6d701b906 100644 --- a/src/virtualenv/util/path/_sync.py +++ b/src/virtualenv/util/path/_sync.py @@ -5,17 +5,21 @@ import shutil import sys from stat import S_IWUSR +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path LOGGER = logging.getLogger(__name__) -def ensure_dir(path): +def ensure_dir(path: Path) -> None: if not path.exists(): LOGGER.debug("create folder %s", path) os.makedirs(str(path)) -def ensure_safe_to_do(src, dest): +def ensure_safe_to_do(src: Path, dest: Path) -> None: if src == dest: msg = f"source and destination is the same {src}" raise ValueError(msg) @@ -29,13 +33,13 @@ def ensure_safe_to_do(src, dest): dest.unlink() -def symlink(src, dest): +def symlink(src: Path, dest: Path) -> None: ensure_safe_to_do(src, dest) LOGGER.debug("symlink %s", _Debug(src, dest)) dest.symlink_to(src, target_is_directory=src.is_dir()) -def copy(src, dest): +def copy(src: Path, dest: Path) -> None: ensure_safe_to_do(src, dest) is_dir = src.is_dir() method = copytree if is_dir else shutil.copy @@ -43,7 +47,7 @@ def copy(src, dest): method(str(src), str(dest)) -def copytree(src, dest): +def copytree(src: str, dest: str) -> None: for root, _, files in os.walk(src): dest_dir = os.path.join(dest, os.path.relpath(root, src)) if not os.path.isdir(dest_dir): @@ -54,20 +58,22 @@ def copytree(src, dest): shutil.copy(src_f, dest_f) -def safe_delete(dest): - def onerror(func, path, exc_info): # noqa: ARG001 +def safe_delete(dest: Path) -> None: + def onerror(func: object, path: str, exc_info: object) -> None: # noqa: ARG001 if not os.access(path, os.W_OK): os.chmod(path, S_IWUSR) - func(path) + func(path) # ty: ignore[call-non-callable] else: raise # noqa: PLE0704 - kwargs = {"onexc" if sys.version_info >= (3, 12) else "onerror": onerror} - shutil.rmtree(str(dest), ignore_errors=True, **kwargs) + if sys.version_info >= (3, 12): + shutil.rmtree(str(dest), ignore_errors=True, onexc=onerror) + else: + shutil.rmtree(str(dest), ignore_errors=True, onerror=onerror) class _Debug: - def __init__(self, src, dest) -> None: + def __init__(self, src: Path, dest: Path) -> None: self.src = src self.dest = dest diff --git a/src/virtualenv/util/path/_win.py b/src/virtualenv/util/path/_win.py index 6404cda64..5498c0130 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): +def get_short_path_name(long_name: str) -> str: """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 # noqa: N806 + GetShortPathNameW = ctypes.windll.kernel32.GetShortPathNameW # noqa: N806 # ty: ignore[unresolved-attribute] GetShortPathNameW.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD] GetShortPathNameW.restype = wintypes.DWORD output_buf_size = 0 diff --git a/src/virtualenv/util/specifier.py b/src/virtualenv/util/specifier.py deleted file mode 100644 index eca5b3389..000000000 --- a/src/virtualenv/util/specifier.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Version specifier support using only standard library (PEP 440 compatible).""" - -from __future__ import annotations - -import contextlib -import operator -import re - - -class SimpleVersion: - """Simple PEP 440-like version parser using only standard library.""" - - def __init__(self, version_str: str) -> None: - self.version_str = version_str - # Parse version string into components - # Support formats like: "3.11", "3.11.0", "3.11.0a1", "3.11.0b2", "3.11.0rc1" - match = re.match( - r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(a|b|rc)(\d+))?$", - version_str.strip(), - ) - if not match: - msg = f"Invalid version: {version_str}" - raise ValueError(msg) - - self.major = int(match.group(1)) - self.minor = int(match.group(2)) if match.group(2) else 0 - self.micro = int(match.group(3)) if match.group(3) else 0 - self.pre_type = match.group(4) # a, b, rc or None - self.pre_num = int(match.group(5)) if match.group(5) else None - self.release = (self.major, self.minor, self.micro) - - def __eq__(self, other): - if not isinstance(other, SimpleVersion): - return NotImplemented - return self.release == other.release and self.pre_type == other.pre_type and self.pre_num == other.pre_num - - def __hash__(self): - return hash((self.release, self.pre_type, self.pre_num)) - - def __lt__(self, other): - if not isinstance(other, SimpleVersion): - return NotImplemented - # Compare release tuples first - if self.release != other.release: - return self.release < other.release - return self._compare_prerelease(other) - - def _compare_prerelease(self, other): - """Compare pre-release versions.""" - # If releases are equal, compare pre-release - # No pre-release is greater than any pre-release - if self.pre_type is None and other.pre_type is None: - return False - if self.pre_type is None: - return False # self is final, other is pre-release - if other.pre_type is None: - return True # self is pre-release, other is final - # Both are pre-releases, compare type then number - pre_order = {"a": 1, "b": 2, "rc": 3} - if pre_order[self.pre_type] != pre_order[other.pre_type]: - return pre_order[self.pre_type] < pre_order[other.pre_type] - return (self.pre_num or 0) < (other.pre_num or 0) - - def __le__(self, other): - return self == other or self < other - - def __gt__(self, other): - if not isinstance(other, SimpleVersion): - return NotImplemented - return not self <= other - - def __ge__(self, other): - return not self < other - - def __str__(self): - return self.version_str - - def __repr__(self): - return f"SimpleVersion('{self.version_str}')" - - -class SimpleSpecifier: - """Simple PEP 440-like version specifier using only standard library.""" - - __slots__ = ( - "is_wildcard", - "operator", - "spec_str", - "version", - "version_str", - "wildcard_precision", - "wildcard_version", - ) - - def __init__(self, spec_str: str) -> None: - self.spec_str = spec_str.strip() - # Parse operator and version - match = re.match(r"^(===|==|~=|!=|<=|>=|<|>)\s*(.+)$", self.spec_str) - if not match: - msg = f"Invalid specifier: {spec_str}" - raise ValueError(msg) - - self.operator = match.group(1) - self.version_str = match.group(2).strip() - - # Handle wildcard versions like "3.11.*" - if self.version_str.endswith(".*"): - self.is_wildcard = True - self.wildcard_version = self.version_str[:-2] - # Count the precision for wildcard matching - self.wildcard_precision = len(self.wildcard_version.split(".")) - self.version_str = self.wildcard_version - else: - self.is_wildcard = False - self.wildcard_precision = None - - try: - self.version = SimpleVersion(self.version_str) - except ValueError: - # If version parsing fails, store as string for prefix matching - self.version = None - - def contains(self, version_str: str) -> bool: - """Check if a version string satisfies this specifier.""" - try: - candidate = SimpleVersion(version_str) if isinstance(version_str, str) else version_str - except ValueError: - return False - - if self.version is None: - return False - - if self.is_wildcard: - return self._check_wildcard(candidate) - return self._check_standard(candidate) - - def _check_wildcard(self, candidate): - """Check wildcard version matching.""" - if self.operator == "==": - return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision] - if self.operator == "!=": - return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision] - # Other operators with wildcards are not standard - return False - - def _check_standard(self, candidate): - """Check standard version comparisons.""" - if self.operator == "===": - return str(candidate) == str(self.version) - if self.operator == "~=": - return self._check_compatible_release(candidate) - # Use operator module for comparisons - cmp_ops = { - "==": operator.eq, - "!=": operator.ne, - "<": operator.lt, - "<=": operator.le, - ">": operator.gt, - ">=": operator.ge, - } - if self.operator in cmp_ops: - return cmp_ops[self.operator](candidate, self.version) - return False - - def _check_compatible_release(self, candidate): - """Check compatible release version (~=).""" - if candidate < self.version: - return False - if len(self.version.release) >= 2: # noqa: PLR2004 - upper_parts = list(self.version.release[:-1]) - upper_parts[-1] += 1 - upper = SimpleVersion(".".join(str(p) for p in upper_parts)) - return candidate < upper - return True - - def __eq__(self, other): - if not isinstance(other, SimpleSpecifier): - return NotImplemented - return self.spec_str == other.spec_str - - def __hash__(self): - return hash(self.spec_str) - - def __str__(self): - return self.spec_str - - def __repr__(self): - return f"SimpleSpecifier('{self.spec_str}')" - - -class SimpleSpecifierSet: - """Simple PEP 440-like specifier set using only standard library.""" - - __slots__ = ("specifiers", "specifiers_str") - - def __init__(self, specifiers_str: str = "") -> None: - self.specifiers_str = specifiers_str.strip() - self.specifiers = [] - - if self.specifiers_str: - # Split by comma for compound specifiers - for spec_item in self.specifiers_str.split(","): - stripped = spec_item.strip() - if stripped: - with contextlib.suppress(ValueError): - self.specifiers.append(SimpleSpecifier(stripped)) - - def contains(self, version_str: str) -> bool: - """Check if a version satisfies all specifiers in the set.""" - if not self.specifiers: - return True - # All specifiers must be satisfied - return all(spec.contains(version_str) for spec in self.specifiers) - - def __iter__(self): - return iter(self.specifiers) - - def __eq__(self, other): - if not isinstance(other, SimpleSpecifierSet): - return NotImplemented - return self.specifiers_str == other.specifiers_str - - def __hash__(self): - return hash(self.specifiers_str) - - def __str__(self): - return self.specifiers_str - - def __repr__(self): - return f"SimpleSpecifierSet('{self.specifiers_str}')" - - -__all__ = [ - "SimpleSpecifier", - "SimpleSpecifierSet", - "SimpleVersion", -] diff --git a/src/virtualenv/util/subprocess/__init__.py b/src/virtualenv/util/subprocess/__init__.py index e6d5fc885..3398ac2c5 100644 --- a/src/virtualenv/util/subprocess/__init__.py +++ b/src/virtualenv/util/subprocess/__init__.py @@ -1,11 +1,28 @@ from __future__ import annotations import subprocess +from shlex import quote +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping CREATE_NO_WINDOW = 0x80000000 -def run_cmd(cmd): +class LogCmd: + def __init__(self, cmd: list[str], env: Mapping[str, str] | None = None) -> None: + self.cmd = cmd + self.env = env + + def __repr__(self) -> str: + cmd_repr = " ".join(quote(str(c)) for c in self.cmd) + if self.env is not None: + cmd_repr = f"{cmd_repr} env of {self.env!r}" + return cmd_repr + + +def run_cmd(cmd: list[str]) -> tuple[int, str, str]: try: process = subprocess.Popen( cmd, @@ -19,12 +36,13 @@ def run_cmd(cmd): code = process.returncode except OSError as error: code, out, err = error.errno, "", error.strerror - if code == 2 and "file" in err: # noqa: PLR2004 + if code == 2 and err is not None and "file" in err: # noqa: PLR2004 err = str(error) # FileNotFoundError in Python >= 3.3 - return code, out, err + return code, out, err # ty: ignore[invalid-return-type] __all__ = ( "CREATE_NO_WINDOW", + "LogCmd", "run_cmd", ) diff --git a/src/virtualenv/util/zipapp.py b/src/virtualenv/util/zipapp.py index 183dd07db..3e591344e 100644 --- a/src/virtualenv/util/zipapp.py +++ b/src/virtualenv/util/zipapp.py @@ -3,19 +3,23 @@ import logging import os import zipfile +from typing import TYPE_CHECKING from virtualenv.info import IS_WIN, ROOT +if TYPE_CHECKING: + from pathlib import Path + LOGGER = logging.getLogger(__name__) -def read(full_path): +def read(full_path: str | Path) -> str: sub_file = _get_path_within_zip(full_path) 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): +def extract(full_path: str | Path, dest: Path) -> None: 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: @@ -24,7 +28,7 @@ def extract(full_path, dest): zip_file.extract(info, str(dest.parent)) -def _get_path_within_zip(full_path): +def _get_path_within_zip(full_path: str | Path) -> str: full_path = os.path.realpath(os.path.abspath(str(full_path))) prefix = f"{ROOT}{os.sep}" if not full_path.startswith(prefix): diff --git a/tasks/__main__zipapp.py b/tasks/__main__zipapp.py index 9118e2007..1612a8b62 100644 --- a/tasks/__main__zipapp.py +++ b/tasks/__main__zipapp.py @@ -7,6 +7,13 @@ from functools import cached_property from importlib.abc import SourceLoader from importlib.util import spec_from_file_location +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterator + from importlib.machinery import ModuleSpec + from types import ModuleType, TracebackType + from typing import Self ABS_HERE = os.path.abspath(os.path.dirname(__file__)) @@ -20,7 +27,7 @@ def __init__(self) -> None: self.distributions = self._load("distributions.json") self.__cache = {} - def _load(self, of_file): + def _load(self, of_file: str) -> dict[str, str]: version = ".".join(str(i) for i in sys.version_info[0:2]) 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"] @@ -32,22 +39,24 @@ def _load(self, of_file): content.update(all_platforms.get(f"=={sys.platform}", {})) # and finish it off with our platform return content - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: self._zip_file.close() - def find_mod(self, fullname): + def find_mod(self, fullname: str) -> str | None: if fullname in self.modules: return self.modules[fullname] return None - def get_filename(self, fullname): + def get_filename(self, fullname: str) -> str | None: zip_path = self.find_mod(fullname) return None if zip_path is None else os.path.join(ABS_HERE, zip_path) - def get_data(self, filename): + def get_data(self, filename: str) -> bytes: if filename.startswith(ABS_HERE): # keep paths relative from the zipfile filename = filename[len(ABS_HERE) + 1 :] @@ -58,17 +67,18 @@ def get_data(self, filename): with self._zip_file.open(filename) as file_handler: return file_handler.read() - def find_distributions(self, context): + def find_distributions(self, context: Any) -> Iterator[Any]: # noqa: ANN401 dist_class = versioned_distribution_class() - name = context.name + if context.name is None: + return + name = context.name.replace("_", "-") if name in self.distributions: - result = dist_class(file_loader=self.get_data, dist_path=self.distributions[name]) - yield result + yield dist_class(file_loader=self.get_data, dist_path=self.distributions[name]) def __repr__(self) -> str: return f"{self.__class__.__name__}(path={ABS_HERE})" - def _register_distutils_finder(self): # noqa: C901 + def _register_distutils_finder(self) -> None: # noqa: C901 if "distlib" not in self.modules: return @@ -102,14 +112,14 @@ def resources(self) -> list[str]: ] class DistlibFinder: - def __init__(self, path, loader) -> None: + def __init__(self, path: str, loader: Any) -> None: # noqa: ANN401 self.path = path self.loader = loader - def find(self, name): + def find(self, name: str) -> Any: # noqa: ANN401 return Resource(self.path, name, self.loader) - def iterator(self, resource_name): + def iterator(self, resource_name: str) -> Iterator[Any]: resource = self.find(resource_name) if resource is not None: todo = [resource] @@ -134,20 +144,20 @@ def iterator(self, resource_name): _VER_DISTRIBUTION_CLASS = None -def versioned_distribution_class(): +def versioned_distribution_class() -> type: global _VER_DISTRIBUTION_CLASS # noqa: PLW0603 if _VER_DISTRIBUTION_CLASS is None: from importlib.metadata import Distribution # noqa: PLC0415 class VersionedDistribution(Distribution): - def __init__(self, file_loader, dist_path) -> None: + def __init__(self, file_loader: Any, dist_path: str) -> None: # noqa: ANN401 self.file_loader = file_loader self.dist_path = dist_path - def read_text(self, filename): + def read_text(self, filename: str) -> str: return self.file_loader(self.locate_file(filename)).decode("utf-8") - def locate_file(self, path): + def locate_file(self, path: str) -> str: return os.path.join(self.dist_path, path) _VER_DISTRIBUTION_CLASS = VersionedDistribution @@ -155,21 +165,21 @@ def locate_file(self, path): class VersionedFindLoad(VersionPlatformSelect, SourceLoader): - def find_spec(self, fullname, path, target=None): # noqa: ARG002 + def find_spec(self, fullname: str, path: Any, target: ModuleType | None = None) -> ModuleSpec | None: # noqa: ARG002, ANN401 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): + def module_repr(self, module: ModuleType) -> str: raise NotImplementedError -def run(): +def run() -> None: with VersionedFindLoad() as finder: sys.meta_path.insert(0, finder) finder._register_distutils_finder() # noqa: SLF001 - from virtualenv.__main__ import run as run_virtualenv # noqa: PLC0415, PLC2701 + from virtualenv.__main__ import run as run_virtualenv # noqa: PLC0415 run_virtualenv() diff --git a/tasks/make_zipapp.py b/tasks/make_zipapp.py index 608efcf8c..62e583555 100644 --- a/tasks/make_zipapp.py +++ b/tasks/make_zipapp.py @@ -17,16 +17,20 @@ from shlex import quote from stat import S_IWUSR from tempfile import TemporaryDirectory +from typing import TYPE_CHECKING, Any from packaging.markers import Marker from packaging.requirements import Requirement +if TYPE_CHECKING: + from collections.abc import Iterator + HERE = Path(__file__).parent.absolute() -VERSIONS = [f"3.{i}" for i in range(13, 7, -1)] +VERSIONS = [f"3.{i}" for i in range(14, 7, -1)] -def main(): +def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--dest", default="virtualenv.pyz") args = parser.parse_args() @@ -35,7 +39,7 @@ def main(): create_zipapp(os.path.abspath(args.dest), packages) -def create_zipapp(dest, packages): +def create_zipapp(dest: str, packages: dict[str, Any]) -> None: bio = io.BytesIO() base = PurePosixPath("__virtualenv__") modules = defaultdict(lambda: defaultdict(dict)) @@ -52,7 +56,13 @@ def create_zipapp(dest, packages): 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): # noqa: C901, PLR0912 +def write_packages_to_zipapp( # noqa: C901, PLR0912 + base: PurePosixPath, + dist: dict[str, Any], + modules: dict[str, Any], + packages: dict[str, Any], + zip_app: zipfile.ZipFile, +) -> None: has = set() for name, p_w_v in packages.items(): # noqa: PLR1702 for platform, w_v in p_w_v.items(): @@ -71,8 +81,9 @@ def write_packages_to_zipapp(base, dist, modules, packages, zip_app): # noqa: C for version in wheel_data.versions: modules[version][platform][key] = str(dest) if dest.parent.suffix == ".dist-info": + dist_name = dest.parent.stem.split("-")[0].replace("_", "-") for version in wheel_data.versions: - dist[version][platform][dest.parent.stem.split("-")[0]] = str(dest.parent) + dist[version][platform][dist_name] = str(dest.parent) dest_str = str(dest) if dest_str in has: continue @@ -86,7 +97,7 @@ def write_packages_to_zipapp(base, dist, modules, packages, zip_app): # noqa: C class WheelDownloader: - def __init__(self, into) -> None: + def __init__(self, into: Path) -> None: if into.exists(): shutil.rmtree(into) into.mkdir(parents=True) @@ -95,7 +106,7 @@ def __init__(self, into) -> None: self.pip_cmd = [str(Path(sys.executable).parent / "pip")] self._cmd = [*self.pip_cmd, "download", "-q", "--no-deps", "--no-cache-dir", "--dest", str(self.into)] - def run(self, target, versions): + def run(self, target: Path, versions: list[str]) -> None: whl = self.build_sdist(target) todo = deque((version, None, whl) for version in versions) wheel_store = {} @@ -115,7 +126,7 @@ def run(self, target, versions): self.collected[version][dep_str][platform] = whl todo.extend(self.get_dependencies(whl, version)) - def _get_wheel(self, dep, platform, version): + def _get_wheel(self, dep: Requirement | Path, platform: str | None, version: str) -> Path | None: if isinstance(dep, Requirement): before = set(self.into.iterdir()) if self._download( @@ -141,14 +152,14 @@ def _get_wheel(self, dep, platform, version): assert new_file.suffix == ".whl" # noqa: S101 return new_file - def _download(self, platform, stop_print_on_fail, *args): + def _download(self, platform: str | None, stop_print_on_fail: bool, *args: str) -> int: exe_cmd = self._cmd + list(args) if platform is not None: exe_cmd.extend(["--platform", platform]) return run_suppress_output(exe_cmd, stop_print_on_fail=stop_print_on_fail) @staticmethod - def get_dependencies(whl, version): + def get_dependencies(whl: Path, version: str) -> Iterator[tuple[str, str | None, Requirement]]: with zipfile.ZipFile(str(whl), "r") as zip_file: name = "/".join([f"{'-'.join(whl.name.split('-')[0:2])}.dist-info", "METADATA"]) with zip_file.open(name) as file_handler: @@ -190,7 +201,7 @@ def get_dependencies(whl, version): yield version, platform, req @staticmethod - def _marker_at(markers, key): + def _marker_at(markers: list[Any], key: str) -> list[int]: return [ i for i, m in enumerate(markers) @@ -198,7 +209,7 @@ def _marker_at(markers, key): ] @staticmethod - def _del_marker_at(markers, at): + def _del_marker_at(markers: list[Any], at: int) -> int: del markers[at] deleted = 1 op = max(at - 1, 0) @@ -207,7 +218,7 @@ def _del_marker_at(markers, at): deleted += 1 return deleted - def build_sdist(self, target): + def build_sdist(self, target: Path) -> Path: if target.is_dir(): # pip 20.1 no longer guarantees this to be parallel safe, need to copy/lock with TemporaryDirectory() as temp_folder: @@ -221,7 +232,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): # noqa: ARG001 + def onerror(func: Any, path: str, exc_info: Any) -> None: # noqa: ARG001, ANN401 os.chmod(path, S_IWUSR) func(path) @@ -230,14 +241,14 @@ def onerror(func, path, exc_info): # noqa: ARG001 else: return self._build_sdist(target.parent / target.stem, target) - def _build_sdist(self, folder, target): + def _build_sdist(self, folder: Path, target: Path) -> Path: if not folder.exists() or not list(folder.iterdir()): cmd = [*self.pip_cmd, "wheel", "-w", str(folder), "--no-deps", str(target), "-q"] run_suppress_output(cmd, stop_print_on_fail=True) return next(iter(folder.iterdir())) -def run_suppress_output(cmd, stop_print_on_fail=False): # noqa: FBT002 +def run_suppress_output(cmd: list[str], stop_print_on_fail: bool = False) -> int: # noqa: FBT002 process = subprocess.Popen( cmd, stdout=subprocess.PIPE, @@ -256,7 +267,7 @@ def run_suppress_output(cmd, stop_print_on_fail=False): # noqa: FBT002 return process.returncode -def get_wheels_for_support_versions(folder): +def get_wheels_for_support_versions(folder: Path) -> dict[str, Any]: downloader = WheelDownloader(folder / "wheel-store") downloader.run(HERE.parent, VERSIONS) packages = defaultdict(lambda: defaultdict(lambda: defaultdict(WheelForVersion))) @@ -277,7 +288,7 @@ def get_wheels_for_support_versions(folder): class WheelForVersion: - def __init__(self, wheel=None, versions=None) -> None: + def __init__(self, wheel: Path | None = None, versions: list[str] | None = None) -> None: self.wheel = wheel self.versions = versions or [] diff --git a/tasks/release.py b/tasks/release.py index feda8b3c2..f876d5cb8 100644 --- a/tasks/release.py +++ b/tasks/release.py @@ -1,51 +1,60 @@ -"""Handles creating a release PR.""" +"""Handles creating a release.""" from __future__ import annotations from pathlib import Path -from subprocess import check_call +from subprocess import call, check_call -from git import Commit, Head, Remote, Repo, TagReference +from git import Commit, Remote, Repo, TagReference from packaging.version import Version ROOT_SRC_DIR = Path(__file__).resolve().parents[1] +CHANGELOG_DIR = ROOT_SRC_DIR / "docs" / "changelog" -def main(version_str: str) -> None: - version = Version(version_str) +def main(version_str: str, *, push: bool) -> None: repo = Repo(str(ROOT_SRC_DIR)) - if repo.is_dirty(): msg = "Current repository is dirty. Please commit any changes and try again." raise RuntimeError(msg) - upstream, release_branch = create_release_branch(repo, version) + remote = get_remote(repo) + remote.fetch() + version = resolve_version(version_str, repo) + print(f"releasing {version}") # noqa: T201 release_commit = release_changelog(repo, version) tag = tag_release_commit(release_commit, repo, version) - print("push release commit") # noqa: T201 - repo.git.push(upstream.name, release_branch) - print("push release tag") # noqa: T201 - repo.git.push(upstream.name, tag) + if push: + print("push release commit") # noqa: T201 + repo.git.push(remote.name, "HEAD:main") + print("push release tag") # noqa: T201 + repo.git.push(remote.name, tag) print("All done! ✨ 🍰 ✨") # noqa: T201 -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}" - release_branch = repo.create_head(branch_name, upstream.refs.main, force=True) - upstream.push(refspec=f"{branch_name}:{branch_name}", force=True) - release_branch.set_tracking_branch(repo.refs[f"{upstream.name}/{branch_name}"]) - release_branch.checkout() - return upstream, release_branch - - -def get_upstream(repo: Repo) -> Remote: - upstream_remote = "pypa/virtualenv.git" +def resolve_version(version_str: str, repo: Repo) -> Version: + if version_str not in {"auto", "major", "minor", "patch"}: + return Version(version_str) + latest_tag = repo.git.describe("--tags", "--abbrev=0") + parts = [int(x) for x in latest_tag.split(".")] + if version_str == "major": + parts = [parts[0] + 1, 0, 0] + elif version_str == "minor": + parts = [parts[0], parts[1] + 1, 0] + elif version_str == "patch": + parts[2] += 1 + elif any(CHANGELOG_DIR.glob("*.feature.rst")) or any(CHANGELOG_DIR.glob("*.removal.rst")): + parts = [parts[0], parts[1] + 1, 0] + else: + parts[2] += 1 + return Version(".".join(str(p) for p in parts)) + + +def get_remote(repo: Repo) -> Remote: + upstream_remote = "pypa/virtualenv" urls = set() for remote in repo.remotes: for url in remote.urls: - if url.endswith(upstream_remote): + if url.rstrip(".git").endswith(upstream_remote): return remote urls.add(url) msg = f"could not find {upstream_remote} remote, has {urls}" @@ -55,10 +64,13 @@ def get_upstream(repo: Repo) -> Remote: def release_changelog(repo: Repo, version: Version) -> Commit: print("generate release commit") # noqa: T201 check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_SRC_DIR)) # noqa: S607 + call(["pre-commit", "run", "--all-files"], cwd=str(ROOT_SRC_DIR)) # noqa: S607 + repo.git.add(".") + check_call(["pre-commit", "run", "--all-files"], cwd=str(ROOT_SRC_DIR)) # noqa: S607 return repo.index.commit(f"release {version}") -def tag_release_commit(release_commit, repo, version) -> TagReference: +def tag_release_commit(release_commit: Commit, repo: Repo, version: Version) -> TagReference: print("tag release commit") # noqa: T201 existing_tags = [x.name for x in repo.tags] if version in existing_tags: @@ -72,6 +84,7 @@ def tag_release_commit(release_commit, repo, version) -> TagReference: import argparse parser = argparse.ArgumentParser(prog="release") - parser.add_argument("--version", required=True) + parser.add_argument("--version", default="auto") + parser.add_argument("--no-push", action="store_true") options = parser.parse_args() - main(options.version) + main(options.version, push=not options.no_push) diff --git a/tasks/update_embedded.py b/tasks/update_embedded.py old mode 100755 new mode 100644 index 9134c83c5..99b4ffa4c --- a/tasks/update_embedded.py +++ b/tasks/update_embedded.py @@ -1,4 +1,4 @@ -"""Helper script to rebuild virtualenv.py from virtualenv_support.""" # noqa: EXE002 +"""Helper script to rebuild virtualenv.py from virtualenv_support.""" from __future__ import annotations @@ -6,10 +6,14 @@ import locale import os import re +from typing import TYPE_CHECKING, NoReturn from zlib import crc32 as _crc32 +if TYPE_CHECKING: + from pathlib import Path -def crc32(data): + +def crc32(data: str) -> int: """Python version idempotent.""" return _crc32(data.encode()) & 0xFFFFFFFF @@ -24,7 +28,7 @@ def crc32(data): file_template = '# file {filename}\n{variable} = convert(\n """\n{data}"""\n)' -def rebuild(script_path): +def rebuild(script_path: Path) -> None: encoding = ( locale.getencoding() if hasattr(locale, "getencoding") else locale.getpreferredencoding(do_setlocale=False) ) @@ -49,7 +53,7 @@ def rebuild(script_path): report(1 if not count or did_update else 0, new_content, next_match, script_content, script_path) -def handle_file(previous_content, filename, variable_name, previous_encoded): +def handle_file(previous_content: str, filename: str, variable_name: str, previous_encoded: str) -> tuple[bool, str]: 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) @@ -69,7 +73,7 @@ def handle_file(previous_content, filename, variable_name, previous_encoded): return True, new_part -def report(exit_code, new, next_match, current, script_path): +def report(exit_code: int, new: str, next_match: re.Match[str] | None, current: str, script_path: Path) -> NoReturn: if new != current: print("Content updated; overwriting... ", end="") # noqa: T201 script_path.write_bytes(new) diff --git a/tasks/upgrade_wheels.py b/tasks/upgrade_wheels.py index 52696d225..881a18e66 100644 --- a/tasks/upgrade_wheels.py +++ b/tasks/upgrade_wheels.py @@ -11,6 +11,7 @@ from tempfile import TemporaryDirectory from textwrap import dedent from threading import Thread +from typing import NoReturn STRICT = "UPGRADE_ADVISORY" not in os.environ @@ -19,7 +20,7 @@ DEST = Path(__file__).resolve().parents[1] / "src" / "virtualenv" / "seed" / "wheels" / "embed" -def download(ver, dest, package): +def download(ver: str, dest: str, package: str) -> None: subprocess.call( [ sys.executable, @@ -40,7 +41,7 @@ def download(ver, dest, package): ) -def run(): # noqa: C901, PLR0912 +def run() -> NoReturn: # noqa: C901, PLR0912 old_batch = {i.name for i in DEST.iterdir() if i.suffix == ".whl"} with TemporaryDirectory() as temp: temp_path = Path(temp) @@ -101,6 +102,8 @@ def run(): # noqa: C901, PLR0912 ) msg = dedent( f""" + from __future__ import annotations + from pathlib import Path from virtualenv.seed.wheels.util import Wheel @@ -110,7 +113,7 @@ def run(): # noqa: C901, PLR0912 MAX = {next(iter(support_table.keys()))!r} - def get_embed_wheel(distribution, for_py_version): + def get_embed_wheel(distribution: str, for_py_version: str) -> Wheel | None: mapping = BUNDLE_SUPPORT.get(for_py_version, {{}}) or BUNDLE_SUPPORT[MAX] wheel_file = mapping.get(distribution) if wheel_file is None: @@ -119,10 +122,10 @@ def get_embed_wheel(distribution, for_py_version): return Wheel.from_path(path) __all__ = [ - "get_embed_wheel", + "BUNDLE_FOLDER", "BUNDLE_SUPPORT", "MAX", - "BUNDLE_FOLDER", + "get_embed_wheel", ] """, @@ -135,11 +138,11 @@ def get_embed_wheel(distribution, for_py_version): raise SystemExit(outcome) -def fmt_version(versions): +def fmt_version(versions: list[str]) -> str: return ", ".join(f"``{v}``" for v in versions) -def collect_package_versions(new_packages): +def collect_package_versions(new_packages: set[str]) -> dict[str, list[str]]: result = defaultdict(list) for package in new_packages: split = package.split("-") diff --git a/tests/conftest.py b/tests/conftest.py index e4fc28479..3ec4147c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,19 +10,19 @@ from typing import ClassVar import pytest +from python_discovery import PythonInfo from virtualenv.app_data import AppDataDiskFolder -from virtualenv.discovery.py_info import PythonInfo -from virtualenv.info import IS_GRAALPY, IS_PYPY, IS_WIN, fs_supports_symlink +from virtualenv.info import IS_GRAALPY, IS_PYPY, IS_RUSTPYTHON, IS_WIN, fs_supports_symlink from virtualenv.report import LOGGER -def pytest_addoption(parser): +def pytest_addoption(parser) -> None: parser.addoption("--int", action="store_true", default=False, help="run integration tests") parser.addoption("--skip-slow", action="store_true", default=False, help="skip slow tests") -def pytest_configure(config): +def pytest_configure(config) -> None: """Ensure randomly is called before we re-order""" manager = config.pluginmanager @@ -35,7 +35,7 @@ def pytest_configure(config): order[from_pos] = temp -def pytest_collection_modifyitems(config, items): +def pytest_collection_modifyitems(config, items) -> None: int_location = os.path.join("tests", "integration", "").rstrip() if len(items) == 1: return @@ -54,7 +54,7 @@ def pytest_collection_modifyitems(config, items): @pytest.fixture(scope="session") -def has_symlink_support(tmp_path_factory): # noqa: ARG001 +def has_symlink_support(): return fs_supports_symlink() @@ -153,7 +153,7 @@ 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 if k.startswith(("VIRTUALENV_", "TOX_")) or "VIRTUAL_ENV" in k} - cleaned = {k: os.environ[k] for k, v in os.environ.items()} + cleaned = {k: os.environ[k] for k in os.environ} override = { "VIRTUALENV_NO_PERIODIC_UPDATE": "1", "VIRTUALENV_NO_DOWNLOAD": "1", @@ -198,9 +198,7 @@ def _check_os_environ_stable(): @pytest.fixture(autouse=True) def coverage_env(monkeypatch, link, request): - """ - Enable coverage report collection on the created virtual environments by injecting the coverage project - """ + """Enable coverage report collection on the created virtual environments by injecting the coverage project""" 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 # noqa: PLC0415 @@ -222,7 +220,7 @@ def create_run(): prev_run = run.session_via_cli monkeypatch.setattr(run, "session_via_cli", _session_via_cli) - def finish(): + def finish() -> None: cov = obj["cov"] obj["cov"] = None cov.__exit__(None, None, None) @@ -233,7 +231,7 @@ def finish(): else: - def finish(): + def finish() -> None: pass yield finish @@ -241,7 +239,7 @@ def finish(): # _no_coverage tells coverage_env to disable coverage injection for _no_coverage user. @pytest.fixture -def _no_coverage(): +def _no_coverage() -> None: pass @@ -308,7 +306,9 @@ def special_name_dir(tmp_path, special_char_name): @pytest.fixture(scope="session") def current_creators(session_app_data): - return PythonInfo.current_system(session_app_data).creators() + from virtualenv.run.plugin.creators import CreatorSelector # noqa: PLC0415 + + return CreatorSelector.for_interpreter(PythonInfo.current_system(session_app_data)) @pytest.fixture(scope="session") @@ -326,10 +326,7 @@ def session_app_data(tmp_path_factory): @contextmanager def change_env_var(key, value): - """Temporarily change an environment variable. - :param key: the key of the env var - :param value: the value of the env var - """ + """Temporarily change an environment variable. :param key: the key of the env var :param value: the value of the env var""" already_set = key in os.environ prev_value = os.environ.get(key) os.environ[key] = value @@ -350,18 +347,18 @@ def temp_app_data(monkeypatch, tmp_path): @pytest.fixture(scope="session") -def for_py_version(): +def for_py_version() -> str: return f"{sys.version_info.major}.{sys.version_info.minor}" @pytest.fixture -def _skip_if_test_in_system(session_app_data): +def _skip_if_test_in_system(session_app_data) -> None: 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_GRAALPY: +if IS_PYPY or IS_GRAALPY or IS_RUSTPYTHON: @pytest.fixture def time_freeze(freezer): diff --git a/tests/integration/test_race_condition_simulation.py b/tests/integration/test_race_condition_simulation.py index 857de9aa5..8e4067b54 100644 --- a/tests/integration/test_race_condition_simulation.py +++ b/tests/integration/test_race_condition_simulation.py @@ -6,15 +6,15 @@ from pathlib import Path -def test_race_condition_simulation(tmp_path): +def test_race_condition_simulation(tmp_path) -> None: """Test that simulates the race condition described in the issue. - This test creates a temporary directory with _virtualenv.py and _virtualenv.pth, - then simulates the scenario where: - - One process imports and uses the _virtualenv module (simulating marimo) - - Another process overwrites the _virtualenv.py file (simulating uv venv) + This test creates a temporary directory with _virtualenv.py and _virtualenv.pth, then simulates the scenario where: + - One process imports and uses the _virtualenv module (simulating marimo) - Another process overwrites the + _virtualenv.py file (simulating uv venv) The test verifies that no NameError is raised for _DISTUTILS_PATCH. + """ # Create the _virtualenv.py file virtualenv_file = tmp_path / "_virtualenv.py" diff --git a/tests/integration/test_run_int.py b/tests/integration/test_run_int.py index e1dc6d3f5..f09348873 100644 --- a/tests/integration/test_run_int.py +++ b/tests/integration/test_run_int.py @@ -12,6 +12,7 @@ from pathlib import Path +@pytest.mark.graalpy @pytest.mark.skipif(IS_PYPY, reason="setuptools distutils patching does not work") def test_app_data_pinning(tmp_path: Path) -> None: version = "23.1" diff --git a/tests/integration/test_zipapp.py b/tests/integration/test_zipapp.py index c6c46c7bc..d7af9f664 100644 --- a/tests/integration/test_zipapp.py +++ b/tests/integration/test_zipapp.py @@ -6,8 +6,8 @@ from pathlib import Path import pytest +from python_discovery import PythonInfo -from virtualenv.discovery.py_info import PythonInfo from virtualenv.info import fs_supports_symlink from virtualenv.run import cli_run @@ -52,7 +52,7 @@ def zipapp_build_env(tmp_path_factory): 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) + subprocess.run(cmd, check=True, timeout=300) yield exe if create_env_path is not None: shutil.rmtree(str(create_env_path)) @@ -64,7 +64,7 @@ def zipapp(zipapp_build_env, tmp_path_factory): 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) + subprocess.run(cmd, check=True, timeout=300) yield filename shutil.rmtree(str(into)) @@ -79,38 +79,41 @@ def zipapp_test_env(tmp_path_factory): @pytest.fixture def call_zipapp(zipapp, tmp_path, zipapp_test_env, temp_app_data): # noqa: ARG001 - def _run(*args): + def _run(*args) -> None: cmd = [str(zipapp_test_env), str(zipapp), "-vv", str(tmp_path / "env"), *list(args)] - subprocess.check_call(cmd) + subprocess.run(cmd, check=True, timeout=120) return _run @pytest.fixture def call_zipapp_symlink(zipapp, tmp_path, zipapp_test_env, temp_app_data): # noqa: ARG001 - def _run(*args): + def _run(*args) -> None: 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) + subprocess.run(cmd, check=True, timeout=120) return _run +@pytest.mark.timeout(600) @pytest.mark.skipif(not fs_supports_symlink(), reason="symlink not supported") -def test_zipapp_in_symlink(capsys, call_zipapp_symlink): +def test_zipapp_in_symlink(capsys, call_zipapp_symlink) -> None: call_zipapp_symlink("--reset-app-data") _out, err = capsys.readouterr() assert not err -def test_zipapp_help(call_zipapp, capsys): +@pytest.mark.timeout(600) +def test_zipapp_help(call_zipapp, capsys) -> None: call_zipapp("-h") _out, err = capsys.readouterr() assert not err +@pytest.mark.timeout(600) @pytest.mark.slow @pytest.mark.parametrize("seeder", ["app-data", "pip"]) -def test_zipapp_create(call_zipapp, seeder): +def test_zipapp_create(call_zipapp, seeder) -> None: call_zipapp("--seeder", seeder) diff --git a/tests/types.py b/tests/types.py new file mode 100644 index 000000000..0466460b2 --- /dev/null +++ b/tests/types.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Protocol + + +class _VersionInfo(Protocol): + major: int + minor: int + + +class Interpreter(Protocol): + prefix: str + system_prefix: str + system_executable: str + free_threaded: bool + version_info: _VersionInfo + sysconfig_vars: dict[str, object] + + +class MakeInterpreter(Protocol): + def __call__( + self, + sysconfig_vars: dict[str, object] | None = ..., + prefix: str = ..., + free_threaded: bool = ..., + version_info: tuple[int, ...] = ..., + ) -> Interpreter: ... diff --git a/tests/unit/activation/conftest.py b/tests/unit/activation/conftest.py index 53a819f96..804be848a 100644 --- a/tests/unit/activation/conftest.py +++ b/tests/unit/activation/conftest.py @@ -76,13 +76,18 @@ def __call__(self, monkeypatch, tmp_path): try: process = Popen(invoke, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) - raw_, _ = process.communicate() - raw = raw_.decode(errors="replace") - assert process.returncode == 0, raw + raw_, _ = process.communicate(timeout=120) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + pytest.fail(f"Activation script timed out: {invoke}") except subprocess.CalledProcessError as exception: output = exception.output + exception.stderr assert not exception.returncode, output # noqa: PT017 return None + else: + raw = raw_.decode(errors="replace") + assert process.returncode == 0, raw 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) @@ -129,7 +134,7 @@ def _get_test_lines(self, activate_script): "", # just finish with an empty new line ] - def assert_output(self, out, raw, tmp_path): + def assert_output(self, out, raw, tmp_path) -> None: """Compare _get_test_lines() with the expected values.""" assert out[0], raw assert out[1] == "None", raw @@ -155,7 +160,7 @@ def assert_output(self, out, raw, tmp_path): def quote(self, s): return self.of_class.quote(s) - def python_cmd(self, cmd): + def python_cmd(self, cmd) -> str: return f"{os.path.basename(sys.executable)} -c {self.quote(cmd)}" def print_python_exe(self): diff --git a/tests/unit/activation/test_activation_support.py b/tests/unit/activation/test_activation_support.py index e25fc96e8..a0e2a756b 100644 --- a/tests/unit/activation/test_activation_support.py +++ b/tests/unit/activation/test_activation_support.py @@ -3,6 +3,7 @@ from argparse import Namespace import pytest +from python_discovery import PythonInfo from virtualenv.activation import ( BashActivator, @@ -12,14 +13,13 @@ PowerShellActivator, PythonActivator, ) -from virtualenv.discovery.py_info import PythonInfo @pytest.mark.parametrize( "activator_class", [BatchActivator, PowerShellActivator, PythonActivator, BashActivator, FishActivator], ) -def test_activator_support_windows(mocker, activator_class): +def test_activator_support_windows(mocker, activator_class) -> None: activator = activator_class(Namespace(prompt=None)) interpreter = mocker.Mock(spec=PythonInfo) @@ -28,7 +28,7 @@ def test_activator_support_windows(mocker, activator_class): @pytest.mark.parametrize("activator_class", [CShellActivator]) -def test_activator_no_support_windows(mocker, activator_class): +def test_activator_no_support_windows(mocker, activator_class) -> None: activator = activator_class(Namespace(prompt=None)) interpreter = mocker.Mock(spec=PythonInfo) @@ -40,7 +40,7 @@ def test_activator_no_support_windows(mocker, activator_class): "activator_class", [BashActivator, CShellActivator, FishActivator, PowerShellActivator, PythonActivator], ) -def test_activator_support_posix(mocker, activator_class): +def test_activator_support_posix(mocker, activator_class) -> None: activator = activator_class(Namespace(prompt=None)) interpreter = mocker.Mock(spec=PythonInfo) interpreter.os = "posix" @@ -48,7 +48,7 @@ def test_activator_support_posix(mocker, activator_class): @pytest.mark.parametrize("activator_class", [BatchActivator]) -def test_activator_no_support_posix(mocker, activator_class): +def test_activator_no_support_posix(mocker, activator_class) -> None: activator = activator_class(Namespace(prompt=None)) interpreter = mocker.Mock(spec=PythonInfo) interpreter.os = "posix" diff --git a/tests/unit/activation/test_activator.py b/tests/unit/activation/test_activator.py index b6b5b4986..21be96d06 100644 --- a/tests/unit/activation/test_activator.py +++ b/tests/unit/activation/test_activator.py @@ -2,10 +2,13 @@ from argparse import Namespace +import pytest + from virtualenv.activation.activator import Activator -def test_activator_prompt_cwd(monkeypatch, tmp_path): +@pytest.mark.graalpy +def test_activator_prompt_cwd(monkeypatch, tmp_path) -> None: class FakeActivator(Activator): def generate(self, creator): raise NotImplementedError diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py index 0a9c588cf..7a1bd35f0 100644 --- a/tests/unit/activation/test_bash.py +++ b/tests/unit/activation/test_bash.py @@ -1,11 +1,14 @@ from __future__ import annotations +import shutil +import subprocess from argparse import Namespace import pytest from virtualenv.activation import BashActivator from virtualenv.info import IS_WIN +from virtualenv.run import cli_run @pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") @@ -16,7 +19,7 @@ (None, None, False), ], ) -def test_bash_tkinter_generation(tmp_path, tcl_lib, tk_lib, present): +def test_bash_tkinter_generation(tmp_path, tcl_lib, tk_lib, present) -> None: # GIVEN class MockInterpreter: pass @@ -26,7 +29,7 @@ class MockInterpreter: interpreter.tk_lib = tk_lib class MockCreator: - def __init__(self, dest): + def __init__(self, dest) -> None: self.dest = dest self.bin_dir = dest / "bin" self.bin_dir.mkdir() @@ -46,6 +49,13 @@ def __init__(self, dest): # The teardown logic is always present in deactivate() assert "unset _OLD_VIRTUAL_TCL_LIBRARY" in content assert "unset _OLD_VIRTUAL_TK_LIBRARY" in content + assert "unset _OLD_PKG_CONFIG_PATH" in content + + # PKG_CONFIG_PATH is always set + assert '_OLD_PKG_CONFIG_PATH="${PKG_CONFIG_PATH:-}"' in content + assert 'PKG_CONFIG_PATH="${VIRTUAL_ENV}/lib/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}"' in content + assert "export PKG_CONFIG_PATH" in content + assert 'PKG_CONFIG_PATH="$_OLD_PKG_CONFIG_PATH"' in content if present: assert 'if [ /path/to/tcl != "" ]; then' in content @@ -63,9 +73,37 @@ def __init__(self, dest): assert "export TCL_LIBRARY" in content +@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") +def test_bash_activate_relocation_resolves_virtual_env(tmp_path, current_fastest) -> None: + original = tmp_path / "original" + cli_run([ + "--without-pip", + str(original), + "--creator", + current_fastest, + "--no-periodic-update", + "--activators", + "bash", + ]) + relocated = tmp_path / "relocated" + shutil.move(original, relocated) + + work_dir = tmp_path / "workdir" + work_dir.mkdir() + activate_script = relocated / "bin" / "activate" + result = subprocess.run( + ["bash", "-c", f'source "{activate_script}" 2>/dev/null && echo "$VIRTUAL_ENV"'], + capture_output=True, + text=True, + cwd=str(work_dir), + ) + assert result.returncode == 0 + assert result.stdout.strip() == str(relocated) + + @pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") @pytest.mark.parametrize("hashing_enabled", [True, False]) -def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester): +def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester) -> None: class Bash(raise_on_non_source_class): def __init__(self, session) -> None: super().__init__( diff --git a/tests/unit/activation/test_batch.py b/tests/unit/activation/test_batch.py index 74c5a0f97..7882e7f45 100644 --- a/tests/unit/activation/test_batch.py +++ b/tests/unit/activation/test_batch.py @@ -7,7 +7,7 @@ from virtualenv.activation import BatchActivator -def test_batch_pydoc_bat_quoting(tmp_path): +def test_batch_pydoc_bat_quoting(tmp_path) -> None: """Test that pydoc.bat properly quotes python.exe path to handle spaces.""" # GIVEN: A mock interpreter @@ -17,7 +17,7 @@ class MockInterpreter: tk_lib = None class MockCreator: - def __init__(self, dest): + def __init__(self, dest) -> None: self.dest = dest self.bin_dir = dest / "Scripts" self.bin_dir.mkdir(parents=True) @@ -46,7 +46,7 @@ def __init__(self, dest): (None, None, False), ], ) -def test_batch_tkinter_generation(tmp_path, tcl_lib, tk_lib, present): +def test_batch_tkinter_generation(tmp_path, tcl_lib, tk_lib, present) -> None: # GIVEN class MockInterpreter: os = "nt" @@ -56,7 +56,7 @@ class MockInterpreter: interpreter.tk_lib = tk_lib class MockCreator: - def __init__(self, dest): + def __init__(self, dest) -> None: self.dest = dest self.bin_dir = dest / "bin" self.bin_dir.mkdir() @@ -74,6 +74,13 @@ def __init__(self, dest): deactivate_content = (creator.bin_dir / "deactivate.bat").read_text(encoding="utf-8") # THEN + # PKG_CONFIG_PATH is always set + assert '@if defined PKG_CONFIG_PATH @set "_OLD_PKG_CONFIG_PATH=%PKG_CONFIG_PATH%"' in activate_content + assert '@set "PKG_CONFIG_PATH=%VIRTUAL_ENV%\\lib\\pkgconfig;%PKG_CONFIG_PATH%"' in activate_content + assert '@if defined _OLD_PKG_CONFIG_PATH @set "PKG_CONFIG_PATH=%_OLD_PKG_CONFIG_PATH%"' in deactivate_content + assert "@if not defined _OLD_PKG_CONFIG_PATH @set PKG_CONFIG_PATH=" in deactivate_content + assert "@set _OLD_PKG_CONFIG_PATH=" in deactivate_content + if present: assert '@if NOT "C:\\tcl"=="" @set "TCL_LIBRARY=C:\\tcl"' in activate_content assert '@if NOT "C:\\tk"=="" @set "TK_LIBRARY=C:\\tk"' in activate_content @@ -85,7 +92,7 @@ def __init__(self, dest): @pytest.mark.usefixtures("activation_python") -def test_batch(activation_tester_class, activation_tester, tmp_path): +def test_batch(activation_tester_class, activation_tester, tmp_path) -> None: version_script = tmp_path / "version.bat" version_script.write_text("ver", encoding="utf-8") @@ -108,14 +115,14 @@ def quote(self, s): return f'"{text}"' return s - def print_prompt(self): + def print_prompt(self) -> str: return 'echo "%PROMPT%"' activation_tester(Batch) @pytest.mark.usefixtures("activation_python") -def test_batch_output(activation_tester_class, activation_tester, tmp_path): +def test_batch_output(activation_tester_class, activation_tester, tmp_path) -> None: version_script = tmp_path / "version.bat" version_script.write_text("ver", encoding="utf-8") @@ -130,12 +137,7 @@ def __init__(self, session) -> None: 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. - """ + """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 [ @@ -146,7 +148,7 @@ def _get_test_lines(self, activate_script): f"@call {intermediary_script_path}", ] - def assert_output(self, out, raw, tmp_path): # noqa: ARG002 + def assert_output(self, out, raw, tmp_path) -> None: # noqa: ARG002 assert out[0] == "ECHO is on.", raw def quote(self, s): @@ -155,7 +157,7 @@ def quote(self, s): return f'"{text}"' return s - def print_prompt(self): + def print_prompt(self) -> str: return 'echo "%PROMPT%"' activation_tester(Batch) diff --git a/tests/unit/activation/test_csh.py b/tests/unit/activation/test_csh.py index 5cea684ec..891ee08d8 100644 --- a/tests/unit/activation/test_csh.py +++ b/tests/unit/activation/test_csh.py @@ -18,7 +18,7 @@ (None, None, False), ], ) -def test_cshell_tkinter_generation(tmp_path, tcl_lib, tk_lib, present): +def test_cshell_tkinter_generation(tmp_path, tcl_lib, tk_lib, present) -> None: # GIVEN class MockInterpreter: pass @@ -28,7 +28,7 @@ class MockInterpreter: interpreter.tk_lib = tk_lib class MockCreator: - def __init__(self, dest): + def __init__(self, dest) -> None: self.dest = dest self.bin_dir = dest / "bin" self.bin_dir.mkdir() @@ -44,6 +44,14 @@ def __init__(self, dest): activator.generate(creator) content = (creator.bin_dir / "activate.csh").read_text(encoding="utf-8") + # PKG_CONFIG_PATH is always set + assert "test $?_OLD_PKG_CONFIG_PATH != 0" in content + assert 'set _OLD_PKG_CONFIG_PATH="$PKG_CONFIG_PATH"' in content + assert 'setenv PKG_CONFIG_PATH "${VIRTUAL_ENV}/lib/pkgconfig:${PKG_CONFIG_PATH}"' in content + assert 'setenv PKG_CONFIG_PATH "${VIRTUAL_ENV}/lib/pkgconfig"' in content + assert 'setenv PKG_CONFIG_PATH "$_OLD_PKG_CONFIG_PATH:q"' in content + assert "unset _OLD_PKG_CONFIG_PATH" in content + if present: assert "test $?_OLD_VIRTUAL_TCL_LIBRARY != 0" in content assert "test $?_OLD_VIRTUAL_TK_LIBRARY != 0" in content @@ -53,7 +61,7 @@ def __init__(self, dest): assert "setenv TCL_LIBRARY ''" in content -def test_csh(activation_tester_class, activation_tester): +def test_csh(activation_tester_class, activation_tester) -> None: exe = f"tcsh{'.exe' if sys.platform == 'win32' else ''}" if which(exe): version_text = check_output([exe, "--version"], text=True, encoding="utf-8") @@ -65,7 +73,7 @@ class Csh(activation_tester_class): def __init__(self, session) -> None: super().__init__(CShellActivator, session, "csh", "activate.csh", "csh") - def print_prompt(self): + def print_prompt(self) -> str: # 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" diff --git a/tests/unit/activation/test_fish.py b/tests/unit/activation/test_fish.py index c15a8f513..4d28b7866 100644 --- a/tests/unit/activation/test_fish.py +++ b/tests/unit/activation/test_fish.py @@ -17,7 +17,7 @@ (None, None, False), ], ) -def test_fish_tkinter_generation(tmp_path, tcl_lib, tk_lib, present): +def test_fish_tkinter_generation(tmp_path, tcl_lib, tk_lib, present) -> None: # GIVEN class MockInterpreter: pass @@ -27,7 +27,7 @@ class MockInterpreter: interpreter.tk_lib = tk_lib class MockCreator: - def __init__(self, dest): + def __init__(self, dest) -> None: self.dest = dest self.bin_dir = dest / "bin" self.bin_dir.mkdir() @@ -44,6 +44,11 @@ def __init__(self, dest): content = (creator.bin_dir / "activate.fish").read_text(encoding="utf-8") # THEN + # PKG_CONFIG_PATH is always set + assert 'set -gx _OLD_PKG_CONFIG_PATH "$PKG_CONFIG_PATH"' in content + assert 'set -gx PKG_CONFIG_PATH "$VIRTUAL_ENV/lib/pkgconfig:$PKG_CONFIG_PATH"' in content + assert "set -e _OLD_PKG_CONFIG_PATH" in content + if present: assert "set -gx TCL_LIBRARY '/path/to/tcl'" in content assert "set -gx TK_LIBRARY '/path/to/tk'" in content @@ -53,7 +58,7 @@ def __init__(self, dest): @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): +def test_fish(activation_tester_class, activation_tester, monkeypatch, tmp_path) -> None: monkeypatch.setenv("HOME", str(tmp_path)) fish_conf_dir = tmp_path / ".config" / "fish" fish_conf_dir.mkdir(parents=True) @@ -63,7 +68,7 @@ class Fish(activation_tester_class): def __init__(self, session) -> None: super().__init__(FishActivator, session, "fish", "activate.fish", "fish") - def print_prompt(self): + def print_prompt(self) -> str: return "fish_prompt" def _get_test_lines(self, activate_script): @@ -88,7 +93,7 @@ def _get_test_lines(self, activate_script): "", # just finish with an empty new line ] - def assert_output(self, out, raw, _): + def assert_output(self, out, raw, _) -> None: """Compare _get_test_lines() with the expected values.""" assert out[0], raw assert out[1] == "None", raw diff --git a/tests/unit/activation/test_nushell.py b/tests/unit/activation/test_nushell.py index 08c5cb1a1..284de52fe 100644 --- a/tests/unit/activation/test_nushell.py +++ b/tests/unit/activation/test_nushell.py @@ -7,7 +7,7 @@ from virtualenv.info import IS_WIN -def test_nushell_tkinter_generation(tmp_path): +def test_nushell_tkinter_generation(tmp_path) -> None: # GIVEN class MockInterpreter: pass @@ -19,7 +19,7 @@ class MockInterpreter: quoted_tk_path = NushellActivator.quote(interpreter.tk_lib) class MockCreator: - def __init__(self, dest): + def __init__(self, dest) -> None: self.dest = dest self.bin_dir = dest / "bin" self.bin_dir.mkdir() @@ -36,6 +36,11 @@ def __init__(self, dest): content = (creator.bin_dir / "activate.nu").read_text(encoding="utf-8") # THEN + # PKG_CONFIG_PATH is always set + assert "let old_pkg_config_path = if (has-env 'PKG_CONFIG_PATH')" in content + assert "let new_pkg_config_path = " in content + assert "PKG_CONFIG_PATH: $new_pkg_config_path" in content + expected_tcl = f"let $new_env = $new_env | insert TCL_LIBRARY {quoted_tcl_path}" expected_tk = f"let $new_env = $new_env | insert TK_LIBRARY {quoted_tk_path}" @@ -43,19 +48,15 @@ def __init__(self, dest): assert expected_tk in content -def test_nushell(activation_tester_class, activation_tester): +def test_nushell(activation_tester_class, activation_tester) -> None: class Nushell(activation_tester_class): def __init__(self, session) -> None: - cmd = which("nu") - if cmd is None and IS_WIN: - cmd = "c:\\program files\\nu\\bin\\nu.exe" - - super().__init__(NushellActivator, session, cmd, "activate.nu", "nu") + super().__init__(NushellActivator, session, which("nu"), "activate.nu", "nu") self.activate_cmd = "overlay use" self.unix_line_ending = not IS_WIN - def print_prompt(self): + def print_prompt(self) -> str: return r"print $env.VIRTUAL_PREFIX" def activate_call(self, script): diff --git a/tests/unit/activation/test_powershell.py b/tests/unit/activation/test_powershell.py index cd6057eec..8c95705a7 100644 --- a/tests/unit/activation/test_powershell.py +++ b/tests/unit/activation/test_powershell.py @@ -8,7 +8,7 @@ from virtualenv.activation import PowerShellActivator -def test_powershell_pydoc_call_operator(tmp_path): +def test_powershell_pydoc_call_operator(tmp_path) -> None: """Test that PowerShell pydoc function uses call operator to handle spaces in python path.""" # GIVEN: A mock interpreter @@ -18,7 +18,7 @@ class MockInterpreter: tk_lib = None class MockCreator: - def __init__(self, dest): + def __init__(self, dest) -> None: self.dest = dest self.bin_dir = dest / "Scripts" self.bin_dir.mkdir(parents=True) @@ -49,7 +49,7 @@ def __init__(self, dest): (None, None, False), ], ) -def test_powershell_tkinter_generation(tmp_path, tcl_lib, tk_lib, present): +def test_powershell_tkinter_generation(tmp_path, tcl_lib, tk_lib, present) -> None: # GIVEN class MockInterpreter: os = "nt" @@ -59,7 +59,7 @@ class MockInterpreter: interpreter.tk_lib = tk_lib class MockCreator: - def __init__(self, dest): + def __init__(self, dest) -> None: self.dest = dest self.bin_dir = dest / "bin" self.bin_dir.mkdir() @@ -76,6 +76,13 @@ def __init__(self, dest): content = (creator.bin_dir / "activate.ps1").read_text(encoding="utf-8-sig") # THEN + # PKG_CONFIG_PATH is always set + assert "New-Variable -Scope global -Name _OLD_PKG_CONFIG_PATH" in content + assert '$env:PKG_CONFIG_PATH = "$env:VIRTUAL_ENV\\lib\\pkgconfig;$env:PKG_CONFIG_PATH"' in content + assert "if (Test-Path variable:_OLD_PKG_CONFIG_PATH)" in content + assert "$env:PKG_CONFIG_PATH = $variable:_OLD_PKG_CONFIG_PATH" in content + assert 'Remove-Variable "_OLD_PKG_CONFIG_PATH" -Scope global' in content + if present: assert "if ('C:\\tcl' -ne \"\")" in content assert "$env:TCL_LIBRARY = 'C:\\tcl'" in content @@ -89,7 +96,7 @@ def __init__(self, dest): @pytest.mark.slow -def test_powershell(activation_tester_class, activation_tester, monkeypatch): +def test_powershell(activation_tester_class, activation_tester, monkeypatch) -> None: monkeypatch.setenv("TERM", "xterm") class PowerShell(activation_tester_class): @@ -97,7 +104,7 @@ def __init__(self, session) -> None: cmd = "powershell.exe" if sys.platform == "win32" else "pwsh" super().__init__(PowerShellActivator, session, cmd, "activate.ps1", "ps1") self._version_cmd = [cmd, "-c", "$PSVersionTable"] - self._invoke_script = [cmd, "-ExecutionPolicy", "ByPass", "-File"] + self._invoke_script = [cmd, "-NonInteractive", "-NoProfile", "-ExecutionPolicy", "ByPass", "-File"] self.activate_cmd = "." self.script_encoding = "utf-8-sig" @@ -107,15 +114,14 @@ def _get_test_lines(self, activate_script): def invoke_script(self): return [self.cmd, "-File"] - def print_prompt(self): + def print_os_env_var(self, var) -> str: + return f'if ($env:{var} -eq $null) {{ "None" }} else {{ $env:{var} }}' + + def print_prompt(self) -> str: 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. - """ + """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 diff --git a/tests/unit/activation/test_python_activator.py b/tests/unit/activation/test_python_activator.py index 24a3561c5..04f0acb6c 100644 --- a/tests/unit/activation/test_python_activator.py +++ b/tests/unit/activation/test_python_activator.py @@ -2,6 +2,7 @@ import os import sys +from argparse import Namespace from ast import literal_eval from textwrap import dedent @@ -9,7 +10,40 @@ from virtualenv.info import IS_WIN -def test_python(raise_on_non_source_class, activation_tester): +def test_python_activator_generates_pkg_config_path(tmp_path) -> None: + """Test that activate_this.py sets PKG_CONFIG_PATH.""" + + class MockInterpreter: + tcl_lib = None + tk_lib = None + + class MockCreator: + def __init__(self, dest) -> None: + self.dest = dest + self.bin_dir = dest / ("Scripts" if IS_WIN else "bin") + self.bin_dir.mkdir(parents=True) + self.libs = [dest / "Lib" / "site-packages"] + self.env_name = "test-env" + self.interpreter = MockInterpreter() + self.pyenv_cfg = {} + + creator = MockCreator(tmp_path) + options = Namespace(prompt=None) + activator = PythonActivator(options) + + # Generate the activation script + activator.generate(creator) + + # Read the generated script + content = (creator.bin_dir / "activate_this.py").read_text(encoding="utf-8") + + # Verify PKG_CONFIG_PATH is set + assert "PKG_CONFIG_PATH" in content + assert "pkg_config_path" in content + assert 'os.path.join(base, "lib", "pkgconfig")' in content + + +def test_python(raise_on_non_source_class, activation_tester) -> None: class Python(raise_on_non_source_class): def __init__(self, session) -> None: super().__init__( @@ -59,7 +93,7 @@ def print_r(value): """ return dedent(raw).splitlines() - def assert_output(self, out, raw, tmp_path): # noqa: ARG002 + def assert_output(self, out, raw, tmp_path) -> None: # 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 diff --git a/tests/unit/config/cli/test_parser.py b/tests/unit/config/cli/test_parser.py index 1dc7e055a..b470f5fed 100644 --- a/tests/unit/config/cli/test_parser.py +++ b/tests/unit/config/cli/test_parser.py @@ -30,7 +30,7 @@ def _run(*args): return _build -def test_flag(gen_parser_no_conf_env): +def test_flag(gen_parser_no_conf_env) -> None: with gen_parser_no_conf_env() as (parser, run): parser.add_argument("--clear", dest="clear", action="store_true", help="it", default=False) result = run() @@ -39,14 +39,14 @@ def test_flag(gen_parser_no_conf_env): assert result.clear is True -def test_reset_app_data_does_not_conflict_clear(): +def test_reset_app_data_does_not_conflict_clear() -> None: options = VirtualEnvOptions() session_via_cli(["--clear", "venv"], options=options) assert options.clear is True assert options.reset_app_data is False -def test_builtin_discovery_class_preferred(mocker): +def test_builtin_discovery_class_preferred(mocker) -> None: mocker.patch( "virtualenv.run.plugin.discovery._get_default_discovery", return_value=["pluginA", "pluginX", "builtin", "Aplugin", "Xplugin"], diff --git a/tests/unit/config/test___main__.py b/tests/unit/config/test___main__.py index 0bd2569d6..992dcc458 100644 --- a/tests/unit/config/test___main__.py +++ b/tests/unit/config/test___main__.py @@ -3,7 +3,7 @@ import re import sys from subprocess import PIPE, Popen, check_output -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NoReturn import pytest @@ -14,7 +14,7 @@ from pathlib import Path -def test_main(): +def test_main() -> None: process = Popen( [sys.executable, "-m", "virtualenv", "--help"], universal_newlines=True, @@ -28,12 +28,12 @@ def test_main(): @pytest.fixture def raise_on_session_done(mocker): - def _func(exception): + def _func(exception) -> None: from virtualenv.run import session_via_cli # noqa: PLC0415 prev_session = session_via_cli - def _session_via_cli(args, options=None, setup_logging=True, env=None): + def _session_via_cli(args, options=None, setup_logging=True, env=None) -> NoReturn: prev_session(args, options, setup_logging, env) raise exception @@ -42,7 +42,7 @@ def _session_via_cli(args, options=None, setup_logging=True, env=None): return _func -def test_fail_no_traceback(raise_on_session_done, tmp_path, capsys): +def test_fail_no_traceback(raise_on_session_done, tmp_path, capsys) -> None: 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)]) @@ -52,7 +52,7 @@ def test_fail_no_traceback(raise_on_session_done, tmp_path, capsys): assert err == "err\n" -def test_discovery_fails_no_discovery_plugin(mocker, tmp_path, capsys): +def test_discovery_fails_no_discovery_plugin(mocker, tmp_path, capsys) -> None: mocker.patch("virtualenv.run.plugin.discovery.Discovery.entry_points_for", return_value={}) with pytest.raises(SystemExit) as context: run_with_catch([str(tmp_path)]) @@ -62,7 +62,7 @@ def test_discovery_fails_no_discovery_plugin(mocker, tmp_path, capsys): assert not err -def test_fail_with_traceback(raise_on_session_done, tmp_path, capsys): +def test_fail_with_traceback(raise_on_session_done, tmp_path, capsys) -> None: raise_on_session_done(TypeError("something bad")) with pytest.raises(TypeError, match="something bad"): @@ -88,14 +88,14 @@ def test_session_report_full(tmp_path: Path, capsys: pytest.CaptureFixture[str]) _match_regexes(lines, regexes) -def _match_regexes(lines, regexes): +def _match_regexes(lines, regexes) -> None: for line, regex in zip(lines, regexes): comp_regex = re.compile(rf"^{regex}$") assert comp_regex.match(line), line @pytest.mark.usefixtures("session_app_data") -def test_session_report_minimal(tmp_path, capsys): +def test_session_report_minimal(tmp_path, capsys) -> None: run_with_catch([str(tmp_path), "--activators", "", "--without-pip"]) out, err = capsys.readouterr() assert not err @@ -108,7 +108,7 @@ def test_session_report_minimal(tmp_path, capsys): @pytest.mark.usefixtures("session_app_data") -def test_session_report_subprocess(tmp_path): +def test_session_report_subprocess(tmp_path) -> None: # when called via a subprocess the logging framework should flush and POSIX line normalization happen out = check_output( [sys.executable, "-m", "virtualenv", str(tmp_path), "--activators", "powershell", "--without-pip"], diff --git a/tests/unit/config/test_env_var.py b/tests/unit/config/test_env_var.py index 5f364978d..3c75558ba 100644 --- a/tests/unit/config/test_env_var.py +++ b/tests/unit/config/test_env_var.py @@ -4,30 +4,30 @@ from pathlib import Path import pytest +from python_discovery import PythonInfo 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 @pytest.fixture -def _empty_conf(tmp_path, monkeypatch): +def _empty_conf(tmp_path, monkeypatch) -> None: conf = tmp_path / "conf.ini" monkeypatch.setenv(IniConfig.VIRTUALENV_CONFIG_FILE_ENV_VAR, str(conf)) conf.write_text("[virtualenv]", encoding="utf-8") @pytest.mark.usefixtures("_empty_conf") -def test_value_ok(monkeypatch): +def test_value_ok(monkeypatch) -> None: monkeypatch.setenv("VIRTUALENV_VERBOSE", "5") result = session_via_cli(["venv"]) assert result.verbosity == 5 @pytest.mark.usefixtures("_empty_conf") -def test_value_bad(monkeypatch, caplog): +def test_value_bad(monkeypatch, caplog) -> None: monkeypatch.setenv("VIRTUALENV_VERBOSE", "a") result = session_via_cli(["venv"]) assert result.verbosity == 2 @@ -36,35 +36,35 @@ def test_value_bad(monkeypatch, caplog): assert "invalid literal" in caplog.messages[0] -def test_python_via_env_var(monkeypatch): +def test_python_via_env_var(monkeypatch) -> None: options = VirtualEnvOptions() monkeypatch.setenv("VIRTUALENV_PYTHON", "python3") session_via_cli(["venv"], options=options) assert options.python == ["python3"] -def test_python_multi_value_via_env_var(monkeypatch): +def test_python_multi_value_via_env_var(monkeypatch) -> None: options = VirtualEnvOptions() 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): +def test_python_multi_value_newline_via_env_var(monkeypatch) -> None: options = VirtualEnvOptions() 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): +def test_python_multi_value_prefer_newline_via_env_var(monkeypatch) -> None: options = VirtualEnvOptions() 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): +def test_extra_search_dir_via_env_var(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) value = f"a{os.linesep}0{os.linesep}b{os.pathsep}c" monkeypatch.setenv("VIRTUALENV_EXTRA_SEARCH_DIR", str(value)) @@ -77,7 +77,7 @@ def test_extra_search_dir_via_env_var(tmp_path, monkeypatch): @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): +def test_value_alias(monkeypatch, mocker) -> None: from virtualenv.config.cli.parser import VirtualEnvConfigParser # noqa: PLC0415 prev = VirtualEnvConfigParser._fix_default # noqa: SLF001 diff --git a/tests/unit/config/test_ini.py b/tests/unit/config/test_ini.py index a1621fa30..80d020642 100644 --- a/tests/unit/config/test_ini.py +++ b/tests/unit/config/test_ini.py @@ -15,7 +15,7 @@ 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): +def test_ini_can_be_overwritten_by_flag(tmp_path, monkeypatch) -> None: custom_ini = tmp_path / "conf.ini" custom_ini.write_text( dedent( diff --git a/tests/unit/create/conftest.py b/tests/unit/create/conftest.py index 58d390c5c..1da339701 100644 --- a/tests/unit/create/conftest.py +++ b/tests/unit/create/conftest.py @@ -1,10 +1,10 @@ -""" -It's possible to use multiple types of host pythons to create virtual environments and all should work: +"""It's possible to use multiple types of host pythons to create virtual environments and all should work: - host installation - invoking from a venv (if Python 3.3+) - invoking from an old style virtualenv (<17.0.0) - invoking from our own venv + """ from __future__ import annotations @@ -13,8 +13,7 @@ from subprocess import Popen import pytest - -from virtualenv.discovery.py_info import PythonInfo +from python_discovery import PythonInfo CURRENT = PythonInfo.current_system() diff --git a/tests/unit/create/console_app/demo/__init__.py b/tests/unit/create/console_app/demo/__init__.py index d7f2575eb..8e396504b 100644 --- a/tests/unit/create/console_app/demo/__init__.py +++ b/tests/unit/create/console_app/demo/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations -def run(): +def run() -> None: print("magic") # noqa: T201 diff --git a/tests/unit/create/console_app/demo/__main__.py b/tests/unit/create/console_app/demo/__main__.py index d7f2575eb..8e396504b 100644 --- a/tests/unit/create/console_app/demo/__main__.py +++ b/tests/unit/create/console_app/demo/__main__.py @@ -1,7 +1,7 @@ from __future__ import annotations -def run(): +def run() -> None: print("magic") # noqa: T201 diff --git a/tests/unit/create/test_creator.py b/tests/unit/create/test_creator.py index 87605f079..3a8ad25dc 100644 --- a/tests/unit/create/test_creator.py +++ b/tests/unit/create/test_creator.py @@ -21,21 +21,22 @@ from threading import Thread import pytest +from python_discovery import PythonInfo 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 import api -from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew +from virtualenv.create.via_global_ref.builtin.cpython.common import is_mac_os_framework, is_macos_brew from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix -from virtualenv.discovery.py_info import PythonInfo from virtualenv.info import IS_PYPY, IS_WIN, fs_is_case_sensitive from virtualenv.run import cli_run, session_via_cli +from virtualenv.run.plugin.creators import CreatorSelector CURRENT = PythonInfo.current_system() -def test_os_path_sep_not_allowed(tmp_path, capsys): +def test_os_path_sep_not_allowed(tmp_path, capsys) -> None: target = str(tmp_path / f"a{os.pathsep}b") err = _non_success_exit_code(capsys, target) msg = ( @@ -54,7 +55,7 @@ def _non_success_exit_code(capsys, target): return err -def test_destination_exists_file(tmp_path, capsys): +def test_destination_exists_file(tmp_path, capsys) -> None: target = tmp_path / "out" target.write_text("", encoding="utf-8") err = _non_success_exit_code(capsys, str(target)) @@ -63,7 +64,7 @@ def test_destination_exists_file(tmp_path, capsys): @pytest.mark.skipif(sys.platform == "win32", reason="Windows only applies R/O to files") -def test_destination_not_write_able(tmp_path, capsys): +def test_destination_not_write_able(tmp_path, capsys) -> None: if hasattr(os, "geteuid") and os.geteuid() == 0: pytest.skip("no way to check permission restriction when running under root") @@ -93,9 +94,9 @@ def system(session_app_data): return get_env_debug_info(Path(CURRENT.system_executable), DEBUG_SCRIPT, session_app_data, os.environ) -CURRENT_CREATORS = [i for i in CURRENT.creators().key_to_class if i != "builtin"] +CURRENT_CREATORS = [i for i in CreatorSelector.for_interpreter(CURRENT).key_to_class if i != "builtin"] CREATE_METHODS = [] -for k, v in CURRENT.creators().key_to_meta.items(): +for k, v in CreatorSelector.for_interpreter(CURRENT).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]: @@ -105,6 +106,7 @@ def system(session_app_data): CREATE_METHODS.append((k, "symlinks")) +@pytest.mark.graalpy @pytest.mark.parametrize( ("creator", "isolated"), [pytest.param(*i, id=f"{'-'.join(i[0])}-{i[1]}") for i in product(CREATE_METHODS, ["isolated", "global"])], @@ -116,7 +118,7 @@ def test_create_no_seed( # noqa: C901, PLR0912, PLR0913, PLR0915 system, coverage_env, special_name_dir, -): +) -> None: dest = special_name_dir creator_key, method = creator cmd = [ @@ -218,14 +220,10 @@ def list_to_str(iterable): assert result == "None" 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, "*"] + assert git_ignore.splitlines() == ["# created by virtualenv automatically", "*"] -def test_create_cachedir_tag(tmp_path): +def test_create_cachedir_tag(tmp_path) -> None: cachedir_tag_file = tmp_path / "CACHEDIR.TAG" cli_run([str(tmp_path), "--without-pip", "--activators", ""]) @@ -252,20 +250,20 @@ def test_create_cachedir_tag_exists_override(tmp_path: Path) -> None: assert cachedir_tag_file.read_text(encoding="utf-8") == "magic" -def test_create_vcs_ignore_exists(tmp_path): +def test_create_vcs_ignore_exists(tmp_path) -> None: git_ignore = tmp_path / ".gitignore" git_ignore.write_text("magic", encoding="utf-8") cli_run([str(tmp_path), "--without-pip", "--activators", ""]) assert git_ignore.read_text(encoding="utf-8") == "magic" -def test_create_vcs_ignore_override(tmp_path): +def test_create_vcs_ignore_override(tmp_path) -> None: git_ignore = tmp_path / ".gitignore" cli_run([str(tmp_path), "--without-pip", "--no-vcs-ignore", "--activators", ""]) assert not git_ignore.exists() -def test_create_vcs_ignore_exists_override(tmp_path): +def test_create_vcs_ignore_exists_override(tmp_path) -> None: git_ignore = tmp_path / ".gitignore" git_ignore.write_text("magic", encoding="utf-8") cli_run([str(tmp_path), "--without-pip", "--no-vcs-ignore", "--activators", ""]) @@ -273,7 +271,7 @@ def test_create_vcs_ignore_exists_override(tmp_path): @pytest.mark.skipif(not CURRENT.has_venv, reason="requires interpreter with venv") -def test_venv_fails_not_inline(tmp_path, capsys, mocker): +def test_venv_fails_not_inline(tmp_path, capsys, mocker) -> None: if hasattr(os, "geteuid") and os.geteuid() == 0: pytest.skip("no way to check permission restriction when running under root") @@ -302,7 +300,7 @@ def _session_via_cli(args, options=None, setup_logging=True, env=None): @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): +def test_create_clear_resets(tmp_path, creator, clear, caplog) -> None: caplog.set_level(logging.DEBUG) if creator == "venv" and clear is False: pytest.skip("venv without clear might fail") @@ -318,24 +316,55 @@ def test_create_clear_resets(tmp_path, creator, clear, caplog): @pytest.mark.parametrize("creator", CURRENT_CREATORS) -@pytest.mark.parametrize("prompt", [None, "magic"]) -def test_prompt_set(tmp_path, creator, prompt): - cmd = [str(tmp_path), "--seeder", "app-data", "--without-pip", "--creator", creator] +@pytest.mark.parametrize("prompt", [None, "magic", "."]) +def test_prompt_set(tmp_path: Path, creator: str, prompt: str | None, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + cmd = [str(tmp_path / "env"), "--seeder", "app-data", "--without-pip", "--creator", creator] if prompt is not None: - cmd.extend(["--prompt", "magic"]) + cmd.extend(["--prompt", prompt]) result = cli_run(cmd) - actual_prompt = tmp_path.name if prompt is None else prompt cfg = PyEnvCfg.from_file(result.creator.pyenv_cfg.path) if prompt is None: assert "prompt" not in cfg elif creator != "venv": + expected = tmp_path.name if prompt == "." else prompt assert "prompt" in cfg, list(cfg.content.keys()) - assert cfg["prompt"] == actual_prompt + assert cfg["prompt"] == expected @pytest.mark.parametrize("creator", CURRENT_CREATORS) -def test_home_path_is_exe_parent(tmp_path, creator): +def test_version_key_in_pyenv_cfg(tmp_path: Path, creator: str) -> None: + result = cli_run([str(tmp_path), "--seeder", "app-data", "--without-pip", "--creator", creator]) + cfg = PyEnvCfg.from_file(result.creator.pyenv_cfg.path) + assert "version" in cfg + parts = cfg["version"].split(".") + assert len(parts) == 3 + assert all(p.isdigit() for p in parts) + + +@pytest.mark.parametrize("creator", [c for c in CURRENT_CREATORS if c != "venv"]) +def test_executable_and_command_keys(tmp_path: Path, creator: str) -> None: + result = cli_run([str(tmp_path), "--seeder", "app-data", "--without-pip", "--creator", creator]) + cfg = PyEnvCfg.from_file(result.creator.pyenv_cfg.path) + assert "executable" in cfg + assert Path(cfg["executable"]).exists() + assert "command" in cfg + assert "virtualenv" in cfg["command"] + + +@pytest.mark.parametrize("creator", [c for c in CURRENT_CREATORS if c != "venv"]) +def test_include_dir_created(tmp_path: Path, creator: str) -> None: + result = cli_run([str(tmp_path), "--seeder", "app-data", "--without-pip", "--creator", creator]) + if sys.platform == "win32": + include = result.creator.dest / "Include" + else: + include = result.creator.dest / "include" + assert include.is_dir() + + +@pytest.mark.parametrize("creator", CURRENT_CREATORS) +def test_home_path_is_exe_parent(tmp_path, creator) -> None: cmd = [str(tmp_path), "--seeder", "app-data", "--without-pip", "--creator", creator] result = cli_run(cmd) @@ -357,8 +386,8 @@ def test_home_path_is_exe_parent(tmp_path, creator): @pytest.mark.usefixtures("temp_app_data") -def test_create_parallel(tmp_path): - def create(count): +def test_create_parallel(tmp_path) -> None: + def create(count) -> None: subprocess.check_call( [sys.executable, "-m", "virtualenv", "-vvv", str(tmp_path / f"venv{count}"), "--without-pip"], ) @@ -370,21 +399,21 @@ def create(count): thread.join() -def test_creator_input_passed_is_abs(tmp_path, monkeypatch): +def test_creator_input_passed_is_abs(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) result = Creator.validate_dest("venv") assert str(result) == str(tmp_path / "venv") @pytest.mark.skipif(os.altsep is None, reason="OS does not have an altsep") -def test_creator_replaces_altsep_in_dest(tmp_path): +def test_creator_replaces_altsep_in_dest(tmp_path) -> None: dest = str(tmp_path / "venv{}foobar") result = Creator.validate_dest(dest.format(os.altsep)) assert str(result) == dest.format(os.sep) @pytest.mark.usefixtures("current_fastest") -def test_create_long_path(tmp_path): +def test_create_long_path(tmp_path) -> None: if sys.platform == "darwin": max_shebang_length = 512 else: @@ -394,15 +423,17 @@ def test_create_long_path(tmp_path): folder = tmp_path / ("a" * (count // 2)) / ("b" * (count // 2)) / "c" folder.mkdir(parents=True) - cmd = [str(folder)] + cmd = [str(folder), "--without-pip"] result = cli_run(cmd) - subprocess.check_call([str(result.creator.script("pip")), "--version"]) + subprocess.check_call([str(result.creator.exe), "--version"]) @pytest.mark.slow -@pytest.mark.parametrize("creator", sorted(set(PythonInfo.current_system().creators().key_to_class) - {"builtin"})) +@pytest.mark.parametrize( + "creator", sorted(set(CreatorSelector.for_interpreter(PythonInfo.current_system()).key_to_class) - {"builtin"}) +) @pytest.mark.usefixtures("session_app_data") -def test_create_distutils_cfg(creator, tmp_path, monkeypatch): +def test_create_distutils_cfg(creator, tmp_path, monkeypatch) -> None: result = cli_run( [ str(tmp_path / "venv"), @@ -465,7 +496,7 @@ def list_files(path): @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): +def test_zip_importer_can_import_setuptools(tmp_path) -> None: """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", "--setuptools", "bundle"], @@ -498,7 +529,7 @@ def test_zip_importer_can_import_setuptools(tmp_path): reason="https://foss.heptapod.net/pypy/pypy/-/issues/3269", ) @pytest.mark.usefixtures("_no_coverage") -def test_no_preimport_threading(tmp_path): +def test_no_preimport_threading(tmp_path) -> None: session = cli_run([str(tmp_path)]) out = subprocess.check_output( [str(session.creator.exe), "-c", r"import sys; print('\n'.join(sorted(sys.modules)))"], @@ -510,7 +541,7 @@ def test_no_preimport_threading(tmp_path): # verify that .pth files in site-packages/ are always processed even if $PYTHONPATH points to it. -def test_pth_in_site_vs_python_path(tmp_path): +def test_pth_in_site_vs_python_path(tmp_path) -> None: session = cli_run([str(tmp_path)]) site_packages = session.creator.purelib # install test.pth that sets sys.testpth='ok' @@ -537,7 +568,7 @@ def test_pth_in_site_vs_python_path(tmp_path): assert out == "ok\n" -def test_getsitepackages_system_site(tmp_path): +def test_getsitepackages_system_site(tmp_path) -> None: # Test without --system-site-packages session = cli_run([str(tmp_path)]) @@ -580,7 +611,7 @@ def get_expected_system_site_packages(session): return system_site_packages -def test_get_site_packages(tmp_path): +def test_get_site_packages(tmp_path) -> None: case_sensitive = fs_is_case_sensitive() session = cli_run([str(tmp_path)]) env_site_packages = [str(session.creator.purelib), str(session.creator.platlib)] @@ -599,7 +630,7 @@ def test_get_site_packages(tmp_path): assert env_site_package in site_packages -def test_debug_bad_virtualenv(tmp_path): +def test_debug_bad_virtualenv(tmp_path) -> None: 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 @@ -615,8 +646,9 @@ def test_debug_bad_virtualenv(tmp_path): assert debug_info["exception"] +@pytest.mark.graalpy @pytest.mark.parametrize("python_path_on", [True, False], ids=["on", "off"]) -def test_python_path(monkeypatch, tmp_path, python_path_on): +def test_python_path(monkeypatch, tmp_path, python_path_on) -> None: result = cli_run([str(tmp_path), "--without-pip", "--activators", ""]) monkeypatch.chdir(tmp_path) case_sensitive = fs_is_case_sensitive() @@ -675,12 +707,12 @@ def _get_sys_path(flag=None): # # 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): +def test_venv_creator_without_write_perms(tmp_path, mocker) -> None: from virtualenv.run.session import Session # noqa: PLC0415 prev = Session._create # noqa: SLF001 - def func(self): + def func(self) -> None: prev(self) scripts_dir = self.creator.dest / "bin" for script in scripts_dir.glob("*ctivate*"): @@ -692,7 +724,7 @@ def func(self): cli_run(cmd) -def test_fallback_to_copies_if_symlink_unsupported(tmp_path, python, mocker): +def test_fallback_to_copies_if_symlink_unsupported(tmp_path, python, mocker) -> None: """Test that creating a virtual environment falls back to copies when filesystem has no symlink support.""" if is_macos_brew(PythonInfo.from_exe(python)): pytest.skip("brew python on darwin may not support copies, which is tested separately") @@ -717,7 +749,7 @@ def test_fallback_to_copies_if_symlink_unsupported(tmp_path, python, mocker): assert result.creator.symlinks is False -def test_fail_gracefully_if_no_method_supported(tmp_path, python, mocker): +def test_fail_gracefully_if_no_method_supported(tmp_path, python, mocker) -> None: """Test that virtualenv fails gracefully when no creation method is supported.""" # Given a filesystem that does not support symlinks mocker.patch("virtualenv.create.via_global_ref.api.fs_supports_symlink", return_value=False) @@ -726,7 +758,7 @@ def test_fail_gracefully_if_no_method_supported(tmp_path, python, mocker): if not is_macos_brew(PythonInfo.from_exe(python)): original_init = api.ViaGlobalRefMeta.__init__ - def new_init(self, *args, **kwargs): + def new_init(self, *args, **kwargs) -> None: original_init(self, *args, **kwargs) self.copy_error = "copying is not supported" @@ -746,13 +778,16 @@ def new_init(self, *args, **kwargs): # Then a RuntimeError should be raised with a detailed message assert "neither symlink or copy method supported" in str(excinfo.value) assert "symlink: the filesystem does not supports symlink" in str(excinfo.value) - if is_macos_brew(PythonInfo.from_exe(python)): + interpreter = PythonInfo.from_exe(python) + if is_macos_brew(interpreter): assert "copy: Brew disables copy creation" in str(excinfo.value) + elif is_mac_os_framework(interpreter): + assert "copy: macOS framework builds do not support copy-based virtual environments" in str(excinfo.value) else: assert "copy: copying is not supported" in str(excinfo.value) -def test_pyenv_cfg_preserves_symlinks(tmp_path): +def test_pyenv_cfg_preserves_symlinks(tmp_path) -> None: """Test that PyEnvCfg.write() preserves symlinks and doesn't resolve them (issue #2770).""" # Create a real directory and a symlink to it real_dir = tmp_path / "real_directory" diff --git a/tests/unit/create/test_interpreters.py b/tests/unit/create/test_interpreters.py index ae4452b13..73668ce2e 100644 --- a/tests/unit/create/test_interpreters.py +++ b/tests/unit/create/test_interpreters.py @@ -4,13 +4,13 @@ from uuid import uuid4 import pytest +from python_discovery import PythonInfo -from virtualenv.discovery.py_info import PythonInfo from virtualenv.run import cli_run @pytest.mark.slow -def test_failed_to_find_bad_spec(): +def test_failed_to_find_bad_spec() -> None: of_id = uuid4().hex with pytest.raises(RuntimeError) as context: cli_run(["-p", of_id]) @@ -25,7 +25,7 @@ def test_failed_to_find_bad_spec(): "of_id", ({sys.executable} if sys.executable != SYSTEM.executable else set()) | {SYSTEM.implementation}, ) -def test_failed_to_find_implementation(of_id, mocker): +def test_failed_to_find_implementation(of_id, mocker) -> None: mocker.patch("virtualenv.run.plugin.creators.CreatorSelector._OPTIONS", return_value={}) with pytest.raises(RuntimeError) as context: cli_run(["-p", of_id]) diff --git a/tests/unit/create/via_global_ref/_test_race_condition_helper.py b/tests/unit/create/via_global_ref/_test_race_condition_helper.py index 8027f17d3..732f95f0c 100644 --- a/tests/unit/create/via_global_ref/_test_race_condition_helper.py +++ b/tests/unit/create/via_global_ref/_test_race_condition_helper.py @@ -7,7 +7,7 @@ class _Finder: fullname = None lock: ClassVar[list] = [] - def find_spec(self, fullname, path, target=None): # noqa: ARG002 + def find_spec(self, fullname, path, target=None) -> None: # noqa: ARG002 # This should handle the NameError gracefully try: distutils_patch = _DISTUTILS_PATCH @@ -18,7 +18,7 @@ def find_spec(self, fullname, path, target=None): # noqa: ARG002 return @staticmethod - def exec_module(old, module): + def exec_module(old, module) -> None: old(module) try: distutils_patch = _DISTUTILS_PATCH diff --git a/tests/unit/create/via_global_ref/builtin/conftest.py b/tests/unit/create/via_global_ref/builtin/conftest.py index a5808c58b..7d913fb96 100644 --- a/tests/unit/create/via_global_ref/builtin/conftest.py +++ b/tests/unit/create/via_global_ref/builtin/conftest.py @@ -2,11 +2,16 @@ import sys from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import MagicMock import pytest from testing import path from testing.py_info import read_fixture +if TYPE_CHECKING: + from tests.types import Interpreter, MakeInterpreter + # Allows to import from `testing` into test submodules. sys.path.append(str(Path(__file__).parent)) @@ -24,3 +29,23 @@ def mock_files(mocker): @pytest.fixture def mock_pypy_libs(mocker): return lambda pypy, libs: path.mock_pypy_libs(mocker, pypy, libs) + + +@pytest.fixture +def make_interpreter() -> MakeInterpreter: + def _make( + sysconfig_vars: dict[str, object] | None = None, + prefix: str = "/usr", + free_threaded: bool = False, + version_info: tuple[int, ...] = (3, 14, 0), + ) -> Interpreter: + interpreter = MagicMock() + interpreter.prefix = prefix + interpreter.system_prefix = prefix + interpreter.system_executable = f"{prefix}/bin/python3" + interpreter.free_threaded = free_threaded + interpreter.version_info = MagicMock(major=version_info[0], minor=version_info[1]) + interpreter.sysconfig_vars = sysconfig_vars or {} + return interpreter + + return _make # type: ignore[return-value] diff --git a/tests/unit/create/via_global_ref/builtin/cpython/conftest.py b/tests/unit/create/via_global_ref/builtin/cpython/conftest.py new file mode 100644 index 000000000..9a3ce4ad2 --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/cpython/conftest.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + from pytest_mock import MockerFixture + + from tests.types import MakeInterpreter + from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest + + CollectSources = Callable[[dict[str, object]], list[PathRefToDest]] + + +@pytest.fixture +def collect_sources(tmp_path: Path, make_interpreter: MakeInterpreter, mocker: MockerFixture) -> CollectSources: + def _collect(sysconfig_vars: dict[str, object]) -> list[PathRefToDest]: + interpreter = make_interpreter( + sysconfig_vars={**sysconfig_vars, "PYTHONFRAMEWORK": ""}, + prefix=str(tmp_path), + ) + interpreter.system_executable = str(tmp_path / "bin" / "python3") + mocker.patch( + "virtualenv.create.via_global_ref.builtin.cpython.cpython3.Path.exists", + return_value=True, + ) + return list(CPython3Posix.sources(interpreter)) + + return _collect diff --git a/tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_free_threaded.json b/tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_free_threaded.json new file mode 100644 index 000000000..f39a6059c --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/cpython/cpython3_win_free_threaded.json @@ -0,0 +1,62 @@ +{ + "platform": "win32", + "implementation": "CPython", + "version_info": { + "major": 3, + "minor": 13, + "micro": 0, + "releaselevel": "final", + "serial": 0 + }, + "architecture": 64, + "version_nodot": "313", + "version": "3.13.0 (main, Oct 07 2024, 00:00:00) [MSC v.1941 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\\python3.13t.exe", + "original_executable": "c:\\path\\to\\python\\python3.13t.exe", + "system_executable": "c:\\path\\to\\python\\python3.13t.exe", + "has_venv": false, + "path": [ + "c:\\path\\to\\python\\Scripts\\virtualenv.exe", + "c:\\path\\to\\python\\python313.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, + "free_threaded": true +} diff --git a/tests/unit/create/via_global_ref/builtin/cpython/test_cpython3_posix.py b/tests/unit/create/via_global_ref/builtin/cpython/test_cpython3_posix.py new file mode 100644 index 000000000..53ced18b2 --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/cpython/test_cpython3_posix.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Posix +from virtualenv.create.via_global_ref.builtin.ref import PathRefToDest, RefWhen + +if TYPE_CHECKING: + from pathlib import Path + + from conftest import CollectSources + + from tests.types import MakeInterpreter + + +def _shared_lib_copy_refs(sources: list[PathRefToDest]) -> list[PathRefToDest]: + return [s for s in sources if isinstance(s, PathRefToDest) and s.when == RefWhen.COPY] + + +def test_shared_lib_included(tmp_path: Path, collect_sources: CollectSources) -> None: + lib_dir = tmp_path / "lib" + lib_dir.mkdir() + (lib_dir / "libpython3.14.so").touch() + sources = collect_sources({"Py_ENABLE_SHARED": 1, "INSTSONAME": "libpython3.14.so", "LIBDIR": str(lib_dir)}) + shared_refs = _shared_lib_copy_refs(sources) + assert len(shared_refs) == 1 + assert shared_refs[0].src.name == "libpython3.14.so" + + +def test_shared_lib_excluded_when_static(collect_sources: CollectSources) -> None: + sources = collect_sources({"Py_ENABLE_SHARED": 0}) + assert _shared_lib_copy_refs(sources) == [] + + +def test_shared_lib_excluded_when_no_lib_name(collect_sources: CollectSources) -> None: + sources = collect_sources({"Py_ENABLE_SHARED": 1}) + assert _shared_lib_copy_refs(sources) == [] + + +def test_shared_lib_excluded_when_no_lib_dir(collect_sources: CollectSources) -> None: + sources = collect_sources({"Py_ENABLE_SHARED": 1, "INSTSONAME": "libpython3.14.so"}) + assert _shared_lib_copy_refs(sources) == [] + + +def test_shared_lib_excluded_when_file_missing(tmp_path: Path, make_interpreter: MakeInterpreter) -> None: + lib_dir = tmp_path / "lib" + lib_dir.mkdir() + interpreter = make_interpreter( + sysconfig_vars={ + "Py_ENABLE_SHARED": 1, + "INSTSONAME": "libpython3.14.so", + "LIBDIR": str(lib_dir), + "PYTHONFRAMEWORK": "", + }, + prefix=str(tmp_path), + ) + interpreter.system_executable = str(tmp_path / "bin" / "python3") + sources = list(CPython3Posix.sources(interpreter)) + assert _shared_lib_copy_refs(sources) == [] 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 index 429a17ed4..94e02bfb9 100644 --- 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 @@ -1,7 +1,7 @@ from __future__ import annotations import pytest -from testing.helpers import contains_exe, contains_ref +from testing.helpers import contains_exe, contains_ref, has_src, is_exe from testing.path import join as path from virtualenv.create.via_global_ref.builtin.cpython.cpython3 import CPython3Windows @@ -13,7 +13,7 @@ @pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) -def test_2_exe_on_default_py_host(py_info, mock_files): +def test_2_exe_on_default_py_host(py_info, mock_files) -> None: mock_files(CPYTHON3_PATH, [py_info.system_executable]) sources = tuple(CPython3Windows.sources(interpreter=py_info)) # Default Python exe. @@ -23,7 +23,7 @@ def test_2_exe_on_default_py_host(py_info, mock_files): @pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) -def test_3_exe_on_not_default_py_host(py_info, mock_files): +def test_3_exe_on_not_default_py_host(py_info, mock_files) -> None: # Not default python host. py_info.system_executable = path(py_info.prefix, "python666.exe") mock_files(CPYTHON3_PATH, [py_info.system_executable]) @@ -36,7 +36,7 @@ def test_3_exe_on_not_default_py_host(py_info, mock_files): @pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) -def test_only_shim(py_info, mock_files): +def test_only_shim(py_info, mock_files) -> None: shim = path(py_info.system_stdlib, "venv\\scripts\\nt\\python.exe") py_files = ( path(py_info.prefix, "libcrypto-1_1.dll"), @@ -54,7 +54,7 @@ def test_only_shim(py_info, mock_files): @pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) -def test_exe_dll_pyd_without_shim(py_info, mock_files): +def test_exe_dll_pyd_without_shim(py_info, mock_files) -> None: py_files = ( path(py_info.prefix, "libcrypto-1_1.dll"), path(py_info.prefix, "libffi-7.dll"), @@ -70,7 +70,7 @@ def test_exe_dll_pyd_without_shim(py_info, mock_files): @pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) -def test_python_zip_if_exists_and_set_in_path(py_info, mock_files): +def test_python_zip_if_exists_and_set_in_path(py_info, mock_files) -> None: 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]) @@ -80,7 +80,7 @@ def test_python_zip_if_exists_and_set_in_path(py_info, mock_files): @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): +def test_no_python_zip_if_exists_and_not_set_in_path(py_info, mock_files) -> None: 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) @@ -91,7 +91,7 @@ def test_no_python_zip_if_exists_and_not_set_in_path(py_info, mock_files): @pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) -def test_no_python_zip_if_not_exists(py_info, mock_files): +def test_no_python_zip_if_not_exists(py_info, mock_files) -> None: 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. @@ -102,7 +102,7 @@ def test_no_python_zip_if_not_exists(py_info, mock_files): @pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) -def test_python3_exe_present(py_info, mock_files): +def test_python3_exe_present(py_info, mock_files) -> None: mock_files(CPYTHON3_PATH, [py_info.system_executable]) sources = tuple(CPython3Windows.sources(interpreter=py_info)) assert contains_exe(sources, py_info.system_executable, "python3.exe") @@ -110,7 +110,27 @@ def test_python3_exe_present(py_info, mock_files): @pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) -def test_pywin32_dll_exclusion(py_info, mock_files): +def test_pythonw3_exe_present(py_info, mock_files) -> None: + mock_files(CPYTHON3_PATH, [py_info.system_executable]) + sources = tuple(CPython3Windows.sources(interpreter=py_info)) + pythonw_refs = [s for s in sources if is_exe(s) and has_src(path(py_info.prefix, "pythonw.exe"))(s)] + assert len(pythonw_refs) == 1 + assert "pythonw3.exe" in pythonw_refs[0].aliases + + +@pytest.mark.parametrize("py_info_name", ["cpython3_win_free_threaded"]) +def test_free_threaded_exe_naming(py_info, mock_files) -> None: + mock_files(CPYTHON3_PATH, [py_info.system_executable]) + sources = tuple(CPython3Windows.sources(interpreter=py_info)) + assert contains_exe(sources, py_info.system_executable, "python3.13t.exe") + pythonw_refs = [s for s in sources if is_exe(s) and has_src(path(py_info.prefix, "pythonw.exe"))(s)] + assert len(pythonw_refs) == 1 + assert "pythonw3.exe" in pythonw_refs[0].aliases + assert "pythonw3.13t.exe" in pythonw_refs[0].aliases + + +@pytest.mark.parametrize("py_info_name", ["cpython3_win_embed"]) +def test_pywin32_dll_exclusion(py_info, mock_files) -> None: """Test that pywin32 DLLs are excluded from virtualenv creation.""" # Mock pywin32 DLLs that should be excluded pywin32_dlls = ( 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 3ae905fdb..d63f36d62 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,10 +1,19 @@ from __future__ import annotations +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + 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 PathRefToDest + +if TYPE_CHECKING: + from pathlib import Path + + from pytest_mock import MockerFixture PYPY3_PATH = ( "virtualenv.create.via_global_ref.builtin.pypy.common.Path", @@ -15,7 +24,7 @@ # 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): +def test_portable_pypy3_virtualenvs_get_their_libs(py_info, mock_files, mock_pypy_libs) -> None: 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") @@ -28,7 +37,7 @@ def test_portable_pypy3_virtualenvs_get_their_libs(py_info, mock_files, mock_pyp @pytest.mark.parametrize("py_info_name", ["deb_pypy37"]) -def test_debian_pypy37_virtualenvs(py_info, mock_files, mock_pypy_libs): +def test_debian_pypy37_virtualenvs(py_info, mock_files, mock_pypy_libs) -> None: # Debian's pypy3 layout, installed to /usr, before 3.8 allowed a /usr prefix mock_files(PYPY3_PATH, [py_info.system_executable]) lib_file = path(py_info.prefix, "bin/libpypy3-c.so") @@ -40,10 +49,36 @@ def test_debian_pypy37_virtualenvs(py_info, mock_files, mock_pypy_libs): @pytest.mark.parametrize("py_info_name", ["deb_pypy38"]) -def test_debian_pypy38_virtualenvs_exclude_usr(py_info, mock_files, mock_pypy_libs): +def test_debian_pypy38_virtualenvs_exclude_usr(py_info, mock_files, mock_pypy_libs) -> None: mock_files(PYPY3_PATH, [py_info.system_executable, "/usr/lib/foo"]) # libpypy3-c.so lives on the ld search path mock_pypy_libs(PyPy3Posix, []) sources = tuple(PyPy3Posix.sources(interpreter=py_info)) assert len(sources) == 1 assert contains_exe(sources, py_info.system_executable) + + +def test_pypy_portable_deps_txt(tmp_path: Path, mocker: MockerFixture) -> None: + host_lib = tmp_path / "lib" + host_lib.mkdir() + stdlib = host_lib / "pypy3.10" + stdlib.mkdir() + (host_lib / "libssl.so").touch() + (host_lib / "libcrypto.so").touch() + (host_lib / "unneeded.so").touch() + deps_file = host_lib / "PYPY_PORTABLE_DEPS.txt" + deps_file.write_text("libssl.so\nlibcrypto.so\n", encoding="utf-8") + + interpreter = MagicMock() + interpreter.system_prefix = str(tmp_path) + interpreter.system_executable = str(tmp_path / "bin" / "pypy3") + interpreter.system_stdlib = str(stdlib) + interpreter.version_info = MagicMock(major=3, minor=10) + + mocker.patch.object(PyPy3Posix, "_shared_libs", return_value=[]) + + sources = list(PyPy3Posix.sources(interpreter)) + ref_names = {s.src.name for s in sources if isinstance(s, PathRefToDest)} + assert "libssl.so" in ref_names + assert "libcrypto.so" in ref_names + assert "unneeded.so" not in ref_names diff --git a/tests/unit/create/via_global_ref/builtin/rustpython/rustpython_posix.json b/tests/unit/create/via_global_ref/builtin/rustpython/rustpython_posix.json new file mode 100644 index 000000000..23bde801b --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/rustpython/rustpython_posix.json @@ -0,0 +1,61 @@ +{ + "platform": "linux", + "implementation": "RustPython", + "version_info": { + "major": 3, + "minor": 14, + "micro": 0, + "releaselevel": "alpha", + "serial": 0 + }, + "architecture": 64, + "version": "3.14.0alpha (default, Nov 10 2025, 09:14:50)\n[RustPython 0.4.0 with rustc 1.83.0 (90b35a623 2024-11-26)]", + "os": "posix", + "prefix": "/usr/local", + "base_prefix": "/usr/local", + "real_prefix": null, + "base_exec_prefix": "/usr/local", + "exec_prefix": "/usr/local", + "executable": "/usr/local/bin/rustpython", + "original_executable": "/usr/local/bin/rustpython", + "system_executable": "/usr/local/bin/rustpython", + "has_venv": true, + "path": [ + "/usr/local/lib/python3.14", + "/usr/local/lib/python3.14/site-packages" + ], + "file_system_encoding": "utf-8", + "stdout_encoding": "UTF-8", + "sysconfig_scheme": null, + "sysconfig_paths": { + "stdlib": "{installed_base}/lib/python{py_version_short}", + "platstdlib": "{platbase}/lib/python{py_version_short}", + "purelib": "{base}/lib/python{py_version_short}/site-packages", + "platlib": "{platbase}/lib/python{py_version_short}/site-packages", + "include": "{installed_base}/include/python{py_version_short}{abiflags}", + "scripts": "{base}/bin", + "data": "{base}" + }, + "distutils_install": { + "purelib": "lib/python3.14/site-packages", + "platlib": "lib/python3.14/site-packages", + "headers": "include/python3.14/UNKNOWN", + "scripts": "bin", + "data": "" + }, + "sysconfig": {}, + "sysconfig_vars": { + "installed_base": "/usr/local", + "implementation_lower": "rustpython", + "py_version_short": "3.14", + "platbase": "/usr/local", + "base": "/usr/local", + "abiflags": "t", + "PYTHONFRAMEWORK": "" + }, + "system_stdlib": "/usr/local/lib/python3.14", + "system_stdlib_platform": "/usr/local/lib/python3.14", + "max_size": 9223372036854775807, + "_creators": null, + "free_threaded": false +} diff --git a/tests/unit/create/via_global_ref/builtin/rustpython/rustpython_windows.json b/tests/unit/create/via_global_ref/builtin/rustpython/rustpython_windows.json new file mode 100644 index 000000000..f87b6dcfe --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/rustpython/rustpython_windows.json @@ -0,0 +1,58 @@ +{ + "platform": "win32", + "implementation": "RustPython", + "version_info": { + "major": 3, + "minor": 14, + "micro": 0, + "releaselevel": "alpha", + "serial": 0 + }, + "architecture": 64, + "version": "3.14.0alpha (default, Nov 10 2025, 09:14:50)\n[RustPython 0.4.0 with rustc 1.83.0 (90b35a623 2024-11-26) MSC v.1929 64 bit (AMD64)]", + "os": "nt", + "prefix": "C:\\RustPython", + "base_prefix": "C:\\RustPython", + "real_prefix": null, + "base_exec_prefix": "C:\\RustPython", + "exec_prefix": "C:\\RustPython", + "executable": "C:\\RustPython\\rustpython.exe", + "original_executable": "C:\\RustPython\\rustpython.exe", + "system_executable": "C:\\RustPython\\rustpython.exe", + "has_venv": true, + "path": ["C:\\RustPython\\Lib", "C:\\RustPython\\Lib\\site-packages"], + "file_system_encoding": "utf-8", + "stdout_encoding": "UTF-8", + "sysconfig_scheme": null, + "sysconfig_paths": { + "stdlib": "{installed_base}\\Lib", + "platstdlib": "{platbase}\\Lib", + "purelib": "{base}\\Lib\\site-packages", + "platlib": "{platbase}\\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": {}, + "sysconfig_vars": { + "installed_base": "C:\\RustPython", + "implementation_lower": "rustpython", + "py_version_short": "3.14", + "platbase": "C:\\RustPython", + "base": "C:\\RustPython", + "abiflags": "t", + "PYTHONFRAMEWORK": "" + }, + "system_stdlib": "C:\\RustPython\\Lib", + "system_stdlib_platform": "C:\\RustPython\\Lib", + "max_size": 9223372036854775807, + "_creators": null, + "free_threaded": false +} diff --git a/tests/unit/create/via_global_ref/builtin/rustpython/test_rustpython.py b/tests/unit/create/via_global_ref/builtin/rustpython/test_rustpython.py new file mode 100644 index 000000000..584b06945 --- /dev/null +++ b/tests/unit/create/via_global_ref/builtin/rustpython/test_rustpython.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from testing.helpers import contains_exe + +from virtualenv.create.via_global_ref.builtin.rustpython import RustPythonPosix, RustPythonWindows + + +@pytest.mark.parametrize("py_info_name", ["rustpython_posix"]) +def test_can_describe_rustpython_posix(py_info: MagicMock) -> None: + assert RustPythonPosix.can_describe(py_info) + + +@pytest.mark.parametrize("py_info_name", ["rustpython_windows"]) +def test_can_describe_rustpython_windows(py_info: MagicMock) -> None: + assert RustPythonWindows.can_describe(py_info) + + +def test_can_describe_rejects_cpython() -> None: + interpreter = MagicMock() + interpreter.implementation = "CPython" + interpreter.os = "posix" + assert not RustPythonPosix.can_describe(interpreter) + interpreter.os = "nt" + assert not RustPythonWindows.can_describe(interpreter) + + +@pytest.mark.parametrize("py_info_name", ["rustpython_posix"]) +def test_sources_posix(py_info: MagicMock, mock_files: object) -> None: + mock_files(("virtualenv.create.via_global_ref.builtin.rustpython.Path",), [py_info.system_executable]) + sources = list(RustPythonPosix.sources(interpreter=py_info)) + assert len(sources) == 1 + assert contains_exe(sources, py_info.system_executable) + + +@pytest.mark.parametrize("py_info_name", ["rustpython_windows"]) +def test_sources_windows(py_info: MagicMock, mock_files: object) -> None: + mock_files(("virtualenv.create.via_global_ref.builtin.rustpython.Path",), [py_info.system_executable]) + sources = list(RustPythonWindows.sources(interpreter=py_info)) + assert len(sources) == 1 + assert contains_exe(sources, py_info.system_executable) + + +def test_exe_names() -> None: + interpreter = MagicMock() + interpreter.version_info = MagicMock(major=3, minor=14) + names = RustPythonPosix.exe_names(interpreter) + assert names == {"rustpython", "python", "python3", "python3.14"} diff --git a/tests/unit/create/via_global_ref/builtin/testing/path.py b/tests/unit/create/via_global_ref/builtin/testing/path.py index 06ba921c5..eeeebcf07 100644 --- a/tests/unit/create/via_global_ref/builtin/testing/path.py +++ b/tests/unit/create/via_global_ref/builtin/testing/path.py @@ -76,19 +76,17 @@ def iterdir(self): def MetaPathMock(filelist): # noqa: N802 - """ - Metaclass that creates a `PathMock` class with the `filelist` defined. - """ + """Metaclass that creates a `PathMock` class with the `filelist` defined.""" return type("PathMock", (PathMockABC,), {"filelist": filelist}) -def mock_files(mocker, pathlist, filelist): +def mock_files(mocker, pathlist, filelist) -> None: PathMock = MetaPathMock(set(filelist)) # noqa: N806 for path in pathlist: mocker.patch(path, PathMock) -def mock_pypy_libs(mocker, pypy_creator_cls, libs): +def mock_pypy_libs(mocker, pypy_creator_cls, libs) -> None: paths = tuple(set(map(Path, libs))) mocker.patch.object(pypy_creator_cls, "_shared_libs", return_value=paths) 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 index 27a660c4a..99fc13e71 100644 --- a/tests/unit/create/via_global_ref/builtin/testing/py_info.py +++ b/tests/unit/create/via_global_ref/builtin/testing/py_info.py @@ -2,7 +2,7 @@ from pathlib import Path -from virtualenv.discovery.py_info import PythonInfo +from python_discovery import PythonInfo def fixture_file(fixture_name): @@ -18,4 +18,4 @@ def fixture_file(fixture_name): def read_fixture(fixture_name): fixture_json = fixture_file(fixture_name).read_text(encoding="utf-8") - return PythonInfo._from_json(fixture_json) # noqa: SLF001 + return PythonInfo.from_json(fixture_json) diff --git a/tests/unit/create/via_global_ref/test_api.py b/tests/unit/create/via_global_ref/test_api.py index a863b0e45..aa6dad31f 100644 --- a/tests/unit/create/via_global_ref/test_api.py +++ b/tests/unit/create/via_global_ref/test_api.py @@ -3,6 +3,6 @@ from virtualenv.create.via_global_ref import api -def test_can_symlink_when_symlinks_not_enabled(mocker): +def test_can_symlink_when_symlinks_not_enabled(mocker) -> None: mocker.patch.object(api, "fs_supports_symlink", return_value=False) assert api.ViaGlobalRefMeta().can_symlink is False 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 5195ff856..97cf8df7c 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 @@ -7,15 +7,17 @@ from subprocess import Popen import pytest +from python_discovery import PythonInfo -from virtualenv.discovery.py_info import PythonInfo from virtualenv.run import cli_run +from virtualenv.run.plugin.creators import CreatorSelector +from virtualenv.seed.wheels.embed import BUNDLE_FOLDER as EMBED_WHEEL_DIR CURRENT = PythonInfo.current_system() -CREATOR_CLASSES = CURRENT.creators().key_to_class +CREATOR_CLASSES = CreatorSelector.for_interpreter(CURRENT).key_to_class -def builtin_shows_marker_missing(): +def builtin_shows_marker_missing() -> bool: builtin_classs = CREATOR_CLASSES.get("builtin") if builtin_classs is None: return False @@ -37,24 +39,17 @@ def builtin_shows_marker_missing(): reason="Building C-Extensions requires header files with host python", ) @pytest.mark.parametrize("creator", [i for i in CREATOR_CLASSES if i != "builtin"]) -def test_can_build_c_extensions(creator, tmp_path, coverage_env): +def test_can_build_c_extensions(creator, tmp_path, coverage_env) -> None: env, greet = tmp_path / "env", str(tmp_path / "greet") shutil.copytree(str(Path(__file__).parent.resolve() / "greet"), greet) session = cli_run(["--creator", creator, "--seeder", "app-data", str(env), "-vvv"]) coverage_env() - setuptools_index_args = () - if CURRENT.version_info >= (3, 12): - # requires to be able to install setuptools as build dependency - setuptools_index_args = ( - "--find-links", - "https://pypi.org/simple/setuptools/", - ) - cmd = [ str(session.creator.script("pip")), "install", "--no-index", - *setuptools_index_args, + "--find-links", + str(EMBED_WHEEL_DIR), "--no-deps", "--disable-pip-version-check", "-vvv", diff --git a/tests/unit/create/via_global_ref/test_race_condition.py b/tests/unit/create/via_global_ref/test_race_condition.py index 3b044167c..1b3dc9886 100644 --- a/tests/unit/create/via_global_ref/test_race_condition.py +++ b/tests/unit/create/via_global_ref/test_race_condition.py @@ -4,7 +4,7 @@ from pathlib import Path -def test_virtualenv_py_race_condition_find_spec(tmp_path): +def test_virtualenv_py_race_condition_find_spec(tmp_path) -> None: """Test that _Finder.find_spec handles NameError gracefully when _DISTUTILS_PATCH is not defined.""" # Create a temporary file with partial _virtualenv.py content (simulating race condition) venv_file = tmp_path / "_virtualenv_test.py" @@ -31,7 +31,7 @@ class MockModule: __name__ = "distutils.dist" # Try to call exec_module - this should not raise NameError - def mock_old_exec(_x): + def mock_old_exec(_x) -> None: pass finder.exec_module(mock_old_exec, MockModule()) @@ -49,7 +49,7 @@ def mock_old_load(_name): del sys.modules["_virtualenv_test"] -def test_virtualenv_py_normal_operation(): +def test_virtualenv_py_normal_operation() -> None: """Test that the fix doesn't break normal operation when _DISTUTILS_PATCH is defined.""" # Read the actual _virtualenv.py file virtualenv_py_path = ( diff --git a/tests/unit/discovery/py_info/test_py_info.py b/tests/unit/discovery/py_info/test_py_info.py deleted file mode 100644 index 15472daab..000000000 --- a/tests/unit/discovery/py_info/test_py_info.py +++ /dev/null @@ -1,531 +0,0 @@ -from __future__ import annotations - -import copy -import functools -import itertools -import json -import logging -import os -import sys -import sysconfig -from pathlib import Path -from textwrap import dedent -from typing import NamedTuple - -import pytest - -from virtualenv.create.via_global_ref.builtin.cpython.common import is_macos_brew -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, IS_WIN, fs_supports_symlink - -CURRENT = PythonInfo.current_system() - - -def test_current_as_json(): - result = CURRENT._to_json() # noqa: SLF001 - parsed = json.loads(result) - a, b, c, d, e = sys.version_info - f = sysconfig.get_config_var("Py_GIL_DISABLED") == 1 - assert parsed["version_info"] == {"major": a, "minor": b, "micro": c, "releaselevel": d, "serial": e} - assert parsed["free_threaded"] is f - - -def test_bad_exe_py_info_raise(tmp_path, session_app_data): - exe = str(tmp_path) - with pytest.raises(RuntimeError) as context: - PythonInfo.from_exe(exe, session_app_data) - msg = str(context.value) - assert "code" in msg - assert exe in msg - - -def test_bad_exe_py_info_no_raise(tmp_path, caplog, capsys, session_app_data): - caplog.set_level(logging.NOTSET) - exe = str(tmp_path) - result = PythonInfo.from_exe(exe, session_app_data, raise_on_error=False) - assert result is None - out, _ = capsys.readouterr() - assert not out - messages = [r.message for r in caplog.records if r.name != "filelock"] - assert len(messages) == 2 - msg = messages[0] - assert "get interpreter info via cmd: " in msg - msg = messages[1] - assert str(exe) in msg - assert "code" in msg - - -@pytest.mark.parametrize( - "spec", - itertools.chain( - [sys.executable], - [ - f"{impl}{'.'.join(str(i) for i in ver)}{'t' if CURRENT.free_threaded else ''}{arch}" - for impl, ver, arch in itertools.product( - ( - [CURRENT.implementation] - + (["python"] if CURRENT.implementation == "CPython" else []) - + ( - [CURRENT.implementation.lower()] - if CURRENT.implementation != CURRENT.implementation.lower() - else [] - ) - ), - [sys.version_info[0 : i + 1] for i in range(3)], - ["", f"-{CURRENT.architecture}"], - ) - ], - ), -) -def test_satisfy_py_info(spec): - parsed_spec = PythonSpec.from_string_spec(spec) - matches = CURRENT.satisfies(parsed_spec, True) - assert matches is True - - -def test_satisfy_not_arch(): - parsed_spec = PythonSpec.from_string_spec( - f"{CURRENT.implementation}-{64 if CURRENT.architecture == 32 else 32}", - ) - matches = CURRENT.satisfies(parsed_spec, True) - assert matches is False - - -def test_satisfy_not_threaded(): - parsed_spec = PythonSpec.from_string_spec( - f"{CURRENT.implementation}{CURRENT.version_info.major}{'' if CURRENT.free_threaded else 't'}", - ) - matches = CURRENT.satisfies(parsed_spec, True) - assert matches is False - - -def _generate_not_match_current_interpreter_version(): - result = [] - for i in range(3): - ver = sys.version_info[0 : i + 1] - for a in range(len(ver)): - for o in [-1, 1]: - temp = list(ver) - temp[a] += o - result.append(".".join(str(i) for i in temp)) - return result - - -_NON_MATCH_VER = _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(f"{CURRENT.implementation}{spec}") - matches = CURRENT.satisfies(parsed_spec, True) - assert matches is False - - -def test_py_info_cached_error(mocker, tmp_path, session_app_data): - spy = mocker.spy(cached_py_info, "_run_subprocess") - with pytest.raises(RuntimeError): - PythonInfo.from_exe(str(tmp_path), session_app_data) - with pytest.raises(RuntimeError): - PythonInfo.from_exe(str(tmp_path), session_app_data) - assert spy.call_count == 1 - - -@pytest.mark.skipif(not fs_supports_symlink(), reason="symlink is not supported") -def test_py_info_cached_symlink_error(mocker, tmp_path, session_app_data): - spy = mocker.spy(cached_py_info, "_run_subprocess") - with pytest.raises(RuntimeError): - PythonInfo.from_exe(str(tmp_path), session_app_data) - symlinked = tmp_path / "a" - symlinked.symlink_to(tmp_path) - with pytest.raises(RuntimeError): - PythonInfo.from_exe(str(symlinked), session_app_data) - assert spy.call_count == 2 - - -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 - count = 1 if result.executable == sys.executable else 2 # at least two, one for the venv, one more for the host - assert spy.call_count >= count - PythonInfo.clear_cache(session_app_data) - assert PythonInfo.from_exe(sys.executable, session_app_data) is not None - assert spy.call_count >= 2 * count - - -def test_py_info_cache_invalidation_on_py_info_change(mocker, session_app_data): - # 1. Get a PythonInfo object for the current executable, this will cache it. - PythonInfo.from_exe(sys.executable, session_app_data) - - # 2. Spy on _run_subprocess - spy = mocker.spy(cached_py_info, "_run_subprocess") - - # 3. Backup py_info.py - py_info_script = Path(cached_py_info.__file__).parent / "py_info.py" - original_content = py_info_script.read_text(encoding="utf-8") - original_stat = py_info_script.stat() - - try: - # 4. Clear the in-memory cache - mocker.patch.dict(cached_py_info._CACHE, {}, clear=True) # noqa: SLF001 - - # 5. Modify py_info.py to invalidate the cache - py_info_script.write_text(original_content + "\n# a comment", encoding="utf-8") - - # 6. Get the PythonInfo object again - info = PythonInfo.from_exe(sys.executable, session_app_data) - - # 7. Assert that _run_subprocess was called again - native_difference = 1 if info.system_executable == info.executable else 0 - if is_macos_brew(info): - assert spy.call_count + native_difference in {2, 3} - else: - assert spy.call_count + native_difference == 2 - - finally: - # 8. Restore the original content and timestamp - py_info_script.write_text(original_content, encoding="utf-8") - os.utime(str(py_info_script), (original_stat.st_atime, original_stat.st_mtime)) - - -@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") - first_result = PythonInfo.from_exe(sys.executable, session_app_data) - assert first_result is not None - count = spy.call_count - # at least two, one for the venv, one more for the host - exp_count = 1 if first_result.executable == sys.executable else 2 - assert count >= exp_count # at least two, one for the venv, one more for the host - - new_exe = tmp_path / "a" - 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(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 - - -class PyInfoMock(NamedTuple): - implementation: str - architecture: int - version_info: VersionInfo - - -@pytest.mark.parametrize( - ("target", "position", "discovered"), - [ - ( - PyInfoMock("CPython", 64, VersionInfo(3, 6, 8, "final", 0)), - 0, - [ - PyInfoMock("CPython", 64, VersionInfo(3, 6, 9, "final", 0)), - PyInfoMock("PyPy", 64, VersionInfo(3, 6, 8, "final", 0)), - ], - ), - ( - PyInfoMock("CPython", 64, VersionInfo(3, 6, 8, "final", 0)), - 0, - [ - PyInfoMock("CPython", 64, VersionInfo(3, 6, 9, "final", 0)), - PyInfoMock("CPython", 32, VersionInfo(3, 6, 9, "final", 0)), - ], - ), - ( - PyInfoMock("CPython", 64, VersionInfo(3, 8, 1, "final", 0)), - 0, - [ - PyInfoMock("CPython", 32, VersionInfo(2, 7, 12, "rc", 2)), - PyInfoMock("PyPy", 64, VersionInfo(3, 8, 1, "final", 0)), - ], - ), - ], -) -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) - - def _make_py_info(of): - base = copy.deepcopy(CURRENT) - base.implementation = of.implementation - base.version_info = of.version_info - base.architecture = of.architecture - return base - - discovered_with_path = {} - names = [] - selected = None - for pos, i in enumerate(discovered): - path = tmp_path / str(pos) - 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 - py_info.base_executable = str(path) - if pos == position: - selected = py_info - discovered_with_path[str(path)] = py_info - names.append(path.name) - - target_py_info = _make_py_info(target) - 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)]) - - 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) - target_py_info.real_prefix = str(tmp_path) - - 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) # noqa: SLF001 - assert mapped.system_executable == CURRENT.system_executable - found = discovered_with_path[mapped.base_executable] - assert found is selected - - assert caplog.records[0].msg == "discover exe for %s in %s" - for record in caplog.records[1:-1]: - assert record.message.startswith("refused interpreter ") - assert record.levelno == logging.DEBUG - - warn_similar = caplog.records[-1] - assert warn_similar.levelno == logging.DEBUG - assert warn_similar.msg.startswith("no exact match found, chosen most similar") - - -def test_py_info_ignores_distutils_config(monkeypatch, tmp_path): - 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)), f"{key}={value}" - - -def test_discover_exe_on_path_non_spec_name_match(mocker): - 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 / suffixed_name)) - assert CURRENT.satisfies(spec, impl_must_match=True) is True - - -def test_discover_exe_on_path_non_spec_name_not_match(mocker): - 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 / f"e{suffixed_name}"), - ) - assert CURRENT.satisfies(spec, impl_must_match=True) is False - - -@pytest.mark.skipif(IS_PYPY, reason="setuptools distutils patching does not work") -def test_py_info_setuptools(): - from setuptools.dist import Distribution # noqa: PLC0415 - - assert Distribution - PythonInfo() - - -@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 = f"ignore {sys.executable} due cannot resolve system due to RuntimeError('failed to detect " - assert expected in log.message - - -def _stringify_schemes_dict(schemes_dict): - """ - Since this file has from __future__ import unicode_literals, we manually cast all values of mocked install_schemes - to str() as the original schemes are not unicode on Python 2. - """ - return {str(n): {str(k): str(v) for k, v in s.items()} for n, s in schemes_dict.items()} - - -def test_custom_venv_install_scheme_is_prefered(mocker): - # The paths in this test are Fedora paths, but we set them for nt as well, so the test also works on Windows, - # despite the actual values are nonsense there. - # Values were simplified to be compatible with all the supported Python versions. - default_scheme = { - "stdlib": "{base}/lib/python{py_version_short}", - "platstdlib": "{platbase}/lib/python{py_version_short}", - "purelib": "{base}/local/lib/python{py_version_short}/site-packages", - "platlib": "{platbase}/local/lib/python{py_version_short}/site-packages", - "include": "{base}/include/python{py_version_short}", - "platinclude": "{platbase}/include/python{py_version_short}", - "scripts": "{base}/local/bin", - "data": "{base}/local", - } - venv_scheme = {key: path.replace("local", "") for key, path in default_scheme.items()} - sysconfig_install_schemes = { - "posix_prefix": default_scheme, - "nt": default_scheme, - "pypy": default_scheme, - "pypy_nt": default_scheme, - "venv": venv_scheme, - } - if getattr(sysconfig, "get_preferred_scheme", None): - # 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 - distutils_scheme = { - "purelib": "$base/local/lib/python$py_version_short/site-packages", - "platlib": "$platbase/local/lib/python$py_version_short/site-packages", - "headers": "$base/include/python$py_version_short/$dist_name", - "scripts": "$base/local/bin", - "data": "$base/local", - } - distutils_schemes = { - "unix_prefix": distutils_scheme, - "nt": distutils_scheme, - } - - # 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 - # and hence this is the first time we are touching distutils in this process. - # If distutils saw our mocked sysconfig INSTALL_SCHEMES, we would need - # to define all install schemes. - mocker.patch("distutils.command.install.INSTALL_SCHEMES", distutils_schemes) - mocker.patch("sysconfig._INSTALL_SCHEMES", sysconfig_install_schemes) - - 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, "/") == f"lib/python{pyver}/site-packages" - - -@pytest.mark.skipif( - IS_PYPY or 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 - system_executable = current._fast_get_system_executable() # noqa: SLF001 - assert os.path.basename(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(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, "/") == 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 deleted file mode 100644 index 90894a59c..000000000 --- a/tests/unit/discovery/py_info/test_py_info_exe_based_of.py +++ /dev/null @@ -1,56 +0,0 @@ -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 - -CURRENT = PythonInfo.current() - - -def test_discover_empty_folder(tmp_path, session_app_data): - with pytest.raises(RuntimeError): - CURRENT.discover_exe(session_app_data, prefix=str(tmp_path)) - - -BASE = (CURRENT.install_path("scripts"), ".") - - -@pytest.mark.skipif(not fs_supports_symlink(), reason="symlink is not supported") -@pytest.mark.parametrize("suffix", sorted({".exe", ".cmd", ""} & set(EXTENSIONS) if IS_WIN else [""])) -@pytest.mark.parametrize("into", BASE) -@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, 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 = f"{impl}{version}{'t' if CURRENT.free_threaded else ''}" - if 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(encoding="utf-8"), encoding="utf-8") - inside_folder = str(tmp_path) - base = CURRENT.discover_exe(session_app_data, inside_folder) - found = base.executable - dest_str = str(dest) - if not fs_is_case_sensitive(): - found = found.lower() - dest_str = dest_str.lower() - assert found == dest_str - assert len(caplog.messages) >= 1, caplog.text - assert "get interpreter info via cmd: " in caplog.text - - dest.rename(dest.parent / (dest.name + "-1")) - 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 06fdfd1ab..b0c920bb3 100644 --- a/tests/unit/discovery/test_discovery.py +++ b/tests/unit/discovery/test_discovery.py @@ -6,77 +6,16 @@ import sys from argparse import Namespace from pathlib import Path -from unittest.mock import patch -from uuid import uuid4 import pytest +from python_discovery import PythonInfo -from virtualenv.discovery.builtin import Builtin, LazyPathDump, get_interpreter, get_paths -from virtualenv.discovery.py_info import PythonInfo -from virtualenv.info import IS_WIN, fs_supports_symlink +from virtualenv.discovery.builtin import Builtin, get_interpreter +from virtualenv.info import IS_WIN -@pytest.mark.skipif(not fs_supports_symlink(), reason="symlink not supported") -@pytest.mark.parametrize("case", ["mixed", "lower", "upper"]) -@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) - name = "somethingVeryCryptic" - threaded = "t" if current.free_threaded else "" - if case == "lower": - name = name.lower() - elif case == "upper": - name = name.upper() - 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]) + threaded - 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}{threaded}" - 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, 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("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("PATH", str(tmp_path)) - interpreter = get_interpreter(uuid4().hex, []) - assert interpreter is None - - -def test_discovery_via_path_in_nonbrowseable_directory(tmp_path, monkeypatch): - bad_perm = tmp_path / "bad_perm" - bad_perm.mkdir(mode=0o000) - # path entry is unreadable - monkeypatch.setenv("PATH", str(bad_perm)) - interpreter = get_interpreter(uuid4().hex, []) - assert interpreter is None - # path entry parent is unreadable - 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) +def test_relative_path(session_app_data, monkeypatch) -> None: + sys_executable = Path(PythonInfo.current_system(session_app_data).system_executable) cwd = sys_executable.parents[1] monkeypatch.chdir(str(cwd)) relative = str(sys_executable.relative_to(cwd)) @@ -84,69 +23,7 @@ def test_relative_path(session_app_data, monkeypatch): assert result is not None -def test_uv_python(monkeypatch, tmp_path_factory, mocker): - monkeypatch.delenv("UV_PYTHON_INSTALL_DIR", raising=False) - monkeypatch.delenv("XDG_DATA_HOME", raising=False) - monkeypatch.setenv("PATH", "") - mocker.patch.object(PythonInfo, "satisfies", return_value=False) - - # UV_PYTHON_INSTALL_DIR - uv_python_install_dir = tmp_path_factory.mktemp("uv_python_install_dir") - with patch("virtualenv.discovery.builtin.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m: - m.setenv("UV_PYTHON_INSTALL_DIR", str(uv_python_install_dir)) - - get_interpreter("python", []) - mock_from_exe.assert_not_called() - - bin_path = uv_python_install_dir.joinpath("some-py-impl", "bin") - bin_path.mkdir(parents=True) - bin_path.joinpath("python").touch() - get_interpreter("python", []) - mock_from_exe.assert_called_once() - assert mock_from_exe.call_args[0][0] == str(bin_path / "python") - - # PATH takes precedence - mock_from_exe.reset_mock() - python_exe = "python.exe" if IS_WIN else "python" - dir_in_path = tmp_path_factory.mktemp("path_bin_dir") - dir_in_path.joinpath(python_exe).touch() - m.setenv("PATH", str(dir_in_path)) - get_interpreter("python", []) - mock_from_exe.assert_called_once() - assert mock_from_exe.call_args[0][0] == str(dir_in_path / python_exe) - - # XDG_DATA_HOME - xdg_data_home = tmp_path_factory.mktemp("xdg_data_home") - with patch("virtualenv.discovery.builtin.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m: - m.setenv("XDG_DATA_HOME", str(xdg_data_home)) - - get_interpreter("python", []) - mock_from_exe.assert_not_called() - - bin_path = xdg_data_home.joinpath("uv", "python", "some-py-impl", "bin") - bin_path.mkdir(parents=True) - bin_path.joinpath("python").touch() - get_interpreter("python", []) - mock_from_exe.assert_called_once() - assert mock_from_exe.call_args[0][0] == str(bin_path / "python") - - # User data path - user_data_path = tmp_path_factory.mktemp("user_data_path") - with patch("virtualenv.discovery.builtin.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m: - m.setattr("virtualenv.discovery.builtin.user_data_path", lambda x: user_data_path / x) - - get_interpreter("python", []) - mock_from_exe.assert_not_called() - - bin_path = user_data_path.joinpath("uv", "python", "some-py-impl", "bin") - bin_path.mkdir(parents=True) - bin_path.joinpath("python").touch() - get_interpreter("python", []) - mock_from_exe.assert_called_once() - assert mock_from_exe.call_args[0][0] == str(bin_path / "python") - - -def test_discovery_fallback_fail(session_app_data, caplog): +def test_discovery_fallback_fail(session_app_data, caplog) -> None: caplog.set_level(logging.DEBUG) builtin = Builtin( Namespace(app_data=session_app_data, try_first_with=[], python=["magic-one", "magic-two"], env=os.environ), @@ -158,7 +35,7 @@ def test_discovery_fallback_fail(session_app_data, caplog): assert "accepted" not in caplog.text -def test_discovery_fallback_ok(session_app_data, caplog): +def test_discovery_fallback_ok(session_app_data, caplog) -> None: caplog.set_level(logging.DEBUG) builtin = Builtin( Namespace(app_data=session_app_data, try_first_with=[], python=["magic-one", sys.executable], env=os.environ), @@ -175,12 +52,14 @@ def test_discovery_fallback_ok(session_app_data, caplog): def mock_get_interpreter(mocker): return mocker.patch( "virtualenv.discovery.builtin.get_interpreter", - lambda key, *args, **kwargs: getattr(mocker.sentinel, key), # noqa: ARG005 + lambda key, *_args, **_kwargs: getattr(mocker.sentinel, key), ) @pytest.mark.usefixtures("mock_get_interpreter") -def test_returns_first_python_specified_when_only_env_var_one_is_specified(mocker, monkeypatch, session_app_data): +def test_returns_first_python_specified_when_only_env_var_one_is_specified( + mocker, monkeypatch, session_app_data +) -> None: monkeypatch.setenv("VIRTUALENV_PYTHON", "python_from_env_var") builtin = Builtin( Namespace(app_data=session_app_data, try_first_with=[], python=["python_from_env_var"], env=os.environ), @@ -194,7 +73,7 @@ def test_returns_first_python_specified_when_only_env_var_one_is_specified(mocke @pytest.mark.usefixtures("mock_get_interpreter") def test_returns_second_python_specified_when_more_than_one_is_specified_and_env_var_is_specified( mocker, monkeypatch, session_app_data -): +) -> None: monkeypatch.setenv("VIRTUALENV_PYTHON", "python_from_env_var") builtin = Builtin( Namespace( @@ -210,22 +89,18 @@ def test_returns_second_python_specified_when_more_than_one_is_specified_and_env assert result == mocker.sentinel.python_from_cli -def test_discovery_absolute_path_with_try_first(tmp_path, session_app_data): +def test_discovery_absolute_path_with_try_first(tmp_path, session_app_data) -> None: good_env = tmp_path / "good" bad_env = tmp_path / "bad" - # Create two real virtual environments subprocess.check_call([sys.executable, "-m", "virtualenv", str(good_env)]) subprocess.check_call([sys.executable, "-m", "virtualenv", str(bad_env)]) - # On Windows, the executable is in Scripts/python.exe scripts_dir = "Scripts" if IS_WIN else "bin" exe_name = "python.exe" if IS_WIN else "python" good_exe = good_env / scripts_dir / exe_name bad_exe = bad_env / scripts_dir / exe_name - # The spec is an absolute path, this should be a hard requirement. - # The --try-first-with option should be rejected as it does not match the spec. interpreter = get_interpreter( str(good_exe), try_first_with=[str(bad_exe)], @@ -236,20 +111,8 @@ def test_discovery_absolute_path_with_try_first(tmp_path, session_app_data): assert Path(interpreter.executable) == good_exe -def test_discovery_via_path_with_file(tmp_path, monkeypatch): - a_file = tmp_path / "a_file" - a_file.touch() - monkeypatch.setenv("PATH", str(a_file)) - interpreter = get_interpreter(uuid4().hex, []) - assert interpreter is None - - -def test_absolute_path_does_not_exist(tmp_path): - """ - Test that virtualenv does not fail when an absolute path that does not exist is provided. - """ - # Create a command that uses an absolute path that does not exist - # and a valid python executable. +def test_absolute_path_does_not_exist(tmp_path) -> None: + """Test that virtualenv does not fail when an absolute path that does not exist is provided.""" command = [ sys.executable, "-m", @@ -261,7 +124,6 @@ def test_absolute_path_does_not_exist(tmp_path): str(tmp_path / "dest"), ] - # Run the command process = subprocess.run( command, capture_output=True, @@ -270,15 +132,11 @@ def test_absolute_path_does_not_exist(tmp_path): encoding="utf-8", ) - # Check that the command was successful assert process.returncode == 0, process.stderr -def test_absolute_path_does_not_exist_fails(tmp_path): - """ - Test that virtualenv fails when a single absolute path that does not exist is provided. - """ - # Create a command that uses an absolute path that does not exist +def test_absolute_path_does_not_exist_fails(tmp_path) -> None: + """Test that virtualenv fails when a single absolute path that does not exist is provided.""" command = [ sys.executable, "-m", @@ -288,7 +146,6 @@ def test_absolute_path_does_not_exist_fails(tmp_path): str(tmp_path / "dest"), ] - # Run the command process = subprocess.run( command, capture_output=True, @@ -297,30 +154,11 @@ def test_absolute_path_does_not_exist_fails(tmp_path): encoding="utf-8", ) - # Check that the command failed assert process.returncode != 0, process.stderr -def test_get_paths_no_path_env(monkeypatch): - monkeypatch.delenv("PATH", raising=False) - paths = list(get_paths({})) - assert paths - - -def test_lazy_path_dump_debug(monkeypatch, tmp_path): - monkeypatch.setenv("_VIRTUALENV_DEBUG", "1") - a_dir = tmp_path - executable_file = "a_file.exe" if IS_WIN else "a_file" - (a_dir / executable_file).touch(mode=0o755) - (a_dir / "b_file").touch(mode=0o644) - dumper = LazyPathDump(0, a_dir, os.environ) - output = repr(dumper) - assert executable_file in output - assert "b_file" not in output - - @pytest.mark.usefixtures("mock_get_interpreter") -def test_returns_first_python_specified_when_no_env_var_is_specified(mocker, monkeypatch, session_app_data): +def test_returns_first_python_specified_when_no_env_var_is_specified(mocker, monkeypatch, session_app_data) -> None: monkeypatch.delenv("VIRTUALENV_PYTHON", raising=False) builtin = Builtin( Namespace(app_data=session_app_data, try_first_with=[], python=["python_from_cli"], env=os.environ), @@ -331,28 +169,53 @@ def test_returns_first_python_specified_when_no_env_var_is_specified(mocker, mon assert result == mocker.sentinel.python_from_cli -def test_discovery_via_version_specifier(session_app_data): - """Test that version specifiers like >=3.11 work correctly.""" +def test_discovery_via_version_specifier(session_app_data) -> None: + """Test that version specifiers like >=3.11 work correctly through the virtualenv wrapper.""" current = PythonInfo.current_system(session_app_data) major, minor = current.version_info.major, current.version_info.minor - # Test with >= specifier that should match current Python spec = f">={major}.{minor}" interpreter = get_interpreter(spec, [], session_app_data) assert interpreter is not None assert interpreter.version_info.major == major assert interpreter.version_info.minor >= minor - # Test with compound specifier spec = f">={major}.{minor},<{major}.{minor + 10}" interpreter = get_interpreter(spec, [], session_app_data) assert interpreter is not None assert interpreter.version_info.major == major assert minor <= interpreter.version_info.minor < minor + 10 - # Test with implementation prefix spec = f"cpython>={major}.{minor}" interpreter = get_interpreter(spec, [], session_app_data) if current.implementation == "CPython": assert interpreter is not None assert interpreter.implementation == "CPython" + + +def test_invalid_discovery_via_env_var(monkeypatch, tmp_path) -> None: + """When VIRTUALENV_DISCOVERY is set to an unavailable plugin, raise a clear error instead of KeyError.""" + monkeypatch.setenv("VIRTUALENV_DISCOVERY", "nonexistent_plugin") + process = subprocess.run( + [sys.executable, "-m", "virtualenv", str(tmp_path / "env")], + capture_output=True, + text=True, + check=False, + encoding="utf-8", + ) + assert process.returncode != 0 + output = process.stdout + process.stderr + assert "nonexistent_plugin" in output + assert "is not available" in output + assert "KeyError" not in output + + +def test_invalid_discovery_via_env_var_unit(monkeypatch) -> None: + """Unit test: get_discover raises RuntimeError with helpful message for unknown discovery method.""" + from virtualenv.config.cli.parser import VirtualEnvConfigParser # noqa: PLC0415 + from virtualenv.run.plugin.discovery import get_discover # noqa: PLC0415 + + monkeypatch.setenv("VIRTUALENV_DISCOVERY", "nonexistent_plugin") + parser = VirtualEnvConfigParser() + with pytest.raises(RuntimeError, match=r"nonexistent_plugin.*is not available"): + get_discover(parser, []) diff --git a/tests/unit/discovery/test_py_spec.py b/tests/unit/discovery/test_py_spec.py deleted file mode 100644 index 2c037b6bb..000000000 --- a/tests/unit/discovery/test_py_spec.py +++ /dev/null @@ -1,161 +0,0 @@ -from __future__ import annotations - -import sys -from copy import copy - -import pytest - -from virtualenv.discovery.py_spec import PythonSpec -from virtualenv.util.specifier import SimpleSpecifierSet as SpecifierSet - - -def test_bad_py_spec(): - text = "python2.3.4.5" - spec = PythonSpec.from_string_spec(text) - assert text in repr(spec) - assert spec.str_spec == text - assert spec.path == text - content = vars(spec) - del content["str_spec"] - del content["path"] - assert all(v is None for v in content.values()) - - -def test_py_spec_first_digit_only_major(): - spec = PythonSpec.from_string_spec("278") - assert spec.major == 2 - assert spec.minor == 78 - - -def test_spec_satisfies_path_ok(): - spec = PythonSpec.from_string_spec(sys.executable) - assert spec.satisfies(spec) is True - - -def test_spec_satisfies_path_nok(tmp_path): - spec = PythonSpec.from_string_spec(sys.executable) - of = PythonSpec.from_string_spec(str(tmp_path)) - assert spec.satisfies(of) is False - - -def test_spec_satisfies_arch(): - spec_1 = PythonSpec.from_string_spec("python-32") - spec_2 = PythonSpec.from_string_spec("python-64") - - assert spec_1.satisfies(spec_1) is True - assert spec_2.satisfies(spec_1) is False - - -def test_spec_satisfies_free_threaded(): - spec_1 = PythonSpec.from_string_spec("python3.13t") - spec_2 = PythonSpec.from_string_spec("python3.13") - - assert spec_1.satisfies(spec_1) is True - assert spec_1.free_threaded is True - assert spec_2.satisfies(spec_1) is False - assert spec_2.free_threaded is False - - -@pytest.mark.parametrize( - ("req", "spec"), - [("py", "python"), ("jython", "jython"), ("CPython", "cpython")], -) -def test_spec_satisfies_implementation_ok(req, spec): - spec_1 = PythonSpec.from_string_spec(req) - spec_2 = PythonSpec.from_string_spec(spec) - assert spec_1.satisfies(spec_1) is True - assert spec_2.satisfies(spec_1) is True - - -def test_spec_satisfies_implementation_nok(): - 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 - - -def _version_satisfies_pairs(): - target = set() - version = tuple(str(i) for i in sys.version_info[0:3]) - for threading in (False, True): - for i in range(len(version) + 1): - req = ".".join(version[0:i]) - for j in range(i + 1): - sat = ".".join(version[0:j]) - # can be satisfied in both directions - if sat: - target.add((req, sat)) - # else: no version => no free-threading info - target.add((sat, req)) - if not threading or not sat or not req: - # free-threading info requires a version - continue - target.add((f"{req}t", f"{sat}t")) - target.add((f"{sat}t", f"{req}t")) - - return sorted(target) - - -@pytest.mark.parametrize(("req", "spec"), _version_satisfies_pairs()) -def test_version_satisfies_ok(req, 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 - - -def _version_not_satisfies_pairs(): - target = set() - version = tuple(str(i) for i in sys.version_info[0:3]) - for major in range(len(version)): - req = ".".join(version[0 : major + 1]) - for minor in range(major + 1): - sat_ver = list(sys.version_info[0 : minor + 1]) - for patch in range(minor + 1): - for o in [1, -1]: - temp = copy(sat_ver) - temp[patch] += o - if temp[patch] < 0: - continue - sat = ".".join(str(i) for i in temp) - target.add((req, sat)) - return sorted(target) - - -@pytest.mark.parametrize(("req", "spec"), _version_not_satisfies_pairs()) -def test_version_satisfies_nok(req, 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 - - -def test_relative_spec(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - a_relative_path = str((tmp_path / "a" / "b").relative_to(tmp_path)) - spec = PythonSpec.from_string_spec(a_relative_path) - assert spec.path == a_relative_path - - -@pytest.mark.parametrize( - ("text", "expected"), - [ - (">=3.12", ">=3.12"), - ("python>=3.12", ">=3.12"), - ("cpython!=3.11.*", "!=3.11.*"), - ("<=3.13,>=3.12", "<=3.13,>=3.12"), - ], -) -def test_specifier_parsing(text, expected): - spec = PythonSpec.from_string_spec(text) - assert spec.version_specifier == SpecifierSet(expected) - - -def test_specifier_with_implementation(): - spec = PythonSpec.from_string_spec("cpython>=3.12") - assert spec.implementation == "cpython" - assert spec.version_specifier == SpecifierSet(">=3.12") - - -def test_specifier_satisfies_with_partial_information(): - spec = PythonSpec.from_string_spec(">=3.12") - candidate = PythonSpec.from_string_spec("python3.12") - assert candidate.satisfies(spec) is True diff --git a/tests/unit/discovery/windows/conftest.py b/tests/unit/discovery/windows/conftest.py deleted file mode 100644 index f75278e06..000000000 --- a/tests/unit/discovery/windows/conftest.py +++ /dev/null @@ -1,105 +0,0 @@ -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, threaded=False): - """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) - info.free_threaded = threaded - 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 - python_core_path = "C:\\Users\\user\\AppData\\Local\\Programs\\Python" - interpreters = [ - ("ContinuumAnalytics", 3, 10, 32, False, "C:\\Users\\user\\Miniconda3\\python.exe"), - ("ContinuumAnalytics", 3, 10, 64, False, "C:\\Users\\user\\Miniconda3-64\\python.exe"), - ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"), - ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"), - ("PythonCore", 3, 5, 64, False, f"{python_core_path}\\Python35\\python.exe"), - ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"), - ("PythonCore", 3, 7, 32, False, f"{python_core_path}\\Python37-32\\python.exe"), - ("PythonCore", 3, 12, 64, False, f"{python_core_path}\\Python312\\python.exe"), - ("PythonCore", 3, 13, 64, True, f"{python_core_path}\\Python313\\python3.13t.exe"), - ("PythonCore", 2, 7, 64, False, "C:\\Python27\\python.exe"), - ("PythonCore", 3, 4, 64, False, "C:\\Python34\\python.exe"), - ("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe"), - ] - for _, major, minor, arch, threaded, exe in interpreters: - info = _mock_pyinfo(major, minor, arch, exe, threaded) - 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 deleted file mode 100644 index 594a1302f..000000000 --- a/tests/unit/discovery/windows/test_windows.py +++ /dev/null @@ -1,40 +0,0 @@ -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\\Python313\\python3.13t.exe"), - ("cpython", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"), - ("python3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), - ("cpython3", "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"), - # free-threaded - ("3t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"), - ("python3.13t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.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 deleted file mode 100644 index 0498352aa..000000000 --- a/tests/unit/discovery/windows/test_windows_pep514.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -import sys -import textwrap - -import pytest - - -@pytest.mark.skipif(sys.platform != "win32", reason="no Windows registry") -@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, 10, 32, False, "C:\\Users\\user\\Miniconda3\\python.exe", None), - ("ContinuumAnalytics", 3, 10, 64, False, "C:\\Users\\user\\Miniconda3-64\\python.exe", None), - ( - "PythonCore", - 3, - 9, - 64, - False, - "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", - None, - ), - ( - "PythonCore", - 3, - 9, - 64, - False, - "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", - None, - ), - ( - "PythonCore", - 3, - 8, - 64, - False, - "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe", - None, - ), - ( - "PythonCore", - 3, - 9, - 64, - False, - "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", - None, - ), - ( - "PythonCore", - 3, - 10, - 32, - False, - "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe", - None, - ), - ( - "PythonCore", - 3, - 12, - 64, - False, - "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", - None, - ), - ( - "PythonCore", - 3, - 13, - 64, - True, - "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe", - None, - ), - ("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe", None), - ("PythonCore", 2, 7, 64, False, "C:\\Python27\\python.exe", None), - ("PythonCore", 3, 7, 64, False, "C:\\Python37\\python.exe", None), - ] - - -@pytest.mark.skipif(sys.platform != "win32", reason="no Windows registry") -@pytest.mark.usefixtures("_mock_registry") -def test_pep514_run(capsys, caplog): - from virtualenv.discovery.windows import pep514 # noqa: PLC0415 - - pep514._run() # noqa: SLF001 - out, err = capsys.readouterr() - expected = textwrap.dedent( - r""" - ('CompanyA', 3, 6, 64, False, 'Z:\\CompanyA\\Python\\3.6\\python.exe', None) - ('ContinuumAnalytics', 3, 10, 32, False, 'C:\\Users\\user\\Miniconda3\\python.exe', None) - ('ContinuumAnalytics', 3, 10, 64, False, 'C:\\Users\\user\\Miniconda3-64\\python.exe', None) - ('PythonCore', 2, 7, 64, False, 'C:\\Python27\\python.exe', None) - ('PythonCore', 3, 10, 32, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe', None) - ('PythonCore', 3, 12, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe', None) - ('PythonCore', 3, 13, 64, True, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe', None) - ('PythonCore', 3, 7, 64, False, 'C:\\Python37\\python.exe', None) - ('PythonCore', 3, 8, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', None) - ('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None) - ('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None) - ('PythonCore', 3, 9, 64, False, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None) - """, # noqa: E501 - ).strip() - assert out.strip() == expected - assert not err - prefix = "PEP-514 violation in Windows Registry at " - expected_logs = [ - 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 diff --git a/tests/unit/discovery/windows/winreg-mock-values.py b/tests/unit/discovery/windows/winreg-mock-values.py deleted file mode 100644 index fa2619ba7..000000000 --- a/tests/unit/discovery/windows/winreg-mock-values.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations - -import winreg - -hive_open = { - (winreg.HKEY_CURRENT_USER, "Software\\Python", 0, winreg.KEY_READ): 78701856, - (winreg.HKEY_LOCAL_MACHINE, "Software\\Python", 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY): 78701840, - (winreg.HKEY_LOCAL_MACHINE, "Software\\Python", 0, winreg.KEY_READ | winreg.KEY_WOW64_32KEY): OSError( - 2, - "The system cannot find the file specified", - ), -} -key_open = { - 78701152: { - "Anaconda310-32\\InstallPath": 78703200, - "Anaconda310-32": 78703568, - "Anaconda310-64\\InstallPath": 78703520, - "Anaconda310-64": 78702368, - }, - 78701856: {"ContinuumAnalytics": 78701152, "PythonCore": 78702656, "CompanyA": 88800000}, - 78702656: { - "3.1\\InstallPath": 78701824, - "3.1": 78700704, - "3.2\\InstallPath": 78704048, - "3.2": 78704368, - "3.3\\InstallPath": 78701936, - "3.3": 78703024, - "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.13t\\InstallPath": 78703633, - "3.13t": 78702609, - "3.X": 78703088, - }, - 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.10", 1), "SysArchitecture": ("32bit", 1), "DisplayName": ("Python 3.10 (32-bit)", 1)}, - 78703200: { - "ExecutablePath": ("C:\\Users\\user\\Miniconda3\\python.exe", 1), - "ExecutableArguments": OSError(2, "The system cannot find the file specified"), - }, - 78702368: {"SysVersion": ("3.10", 1), "SysArchitecture": ("64bit", 1), "DisplayName": ("Python 3.10 (64-bit)", 1)}, - 78703520: { - "ExecutablePath": ("C:\\Users\\user\\Miniconda3-64\\python.exe", 1), - "ExecutableArguments": OSError(2, "The system cannot find the file specified"), - }, - 78700704: {"SysVersion": ("3.9", 1), "SysArchitecture": ("magic", 1), "DisplayName": ("Python 3.9 (wizardry)", 1)}, - 78701824: { - "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.9", 1), "SysArchitecture": (100, 4), "DisplayName": ("Python 3.9 (64-bit)", 1)}, - 78704048: { - "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.9", 1), "SysArchitecture": ("64bit", 1), "DisplayName": ("Python 3.9 (64-bit)", 1)}, - 78701936: { - "ExecutablePath": OSError(2, "The system cannot find the file specified"), - None: OSError(2, "The system cannot find the file specified"), - }, - 78701792: { - "SysVersion": OSError(2, "The system cannot find the file specified"), - "SysArchitecture": OSError(2, "The system cannot find the file specified"), - "DisplayName": OSError(2, "The system cannot find the file specified"), - }, - 78703792: { - "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.9", 1), "SysArchitecture": ("64bit", 1), "DisplayName": ("Python 3.9 (64-bit)", 1)}, - 78701888: { - "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.10", 1), "SysArchitecture": ("32bit", 1), "DisplayName": ("Python 3.10 (32-bit)", 1)}, - 78703600: { - "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe", 1), - "ExecutableArguments": OSError(2, "The system cannot find the file specified"), - }, - 78700656: { - "SysVersion": OSError(2, "The system cannot find the file specified"), - "SysArchitecture": OSError(2, "The system cannot find the file specified"), - "DisplayName": OSError(2, "The system cannot find the file specified"), - }, - 78702608: { - "SysVersion": ("magic", 1), - "SysArchitecture": ("64bit", 1), - "DisplayName": ("Python 3.12 (wizard edition)", 1), - }, - 78703632: { - "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", 1), - "ExecutableArguments": OSError(2, "The system cannot find the file specified"), - }, - 78702609: { - "SysVersion": ("3.13", 1), - "SysArchitecture": ("64bit", 1), - "DisplayName": ("Python 3.13 (64-bit, freethreaded)", 1), - }, - 78703633: { - "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe", 1), - "ExecutableArguments": OSError(2, "The system cannot find the file specified"), - }, - 78703088: {"SysVersion": (2778, 11)}, - 78703136: { - "SysVersion": OSError(2, "The system cannot find the file specified"), - "SysArchitecture": OSError(2, "The system cannot find the file specified"), - "DisplayName": OSError(2, "The system cannot find the file specified"), - }, - 78700912: { - "ExecutablePath": OSError(2, "The system cannot find the file specified"), - None: ("C:\\Python27\\", 1), - "ExecutableArguments": OSError(2, "The system cannot find the file specified"), - }, - 78704032: { - "SysVersion": OSError(2, "The system cannot find the file specified"), - "SysArchitecture": OSError(2, "The system cannot find the file specified"), - "DisplayName": OSError(2, "The system cannot find the file specified"), - }, - 78703648: { - "ExecutablePath": OSError(2, "The system cannot find the file specified"), - 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), - "DisplayName": OSError(2, "The system cannot find the file specified"), - }, -} -enum_collect = { - 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.8", - "3.9", - "3.10-32", - "3.11", - "3.12", - "3.13t", - "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.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 4f4fadac9..4dd83756a 100644 --- a/tests/unit/seed/embed/test_base_embed.py +++ b/tests/unit/seed/embed/test_base_embed.py @@ -15,14 +15,14 @@ ("args", "download"), [([], False), (["--no-download"], False), (["--never-download"], False), (["--download"], True)], ) -def test_download_cli_flag(args, download, tmp_path): +def test_download_cli_flag(args, download, tmp_path) -> None: session = session_via_cli([*args, str(tmp_path)]) assert session.seeder.download is download @pytest.mark.skipif(sys.version_info[:2] == (3, 8), reason="We still bundle wheel for Python 3.8") @pytest.mark.parametrize("flag", ["--no-wheel", "--wheel=none", "--wheel=embed", "--wheel=bundle"]) -def test_wheel_cli_flags_do_nothing(tmp_path, flag): +def test_wheel_cli_flags_do_nothing(tmp_path, flag) -> None: session = session_via_cli([flag, str(tmp_path)]) if sys.version_info[:2] >= (3, 12): expected = {"pip": "bundle"} @@ -33,14 +33,14 @@ def test_wheel_cli_flags_do_nothing(tmp_path, flag): @pytest.mark.skipif(sys.version_info[:2] == (3, 8), reason="We still bundle wheel for Python 3.8") @pytest.mark.parametrize("flag", ["--no-wheel", "--wheel=none", "--wheel=embed", "--wheel=bundle"]) -def test_wheel_cli_flags_warn(tmp_path, flag, capsys): +def test_wheel_cli_flags_warn(tmp_path, flag, capsys) -> None: session_via_cli([flag, str(tmp_path)]) out, err = capsys.readouterr() assert "The --no-wheel and --wheel options are deprecated." in out + err @pytest.mark.skipif(sys.version_info[:2] == (3, 8), reason="We still bundle wheel for Python 3.8") -def test_unused_wheel_cli_flags_dont_warn(tmp_path, capsys): +def test_unused_wheel_cli_flags_dont_warn(tmp_path, capsys) -> None: session_via_cli([str(tmp_path)]) out, err = capsys.readouterr() assert "The --no-wheel and --wheel options are deprecated." not in out + err @@ -48,7 +48,7 @@ def test_unused_wheel_cli_flags_dont_warn(tmp_path, capsys): @pytest.mark.skipif(sys.version_info[:2] != (3, 8), reason="We only bundle wheel for Python 3.8") @pytest.mark.parametrize("flag", ["--no-wheel", "--wheel=none", "--wheel=embed", "--wheel=bundle"]) -def test_wheel_cli_flags_dont_warn_on_38(tmp_path, flag, capsys): +def test_wheel_cli_flags_dont_warn_on_38(tmp_path, flag, capsys) -> None: session_via_cli([flag, str(tmp_path)]) out, err = capsys.readouterr() assert "The --no-wheel and --wheel options are deprecated." not in out + err 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 4076573e1..502f32a60 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 @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING import pytest +from python_discovery import PythonInfo +from python_discovery import _cached_py_info as cached_py_info -from virtualenv.discovery import cached_py_info -from virtualenv.discovery.py_info import PythonInfo from virtualenv.info import fs_supports_symlink from virtualenv.run import cli_run from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, BUNDLE_SUPPORT @@ -25,7 +25,7 @@ @pytest.mark.slow @pytest.mark.parametrize("copies", [False, True] if fs_supports_symlink() else [True]) -def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies, for_py_version): # noqa: PLR0915 +def test_seed_link_via_app_data(tmp_path, coverage_env, current_fastest, copies, for_py_version) -> None: # noqa: PLR0915 current = PythonInfo.current_system() bundle_ver = BUNDLE_SUPPORT[current.version_release_str] create_cmd = [ @@ -152,7 +152,7 @@ def read_only_app_data(temp_app_data): @pytest.mark.slow @pytest.mark.skipif(sys.platform == "win32", reason="Windows only applies R/O to files") @pytest.mark.usefixtures("read_only_app_data") -def test_base_bootstrap_link_via_app_data_not_writable(tmp_path, current_fastest): +def test_base_bootstrap_link_via_app_data_not_writable(tmp_path, current_fastest) -> None: dest = tmp_path / "venv" result = cli_run(["--seeder", "app-data", "--creator", current_fastest, "-vv", str(dest)]) assert result @@ -160,7 +160,7 @@ def test_base_bootstrap_link_via_app_data_not_writable(tmp_path, current_fastest @pytest.mark.slow @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): +def test_populated_read_only_cache_and_symlinked_app_data(tmp_path, current_fastest, temp_app_data) -> None: dest = tmp_path / "venv" cmd = [ "--seeder", @@ -175,7 +175,7 @@ def test_populated_read_only_cache_and_symlinked_app_data(tmp_path, current_fast assert cli_run(cmd) check_call((str(dest.joinpath("bin/python")), "-c", "import pip")) - cached_py_info._CACHE.clear() # necessary to re-trigger py info discovery # noqa: SLF001 + cached_py_info._CACHE.clear() # noqa: SLF001 # necessary to re-trigger py info discovery safe_delete(dest) # should succeed with special flag when read-only @@ -186,7 +186,7 @@ def test_populated_read_only_cache_and_symlinked_app_data(tmp_path, current_fast @pytest.mark.slow @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): +def test_populated_read_only_cache_and_copied_app_data(tmp_path, current_fastest, temp_app_data) -> None: dest = tmp_path / "venv" cmd = [ "--seeder", @@ -201,7 +201,7 @@ 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 # noqa: SLF001 + cached_py_info._CACHE.clear() # noqa: SLF001 # necessary to re-trigger py info discovery safe_delete(dest) # should succeed with special flag when read-only @@ -212,7 +212,7 @@ def test_populated_read_only_cache_and_copied_app_data(tmp_path, current_fastest @pytest.mark.slow @pytest.mark.parametrize("pkg", ["pip", "setuptools", "wheel"]) @pytest.mark.usefixtures("session_app_data", "current_fastest", "coverage_env") -def test_base_bootstrap_link_via_app_data_no(tmp_path, pkg, for_py_version): +def test_base_bootstrap_link_via_app_data_no(tmp_path, pkg, for_py_version) -> None: if for_py_version != "3.8" and pkg == "wheel": msg = "wheel isn't installed on Python > 3.8" raise pytest.skip(msg) @@ -228,7 +228,7 @@ def test_base_bootstrap_link_via_app_data_no(tmp_path, pkg, for_py_version): @pytest.mark.usefixtures("temp_app_data") -def test_app_data_parallel_ok(tmp_path): +def test_app_data_parallel_ok(tmp_path) -> None: exceptions = _run_parallel_threads(tmp_path) assert not exceptions, "\n".join(exceptions) @@ -246,7 +246,7 @@ def test_app_data_parallel_fail(tmp_path: Path, mocker: MockerFixture) -> None: def _run_parallel_threads(tmp_path): exceptions = [] - def _run(name): + def _run(name) -> None: try: cmd = ["--seeder", "app-data", str(tmp_path / name), "--no-setuptools"] if sys.version_info[:2] == (3, 8): diff --git a/tests/unit/seed/embed/test_pip_invoke.py b/tests/unit/seed/embed/test_pip_invoke.py index 41f4395d2..6753fc20a 100644 --- a/tests/unit/seed/embed/test_pip_invoke.py +++ b/tests/unit/seed/embed/test_pip_invoke.py @@ -14,7 +14,7 @@ @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): # noqa: C901 +def test_base_bootstrap_via_pip_invoke(tmp_path, coverage_env, mocker, current_fastest, no) -> None: # noqa: C901 extra_search_dir = tmp_path / "extra" extra_search_dir.mkdir() for_py_version = f"{sys.version_info.major}.{sys.version_info.minor}" @@ -22,7 +22,7 @@ def test_base_bootstrap_via_pip_invoke(tmp_path, coverage_env, mocker, current_f 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): # noqa: ARG001 + def _load_embed_wheel(app_data, distribution, _for_py_version, version): return load_embed_wheel(app_data, distribution, old_ver, version) old_ver = "3.8" diff --git a/tests/unit/seed/wheels/test_acquire.py b/tests/unit/seed/wheels/test_acquire.py index 27b013d38..7754a8cb9 100644 --- a/tests/unit/seed/wheels/test_acquire.py +++ b/tests/unit/seed/wheels/test_acquire.py @@ -23,17 +23,17 @@ @pytest.fixture(autouse=True) -def _fake_release_date(mocker): +def _fake_release_date(mocker) -> None: mocker.patch("virtualenv.seed.wheels.periodic_update.release_date_for_wheel_path", return_value=None) -def test_pip_wheel_env_run_could_not_find(session_app_data, mocker): +def test_pip_wheel_env_run_could_not_find(session_app_data, mocker) -> None: mocker.patch("virtualenv.seed.wheels.acquire.from_bundle", return_value=None) with pytest.raises(RuntimeError, match="could not find the embedded pip"): pip_wheel_env_run([], session_app_data, os.environ) -def test_download_wheel_bad_output(mocker, for_py_version, session_app_data): +def test_download_wheel_bad_output(mocker, for_py_version, session_app_data) -> None: """if the download contains no match for what wheel was downloaded, pick one that matches from target""" distribution = "setuptools" p_open = mocker.MagicMock() @@ -58,7 +58,7 @@ def test_download_wheel_bad_output(mocker, for_py_version, session_app_data): assert result.path == embed.path -def test_download_fails(mocker, for_py_version, session_app_data): +def test_download_fails(mocker, for_py_version, session_app_data) -> None: p_open = mocker.MagicMock() mocker.patch("virtualenv.seed.wheels.acquire.Popen", return_value=p_open) p_open.communicate.return_value = "out", "err" @@ -89,7 +89,7 @@ def test_download_fails(mocker, for_py_version, session_app_data): ] == exc.cmd -def test_download_wheel_python_io_encoding(mocker, for_py_version, session_app_data): +def test_download_wheel_python_io_encoding(mocker, for_py_version, session_app_data) -> None: mock_popen = mocker.patch("virtualenv.seed.wheels.acquire.Popen") mock_popen.return_value.communicate.return_value = "Saved a-b-c.whl", "" mock_popen.return_value.returncode = 0 @@ -108,7 +108,7 @@ def downloaded_wheel(mocker): @pytest.mark.parametrize("version", ["bundle", "0.0.0"]) -def test_get_wheel_download_called(mocker, for_py_version, session_app_data, downloaded_wheel, version): +def test_get_wheel_download_called(mocker, for_py_version, session_app_data, downloaded_wheel, version) -> None: distribution = "setuptools" write = mocker.patch("virtualenv.app_data.via_disk_folder.JSONStoreDisk.write") wheel = get_wheel(distribution, version, for_py_version, [], True, session_app_data, False, os.environ) @@ -119,7 +119,7 @@ def test_get_wheel_download_called(mocker, for_py_version, session_app_data, dow @pytest.mark.parametrize("version", ["embed", "pinned"]) -def test_get_wheel_download_not_called(mocker, for_py_version, session_app_data, downloaded_wheel, version): +def test_get_wheel_download_not_called(mocker, for_py_version, session_app_data, downloaded_wheel, version) -> None: distribution = "setuptools" expected = get_embed_wheel(distribution, for_py_version) if version == "pinned": diff --git a/tests/unit/seed/wheels/test_acquire_find_wheel.py b/tests/unit/seed/wheels/test_acquire_find_wheel.py index 7822849e5..71d71525b 100644 --- a/tests/unit/seed/wheels/test_acquire_find_wheel.py +++ b/tests/unit/seed/wheels/test_acquire_find_wheel.py @@ -6,24 +6,24 @@ from virtualenv.seed.wheels.embed import BUNDLE_FOLDER, MAX, get_embed_wheel -def test_find_latest_none(for_py_version): +def test_find_latest_none(for_py_version) -> None: 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_latest_string(for_py_version): +def test_find_latest_string(for_py_version) -> None: result = find_compatible_in_house("setuptools", "", 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_exact(for_py_version) -> None: 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(): +def test_find_bad_spec() -> None: 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 d0e95cf31..4cb329569 100644 --- a/tests/unit/seed/wheels/test_bundle.py +++ b/tests/unit/seed/wheels/test_bundle.py @@ -45,31 +45,31 @@ def app_data(tmp_path_factory, for_py_version, next_pip_wheel): return app_data_ -def test_version_embed(app_data, for_py_version): +def test_version_embed(app_data, for_py_version) -> None: wheel = from_bundle("pip", Version.embed, for_py_version, [], app_data, False, os.environ) assert wheel is not None assert wheel.name == get_embed_wheel("pip", for_py_version).name -def test_version_bundle(app_data, for_py_version, next_pip_wheel): +def test_version_bundle(app_data, for_py_version, next_pip_wheel) -> None: wheel = from_bundle("pip", Version.bundle, for_py_version, [], app_data, False, os.environ) assert wheel is not None assert wheel.name == next_pip_wheel.name -def test_version_pinned_not_found(app_data, for_py_version): +def test_version_pinned_not_found(app_data, for_py_version) -> None: wheel = from_bundle("pip", "0.0.0", for_py_version, [], app_data, False, os.environ) assert wheel is None -def test_version_pinned_is_embed(app_data, for_py_version): +def test_version_pinned_is_embed(app_data, for_py_version) -> None: expected_wheel = get_embed_wheel("pip", for_py_version) wheel = from_bundle("pip", expected_wheel.version, for_py_version, [], app_data, False, os.environ) assert wheel is not None assert wheel.name == expected_wheel.name -def test_version_pinned_in_app_data(app_data, for_py_version, next_pip_wheel): +def test_version_pinned_in_app_data(app_data, for_py_version, next_pip_wheel) -> None: wheel = from_bundle("pip", next_pip_wheel.version, for_py_version, [], app_data, False, os.environ) assert wheel is not None assert wheel.name == next_pip_wheel.name diff --git a/tests/unit/seed/wheels/test_periodic_update.py b/tests/unit/seed/wheels/test_periodic_update.py index e11fe5e1a..e683d6cf3 100644 --- a/tests/unit/seed/wheels/test_periodic_update.py +++ b/tests/unit/seed/wheels/test_periodic_update.py @@ -34,13 +34,13 @@ @pytest.fixture(autouse=True) -def _clear_pypi_info_cache(): +def _clear_pypi_info_cache() -> None: 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): +def test_manual_upgrade(session_app_data, caplog, mocker, for_py_version) -> None: wheel = get_embed_wheel("pip", for_py_version) new_version = NewVersion( wheel.path, @@ -49,14 +49,7 @@ def test_manual_upgrade(session_app_data, caplog, mocker, for_py_version): "manual", ) - 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 - ): + def _do_update(distribution, **_kwargs): if distribution == "pip": return [new_version] return [] @@ -79,7 +72,7 @@ def _do_update( # noqa: PLR0913 @pytest.mark.usefixtures("session_app_data") -def test_pick_periodic_update(tmp_path, mocker, for_py_version): +def test_pick_periodic_update(tmp_path, mocker, for_py_version) -> None: 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(tz=timezone.utc) - timedelta(days=29) @@ -109,7 +102,7 @@ def test_pick_periodic_update(tmp_path, mocker, for_py_version): assert f"setuptools-{current.version}.dist-info" in installed -def test_periodic_update_stops_at_current(mocker, session_app_data, for_py_version): +def test_periodic_update_stops_at_current(mocker, session_app_data, for_py_version) -> None: current = get_embed_wheel("setuptools", for_py_version) now, completed = datetime.now(tz=timezone.utc), datetime.now(tz=timezone.utc) - timedelta(days=29) @@ -129,7 +122,7 @@ def test_periodic_update_stops_at_current(mocker, session_app_data, for_py_versi assert result.path == current.path -def test_periodic_update_latest_per_patch(mocker, session_app_data, for_py_version): +def test_periodic_update_latest_per_patch(mocker, session_app_data, for_py_version) -> None: current = get_embed_wheel("setuptools", for_py_version) expected_path = wheel_path(current, (0, 1, 2)) now = datetime.now(tz=timezone.utc) @@ -150,7 +143,7 @@ def test_periodic_update_latest_per_patch(mocker, session_app_data, for_py_versi assert str(result.path) == expected_path -def test_periodic_update_latest_per_patch_prev_is_manual(mocker, session_app_data, for_py_version): +def test_periodic_update_latest_per_patch_prev_is_manual(mocker, session_app_data, for_py_version) -> None: current = get_embed_wheel("setuptools", for_py_version) expected_path = wheel_path(current, (0, 1, 2)) now = datetime.now(tz=timezone.utc) @@ -172,7 +165,7 @@ def test_periodic_update_latest_per_patch_prev_is_manual(mocker, session_app_dat assert str(result.path) == expected_path -def test_manual_update_honored(mocker, session_app_data, for_py_version): +def test_manual_update_honored(mocker, session_app_data, for_py_version) -> None: current = get_embed_wheel("setuptools", for_py_version) expected_path = wheel_path(current, (0, 1, 1)) now = datetime.now(tz=timezone.utc) @@ -231,7 +224,7 @@ 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, time_freeze): +def test_periodic_update_skip(u_log, mocker, for_py_version, session_app_data, time_freeze) -> None: 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) @@ -258,7 +251,7 @@ def test_periodic_update_skip(u_log, mocker, for_py_version, session_app_data, t @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, time_freeze): +def test_periodic_update_trigger(u_log, mocker, for_py_version, session_app_data, time_freeze) -> None: 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") @@ -274,7 +267,7 @@ def test_periodic_update_trigger(u_log, mocker, for_py_version, session_app_data assert load_datetime(wrote_json["started"]) == _UP_NOW -def test_trigger_update_no_debug(for_py_version, session_app_data, tmp_path, mocker, monkeypatch): +def test_trigger_update_no_debug(for_py_version, session_app_data, tmp_path, mocker, monkeypatch) -> None: monkeypatch.delenv("_VIRTUALENV_PERIODIC_UPDATE_INLINE", raising=False) current = get_embed_wheel("setuptools", for_py_version) process = mocker.MagicMock() @@ -322,7 +315,7 @@ def test_trigger_update_no_debug(for_py_version, session_app_data, tmp_path, moc assert process.communicate.call_count == 0 -def test_trigger_update_debug(for_py_version, session_app_data, tmp_path, mocker, monkeypatch): +def test_trigger_update_debug(for_py_version, session_app_data, tmp_path, mocker, monkeypatch) -> None: monkeypatch.setenv("_VIRTUALENV_PERIODIC_UPDATE_INLINE", "1") current = get_embed_wheel("pip", for_py_version) @@ -368,7 +361,7 @@ 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, time_freeze): +def test_do_update_first(tmp_path, mocker, time_freeze) -> None: time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) @@ -446,22 +439,14 @@ def _release(of, context): } -def test_do_update_skip_already_done(tmp_path, mocker, time_freeze): +def test_do_update_skip_already_done(tmp_path, mocker, time_freeze) -> None: 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( # 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 - ): + def _download_wheel(**_kwargs): return wheel.path download_wheel = mocker.patch("virtualenv.seed.wheels.acquire.download_wheel", side_effect=_download_wheel) @@ -501,13 +486,13 @@ def _download_wheel( # noqa: PLR0913 } -def test_new_version_eq(): +def test_new_version_eq() -> None: now = datetime.now(tz=timezone.utc) value = NewVersion("a", now, now, "periodic") assert value == NewVersion("a", now, now, "periodic") -def test_new_version_ne(): +def test_new_version_ne() -> None: assert NewVersion("a", datetime.now(tz=timezone.utc), datetime.now(tz=timezone.utc), "periodic") != NewVersion( "a", datetime.now(tz=timezone.utc), @@ -516,7 +501,7 @@ def test_new_version_ne(): ) -def test_get_release_unsecure(mocker, caplog): +def test_get_release_unsecure(mocker, caplog) -> None: @contextmanager def _release(of, context): assert of == "https://pypi.org/pypi/pip/json" @@ -536,7 +521,7 @@ def _release(of, context): assert " failed " in caplog.text -def test_get_release_fails(mocker, caplog): +def test_get_release_fails(mocker, caplog) -> None: exc = RuntimeError("oh no") url_o = mocker.patch("virtualenv.seed.wheels.periodic_update.urlopen", side_effect=exc) @@ -558,11 +543,11 @@ def download(): do = download() return mocker.patch( "virtualenv.seed.wheels.acquire.download_wheel", - side_effect=lambda *a, **k: next(do), # noqa: ARG005 + side_effect=lambda *_a, **_k: next(do), ) -def test_download_stop_with_embed(tmp_path, mocker, time_freeze): +def test_download_stop_with_embed(tmp_path, mocker, time_freeze) -> None: time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) @@ -585,7 +570,7 @@ def test_download_stop_with_embed(tmp_path, mocker, time_freeze): assert write.call_count == 1 -def test_download_manual_stop_after_one_download(tmp_path, mocker, time_freeze): +def test_download_manual_stop_after_one_download(tmp_path, mocker, time_freeze) -> None: time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) @@ -607,7 +592,7 @@ def test_download_manual_stop_after_one_download(tmp_path, mocker, time_freeze): assert write.call_count == 1 -def test_download_manual_ignores_pre_release(tmp_path, mocker, time_freeze): +def test_download_manual_ignores_pre_release(tmp_path, mocker, time_freeze) -> None: time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) @@ -640,7 +625,7 @@ def test_download_manual_ignores_pre_release(tmp_path, mocker, time_freeze): ] -def test_download_periodic_stop_at_first_usable(tmp_path, mocker, time_freeze): +def test_download_periodic_stop_at_first_usable(tmp_path, mocker, time_freeze) -> None: time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) @@ -652,7 +637,7 @@ def test_download_periodic_stop_at_first_usable(tmp_path, mocker, time_freeze): 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), # noqa: ARG005 + side_effect=lambda *_a, **_k: next(rel_date_gen), ) last_update = _UP_NOW - timedelta(days=14) @@ -668,7 +653,7 @@ def test_download_periodic_stop_at_first_usable(tmp_path, mocker, time_freeze): assert write.call_count == 1 -def test_download_periodic_stop_at_first_usable_with_previous_minor(tmp_path, mocker, time_freeze): +def test_download_periodic_stop_at_first_usable_with_previous_minor(tmp_path, mocker, time_freeze) -> None: time_freeze(_UP_NOW) wheel = get_embed_wheel("pip", "3.9") app_data_outer = AppDataDiskFolder(str(tmp_path / "app")) @@ -684,7 +669,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), # noqa: ARG005 + side_effect=lambda *_a, **_k: next(rel_date_gen), ) 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 4d4ec9b9b..f177a7d21 100644 --- a/tests/unit/seed/wheels/test_wheels_util.py +++ b/tests/unit/seed/wheels/test_wheels_util.py @@ -6,31 +6,31 @@ from virtualenv.seed.wheels.util import Wheel -def test_wheel_support_no_python_requires(mocker): +def test_wheel_support_no_python_requires(mocker) -> None: 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"" # noqa: ARG005 + zip_mock.return_value.__enter__.return_value.read = lambda _name: b"" supports = wheel.support_py("3.8") assert supports is True -def test_bad_as_version_tuple(): +def test_bad_as_version_tuple() -> None: with pytest.raises(ValueError, match="bad"): Wheel.as_version_tuple("bad") -def test_wheel_not_support(): +def test_wheel_not_support() -> None: wheel = get_embed_wheel("setuptools", MAX) assert wheel.support_py("3.3") is False -def test_wheel_repr(): +def test_wheel_repr() -> None: wheel = get_embed_wheel("setuptools", MAX) assert str(wheel.path) in repr(wheel) -def test_unknown_distribution(): +def test_unknown_distribution() -> None: wheel = get_embed_wheel("unknown", MAX) assert wheel is None diff --git a/tests/unit/test_file_limit.py b/tests/unit/test_file_limit.py index e0ab4f826..cde09d0a3 100644 --- a/tests/unit/test_file_limit.py +++ b/tests/unit/test_file_limit.py @@ -11,10 +11,8 @@ @pytest.mark.skipif(sys.platform == "win32", reason="resource module not available on Windows") -def test_too_many_open_files(tmp_path): - """ - Test that we get a specific error when we have too many open files. - """ +def test_too_many_open_files(tmp_path) -> None: + """Test that we get a specific error when we have too many open files.""" import resource # noqa: PLC0415 soft_limit, hard_limit = resource.getrlimit(resource.RLIMIT_NOFILE) diff --git a/tests/unit/test_run.py b/tests/unit/test_run.py index b61731d57..f223ee1b8 100644 --- a/tests/unit/test_run.py +++ b/tests/unit/test_run.py @@ -8,7 +8,7 @@ from virtualenv.run import cli_run, session_via_cli -def test_help(capsys): +def test_help(capsys) -> None: with pytest.raises(SystemExit) as context: cli_run(args=["-h", "-vvv"]) assert context.value.code == 0 @@ -18,7 +18,7 @@ def test_help(capsys): assert out -def test_version(capsys): +def test_version(capsys) -> None: with pytest.raises(SystemExit) as context: cli_run(args=["--version"]) assert context.value.code == 0 @@ -33,7 +33,7 @@ def test_version(capsys): @pytest.mark.parametrize("on", [True, False]) -def test_logging_setup(caplog, on): +def test_logging_setup(caplog, on) -> None: caplog.set_level(logging.DEBUG) session_via_cli(["env"], setup_logging=on) # DEBUG only level output is generated during this phase, default output is WARN, so if on no records should be @@ -41,3 +41,15 @@ def test_logging_setup(caplog, on): assert not caplog.records else: assert caplog.records + + +def test_invalid_discovery_method_via_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VIRTUALENV_DISCOVERY", "pyenv") + with pytest.raises(RuntimeError, match=r"discovery 'pyenv' is not available") as exc_info: + session_via_cli(["env"]) + + error_message = str(exc_info.value) + assert "discovery 'pyenv' is not available" in error_message + assert "Available discovery methods:" in error_message + assert "builtin" in error_message + assert "Is the plugin installed?" in error_message diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index b83c247a2..2cdc03838 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -1,27 +1,33 @@ from __future__ import annotations import concurrent.futures +import os import traceback +from typing import TYPE_CHECKING import pytest +from virtualenv.app_data import _cache_dir_with_migration, _default_app_data_dir from virtualenv.util.lock import ReentrantFileLock from virtualenv.util.subprocess import run_cmd +if TYPE_CHECKING: + from pathlib import Path -def test_run_fail(tmp_path): + +def test_run_fail(tmp_path) -> None: code, out, err = run_cmd([str(tmp_path)]) assert err assert not out assert code -def test_reentrant_file_lock_is_thread_safe(tmp_path): +def test_reentrant_file_lock_is_thread_safe(tmp_path) -> None: lock = ReentrantFileLock(tmp_path) target_file = tmp_path / "target" target_file.touch() - def recreate_target_file(): + def recreate_target_file() -> None: with lock.lock_for_key("target"): target_file.unlink() target_file.touch() @@ -34,3 +40,111 @@ def recreate_target_file(): task.result() except Exception: # noqa: BLE001, PERF203 pytest.fail(traceback.format_exc()) + + +class TestDefaultAppDataDir: + def test_override_env_var(self, tmp_path: Path) -> None: + custom = str(tmp_path / "custom") + env = {"VIRTUALENV_OVERRIDE_APP_DATA": custom} + assert _default_app_data_dir(env) == custom + + def test_no_override_returns_cache_dir(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("VIRTUALENV_OVERRIDE_APP_DATA", raising=False) + result = _default_app_data_dir(os.environ) + assert result + + +class TestCacheDirMigration: + def test_migrate_old_to_new(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + old_dir = str(tmp_path / "old-data") + new_dir = str(tmp_path / "new-cache") + os.makedirs(old_dir) + (tmp_path / "old-data" / "test.txt").write_text("hello") + + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: new_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: old_dir) + + result = _cache_dir_with_migration() + assert result == new_dir + assert os.path.isdir(new_dir) + assert not os.path.isdir(old_dir) + assert (tmp_path / "new-cache" / "test.txt").read_text() == "hello" + + def test_no_migration_when_old_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + old_dir = str(tmp_path / "old-data") + new_dir = str(tmp_path / "new-cache") + + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: new_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: old_dir) + + result = _cache_dir_with_migration() + assert result == new_dir + assert not os.path.isdir(old_dir) + + def test_no_migration_when_new_exists(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + old_dir = str(tmp_path / "old-data") + new_dir = str(tmp_path / "new-cache") + os.makedirs(old_dir) + os.makedirs(new_dir) + (tmp_path / "old-data" / "old.txt").write_text("old") + + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: new_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: old_dir) + + result = _cache_dir_with_migration() + assert result == new_dir + assert os.path.isdir(old_dir) + + def test_same_dir_returns_immediately(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + same_dir = str(tmp_path / "same") + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: same_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: same_dir) + + result = _cache_dir_with_migration() + assert result == same_dir + + def test_fallback_on_migration_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + old_dir = str(tmp_path / "old-data") + new_dir = str(tmp_path / "new-cache") + os.makedirs(old_dir) + + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: new_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: old_dir) + + def broken_move(_src: str, _dst: str) -> None: + msg = "permission denied" + raise OSError(msg) + + monkeypatch.setattr("virtualenv.app_data.shutil.move", broken_move) + + result = _cache_dir_with_migration() + assert result == old_dir + + @pytest.mark.parametrize("symlink_flag", [True, False]) + def test_symlink_app_data_survives_migration( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + symlink_flag: bool, # noqa: ARG002 + ) -> None: + old_dir = str(tmp_path / "old-data") + new_dir = str(tmp_path / "new-cache") + os.makedirs(old_dir) + wheel_img = tmp_path / "old-data" / "wheel" / "3.12" / "image" / "pip" + wheel_img.mkdir(parents=True) + (wheel_img / "pip.dist-info").mkdir() + (wheel_img / "pip.dist-info" / "METADATA").write_text("Name: pip") + + venv_dir = tmp_path / "my-venv" / "lib" / "site-packages" + venv_dir.mkdir(parents=True) + try: + os.symlink(str(wheel_img / "pip.dist-info"), str(venv_dir / "pip.dist-info")) + except OSError: + pytest.skip("symlinks not supported on this filesystem") + + monkeypatch.setattr("virtualenv.app_data.user_cache_dir", lambda **_kw: new_dir) + monkeypatch.setattr("virtualenv.app_data.user_data_dir", lambda **_kw: old_dir) + + result = _cache_dir_with_migration() + assert result == new_dir + assert (tmp_path / "new-cache" / "wheel" / "3.12" / "image" / "pip" / "pip.dist-info" / "METADATA").exists() diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 7cdebdbe7..000000000 --- a/tox.ini +++ /dev/null @@ -1,120 +0,0 @@ -[tox] -requires = - tox>=4.28 -env_list = - fix - pypy3 - 3.14 - 3.13 - 3.12 - 3.11 - 3.10 - 3.9 - 3.8 - graalpy - coverage - readme - docs - 3.14t - 3.13t -skip_missing_interpreters = true - -[testenv] -description = run tests with {basepython} -package = wheel -wheel_build_env = .pkg -extras = - test -pass_env = - CI_RUN - PYTEST_* - TERM -set_env = - COVERAGE_FILE = {toxworkdir}/.coverage.{envname} - COVERAGE_PROCESS_START = {toxinidir}/pyproject.toml - PYTHONWARNDEFAULTENCODING = 1 - _COVERAGE_SRC = {envsitepackagesdir}/virtualenv -commands = - !graalpy: coverage erase - !graalpy: coverage run -m pytest {posargs:--junitxml "{toxworkdir}/junit.{envname}.xml" tests --int} - !graalpy: coverage combine - !graalpy: coverage report --skip-covered --show-missing - !graalpy: coverage xml -o "{toxworkdir}/coverage.{envname}.xml" - !graalpy: coverage html -d {envtmpdir}/htmlcov --show-contexts --title virtualenv-{envname}-coverage - graalpy: pytest {posargs:--junitxml "{toxworkdir}/junit.{envname}.xml" tests --skip-slow} -uv_seed = true - -[testenv:fix] -description = format the code base to adhere to our styles, and complain about what we cannot do automatically -skip_install = true -deps = - pre-commit-uv>=4.1.4 -commands = - pre-commit run --all-files --show-diff-on-failure - -[testenv:readme] -description = check that the long description is valid -skip_install = true -deps = - check-wheel-contents>=0.6.2 - twine>=6.1 - uv>=0.8 -commands = - uv build --sdist --wheel --out-dir {envtmpdir} . - twine check {envtmpdir}{/}* - check-wheel-contents --no-config {envtmpdir} - -[testenv:docs] -description = build documentation -extras = - docs -commands = - 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:3.14t] -base_python = {env:TOX_BASEPYTHON} - -[testenv:3.13t] -base_python = {env:TOX_BASEPYTHON} - -[testenv:upgrade] -description = upgrade pip/wheels/setuptools to latest -skip_install = true -deps = - ruff>=0.12.4 -pass_env = - UPGRADE_ADVISORY -change_dir = {toxinidir}/tasks -commands = - - python upgrade_wheels.py -uv_seed = true - -[testenv:release] -description = do a release, required posarg of the version number -deps = - gitpython>=3.1.44 - packaging>=25 - towncrier>=24.8 -change_dir = {toxinidir}/tasks -commands = - python release.py --version {posargs} - -[testenv:dev] -description = generate a DEV environment -package = editable -extras = - docs - test -commands = - uv pip tree - python -c 'import sys; print(sys.executable)' - -[testenv:zipapp] -description = generate a zipapp -skip_install = true -deps = - packaging>=25 -commands = - python tasks/make_zipapp.py -uv_seed = true diff --git a/tox.toml b/tox.toml new file mode 100644 index 000000000..a0158f51d --- /dev/null +++ b/tox.toml @@ -0,0 +1,187 @@ +requires = [ "tox>=4.45" ] +env_list = [ + "3.14", + "3.13", + "3.12", + "3.11", + "3.10", + "3.9", + "3.8", + "pypy3", + "3.13t", + "3.14t", + "coverage", + "docs", + "fix", + "graalpy", + "readme", + "rp", + "type", +] +skip_missing_interpreters = true + +[env_run_base] +description = "run tests with {env_name}" +package = "wheel" +wheel_build_env = ".pkg" +dependency_groups = [ "test" ] +pass_env = [ "CI_RUN", "PYTEST_*", "TERM" ] +set_env._COVERAGE_SRC = "{env_site_packages_dir}{/}virtualenv" +set_env.COVERAGE_FILE = "{work_dir}{/}.coverage.{env_name}" +set_env.COVERAGE_PROCESS_START = "{tox_root}{/}pyproject.toml" +set_env.PYTHONWARNDEFAULTENCODING = "1" +commands = [ + [ "coverage", "erase" ], + [ + "coverage", + "run", + "-m", + "pytest", + { replace = "posargs", default = [ + "--junitxml", + "{work_dir}{/}junit.{env_name}.xml", + "tests", + "--int", + "-n", + "auto", + "--dist", + "loadfile", + ], extend = true }, + ], + [ "coverage", "combine" ], + [ "coverage", "report", "--skip-covered", "--show-missing" ], + [ "coverage", "xml", "-o", "{work_dir}{/}coverage.{env_name}.xml" ], + [ + "coverage", + "html", + "-d", + "{env_tmp_dir}{/}htmlcov", + "--show-contexts", + "--title", + "virtualenv-{env_name}-coverage", + ], +] +uv_seed = true + +[env.docs] +description = "build documentation" +dependency_groups = [ "docs" ] +set_env.PYTHONUTF8 = "1" +commands = [ + [ "pre-commit", "run", "docstrfmt", "--all-files" ], + [ "proselint", "check", "docs" ], + [ + "sphinx-build", + "-d", + "{env_tmp_dir}{/}doctree", + "docs", + "{env:READTHEDOCS_OUTPUT:{env_tmp_dir}{/}docs_out}/html", + "--color", + "-b", + "html", + "-W", + ], + [ + "python", + "-c", + """\ + import pathlib; print(\"documentation available under file://\" + str(pathlib.Path(r\"{work_dir}\") / \"docs_out\" / \ + \"index.html\"))\ + """, + ], +] + +[env.fix] +description = "format the code base to adhere to our styles, and complain about what we cannot do automatically" +skip_install = true +dependency_groups = [ "lint" ] +commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure" ] ] + +[env.graalpy] +commands = [ + [ + "pytest", + { replace = "posargs", default = [ + "--junitxml", + "{work_dir}{/}junit.{env_name}.xml", + "tests", + "--skip-slow", + ], extend = true }, + ], +] + +[env.readme] +description = "check that the long description is valid" +skip_install = true +dependency_groups = [ "pkg-meta" ] +commands = [ + [ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "." ], + [ "twine", "check", "{env_tmp_dir}{/}*" ], + [ "check-wheel-contents", "--no-config", "{env_tmp_dir}" ], +] + +[env.rp] +commands = [ + [ + "pytest", + { replace = "posargs", default = [ + "--junitxml", + "{work_dir}{/}junit.{env_name}.xml", + "tests", + "--skip-slow", + ], extend = true }, + ], +] + +[env.type] +description = "run type checker (ty) against Python 3.14" +dependency_groups = [ "type" ] +commands = [ + [ + "python", + "-m", + "ty", + "check", + "src/virtualenv", + "--python-version", + "3.14", + "--output-format", + "concise" + ] +] + +[env.dev] +description = "generate a DEV environment" +package = "editable" +dependency_groups = [ "dev" ] +commands = [ + [ "uv", "pip", "tree" ], + [ "python", "-c", "import sys; print(sys.executable)" ], +] + +[env.release] +description = "do a release, required posarg of the version number" +deps = [ "gitpython>=3.1.44", "packaging>=25", "pre-commit-uv>=4.1.4", "towncrier>=24.8" ] +change_dir = "{tox_root}{/}tasks" +commands = [ [ "python", "release.py", { replace = "posargs", extend = true } ] ] + +[env.upgrade] +description = "upgrade pip/wheels/setuptools to latest" +skip_install = true +deps = [ "ruff>=0.12.4" ] +pass_env = [ "UPGRADE_ADVISORY" ] +change_dir = "{tox_root}{/}tasks" +commands = [ [ "python", "upgrade_wheels.py" ] ] +uv_seed = true + +[env.zipapp] +description = "generate a zipapp" +skip_install = true +deps = [ "packaging>=25" ] +commands = [ [ "python", "tasks/make_zipapp.py" ] ] +uv_seed = true + +[env."type-3.8"] +description = "run type checker (ty) against Python 3.8" +commands = [ [ "python", "-m", "ty", "check", "src/virtualenv", "--python-version", "3.8" ] ] +base = [ "type" ]